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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user