diff --git a/reviews_postgres_test.go b/reviews_postgres_test.go new file mode 100644 index 0000000..882ea1b --- /dev/null +++ b/reviews_postgres_test.go @@ -0,0 +1,488 @@ +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 + }) +}