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
This commit is contained in:
202
manage_api_postgres_test.go
Normal file
202
manage_api_postgres_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user