package quizzes_test import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "git.golem15.com/golem15/sm-quizzes-plugin/classes" "git.golem15.com/golem15/sm-quizzes-plugin/controllers/api" "git.golem15.com/golem15/sm-quizzes-plugin/events" "git.golem15.com/golem15/sm-quizzes-plugin/graders" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/surf" "gorm.io/gorm" ) // TestReviewsAgainstDatabase covers the owner review routes the // quizzes_review_* MCP tools bind (D-10..D-14): the permission matrix, the // queue and its filters, the detail, confirm, set_score with a comment the // student sees, bulk confirm with the AttemptReviewed event, regrade // through the quiz's grader, and the recalculation of finished attempts. // Skipped under -short; it runs in the verification phase. func TestReviewsAgainstDatabase(t *testing.T) { if testing.Short() { t.Skip("Postgres test; runs in the verification phase") } app, plugins, gdb := migrated(t) reg, ok := app.Lookup[*graders.Registry]() if !ok || reg == nil { t.Fatal("golem15.quizzes published no grader registry") } grader := &fakeGrader{} must(t, reg.Add("acme.test", grader)) if app.Events == nil { t.Fatal("the app has no event bus") } var ( evMu sync.Mutex reviewed []*events.AnswerReviewedEvent done []*events.AttemptReviewedEvent ) app.Events.Listen[*events.AnswerReviewedEvent]("acme.test", func(_ context.Context, e *events.AnswerReviewedEvent) error { evMu.Lock() defer evMu.Unlock() reviewed = append(reviewed, e) return nil }) app.Events.Listen[*events.AttemptReviewedEvent]("acme.test", func(_ context.Context, e *events.AttemptReviewedEvent) error { evMu.Lock() defer evMu.Unlock() done = append(done, e) return nil }) takeEvents := func() ([]*events.AnswerReviewedEvent, []*events.AttemptReviewedEvent) { evMu.Lock() defer evMu.Unlock() a, b := reviewed, done reviewed, done = nil, nil return a, b } h, err := surf.Assemble(app, plugins) must(t, err) c := &client{t: t, h: h} const prefix = "/api/v1/quiz-reviews" owner, _ := newUser(t, gdb, "owner@example.com") stranger, _ := newUser(t, gdb, "stranger@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 (?, ?)`, owner.ID, groupID).Error) token := apiToken(t, gdb, owner.ID, "read", "write") readOnly := apiToken(t, gdb, owner.ID, "read") // seed writes a published quiz with two open questions worth 2 points. seed := func(t *testing.T, slug, code, mode string) (models.Quiz, models.Question, models.Question) { t.Helper() quiz := models.Quiz{Title: "Eseje " + slug, Slug: slug, IsPublished: true, PassingScore: 50, Grader: code, Mode: mode} must(t, gdb.Create(&quiz).Error) q1 := models.Question{QuizID: quiz.ID, Text: "Wyjaśnij odstraszanie.", Type: models.TypeOpen, AnswerText: strPtr("MODEL"), Rubric: strPtr("R-RUBRIC"), Points: 2, SortOrder: 1} q2 := models.Question{QuizID: quiz.ID, Text: "Opisz dylemat bezpieczeństwa.", Type: models.TypeOpen, AnswerText: strPtr("MODEL 2"), Rubric: strPtr("R-RUBRIC 2"), Points: 2, SortOrder: 2} must(t, gdb.Create(&q1).Error) must(t, gdb.Create(&q2).Error) return quiz, q1, q2 } // attempt is one student's attempt with an answer to each question, // finished when finish is set. It returns the user, the student JWT and // the attempt id. attempt := func(t *testing.T, email, slug string, finish bool, questions ...models.Question) (uint, string, uint) { t.Helper() u, jwt := newUser(t, gdb, email) att := data(c.ok(http.StatusCreated, "POST", apiPrefix+"/quizzes/"+slug+"/attempts", jwt, "")) id := uint(att["id"].(float64)) for i, q := range questions { c.ok(http.StatusOK, "POST", fmt.Sprintf("%s/attempts/%d/answers", apiPrefix, id), jwt, fmt.Sprintf(`{"question_id":%d,"text":"Odpowiedź %d studenta %s."}`, q.ID, i+1, email)) } if finish { c.ok(http.StatusOK, "POST", fmt.Sprintf("%s/attempts/%d/finish", apiPrefix, id), jwt, "") } return u.ID, jwt, id } answerID := func(t *testing.T, attemptID, questionID uint) uint { t.Helper() var row models.AttemptAnswer must(t, gdb.Where("attempt_id = ? AND question_id = ?", attemptID, questionID).Take(&row).Error) return row.ID } // grade stores a grader result directly, as the grading job would. grade := func(t *testing.T, id uint, points float64) { t.Helper() must(t, gdb.Exec(`UPDATE golem15_quizzes_attempt_answers SET grading_status = 'graded', grader = 'fake', grader_points = ?, points = ?, grader_feedback = 'Dobrze.', graded_at = NOW(), updated_at = NOW() WHERE id = ?`, points, points, id).Error) } fail := func(t *testing.T, id uint) { t.Helper() must(t, gdb.Exec(`UPDATE golem15_quizzes_attempt_answers SET grading_status = 'failed', grader = 'fake', grading_attempts = 3, grading_error = 'grader timed out', updated_at = NOW() WHERE id = ?`, id).Error) } attemptRow := func(t *testing.T, id uint) models.Attempt { t.Helper() var a models.Attempt must(t, gdb.Where("id = ?", id).Take(&a).Error) return a } statusOf := func(t *testing.T, id uint) string { t.Helper() var row models.AttemptAnswer must(t, gdb.Where("id = ?", id).Take(&row).Error) if row.GradingStatus == nil { return "" } return *row.GradingStatus } meta := func(m map[string]any) map[string]any { d, _ := m["meta"].(map[string]any); return d } quiz, q1, q2 := seed(t, "eseje", "fake", models.ModeLearning) u1, jwt1, a1 := attempt(t, "s1@example.com", quiz.Slug, true, q1, q2) u2, _, a2 := attempt(t, "s2@example.com", quiz.Slug, true, q1, q2) a1q1, a1q2 := answerID(t, a1, q1.ID), answerID(t, a1, q2.ID) a2q1, a2q2 := answerID(t, a2, q1.ID), answerID(t, a2, q2.ID) grade(t, a1q1, 1.5) fail(t, a2q2) t.Run("permissions", func(t *testing.T) { for name, tc := range map[string]struct { method, path, token string want int }{ "no token on the list": {"GET", prefix, "", http.StatusUnauthorized}, "no token on confirm": {"POST", fmt.Sprintf("%s/%d/confirm", prefix, a1q1), "", http.StatusUnauthorized}, "user without permission": {"GET", prefix, apiToken(t, gdb, stranger.ID, "read", "write"), http.StatusForbidden}, "no permission on regrade": {"POST", prefix + "/regrade", apiToken(t, gdb, stranger.ID, "read", "write"), http.StatusForbidden}, "read token on confirm": {"POST", fmt.Sprintf("%s/%d/confirm", prefix, a1q1), readOnly, http.StatusForbidden}, "read token on set_score": {"POST", fmt.Sprintf("%s/%d/score", prefix, a1q1), readOnly, http.StatusForbidden}, "read token on bulk confirm": {"POST", prefix + "/confirm", readOnly, http.StatusForbidden}, "read token on regrade": {"POST", prefix + "/regrade", readOnly, http.StatusForbidden}, "student session JWT": {"GET", prefix, jwt1, http.StatusUnauthorized}, } { body := "" if tc.method == "POST" { body = `{"points":1,"quiz":"eseje"}` } if code, _ := c.do(tc.method, tc.path, tc.token, body); 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 the list = %d, want 200", code) } if statusOf(t, a1q1) != models.GradingGraded { t.Error("a refused confirm changed the answer") } }) t.Run("queue", func(t *testing.T) { out := c.ok(http.StatusOK, "GET", prefix, token, "") rows := list(out) if len(rows) != 4 || meta(out)["total"] != float64(4) { t.Fatalf("open queue = %d rows, meta %v; want 4", len(rows), meta(out)) } first := rows[0].(map[string]any) for _, key := range []string{"answer_id", "status", "quiz", "question", "attempt", "student", "text", "text_truncated", "grader_points", "points", "graded_at", "reviewed_at", "updated_at"} { if _, ok := first[key]; !ok { t.Errorf("row lacks %q: %v", key, first) } } if strings.Contains(fmt.Sprint(rows), "R-RUBRIC") || strings.Contains(fmt.Sprint(rows), "MODEL") { t.Error("a queue row carries the rubric or the model answer") } count := func(query string) int { t.Helper() return len(list(c.ok(http.StatusOK, "GET", prefix+query, token, ""))) } for query, want := range map[string]int{ "?status=pending": 2, "?status=graded": 1, "?status=failed": 1, "?status=reviewed": 0, "?status=all": 4, "?quiz=eseje": 4, "?quiz=nie-ma": 0, "?user_id=" + fmt.Sprint(u1): 2, "?attempt_id=" + fmt.Sprint(a2): 2, "?user_id=" + fmt.Sprint(u2) + "&status=failed": 1, } { if got := count(query); got != want { t.Errorf("%s = %d rows, want %d", query, got, want) } } failed := list(c.ok(http.StatusOK, "GET", prefix+"?status=failed", token, "")) if row := failed[0].(map[string]any); row["status"] != "failed" || row["answer_id"] != float64(a2q2) { t.Errorf("failed row = %v", row) } student := first["student"].(map[string]any) if !strings.HasSuffix(student["email"].(string), "@example.com") { t.Errorf("student = %v", student) } page := c.ok(http.StatusOK, "GET", prefix+"?per_page=1&page=2", token, "") if len(list(page)) != 1 || meta(page)["last_page"] != float64(4) || meta(page)["current_page"] != float64(2) { t.Errorf("page 2 of 4 = %v", meta(page)) } if code, _ := c.do("GET", prefix+"?status=done", token, ""); code != http.StatusUnprocessableEntity { t.Errorf("status=done = %d, want 422", code) } }) t.Run("show", func(t *testing.T) { out := data(c.ok(http.StatusOK, "GET", fmt.Sprintf("%s/%d", prefix, a2q2), token, "")) q := out["question"].(map[string]any) if q["rubric"] != "R-RUBRIC 2" || q["answer_text"] != "MODEL 2" || q["max_length"] != float64(models.DefaultMaxLength) { t.Errorf("question = %v", q) } if out["grading_error"] != "grader timed out" || out["grading_attempts"] != float64(3) || out["status"] != "failed" { t.Errorf("failed detail = %v", out) } if !strings.HasPrefix(out["text"].(string), "Odpowiedź 2") { t.Errorf("text = %v", out["text"]) } must(t, gdb.Exec(`UPDATE golem15_quizzes_attempt_answers SET grading_status = NULL WHERE id = ?`, a2q1).Error) if code, _ := c.do("GET", fmt.Sprintf("%s/%d", prefix, a2q1), token, ""); code != http.StatusNotFound { t.Errorf("an answer that is not open = %d, want 404", code) } must(t, gdb.Exec(`UPDATE golem15_quizzes_attempt_answers SET grading_status = 'pending' WHERE id = ?`, a2q1).Error) if code, _ := c.do("GET", prefix+"/999999", token, ""); code != http.StatusNotFound { t.Errorf("missing answer = %d, want 404", code) } }) t.Run("confirm", func(t *testing.T) { takeEvents() out := data(c.ok(http.StatusOK, "POST", fmt.Sprintf("%s/%d/confirm", prefix, a1q1), token, "")) by := out["reviewed_by"].(map[string]any) if out["status"] != "reviewed" || out["points"] != float64(1.5) || out["reviewed_at"] == nil || by["user_id"] != float64(owner.ID) || by["admin_id"] != nil { t.Errorf("confirmed = %v", out) } again := data(c.ok(http.StatusOK, "POST", fmt.Sprintf("%s/%d/confirm", prefix, a1q1), token, "")) if again["reviewed_at"] != out["reviewed_at"] || again["points"] != float64(1.5) { t.Errorf("repeated confirm changed the answer: %v", again) } answers, attempts := takeEvents() if len(answers) != 1 || answers[0].AnswerID != a1q1 || answers[0].Points != 1.5 || answers[0].ReviewerUserID == nil || *answers[0].ReviewerUserID != owner.ID { t.Errorf("AnswerReviewed events = %v, want one for the first confirm", answers) } if len(attempts) != 0 { t.Errorf("AttemptReviewed fired with an open answer left: %v", attempts) } code, body := c.do("POST", fmt.Sprintf("%s/%d/confirm", prefix, a1q2), token, "") if code != http.StatusConflict || body["code"] != "no_grader_score" { t.Errorf("confirm of a pending answer = %d %v, want 409 no_grader_score", code, body) } code, body = c.do("POST", fmt.Sprintf("%s/%d/confirm", prefix, a2q2), token, "") if code != http.StatusConflict || body["code"] != "no_grader_score" { t.Errorf("confirm of a failed answer = %d %v, want 409 no_grader_score", code, body) } }) t.Run("set_score and recalculation", func(t *testing.T) { path := fmt.Sprintf("%s/%d/score", prefix, a1q2) for _, body := range []string{`{"points":1.25}`, `{"points":2.5}`, `{"points":-0.5}`, `{}`, `{"points":1,"comment":"` + strings.Repeat("x", 2001) + `"}`} { if code, _ := c.do("POST", path, token, body); code != http.StatusUnprocessableEntity { t.Errorf("set_score %s = %d, want 422", body, code) } } if statusOf(t, a1q2) != models.GradingPending { t.Fatal("a refused set_score changed the answer") } takeEvents() out := data(c.ok(http.StatusOK, "POST", path, token, `{"points":2,"comment":" Bardzo dobrze, pełna odpowiedź. "}`)) if out["status"] != "reviewed" || out["points"] != float64(2) || out["is_correct"] != true || out["review_comment"] != "Bardzo dobrze, pełna odpowiedź." { t.Errorf("set_score = %v", out) } // Attempt 1: 1.5 (confirmed) + 2 (set) of 4 points. if a := attemptRow(t, a1); a.ScorePoints != 3.5 || a.ScorePercent == nil || *a.ScorePercent != 87.5 { t.Errorf("attempt 1 score = %v / %v, want 3.5 / 87.5", a.ScorePoints, a.ScorePercent) } answers, attempts := takeEvents() if len(answers) != 1 || answers[0].Comment == nil || answers[0].GraderPoints != nil { t.Errorf("AnswerReviewed = %v", answers) } if len(attempts) != 1 || attempts[0].AttemptID != a1 || attempts[0].ScorePoints != 3.5 || !attempts[0].Passed || attempts[0].MaxPoints != 4 { t.Errorf("AttemptReviewed = %v, want attempt 1 passed with 3.5 of 4", attempts) } res := data(c.ok(http.StatusOK, "GET", fmt.Sprintf("%s/attempts/%d", apiPrefix, a1), jwt1, "")) var comment any for _, q := range res["questions"].([]any) { if q := q.(map[string]any); q["id"] == float64(q2.ID) { comment = q["review_comment"] } } if comment != "Bardzo dobrze, pełna odpowiedź." { t.Errorf("the student sees review_comment %v", comment) } if strings.Contains(fmt.Sprint(res), "R-RUBRIC") { t.Error("the student's result carries the rubric") } // A lower score moves the attempt and the student's best score. c.ok(http.StatusOK, "POST", path, token, `{"points":0.5}`) if a := attemptRow(t, a1); a.ScorePercent == nil || *a.ScorePercent != 50 { t.Errorf("attempt 1 after a lower score = %v, want 50", a.ScorePercent) } var best any for _, row := range list(c.ok(http.StatusOK, "GET", apiPrefix+"/quizzes", jwt1, "")) { if row := row.(map[string]any); row["slug"] == quiz.Slug { best = row["best_score_percent"] } } if best != float64(50) { t.Errorf("best_score_percent = %v, want 50", best) } if out := data(c.ok(http.StatusOK, "POST", path, token, `{"points":0.5}`)); out["review_comment"] != nil { t.Errorf("a set_score without a comment kept %v", out["review_comment"]) } code, body := c.do("POST", fmt.Sprintf("%s/%d/score", prefix, a2q2), token, `{"points":1}`) if code != http.StatusOK || data(body)["status"] != "reviewed" { t.Errorf("set_score on a failed answer = %d %v", code, body) } }) t.Run("bulk confirm", func(t *testing.T) { bq, b1, b2 := seed(t, "hurt", "fake", models.ModeLearning) _, _, x := attempt(t, "b1@example.com", bq.Slug, true, b1, b2) _, _, y := attempt(t, "b2@example.com", bq.Slug, true, b1, b2) _, _, z := attempt(t, "b3@example.com", bq.Slug, false, b1) grade(t, answerID(t, x, b1.ID), 2) grade(t, answerID(t, x, b2.ID), 1) grade(t, answerID(t, y, b1.ID), 0.5) grade(t, answerID(t, z, b1.ID), 2) yPending := answerID(t, y, b2.ID) takeEvents() for _, body := range []string{`{}`, `{"quiz":"hurt","attempt_id":1}`} { if code, _ := c.do("POST", prefix+"/confirm", token, body); code != http.StatusUnprocessableEntity { t.Errorf("bulk %s = %d, want 422", body, code) } } if code, _ := c.do("POST", prefix+"/confirm", token, `{"quiz":"nie-ma"}`); code != http.StatusNotFound { t.Errorf("bulk on a missing quiz = %d, want 404", code) } out := data(c.ok(http.StatusOK, "POST", prefix+"/confirm", token, fmt.Sprintf(`{"attempt_id":%d}`, x))) if out["confirmed"] != float64(2) { t.Errorf("bulk by attempt = %v, want 2", out) } answers, attempts := takeEvents() if len(answers) != 2 || len(attempts) != 1 || attempts[0].AttemptID != x || attempts[0].ScorePoints != 3 { t.Errorf("events after bulk by attempt = %d answers, %v", len(answers), attempts) } out = data(c.ok(http.StatusOK, "POST", prefix+"/confirm", token, `{"quiz":"hurt"}`)) if out["confirmed"] != float64(2) { t.Errorf("bulk by quiz = %v, want 2 (y's graded and z's)", out) } answers, attempts = takeEvents() if len(answers) != 2 || len(attempts) != 0 { t.Errorf("events after bulk by quiz = %d answers, %v attempts; y still has a pending answer and z is open", len(answers), attempts) } if statusOf(t, yPending) != models.GradingPending { t.Error("bulk confirm touched a pending answer") } if again := data(c.ok(http.StatusOK, "POST", prefix+"/confirm", token, `{"quiz":"hurt"}`)); again["confirmed"] != float64(0) { t.Errorf("second bulk = %v, want 0", again) } // z closes with every open answer reviewed: AttemptReviewed fires // at close. var zUser models.Attempt must(t, gdb.Where("id = ?", z).Take(&zUser).Error) svc := classes.ForApp(app, gdb) if _, err := svc.Finish(t.Context(), zUser.UserID, z); err != nil { t.Fatal(err) } if _, attempts := takeEvents(); len(attempts) != 1 || attempts[0].AttemptID != z { t.Errorf("AttemptReviewed at close = %v, want one for z", attempts) } }) t.Run("regrade", func(t *testing.T) { mq, m1, _ := seed(t, "reczne", "manual", models.ModeLearning) _, _, ma := attempt(t, "m1@example.com", mq.Slug, true, m1) code, body := c.do("POST", prefix+"/regrade", token, `{"quiz":"reczne"}`) if code != http.StatusConflict || body["code"] != "grader_manual" { t.Errorf("regrade of a manual quiz = %d %v, want 409 grader_manual", code, body) } code, body = c.do("POST", prefix+"/regrade", token, fmt.Sprintf(`{"answer_id":%d}`, answerID(t, ma, m1.ID))) if code != http.StatusConflict || body["code"] != "grader_manual" { t.Errorf("regrade of a manual answer = %d %v, want 409 grader_manual", code, body) } if code, _ := c.do("POST", prefix+"/regrade", token, `{"answer_id":1,"quiz":"eseje"}`); code != http.StatusUnprocessableEntity { t.Errorf("regrade with two keys = %d, want 422", code) } rq, r1, r2 := seed(t, "ponownie", "fake", models.ModeLearning) _, _, ra := attempt(t, "r1@example.com", rq.Slug, true, r1, r2) tq, t1, _ := seed(t, "test-otwarty", "fake", models.ModeTest) _, _, ta := attempt(t, "r2@example.com", tq.Slug, false, t1) graded, kept := answerID(t, ra, r1.ID), answerID(t, ra, r2.ID) grade(t, graded, 2) grade(t, kept, 2) c.ok(http.StatusOK, "POST", fmt.Sprintf("%s/%d/confirm", prefix, kept), token, "") if a := attemptRow(t, ra); a.ScorePoints != 4 { t.Fatalf("attempt before regrade = %v, want 4", a.ScorePoints) } code, body = c.do("POST", prefix+"/regrade", token, fmt.Sprintf(`{"answer_id":%d}`, kept)) if code != http.StatusConflict || body["code"] != "already_reviewed" { t.Errorf("regrade of a reviewed answer = %d %v, want 409 already_reviewed", code, body) } // The handler's Service hook swaps in a recording job queue. enq := &recordingEnqueuer{} hook := api.New(app) hook.Service = func(db *gorm.DB) *classes.Service { s := classes.ForApp(app, db) s.Jobs = enq return s } regrade := func(body string) (int, map[string]any) { req := httptest.NewRequest("POST", prefix+"/regrade", strings.NewReader(body)) req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: owner.ID})) rec := httptest.NewRecorder() hook.ReviewRegrade(rec, req) var out map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("regrade %s: %d %v\n%s", body, rec.Code, err, rec.Body.String()) } return rec.Code, out } var before models.AttemptAnswer must(t, gdb.Where("id = ?", graded).Take(&before).Error) code, out := regrade(fmt.Sprintf(`{"attempt_id":%d}`, ra)) if code != http.StatusOK || data(out)["regraded"] != float64(1) { t.Errorf("regrade by attempt = %d %v, want 1 (the reviewed answer is skipped)", code, out) } var after models.AttemptAnswer must(t, gdb.Where("id = ?", graded).Take(&after).Error) if after.GradingStatus == nil || *after.GradingStatus != models.GradingPending || after.GradingSeq != before.GradingSeq+1 || after.GraderPoints != nil || after.Points != 0 || after.GradingAttempts != 0 { t.Errorf("regraded row = %+v", after) } if statusOf(t, kept) != models.GradingReviewed { t.Error("regrade touched a reviewed answer") } if a := attemptRow(t, ra); a.ScorePoints != 2 { t.Errorf("attempt after regrade = %v, want 2 (only the reviewed answer counts)", a.ScorePoints) } if jobs := enq.take(); len(jobs) != 1 || jobs[0].AnswerID != graded || jobs[0].Seq != after.GradingSeq { t.Errorf("jobs = %v, want one for the regraded answer with its new seq", jobs) } code, out = regrade(`{"quiz":"test-otwarty"}`) if code != http.StatusOK || data(out)["regraded"] != float64(1) { t.Errorf("regrade of an open test = %d %v, want 1", code, out) } if jobs := enq.take(); len(jobs) != 0 { t.Errorf("jobs for an open test attempt = %v, want none (close enqueues them)", jobs) } _ = ta }) }