- 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
216 lines
9.1 KiB
Go
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)
|
|
}
|
|
}
|