Files
sm-quizzes-plugin/manage_api_postgres_test.go
Jakub Zych 4a60eda425 feat: create, publish, import and report on quizzes over MCP
- quizzes_create_quiz, update_quiz, delete_quiz (confirm repeats the
  slug), publish, unpublish, import_quiz (the existing import route) and
  quiz_stats on new manage routes
- stats close expired open test attempts first, then report per student
  over completed whole-quiz attempts and per question over every
  completed attempt
- input is checked before the database; TestManageAPIAgainstDatabase is
  written and skipped under -short
2026-11-09 14:30:31 +01:00

203 lines
9.1 KiB
Go

package quizzes_test
import (
"fmt"
"net/http"
"os"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/surf"
)
// TestManageAPIAgainstDatabase covers the owner manage routes the MCP quiz
// tools bind (D-13..D-16): access, quiz CRUD, publish, show with
// solutions, stats with the lazy close of expired test attempts, delete
// with confirm and the import route. Skipped under -short.
func TestManageAPIAgainstDatabase(t *testing.T) {
app, plugins, gdb := migrated(t)
h, err := surf.Assemble(app, plugins)
must(t, err)
c := &client{t: t, h: h}
const prefix = "/api/v1/quizzes"
editor, _ := newUser(t, gdb, "ala@example.com")
stranger, _ := newUser(t, gdb, "ola@example.com")
var groupID uint
must(t, gdb.Raw(`INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
VALUES ('Editors', 'editors', '{"golem15.quizzes.access_quizzes":1}', NOW(), NOW()) RETURNING id`).Scan(&groupID).Error)
must(t, gdb.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, editor.ID, groupID).Error)
token := apiToken(t, gdb, editor.ID, "read", "write")
readOnly := apiToken(t, gdb, editor.ID, "read")
t.Run("access", func(t *testing.T) {
for name, tc := range map[string]struct {
method, path, token string
want int
}{
"no token": {"GET", prefix, "", http.StatusUnauthorized},
"read token on a write": {"POST", prefix, readOnly, http.StatusForbidden},
"user without permission": {"GET", prefix, apiToken(t, gdb, stranger.ID, "read", "write"), http.StatusForbidden},
} {
if code, _ := c.do(tc.method, tc.path, tc.token, `{"title":"X"}`); code != tc.want {
t.Errorf("%s: %s %s = %d, want %d", name, tc.method, tc.path, code, tc.want)
}
}
if code, _ := c.do("GET", prefix, readOnly, ""); code != http.StatusOK {
t.Errorf("read token on a read = %d, want 200", code)
}
})
t.Run("create, update, publish and list", func(t *testing.T) {
out := data(c.ok(http.StatusCreated, "POST", prefix, token, `{"title":"Pojęcia bezpieczeństwa","context":"BM2","mode":"test","max_attempts":2}`))
if out["slug"] != "pojecia-bezpieczenstwa" || out["is_published"] != false || out["mode"] != "test" || out["max_attempts"] != float64(2) || out["passing_score"] != float64(50) {
t.Fatalf("created = %v", out)
}
if code, _ := c.do("POST", prefix, token, `{"title":"Inny","slug":"pojecia-bezpieczenstwa"}`); code != http.StatusConflict {
t.Errorf("taken slug = %d, want 409", code)
}
out = data(c.ok(http.StatusOK, "PATCH", prefix+"/pojecia-bezpieczenstwa", token, `{"max_attempts":0,"description":"Opis"}`))
if out["max_attempts"] != nil || out["description"] != "Opis" || out["mode"] != "test" {
t.Errorf("update clearing max_attempts = %v", out)
}
out = data(c.ok(http.StatusOK, "PATCH", prefix+"/pojecia-bezpieczenstwa", token, `{"description":""}`))
if out["description"] != nil {
t.Errorf("empty description = %v, want null", out["description"])
}
if code, _ := c.do("PATCH", prefix+"/pojecia-bezpieczenstwa", token, `{"is_published":true}`); code != http.StatusUnprocessableEntity {
t.Errorf("update with is_published = %d, want 422", code)
}
for range 2 {
if out := data(c.ok(http.StatusOK, "POST", prefix+"/pojecia-bezpieczenstwa/publish", token, "")); out["is_published"] != true {
t.Errorf("publish = %v", out)
}
}
data(c.ok(http.StatusOK, "POST", prefix+"/pojecia-bezpieczenstwa/unpublish", token, ""))
c.ok(http.StatusCreated, "POST", prefix, token, `{"title":"Drugi"}`)
rows := list(c.ok(http.StatusOK, "GET", prefix, token, ""))
if len(rows) != 2 || rows[0].(map[string]any)["context"] != "BM2" || rows[0].(map[string]any)["is_published"] != false {
t.Errorf("list = %v, want two unpublished quizzes, BM2 first", rows)
}
if rows := list(c.ok(http.StatusOK, "GET", prefix+"?context=BM2", token, "")); len(rows) != 1 {
t.Errorf("context filter = %v", rows)
}
if code, _ := c.do("GET", prefix+"/nie-ma", token, ""); code != http.StatusNotFound {
t.Errorf("missing quiz = %d, want 404", code)
}
})
f := seedQuiz(t, gdb, "statystyka", func(q *models.Quiz) { q.PassingScore = 60 })
t.Run("show includes the solutions", func(t *testing.T) {
out := data(c.ok(http.StatusOK, "GET", prefix+"/statystyka", token, ""))
qs, _ := out["questions"].([]any)
if len(qs) != 3 || out["question_count"] != float64(3) {
t.Fatalf("show = %v", out)
}
first := qs[0].(map[string]any)
if first["explanation"] != "Warszawa od 1596." {
t.Errorf("question explanation = %v", first["explanation"])
}
answers := first["answers"].([]any)
if answers[0].(map[string]any)["is_correct"] != true || answers[1].(map[string]any)["explanation"] != "Dawna stolica." {
t.Errorf("answers = %v", answers)
}
if qs[2].(map[string]any)["answer_text"] != "Zdolność społeczeństwa do..." {
t.Errorf("self_check answer_text = %v", qs[2])
}
})
t.Run("stats", func(t *testing.T) {
finish := func(email string, answers ...string) {
t.Helper()
_, jwt := newUser(t, gdb, email)
att := data(c.ok(http.StatusCreated, "POST", apiPrefix+"/quizzes/statystyka/attempts", jwt, ""))
path := fmt.Sprintf("%s/attempts/%d", apiPrefix, int(att["id"].(float64)))
for _, body := range answers {
c.ok(http.StatusOK, "POST", path+"/answers", jwt, body)
}
c.ok(http.StatusOK, "POST", path+"/finish", jwt, "")
}
right := fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleRight.ID)
wrong := fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleNo.ID)
multiWrong := fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d,%d]}`, f.multi.ID, f.multiA.ID, f.multiC.ID)
known := fmt.Sprintf(`{"question_id":%d,"known":true}`, f.selfQ.ID)
finish("s1@example.com", right, multiWrong, known)
finish("s2@example.com", wrong)
finish("s3@example.com", right)
out := data(c.ok(http.StatusOK, "GET", prefix+"/statystyka/stats", token, ""))
students := out["students"].([]any)
if len(students) != 3 {
t.Fatalf("students = %v", students)
}
for _, s := range students {
s := s.(map[string]any)
if s["attempts"] != float64(1) || s["best_score_percent"] == nil || s["last_completed_at"] == nil {
t.Errorf("student row = %v", s)
}
}
qs := out["questions"].([]any)
single := qs[0].(map[string]any)
if single["answered"] != float64(3) || single["correct"] != float64(2) || single["correct_percent"] != 66.7 {
t.Errorf("single question = %v", single)
}
if w, _ := single["most_chosen_wrong"].(map[string]any); w == nil || w["answer_id"] != float64(f.singleNo.ID) || w["count"] != float64(1) {
t.Errorf("single most_chosen_wrong = %v", single["most_chosen_wrong"])
}
if w, _ := qs[1].(map[string]any)["most_chosen_wrong"].(map[string]any); w == nil || w["answer_id"] != float64(f.multiC.ID) {
t.Errorf("multiple most_chosen_wrong = %v", qs[1])
}
if self := qs[2].(map[string]any); self["most_chosen_wrong"] != nil || self["correct"] != float64(1) {
t.Errorf("self_check stats = %v", self)
}
})
t.Run("stats closes an expired test attempt", func(t *testing.T) {
tq := seedQuiz(t, gdb, "test-wygasly", func(q *models.Quiz) { q.Mode = models.ModeTest; q.TimeLimitSeconds = intPtr(60) })
student, _ := newUser(t, gdb, "late@example.com")
started := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
attempt := models.Attempt{QuizID: tq.quiz.ID, UserID: student.ID, StartedAt: started, MaxPoints: 4}
must(t, gdb.Create(&attempt).Error)
out := data(c.ok(http.StatusOK, "GET", prefix+"/test-wygasly/stats", token, ""))
var closed models.Attempt
must(t, gdb.Take(&closed, attempt.ID).Error)
if closed.CompletedAt == nil || !closed.CompletedAt.Equal(started.Add(time.Minute)) {
t.Errorf("expired attempt completed_at = %v, want the deadline %v", closed.CompletedAt, started.Add(time.Minute))
}
if students := out["students"].([]any); len(students) != 1 || students[0].(map[string]any)["passed"] != false {
t.Errorf("students after lazy close = %v", students)
}
})
t.Run("delete", func(t *testing.T) {
code, body := c.do("DELETE", prefix+"/statystyka?confirm=inny", token, "")
if code != http.StatusUnprocessableEntity || !strings.Contains(fmt.Sprint(body["message"]), "confirm") {
t.Errorf("wrong confirm = %d %v", code, body)
}
out := data(c.ok(http.StatusOK, "DELETE", prefix+"/statystyka?confirm=statystyka", token, ""))
if out["slug"] != "statystyka" || out["questions"] != float64(3) || out["attempts"] != float64(3) {
t.Errorf("delete = %v", out)
}
var n int64
must(t, gdb.Model(&models.Attempt{}).Where("quiz_id = ?", f.quiz.ID).Count(&n).Error)
if n != 0 {
t.Errorf("attempts after delete = %d", n)
}
if code, _ := c.do("GET", prefix+"/statystyka", token, ""); code != http.StatusNotFound {
t.Errorf("deleted quiz = %d, want 404", code)
}
})
t.Run("import still works", func(t *testing.T) {
raw, err := os.ReadFile("testdata/quiz.json")
must(t, err)
c.ok(http.StatusOK, "POST", prefix+"/import", token, string(raw))
if out := data(c.ok(http.StatusOK, "POST", prefix+"/import?replace=true", token, string(raw))); out["created"] != false {
t.Errorf("import with replace=true = %v", out)
}
})
}