Files
sm-quizzes-plugin/mcp_test.go
Jakub Zych 692a647650 feat: set scores, confirm in bulk and regrade open answers over MCP
- SetScore (0..points in 0.5 steps, comment shown to the student),
  ConfirmBulk per quiz or attempt and Regrade through the quiz's grader
- close() announces an attempt reviewed while it was open
- quizzes_review_set_score, quizzes_review_confirm_bulk,
  quizzes_review_regrade; 409 already_reviewed and grader_manual
2026-11-09 18:06:02 +01:00

245 lines
7.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_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)
}