fix(exam): block destructive template PUT once marks recorded (review #1)

PUT full-replace deletes exam_questions, and mark_entries.question_id cascades
ON DELETE — so re-saving the setup canvas after marking began would silently
wipe recorded marks. Guard: 409 if any mark_entry exists for the template's
batches. Mark-scheme edits (PATCH /questions/{id}) are unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
CC Worker
2026-06-06 19:15:35 +00:00
co-authored by Claude Opus 4.8
parent 77bb0766ff
commit e269e67f27
2 changed files with 57 additions and 0 deletions
+31
View File
@@ -79,6 +79,12 @@ class FakeQuery:
self.rows = [r for r in self.rows if r.get(key) != value]
return self
def in_(self, key, values):
values = set(values)
self._filters.append(("in", key, values))
self.rows = [r for r in self.rows if r.get(key) in values]
return self
def order(self, *_a, **_k):
return self
@@ -239,6 +245,31 @@ def test_put_replace_clears_previous_children():
assert ids == {"new"} # old row replaced, not appended
def test_put_replace_blocked_when_marks_recorded():
# Re-saving the structure after marking began would cascade-delete mark_entries → guard 409.
store = {
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
"marking_batches": [{"id": "b1", "template_id": "t1", "teacher_id": TEACHER, "institute_id": INST_A}],
"mark_entries": [{"id": "m1", "batch_id": "b1", "submission_id": "s1", "question_id": "q1", "awarded_marks": 2}],
"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "order": 0}],
}
client, store = make_client(store=store)
r = client.put("/api/exam/templates/t1", json={"questions": [{"id": "q2", "label": "new", "order": 0}]})
assert r.status_code == 409
# original question untouched (no destructive delete happened)
assert {q["id"] for q in store["exam_questions"]} == {"q1"}
def test_put_replace_allowed_when_batch_has_no_marks():
store = {
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
"marking_batches": [{"id": "b1", "template_id": "t1", "teacher_id": TEACHER, "institute_id": INST_A}],
"mark_entries": [],
}
client, _ = make_client(store=store)
assert client.put("/api/exam/templates/t1", json={"questions": [{"id": "q2", "label": "new", "order": 0}]}).status_code == 200
def test_put_replace_denied_for_non_owner():
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": OTHER_TEACHER}]}
# Caller is a colleague in the same institute (can read), but not the owner → 403.