- POST /_journal/api/v1/images/alt {dry_run, changes} sets alt on the
matching img of default or translated content through the tokenizer,
all or nothing (422), with a RecordSaved per changed post
- MCP journal_get_settings, journal_update_settings, journal_fix_headings
(manage_settings) and journal_set_image_alt (access_posts, destructive)
- journal:fix-headings help names its output and the refusal
104 lines
4.5 KiB
Go
104 lines
4.5 KiB
Go
package journal_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"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/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
)
|
|
|
|
// TestJournalMCPSmoke pins the Journal's MCP declaration (P-17, D-15, D-16):
|
|
// exactly the seven post, category and tag tools, the four settings and
|
|
// content-fix tools (33-02) and the draft_post prompt, each with Guard
|
|
// backend and golem15.journal.access_posts (manage_settings for the
|
|
// settings and fix_headings tools), delete and set_image_alt marked
|
|
// destructive, and every tool bound to a route the plugin registers (the
|
|
// same method and pattern in a router built from the booted plugins).
|
|
// Runs under -short (no database).
|
|
func TestJournalMCPSmoke(t *testing.T) {
|
|
var _ pact.HasMCP = (*journal.Plugin)(nil)
|
|
decl := (&journal.Plugin{}).MCP()
|
|
|
|
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post",
|
|
"get_settings", "update_settings", "fix_headings", "set_image_alt"}
|
|
// The settings and heading tools need manage_settings (33-02, D-10).
|
|
manageSettings := map[string]bool{"get_settings": true, "update_settings": true, "fix_headings": true}
|
|
destructive := map[string]bool{"delete_post": true, "set_image_alt": true}
|
|
var names []string
|
|
for _, tool := range decl.Tools {
|
|
names = append(names, tool.Name)
|
|
want := []string{"golem15.journal.access_posts"}
|
|
if manageSettings[tool.Name] {
|
|
want = []string{"golem15.journal.manage_settings"}
|
|
}
|
|
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, want) {
|
|
t.Errorf("tool %s guard %q permissions %v, want backend and %v", tool.Name, tool.Guard, tool.Permissions, want)
|
|
}
|
|
if tool.Title == "" || tool.Description == "" || tool.Input == nil {
|
|
t.Errorf("tool %s lacks a title, description or input", tool.Name)
|
|
}
|
|
if (tool.Destructive != nil && *tool.Destructive) != destructive[tool.Name] {
|
|
t.Errorf("tool %s destructive = %v", tool.Name, tool.Destructive)
|
|
}
|
|
}
|
|
if !slices.Equal(names, wantTools) {
|
|
t.Fatalf("tools %v, want %v", names, wantTools)
|
|
}
|
|
if len(decl.Prompts) != 1 || decl.Prompts[0].Name != "draft_post" || decl.Prompts[0].Guard != "backend" ||
|
|
!slices.Equal(decl.Prompts[0].Permissions, []string{"golem15.journal.access_posts"}) {
|
|
t.Fatalf("prompts %+v, want draft_post (backend, access_posts)", decl.Prompts)
|
|
}
|
|
prompt := decl.Prompts[0]
|
|
if len(prompt.Arguments) != 2 || prompt.Arguments[0].Name != "topic" || !prompt.Arguments[0].Required || prompt.Arguments[1].Name != "tone" || prompt.Arguments[1].Required {
|
|
t.Fatalf("draft_post arguments %+v, want topic (required) and tone", prompt.Arguments)
|
|
}
|
|
msgs, err := prompt.Render(context.Background(), map[string]string{"topic": "Release notes", "tone": "formal"})
|
|
if err != nil || len(msgs) != 1 || msgs[0].Role != "user" ||
|
|
!strings.Contains(msgs[0].Text, "Release notes") || !strings.Contains(msgs[0].Text, "formal") ||
|
|
!strings.Contains(msgs[0].Text, "journal_create_post") || !strings.Contains(msgs[0].Text, "published set to false") {
|
|
t.Fatalf("draft_post render = %+v, %v", msgs, err)
|
|
}
|
|
|
|
// Every tool's method and path is a route the plugin registers.
|
|
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)
|
|
}
|
|
rt, err := surf.BuildRouter(app, plugins)
|
|
if err != nil {
|
|
t.Fatalf("surf.BuildRouter: %v", err)
|
|
}
|
|
routes := map[string]string{}
|
|
for _, r := range rt.Routes() {
|
|
routes[r.Method+" "+r.Pattern] = r.PluginID
|
|
}
|
|
for _, tool := range decl.Tools {
|
|
if owner, ok := routes[tool.Method+" "+tool.Path]; !ok || owner != "golem15.journal" {
|
|
t.Errorf("tool %s targets %s %s, which golem15.journal does not register (owner %q)", tool.Name, tool.Method, tool.Path, owner)
|
|
}
|
|
}
|
|
}
|