Files
sm-quizzes-plugin/controllers/api/reviews_test.go
Jakub Zych 692a647650 feat: set scores, confirm in bulk and regrade open answers over MCP
- SetScore (0..points in 0.5 steps, comment shown to the student),
  ConfirmBulk per quiz or attempt and Regrade through the quiz's grader
- close() announces an attempt reviewed while it was open
- quizzes_review_set_score, quizzes_review_confirm_bulk,
  quizzes_review_regrade; 409 already_reviewed and grader_manual
2026-11-09 18:06:02 +01:00

216 lines
9.1 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"unicode/utf8"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
)
func reviewRowFixture(text string) classes.ReviewRow {
at := time.Date(2026, 11, 9, 12, 0, 0, 0, time.UTC)
gp := 1.5
name := "Anna"
return classes.ReviewRow{
AnswerID: 41, Status: "graded",
QuizID: 3, QuizSlug: "pojecia", QuizTitle: "Pojęcia", QuizMode: "",
QuestionID: 7, QuestionText: "Czym jest odstraszanie?", QuestionPoints: 2,
AttemptID: 9, AttemptCompletedAt: &at,
UserID: 5, Email: "anna@example.test", Name: &name,
Text: text, GraderPoints: &gp, Points: 1.5, GradedAt: &at, UpdatedAt: at,
}
}
func marshalMap(t *testing.T, v any) (string, map[string]any) {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
return string(raw), out
}
// TestReviewJSON: a queue row cuts the text to 300 runes and says so,
// keeps a short text whole, and names its keys in snake_case; the detail
// carries the whole text with the model answer, rubric and grading error.
func TestReviewJSON(t *testing.T) {
long := strings.Repeat("ż", 301)
_, out := marshalMap(t, reviewRowJSON(reviewRowFixture(long)))
if got := out["text"].(string); utf8.RuneCountInString(got) != 300 || out["text_truncated"] != true {
t.Errorf("long text = %d runes, truncated %v; want 300, true", utf8.RuneCountInString(got), out["text_truncated"])
}
short := strings.Repeat("a", 120)
raw, out := marshalMap(t, reviewRowJSON(reviewRowFixture(short)))
if out["text"] != short || out["text_truncated"] != false {
t.Errorf("short text = %v, truncated %v; want whole, false", out["text"], out["text_truncated"])
}
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 := out[key]; !ok {
t.Errorf("row lacks %q: %s", key, raw)
}
}
if q := out["quiz"].(map[string]any); q["slug"] != "pojecia" || q["mode"] != "learning" {
t.Errorf("quiz = %v, want slug pojecia and mode learning", q)
}
if a := out["attempt"].(map[string]any); a["id"] != float64(9) || a["completed"] != true {
t.Errorf("attempt = %v, want id 9 completed", a)
}
if s := out["student"].(map[string]any); s["user_id"] != float64(5) || s["email"] != "anna@example.test" || s["name"] != "Anna" || s["surname"] != nil {
t.Errorf("student = %v", s)
}
for _, leak := range []string{"rubric", "answer_text", "grading_error"} {
if strings.Contains(raw, `"`+leak+`"`) {
t.Errorf("row carries %q: %s", leak, raw)
}
}
model, rubric, failure := "model", "R-RUBRIC", "grader timed out"
d := classes.ReviewDetail{
ReviewRow: reviewRowFixture(long), AnswerText: &model, Rubric: &rubric, MaxLength: 2000,
GradingError: &failure, GradingAttempts: 3,
}
raw, out = marshalMap(t, reviewDetailJSON(d))
if got := out["text"].(string); got != long {
t.Errorf("detail text has %d runes, want the whole 301", utf8.RuneCountInString(got))
}
q := out["question"].(map[string]any)
if q["answer_text"] != "model" || q["rubric"] != "R-RUBRIC" || q["max_length"] != float64(2000) || q["points"] != float64(2) {
t.Errorf("detail question = %v", q)
}
for _, key := range []string{"grader", "grader_feedback", "grading_error", "grading_attempts", "is_correct", "review_comment", "reviewed_by", "reviewed_at"} {
if _, ok := out[key]; !ok {
t.Errorf("detail lacks %q: %s", key, raw)
}
}
if out["grading_error"] != failure || out["grading_attempts"] != float64(3) {
t.Errorf("detail grading = %v / %v", out["grading_error"], out["grading_attempts"])
}
}
// TestReviewErrorMapping: the review conflicts are 409s with a code, a
// missing answer a 404, invalid input a 422, other errors an opaque 500.
func TestReviewErrorMapping(t *testing.T) {
for name, tc := range map[string]struct {
err error
code int
key string
}{
"no grader score": {classes.ErrNoGraderScore, http.StatusConflict, "no_grader_score"},
"already reviewed": {classes.ErrAlreadyReviewed, http.StatusConflict, "already_reviewed"},
"grader manual": {classes.ErrGraderManual, http.StatusConflict, "grader_manual"},
"not found": {classes.ErrNotFound, http.StatusNotFound, ""},
"validation": {&classes.ValidationError{Problems: []string{"status must be one of"}}, http.StatusUnprocessableEntity, ""},
"other": {errors.New("boom"), http.StatusInternalServerError, ""},
} {
rec := httptest.NewRecorder()
writeReview(rec, 0, nil, tc.err)
if rec.Code != tc.code {
t.Errorf("%s = %d, want %d: %s", name, rec.Code, tc.code, rec.Body.String())
continue
}
if tc.key != "" {
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out["code"] != tc.key || out["error"] != true || out["message"] == "" {
t.Errorf("%s body = %v, want code %s", name, out, tc.key)
}
}
}
rec := httptest.NewRecorder()
writeReview(rec, http.StatusOK, map[string]any{"x": 1}, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"data"`) {
t.Errorf("success = %d %s", rec.Code, rec.Body.String())
}
}
// TestReviewHandlersBeforeDatabase: bad filters are a 422 and a bad id a
// 404 before the database; valid requests without one are a 503.
func TestReviewHandlersBeforeDatabase(t *testing.T) {
h := New(backpack.New(nil))
for target, want := range map[string]string{
"/?status=done": "status must be one of",
"/?user_id=abc": "user_id must be a positive integer",
"/?attempt_id=0": "attempt_id must be a positive integer",
"/?attempt_id=-3": "attempt_id must be a positive integer",
"/?user_id=99999999999": "user_id must be a positive integer",
} {
code, out := call(t, h.ReviewList, "GET", target, "", 7, nil)
if msg, _ := out["message"].(string); code != http.StatusUnprocessableEntity || !strings.Contains(msg, want) {
t.Errorf("list %s = %d %v, want 422 with %q", target, code, out, want)
}
}
for _, id := range []string{"", "0", "abc"} {
for name, fn := range map[string]http.HandlerFunc{"show": h.ReviewShow, "confirm": h.ReviewConfirm} {
code, _ := call(t, fn, "POST", "/", "", 7, map[string]string{"id": id})
if code != http.StatusNotFound {
t.Errorf("%s id %q = %d, want 404", name, id, code)
}
}
}
for name, fn := range map[string]http.HandlerFunc{"list": h.ReviewList, "show": h.ReviewShow, "confirm": h.ReviewConfirm} {
code, _ := call(t, fn, "GET", "/?status=graded", "", 7, map[string]string{"id": "3"})
if code != http.StatusServiceUnavailable {
t.Errorf("%s without a database = %d, want 503", name, code)
}
}
if code, _ := call(t, h.ReviewConfirm, "POST", "/", "", 0, map[string]string{"id": "3"}); code != http.StatusUnauthorized {
t.Errorf("confirm without a user = %d, want 401", code)
}
}
// TestReviewWritesBeforeDatabase: the bodies of set_score, confirm_bulk
// and regrade are checked before the database: unknown keys, a missing
// points, a long comment and a scope that is not exactly one key are 422s.
func TestReviewWritesBeforeDatabase(t *testing.T) {
h := New(backpack.New(nil))
long := strings.Repeat("x", 2001)
id := map[string]string{"id": "3"}
for name, tc := range map[string]struct {
fn http.HandlerFunc
body string
want string
}{
"score empty body": {h.ReviewSetScore, "", "invalid JSON"},
"score unknown key": {h.ReviewSetScore, `{"points":1,"status":"reviewed"}`, "invalid JSON"},
"score without points": {h.ReviewSetScore, `{"comment":"ok"}`, "points is required"},
"score long comment": {h.ReviewSetScore, `{"points":1,"comment":"` + long + `"}`, "comment must be at most 2000 characters"},
"bulk none": {h.ReviewConfirmBulk, `{}`, "exactly one of attempt_id and quiz is required"},
"bulk two": {h.ReviewConfirmBulk, `{"quiz":"q","attempt_id":4}`, "exactly one of attempt_id and quiz is required"},
"bulk answer_id": {h.ReviewConfirmBulk, `{"answer_id":4}`, "invalid JSON"},
"regrade none": {h.ReviewRegrade, `{}`, "exactly one of answer_id, attempt_id and quiz is required"},
"regrade two": {h.ReviewRegrade, `{"answer_id":3,"quiz":"q"}`, "exactly one of answer_id, attempt_id and quiz is required"},
} {
code, out := call(t, tc.fn, "POST", "/", tc.body, 7, id)
if msg, _ := out["message"].(string); code != http.StatusUnprocessableEntity || !strings.Contains(msg, tc.want) {
t.Errorf("%s = %d %v, want 422 with %q", name, code, out, tc.want)
}
}
for name, tc := range map[string]struct {
fn http.HandlerFunc
body string
}{
"score": {h.ReviewSetScore, `{"points":1.5,"comment":null}`},
"bulk": {h.ReviewConfirmBulk, `{"quiz":"q"}`},
"regrade": {h.ReviewRegrade, `{"attempt_id":4}`},
} {
if code, _ := call(t, tc.fn, "POST", "/", tc.body, 7, id); code != http.StatusServiceUnavailable {
t.Errorf("%s without a database = %d, want 503", name, code)
}
}
if code, _ := call(t, h.ReviewSetScore, "POST", "/", `{"points":1}`, 7, map[string]string{"id": "x"}); code != http.StatusNotFound {
t.Errorf("score with a bad id = %d, want 404", code)
}
}