feat: edit quiz questions over MCP

- quizzes_create_question, update_question, delete_question and
  reorder_questions on /api/v1/quizzes/{slug}/questions
- update_question replaces answers by id (kept, created, deleted), so
  answer ids stored in past attempts stay valid; foreign ids are refused
- classes.CheckQuestion holds the importer's per-question rules, shared
  with the manage routes; attempt scores are never touched (D-16)
This commit is contained in:
Jakub Zych
2026-11-09 14:33:56 +01:00
parent 4a60eda425
commit c6d8d1a973
11 changed files with 792 additions and 44 deletions

View File

@@ -191,6 +191,88 @@ VALUES ('Editors', 'editors', '{"golem15.quizzes.access_quizzes":1}', NOW(), NOW
}
})
t.Run("questions", func(t *testing.T) {
const qp = prefix + "/pytania/questions"
c.ok(http.StatusCreated, "POST", prefix, token, `{"title":"Pytania"}`)
single := data(c.ok(http.StatusCreated, "POST", qp, token,
`{"text":"Stolica?","type":"single","points":2,"answers":[{"text":"Warszawa","is_correct":true},{"text":"Kraków"},{"text":"Gdańsk"}]}`))
card := data(c.ok(http.StatusCreated, "POST", qp, token, `{"text":"Odporność?","type":"self_check","answer_text":"Zdolność"}`))
if single["sort_order"] != float64(1) || card["sort_order"] != float64(2) || len(single["answers"].([]any)) != 3 {
t.Fatalf("created questions = %v %v", single, card)
}
if code, _ := c.do("POST", qp, token, `{"text":"Zła","type":"single","answers":[{"text":"a"},{"text":"b"}]}`); code != http.StatusUnprocessableEntity {
t.Errorf("single without a correct answer = %d, want 422", code)
}
singleID := uint(single["id"].(float64))
cardID := uint(card["id"].(float64))
ans := single["answers"].([]any)
warszawa := uint(ans[0].(map[string]any)["id"].(float64))
krakow := uint(ans[1].(map[string]any)["id"].(float64))
gdansk := uint(ans[2].(map[string]any)["id"].(float64))
// A student finishes an attempt with the right answer.
c.ok(http.StatusOK, "POST", prefix+"/pytania/publish", token, "")
_, jwt := newUser(t, gdb, "student@example.com")
att := data(c.ok(http.StatusCreated, "POST", apiPrefix+"/quizzes/pytania/attempts", jwt, ""))
attPath := fmt.Sprintf("%s/attempts/%d", apiPrefix, int(att["id"].(float64)))
c.ok(http.StatusOK, "POST", attPath+"/answers", jwt, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, singleID, warszawa))
c.ok(http.StatusOK, "POST", attPath+"/answers", jwt, fmt.Sprintf(`{"question_id":%d,"known":true}`, cardID))
c.ok(http.StatusOK, "POST", attPath+"/finish", jwt, "")
var before models.Attempt
must(t, gdb.Take(&before, int(att["id"].(float64))).Error)
// Kraków becomes the correct answer: Warszawa is kept (same id),
// Gdańsk is deleted, Poznań is created.
body := fmt.Sprintf(`{"text":"Stolica Małopolski?","answers":[{"id":%d,"text":"Kraków","is_correct":true},{"id":%d,"text":"Warszawa"},{"text":"Poznań"}]}`, krakow, warszawa)
upd := data(c.ok(http.StatusOK, "PATCH", fmt.Sprintf("%s/%d", qp, singleID), token, body))
got := upd["answers"].([]any)
if len(got) != 3 || got[0].(map[string]any)["id"] != float64(krakow) || got[1].(map[string]any)["id"] != float64(warszawa) || got[2].(map[string]any)["id"] == float64(gdansk) {
t.Errorf("updated answers = %v", got)
}
var n int64
must(t, gdb.Model(&models.Answer{}).Where("id = ?", gdansk).Count(&n).Error)
if n != 0 {
t.Errorf("Gdańsk was not deleted")
}
var after models.Attempt
must(t, gdb.Take(&after, before.ID).Error)
if after.ScorePoints != before.ScorePoints || *after.ScorePercent != *before.ScorePercent {
t.Errorf("attempt score changed by an edit: %v -> %v", before.ScorePoints, after.ScorePoints)
}
foreign := fmt.Sprintf(`{"answers":[{"id":%d,"text":"x","is_correct":true},{"text":"y"}]}`, gdansk)
if code, _ := c.do("PATCH", fmt.Sprintf("%s/%d", qp, singleID), token, foreign); code != http.StatusUnprocessableEntity {
t.Errorf("foreign answer id = %d, want 422", code)
}
if code, _ := c.do("PATCH", fmt.Sprintf("%s/%d", qp, 999999), token, `{"text":"x"}`); code != http.StatusNotFound {
t.Errorf("missing question = %d, want 404", code)
}
// Reorder refuses a partial list and sets the order.
if code, _ := c.do("PUT", qp+"/order", token, fmt.Sprintf(`{"question_ids":[%d]}`, cardID)); code != http.StatusUnprocessableEntity {
t.Errorf("partial reorder = %d, want 422", code)
}
out := data(c.ok(http.StatusOK, "PUT", qp+"/order", token, fmt.Sprintf(`{"question_ids":[%d,%d]}`, cardID, singleID)))
qs := out["questions"].([]any)
if qs[0].(map[string]any)["id"] != float64(cardID) || qs[0].(map[string]any)["sort_order"] != float64(1) {
t.Errorf("reordered = %v", qs)
}
// Deleting a question removes the attempt answers given to it; the
// attempt keeps its score.
del := data(c.ok(http.StatusOK, "DELETE", fmt.Sprintf("%s/%d", qp, cardID), token, ""))
if del["quiz_slug"] != "pytania" {
t.Errorf("delete question = %v", del)
}
must(t, gdb.Model(&models.AttemptAnswer{}).Where("question_id = ?", cardID).Count(&n).Error)
if n != 0 {
t.Errorf("attempt answers of the deleted question = %d", n)
}
must(t, gdb.Take(&after, before.ID).Error)
if after.ScorePoints != before.ScorePoints {
t.Errorf("attempt score changed by a delete: %v -> %v", before.ScorePoints, after.ScorePoints)
}
})
t.Run("import still works", func(t *testing.T) {
raw, err := os.ReadFile("testdata/quiz.json")
must(t, err)