"""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, 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()