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) } }) }