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.
This commit is contained in:
488
reviews_postgres_test.go
Normal file
488
reviews_postgres_test.go
Normal file
@@ -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 "<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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user