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