pdf-quiz-generator/backend/tests/test_question_trash.py
Daniel d1de9589ad feat: questions are soft-deleted, and the trash holds them
Question ids come from a sequence and are never reissued, and fourteen
tables point at them — attempts, quiz membership, exam membership,
media, article links, notes, favourites, feedback. Deleting the row took
all of that with it, so "restore" could only ever have meant typing the
text in again as a different question.

DELETE now sets deleted_at. The question leaves the bank, the builder,
search and every share path at once, because the exclusion lives in
general_question_predicate rather than at each call site. Restoring puts
back the same id, so everything that pointed at it still does. Erasing
for real requires the trash first and a moderator, and the confirmation
says what goes with it.

The trash page holds questions instead of tests. A test is a selection
you can remake in a minute; nobody wanted those back.

Used and withdrawn invite codes can be removed — an unused one is still
withdrawn rather than deleted, so it stays visible as having been issued
and stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 20:12:58 +02:00

71 lines
3 KiB
Python

"""Deleting a question hides it; restoring it puts back the same question.
The point of the whole arrangement is the id. Ids come from a sequence and are
never reissued, and fourteen tables point at this one — attempts, quiz
membership, exam membership, media, notes, favourites, feedback. Erasing the
row takes all of that with it, so a "restore" that re-inserted the text would
be a different question wearing the same words.
"""
import unittest
import test_quiz_builder as fixtures
from app.models.question import Question
class QuestionTrashTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.bank.user = self.bank.mod
def tearDown(self):
self.bank.tearDown()
def row(self, question_id):
self.bank.db.expire_all()
return self.bank.db.get(Question, question_id)
def test_delete_hides_the_question_but_keeps_the_row(self):
self.assertEqual(self.client.delete("/questions/1").status_code, 204)
kept = self.row(1)
self.assertIsNotNone(kept, "the row must survive or nothing can point at it")
self.assertIsNotNone(kept.deleted_at)
def test_a_deleted_question_leaves_the_bank_and_the_builder(self):
before = self.client.get("/questions/bank").json()
before_ids = [q["id"] for q in before["questions"]]
self.assertIn(1, before_ids)
self.client.delete("/questions/1")
after = self.client.get("/questions/bank").json()
self.assertNotIn(1, [q["id"] for q in after["questions"]])
# Excluded in the shared predicate, so the builder's count moves too
# rather than each caller having to remember.
self.assertEqual(self.client.get("/questions/builder/count", params={"state": "all"}).json()["count"],
len(before_ids) - 1)
def test_the_trash_lists_it_and_restore_brings_back_the_same_id(self):
self.client.delete("/questions/1")
trash = self.client.get("/questions/trash").json()
self.assertEqual([row["id"] for row in trash], [1])
self.assertEqual(self.client.patch("/questions/1/restore").status_code, 200)
self.assertIsNone(self.row(1).deleted_at)
self.assertIn(1, [q["id"] for q in self.client.get("/questions/bank").json()["questions"]])
self.assertEqual(self.client.get("/questions/trash").json(), [])
def test_restoring_something_that_is_not_deleted_is_refused(self):
self.assertEqual(self.client.patch("/questions/1/restore").status_code, 400)
def test_erasing_requires_the_trash_first(self):
# Straight to permanent would make the trash a formality.
self.assertEqual(self.client.delete("/questions/1/permanent").status_code, 400)
self.client.delete("/questions/1")
self.assertEqual(self.client.delete("/questions/1/permanent").status_code, 204)
self.assertIsNone(self.row(1))
if __name__ == "__main__":
unittest.main()