test: TestJournalMCP compiles the MCP declaration through pier and pins its input keys (phase 19.7 plan 05)

- the seven tools compile in pier's catalog against the plugin's routes
- every input field is a path segment, a list query key or a create/update
  body key the routes read
- the draft prompt renders with and without its optional tone
This commit is contained in:
Jakub Zych
2026-10-23 14:06:46 +02:00
parent 4ab94da66c
commit a9a9b8098d

149
mcp_test.go Normal file
View File

@@ -0,0 +1,149 @@
package journal_test
import (
"context"
"net/http"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
journal "git.golem15.com/golem15/sm-journal-plugin"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/pier"
"git.golem15.com/golem15/summercms/modules/surf"
)
// The JSON keys the post routes read (controllers/api/posts_helpers.go:
// body["..."] in the create and update validation, and the list query).
// A tool input field outside these would be sent and silently ignored.
var (
postBodyKeys = []string{"categories", "content", "excerpt", "is_pinned", "metadata", "published", "published_at", "slug", "sources", "tags", "title", "translations"}
postQueryKeys = []string{"author", "category", "include_empty", "page", "per_page", "pinned_first", "published", "search", "sort", "tag"}
)
// mountHost adapts a built surf router to pier.Host for a compile-only mount.
type mountHost struct{ r *surf.Router }
func (h mountHost) Routes() []pier.RouteInfo {
var out []pier.RouteInfo
for _, ri := range h.r.Routes() {
out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware})
}
return out
}
func (h mountHost) Handler(method, pattern string) (http.Handler, bool) {
return h.r.Handler(method, pattern)
}
func (mountHost) MountStream(string, string, http.Handler, ...string) error { return nil }
func (mountHost) MountJSON(string, string, http.Handler) error { return nil }
func (h mountHost) Owner(method, path string) (string, bool) {
for _, ri := range h.r.Routes() {
if ri.Method == method && ri.Pattern == path {
return ri.PluginID, true
}
}
return "", false
}
// journalApp boots the translate and journal plugins on a config without a
// database.
func journalApp(t *testing.T) (*backpack.App, []party.Plugin) {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-mcp\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
if err != nil {
t.Fatal(err)
}
return app, plugins
}
// TestJournalMCP (D-15, D-16, SC-2) extends TestJournalMCPSmoke: the
// declaration compiles through pier's own catalog against the routes the
// plugin registers (prefix journal_, the backend guard, path and query
// tags matching the patterns); every input field is a key the route reads
// (path segment, list query key or create/update body key); the draft
// prompt renders with and without its optional tone. The dispatch marker
// on the optional-principal list and show routes is pinned by
// controllers/api TestOptionalBackendPrincipalDispatch. Runs under -short.
func TestJournalMCP(t *testing.T) {
decl := (&journal.Plugin{}).MCP()
t.Run("compiles through pier against the plugin's routes", func(t *testing.T) {
app, plugins := journalApp(t)
router, err := surf.BuildRouter(app, plugins)
if err != nil {
t.Fatal(err)
}
if err := app.Config.Set(pier.EnabledKey, true); err != nil {
t.Fatal(err)
}
if err := pier.Mount(mountHost{r: router}, app, plugins); err != nil {
t.Fatalf("pier.Mount: %v", err)
}
p, ok := app.Lookup[*pier.Pier]()
if !ok {
t.Fatal("pier was not mounted")
}
want := []string{"journal_create_post", "journal_delete_post", "journal_list_categories", "journal_list_posts", "journal_list_tags", "journal_show_post", "journal_update_post"}
if got := p.ToolNames(); !slices.Equal(got, want) {
t.Fatalf("compiled tools %v, want %v", got, want)
}
})
t.Run("input fields are keys the routes read", func(t *testing.T) {
for _, tool := range decl.Tools {
rt := reflect.TypeOf(tool.Input)
for rt.Kind() == reflect.Pointer {
rt = rt.Elem()
}
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
key, _, _ := strings.Cut(f.Tag.Get("json"), ",")
switch {
case f.Tag.Get("path") != "":
if !strings.Contains(tool.Path, "{"+f.Tag.Get("path")+"}") {
t.Errorf("%s.%s: path tag %q not in %s", tool.Name, f.Name, f.Tag.Get("path"), tool.Path)
}
case f.Tag.Get("query") != "":
if tool.Method != http.MethodGet || !slices.Contains(postQueryKeys, f.Tag.Get("query")) {
t.Errorf("%s.%s: query key %q is not one the list reads", tool.Name, f.Name, f.Tag.Get("query"))
}
default:
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(postBodyKeys, key) {
t.Errorf("%s.%s: body key %q is not one %s %s reads", tool.Name, f.Name, key, tool.Method, tool.Path)
}
}
}
}
})
t.Run("draft prompt with and without tone", func(t *testing.T) {
prompt := decl.Prompts[0]
with, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release", "tone": "playful"})
if err != nil || len(with) != 1 || !strings.Contains(with[0].Text, "Spring release") || !strings.Contains(with[0].Text, "playful") {
t.Fatalf("with tone = %+v, %v", with, err)
}
without, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release"})
if err != nil || len(without) != 1 || !strings.Contains(without[0].Text, "Spring release") || strings.Contains(without[0].Text, "playful") {
t.Fatalf("without tone = %+v, %v", without, err)
}
})
}