pdf-quiz-generator/backend/tests/test_question_versions.py
Daniel 5d59e00144 refactor: remove per-question sharing
`Question.is_shared` defaulted to 1 and was only ever set by a route nothing
called, so in practice it divided the bank into "everything" and "everything,
plus your own private ones" — a distinction that cost every recommendation
denominator a join and never changed an answer. Who may reach the bank is the
site's own access rules; who may manage a question is the category grant tree.

So the two predicates the whole bank was built on are now the same thing, and
say what they actually mean: a question is out of reach if it has been deleted
or belongs to a course. Nothing else. The column is dropped, the route that set
it is gone, the bulk "share" action with it, and the Private tile and pill go
from the question manager.

The tests that turned on it have been rewritten rather than deleted, because
the rule they were really about survives: revoking a question still revokes
every session carrying it — by deleting it, which is the only revocation left.
Several others named a category holding exactly two reachable questions and
then answered two particular ids; that category holds four now, so they name
the pair instead. A session's own sharing flag is untouched — that is a
different thing, and it is still how a session is handed to somebody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:42:51 +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, 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()