Files
sm-quizzes-plugin/mcp_test.go
Jakub Zych e615cc88db feat: list and show quizzes over MCP for their owner
- quizzes_list_quizzes and quizzes_show_quiz bound to GET /api/v1/quizzes
  and /api/v1/quizzes/{slug} behind user.api_token, the per-user
  golem15.quizzes.manage throttle, a scope and the access_quizzes permission
- the permission is grantable through pact.HasFrontendPermissions
- owner JSON shapes with solutions in manage_json.go, never used by a
  student route
2026-11-09 14:25:07 +01:00

185 lines
5.8 KiB
Go

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_list_quizzes",
"quizzes_show_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("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)
}