Files
sm-quizzes-plugin/reviews_postgres_test.go
Jakub Zych 1ce1ebee16 test: cover the review queue against Postgres
TestReviewsAgainstDatabase (skipped under -short): permission matrix,
queue filters and pages, detail, confirm, set_score with the student's
review_comment, bulk confirm with AttemptReviewed, regrade through the
grader and the recalculation of finished attempts.
2026-11-09 18:09:02 +01:00

489 lines
22 KiB
Go

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 "<nil>"
}
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
})
}