Files
sm-quizzes-plugin/controllers/api/reviews_test.go
Jakub Zych 2fa8aa4178 feat: list, show and confirm open answers over MCP
- classes/review.go: review queue, detail and Confirm, shared by MCP and
  the admin screen; AnswerReviewed and AttemptReviewed after commit
- /api/v1/quiz-reviews routes behind the quizzes permission
- quizzes_review_list, quizzes_review_show, quizzes_review_confirm
2026-11-09 18:02:35 +01:00

170 lines
6.7 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"},
"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)
}
}