pdf-quiz-generator/backend/tests/test_question_versions.py
Daniel 817cff569d feat: question version history with restore; TODO list
Editing a question now snapshots its previous state. The last 5 are kept — the
value is undoing a recent mistake, not an audit trail, and an uncapped history of
full question bodies grows without bound (migration a9b0c1d2e3f4).

A restore snapshots the current state first, so the restore is itself undoable.
History is gated by the same per-category grant that gates editing, so it cannot
be read by someone who could not have made the edit. The question editor shows
the versions with their dates and a Restore action.

Also added docs/TODO.md tracking everything requested and not yet delivered:
AI Mode and its citation contract, global search, study-plan editing and
articles-in-blocks, admin settings revamp, image libraries and question folders,
media management, nested article sections with references and per-section notes
and feedback, per-question notes and feedback in the runner, tutorial mode, the
per-question performance table, the Overview dashboard, systems subsystems, and
dropping "Pediatrics" as a discipline.

Tests: 6 new backend (snapshot on edit, cap at five newest-first, restore,
restore is undoable, refused without edit rights, unknown version). Full suites
green: 119 backend, 136 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:27:44 +02:00

99 lines
4.5 KiB
Python

"""Editing a question keeps a short, capped history that can be rolled back.
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
"""
import os
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
import unittest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models.course import Course # noqa — Quiz.course_id FK needs the table in metadata.
from app.models.media import MediaAsset # noqa — health report walks every embeddable table.
from app.models.question import Question, QuestionVersion
from app.models.user import User
from app.routers import questions
from app.utils.auth import get_current_user
class QuestionVersionTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.mod = User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
self.learner = User(id=2, name="Learner", email="learner@example.test", hashed_password="unused")
self.db.add_all([self.mod, self.learner])
self.db.add(Question(id=1, user_id=1, is_shared=1, question_text="Original stem",
question_type="mcq", options=["yes", "no"], correct_answer="yes",
explanation="Original explanation"))
self.db.commit()
self.user = self.mod
app = FastAPI()
app.include_router(questions.router, prefix="/questions")
app.dependency_overrides[get_db] = lambda: self.db
app.dependency_overrides[get_current_user] = lambda: self.user
self.client = TestClient(app)
def tearDown(self):
self.client.close()
self.db.close()
self.engine.dispose()
def edit(self, text):
return self.client.patch("/questions/1", json={"question_text": text})
def test_an_edit_snapshots_the_previous_state(self):
self.assertEqual(self.edit("Second stem").status_code, 200)
versions = self.client.get("/questions/detail/1/versions").json()
self.assertEqual([v["question_text"] for v in versions], ["Original stem"])
self.assertEqual(self.db.get(Question, 1).question_text, "Second stem")
def test_history_is_capped_at_five_newest_first(self):
for n in range(2, 10):
self.edit(f"Stem {n}")
versions = self.client.get("/questions/detail/1/versions").json()
self.assertEqual(len(versions), questions.MAX_VERSIONS)
# Newest snapshot first, and the oldest states have been dropped.
self.assertEqual(versions[0]["question_text"], "Stem 8")
self.assertNotIn("Original stem", [v["question_text"] for v in versions])
self.assertEqual(self.db.query(QuestionVersion).count(), questions.MAX_VERSIONS)
def test_restoring_brings_back_the_earlier_wording(self):
self.edit("Second stem")
self.edit("Third stem")
versions = self.client.get("/questions/detail/1/versions").json()
oldest = versions[-1]
self.assertEqual(oldest["question_text"], "Original stem")
response = self.client.post(f"/questions/detail/1/versions/{oldest['id']}/restore")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(self.db.get(Question, 1).question_text, "Original stem")
def test_a_restore_is_itself_undoable(self):
self.edit("Second stem")
versions = self.client.get("/questions/detail/1/versions").json()
self.client.post(f"/questions/detail/1/versions/{versions[0]['id']}/restore")
# The state before the restore was captured, so the restore can be undone.
after = self.client.get("/questions/detail/1/versions").json()
self.assertIn("Second stem", [v["question_text"] for v in after])
def test_history_is_refused_to_someone_who_cannot_edit(self):
self.user = self.learner
self.assertEqual(self.client.get("/questions/detail/1/versions").status_code, 403)
self.assertEqual(self.client.post("/questions/detail/1/versions/1/restore").status_code, 403)
def test_an_unknown_version_is_refused(self):
self.assertEqual(self.client.post("/questions/detail/1/versions/999/restore").status_code, 404)
if __name__ == "__main__":
unittest.main()