package quizzes import ( "net/http" "os" "path/filepath" "reflect" "slices" "strings" "testing" "unicode/utf8" _ "git.golem15.com/golem15/sm-user-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" ) // mountHost adapts a built surf router to pier.Host for a compile-only // mount: pier compiles every declared tool against the real route table. 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 } // mcpTestApp boots golem15.user and golem15.quizzes on a config without a // database. func mcpTestApp(t *testing.T) (*backpack.App, []party.Plugin) { t.Helper() dir := t.TempDir() files := map[string]string{ "app.yaml": "name: quizzes-mcp\nlocale: en\nfallback_locale: en\nurl: https://quizzes.example.test\n", "http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n", } for name, body := range files { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{ "SUMMER_ENV=testing", "SUMMER_GOLEM15__USER__JWT__SECRET=quizzes-test-only-user-jwt-secret-with-32", "SUMMER_ADMIN__JWT__SECRET=quizzes-test-only-admin-jwt-secret-with-32", }}) if err != nil { t.Fatal(err) } app := backpack.New(cfg) plugins, err := party.Activate(app, []string{"golem15.user", "golem15.quizzes"}) if err != nil { t.Fatalf("Activate: %v", err) } return app, plugins } // pinnedTools is the sorted tool list of golem15.user plus this plugin. var pinnedTools = []string{ "quizzes_create_question", "quizzes_create_quiz", "quizzes_delete_question", "quizzes_delete_quiz", "quizzes_import_quiz", "quizzes_list_quizzes", "quizzes_publish", "quizzes_quiz_stats", "quizzes_reorder_questions", "quizzes_review_confirm", "quizzes_review_confirm_bulk", "quizzes_review_list", "quizzes_review_regrade", "quizzes_review_set_score", "quizzes_review_show", "quizzes_show_quiz", "quizzes_unpublish", "quizzes_update_question", "quizzes_update_quiz", "user_me", } // TestQuizzesMCP (D-13, D-14) mounts pier against golem15.user and // golem15.quizzes and pins the compiled tools; every quiz tool lists the // quizzes permission and says so, GET and DELETE tools carry no body, and // only import_quiz can carry is_published. Runs under -short. func TestQuizzesMCP(t *testing.T) { tools := (&Plugin{}).MCP().Tools t.Run("compiles", func(t *testing.T) { app, plugins := mcpTestApp(t) router, err := surf.BuildRouter(app, plugins) if err != nil { t.Fatalf("BuildRouter: %v", 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") } if got := p.ToolNames(); !slices.Equal(got, pinnedTools) { t.Fatalf("compiled tools %v, want %v", got, pinnedTools) } }) t.Run("permissions", func(t *testing.T) { for _, tool := range tools { if !slices.Contains(tool.Permissions, permQuizzes) { t.Errorf("%s: Permissions %v lack %s", tool.Name, tool.Permissions, permQuizzes) } if !strings.Contains(tool.Description, "Needs scope") || !strings.Contains(tool.Description, "golem15.quizzes.access_quizzes") { t.Errorf("%s: description does not name its scope and permission: %q", tool.Name, tool.Description) } } }) t.Run("inputs", func(t *testing.T) { for _, tool := range 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) 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") != "": default: if tool.Method == http.MethodGet || tool.Method == http.MethodDelete { t.Errorf("%s.%s: a %s tool carries a body field", tool.Name, f.Name, tool.Method) } } } key, found := jsonKeyIn(rt, "is_published") if found != (tool.Name == "import_quiz") { t.Errorf("%s: has a field named %s = %v, want %v", tool.Name, key, found, tool.Name == "import_quiz") } } }) t.Run("review annotations", func(t *testing.T) { destructive := map[string]bool{"review_regrade": true} idempotent := map[string]bool{"review_confirm": true, "review_set_score": true, "review_confirm_bulk": true} n := 0 for _, tool := range tools { if !strings.HasPrefix(tool.Name, "review_") { continue } n++ if !strings.HasPrefix(tool.Path, ReviewPrefix) { t.Errorf("%s: path %s is not under %s", tool.Name, tool.Path, ReviewPrefix) } if got := tool.Destructive != nil && *tool.Destructive; got != destructive[tool.Name] { t.Errorf("%s: Destructive = %v, want %v", tool.Name, got, destructive[tool.Name]) } if got := tool.Idempotent != nil && *tool.Idempotent; got != idempotent[tool.Name] { t.Errorf("%s: Idempotent = %v, want %v", tool.Name, got, idempotent[tool.Name]) } suffix := needsWrite if tool.Method == http.MethodGet { suffix = needsRead } if !strings.HasSuffix(tool.Description, suffix) { t.Errorf("%s: description does not end with %q", tool.Name, suffix) } } if n != 6 { t.Errorf("%d review tools, want 6", n) } }) t.Run("no vendor names", func(t *testing.T) { for _, tool := range tools { // The golem15 namespace of the permission is not the bridge. text := strings.ReplaceAll(strings.ToLower(tool.Name+" "+tool.Title+" "+tool.Description+" "+tool.Path), "golem15.", "") for _, word := range []string{" ai ", "ai.", "openai", "anthropic", "claude", "gpt", "gemini", "llm", "language model", "bridge", "golem"} { if strings.Contains(" "+text+" ", word) { t.Errorf("%s mentions %q", tool.Name, word) } } } }) t.Run("descriptions", func(t *testing.T) { for _, tool := range tools { if n := utf8.RuneCountInString(tool.Description); n > pier.MaxDescriptionRunes { t.Errorf("%s: description has %d runes, more than %d", tool.Name, n, pier.MaxDescriptionRunes) } } }) } // jsonKeyIn reports whether rt, or a struct it contains (through pointers, // slices and nested structs), has a field with the given JSON key. func jsonKeyIn(rt reflect.Type, key string) (string, bool) { seen := map[reflect.Type]bool{} var walk func(reflect.Type) bool walk = func(t reflect.Type) bool { for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array { t = t.Elem() } if t.Kind() != reflect.Struct || seen[t] { return false } seen[t] = true for i := 0; i < t.NumField(); i++ { f := t.Field(i) name, _, _ := strings.Cut(f.Tag.Get("json"), ",") if name == key || (name == "" && strings.EqualFold(f.Name, strings.ReplaceAll(key, "_", ""))) { return true } if walk(f.Type) { return true } } return false } return key, walk(rt) }