pdf-quiz-generator/backend/tests/test_session_lifecycle.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

391 lines
19 KiB
Python

"""What happens to a session when its clock runs out, when it is deleted, and
when a learner resets everything.
Disposable SQLite; Redis is a Mock. The rule under test for expiry: an exam's
clock runs only while the exam is on screen, and running out ends the block but
hands nothing in. The learner is shown Time's Up when they next open it, and
closing that submits — so no request they did not make ever marks a paper.
"""
import json
import sys
import unittest
from datetime import datetime
from types import ModuleType
from unittest.mock import Mock, patch
import test_quiz_builder as fixtures
from app.models.attempt import AttemptAnswer, QuizAttempt
from app.models.favorite import Favorite
from app.models.quiz import Quiz
from app.models.study_plan import StudyPlan, StudyPlanBlock, StudyPlanBlockProgress
from app.models.user_note import QuestionNote
from app.routers import attempts, study_plans
from app.services.attempt_expiry import seconds_remaining
with patch.dict(sys.modules, {"app.services.quiz_service": ModuleType("app.services.quiz_service")}):
from app.routers import quizzes
LONG_AGO = "2000-01-01T00:00:00+00:00"
def fake_redis(store):
"""Just enough of a Redis client: get / setex / delete / mget / scan_iter."""
client = Mock()
client.get.side_effect = lambda k: store.get(k)
client.setex.side_effect = lambda k, ttl, v: store.__setitem__(k, v)
client.mget.side_effect = lambda keys: [store.get(k) for k in keys]
client.scan_iter.side_effect = lambda pattern: [k for k in list(store) if k.startswith(pattern.rstrip("*"))]
def delete(*keys):
n = sum(1 for k in keys if store.pop(k, None) is not None)
return n
client.delete.side_effect = delete
module = Mock()
module.from_url.return_value = client
return module
class SessionLifecycleTests(unittest.TestCase):
# Deleting a single attempt is gone: a session is a record of work done,
# and removing one edits the history every figure on the analysis is
# computed from. Starting again is offered whole, via reset-all.
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
for router, prefix in [(quizzes.router, "/quizzes"), (attempts.router, "/attempts"),
(study_plans.router, "/study-plans")]:
self.client.app.include_router(router, prefix=prefix)
self.bank.user = self.bank.owner
self.store = {}
self.redis = fake_redis(self.store)
def tearDown(self):
self.bank.tearDown()
def timed_quiz(self):
quiz_id = self.bank.saved_test([1, 2], is_shared=True, mode="timed")
with patch.dict(sys.modules, {"redis": self.redis}):
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&fresh=true").json()["id"]
return quiz_id, aid
def save(self, uid, aid, **extra):
blob = {"answers": {"1": "yes"}, "total_time": 600, "started_at": LONG_AGO, **extra}
self.store[f"quiz_progress:{uid}:{aid}"] = json.dumps(blob)
# ── expiry ────────────────────────────────────────────────────────────────
def test_a_suspended_exam_never_expires(self):
self.assertIsNone(seconds_remaining({"total_time": 1, "started_at": LONG_AGO, "suspended": True}))
self.assertIsNone(seconds_remaining({"answers": {}}))
self.assertLess(seconds_remaining({"total_time": 1, "started_at": LONG_AGO}), 0)
def test_a_closed_exam_spends_no_time_while_it_is_closed(self):
"""The crux: what is left is what the player last saved.
It used to be computed from `started_at` and `total_time`, so an
afternoon with the tab closed spent an afternoon of the exam on
questions nobody was shown. An exam not on screen is not being sat.
"""
# Started long ago by the wall clock, but with eight minutes still on
# it — because the clock only ran while it was open.
self.assertEqual(
seconds_remaining({"total_time": 600, "started_at": LONG_AGO, "time_left": 480}),
480.0)
def test_opening_and_closing_still_spends_the_time_it_is_open_for(self):
"""And eventually runs out, which is the other half of the rule."""
left = 600.0
for spent in (200, 200, 200):
# Each sitting saves what is left when it ends.
left = max(0.0, left - spent)
self.assertEqual(
seconds_remaining({"total_time": 600, "started_at": LONG_AGO, "time_left": left}),
left)
self.assertEqual(left, 0.0)
def test_an_exam_closed_at_zero_is_not_marked_by_looking_at_the_list(self):
"""Listing sessions reads; it does not hand papers in.
It used to settle any attempt whose clock had run out, so merely
opening the sessions page could mark a block — and the learner's first
sight of an exam they had walked away from was a score.
"""
quiz_id, aid = self.timed_quiz()
self.save(self.bank.owner.id, aid, time_left=0)
with patch.dict(sys.modules, {"redis": self.redis}):
rows = {row["quiz_id"]: row for row in self.client.get("/quizzes/sessions").json()}
self.assertNotEqual(rows[quiz_id]["state"], "completed")
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
def test_an_exam_with_time_on_it_is_left_alone(self):
quiz_id, aid = self.timed_quiz()
self.save(self.bank.owner.id, aid, time_left=90)
with patch.dict(sys.modules, {"redis": self.redis}):
rows = {row["quiz_id"]: row for row in self.client.get("/quizzes/sessions").json()}
# Still in progress, however long ago it was started.
self.assertNotEqual(rows[quiz_id]["state"], "completed")
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
def test_an_out_of_time_exam_stays_open_in_the_session_list(self):
quiz_id, aid = self.timed_quiz()
self.save(self.bank.owner.id, aid)
# A second one, also out of time: neither is touched, and the answered
# counts are still reported for both.
other_quiz, other_aid = self.timed_quiz()
self.save(self.bank.owner.id, other_aid)
with patch.dict(sys.modules, {"redis": self.redis}):
rows = {row["quiz_id"]: row for row in self.client.get("/quizzes/sessions").json()}
for qid in (quiz_id, other_quiz):
self.assertNotEqual(rows[qid]["state"], "completed")
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
# The saved progress is still there for the player to open.
self.assertIn(f"quiz_progress:{self.bank.owner.id}:{aid}", self.store)
def test_resuming_an_out_of_time_exam_hands_back_the_paper_unmarked(self):
"""The player is given the block, not a verdict.
It opens on Time's Up because there is no time on it, and submits when
the learner closes that. Marking it here instead meant the answer sheet
was taken and scored by a request the learner never made.
"""
quiz_id, aid = self.timed_quiz()
self.save(self.bank.owner.id, aid)
with patch.dict(sys.modules, {"redis": self.redis}):
saved = self.client.get(f"/attempts/progress?quiz_id={quiz_id}").json()
self.assertNotIn("expired_submitted", saved)
self.assertEqual(saved["answers"], {"1": "yes"})
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
def test_a_suspended_exam_resumes_with_its_clock_held(self):
quiz_id, aid = self.timed_quiz()
self.save(self.bank.owner.id, aid, suspended=True, time_left=120)
with patch.dict(sys.modules, {"redis": self.redis}):
saved = self.client.get(f"/attempts/progress?quiz_id={quiz_id}").json()
self.assertEqual(saved["total_time"], 120)
self.assertFalse(saved["suspended"])
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
# ── delete ────────────────────────────────────────────────────────────────
def test_reset_removes_what_was_practised_and_keeps_what_was_made(self):
quiz_id, aid = self.timed_quiz()
uid = self.bank.owner.id
self.db.add_all([
AttemptAnswer(attempt_id=aid, question_id=1, user_answer="A", is_correct=True),
Favorite(user_id=uid, question_id=1),
QuestionNote(user_id=uid, question_id=1, content="remember this"),
])
# Another learner's data is untouched.
self.db.add(QuizAttempt(id=901, quiz_id=quiz_id, user_id=self.bank.peer.id, mode="exam",
total_questions=2, score=1, started_at=datetime(2026, 1, 1),
completed_at=datetime(2026, 1, 1)))
self.db.commit()
self.store[f"quiz_progress:{uid}:{aid}"] = "{}"
self.store[f"quiz_active:{uid}:{aid}"] = "dev"
self.store[f"quiz_progress:{self.bank.peer.id}:901"] = "{}"
with patch.dict(sys.modules, {"redis": self.redis}):
refused = self.client.post("/attempts/reset-all", json={"confirm": "yes"})
self.assertEqual(refused.status_code, 400)
response = self.client.post("/attempts/reset-all", json={"confirm": "reset"})
self.assertEqual(response.status_code, 200, response.text)
removed = response.json()["removed"]
self.assertEqual((removed["attempts"], removed["answers"], removed["saved_questions"],
removed["question_notes"], removed["in_progress"]), (1, 1, 1, 1, 2))
self.db.expire_all()
self.assertEqual(self.db.query(QuizAttempt).filter_by(user_id=uid).count(), 0)
self.assertIsNotNone(self.db.get(QuizAttempt, 901))
self.assertIn(f"quiz_progress:{self.bank.peer.id}:901", self.store)
# The test they built is still theirs.
self.assertIsNotNone(self.db.get(Quiz, quiz_id))
self.assertEqual(self.db.query(Favorite).filter_by(user_id=uid).count(), 0)
self.assertEqual(self.db.query(QuestionNote).filter_by(user_id=uid).count(), 0)
if __name__ == "__main__":
unittest.main()
class QuizAnalysisTests(unittest.TestCase):
"""A session addressed by quiz: the same page at zero."""
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
self.client.app.include_router(attempts.router, prefix="/attempts")
self.bank.user = self.bank.owner
def tearDown(self):
self.bank.tearDown()
def test_a_quiz_nobody_has_sat_answers_with_zeroes_and_every_question_skipped(self):
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
body = self.client.get(f"/attempts/quiz/{quiz_id}/analysis").json()
self.assertTrue(body["not_started"])
self.assertIsNone(body["attempt_id"])
self.assertEqual((body["answered"], body["score"], body["percent"]), (0, 0, 0))
self.assertEqual(body["total"], len(body["questions"]))
self.assertTrue(body["questions"])
self.assertEqual({q["status"] for q in body["questions"]}, {"skipped"})
self.assertEqual([q["position"] for q in body["questions"]],
list(range(1, len(body["questions"]) + 1)))
# Where to go back to is its own call, grouped three ways.
self.assertNotIn("recommendations", body)
def test_once_sat_the_quiz_address_answers_with_the_real_analysis(self):
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
self.db.add(QuizAttempt(id=910, quiz_id=quiz_id, user_id=self.bank.owner.id, mode="learning",
total_questions=1, score=1, started_at=datetime(2026, 5, 1),
completed_at=datetime(2026, 5, 1, 1)))
self.db.add(AttemptAnswer(attempt_id=910, question_id=1, user_answer="A", is_correct=True))
self.db.commit()
body = self.client.get(f"/attempts/quiz/{quiz_id}/analysis").json()
self.assertFalse(body["not_started"])
self.assertEqual(body["attempt_id"], 910)
self.assertEqual(body["percent"], 100)
def test_a_quiz_the_learner_cannot_see_is_refused(self):
quiz_id = self.bank.generate(is_shared=False, category_ids=[1], count=2).json()["id"]
self.bank.user = self.bank.peer
self.assertEqual(self.client.get(f"/attempts/quiz/{quiz_id}/analysis").status_code, 403)
class LiveAnalysisTests(unittest.TestCase):
"""A session can be analysed while it is still being sat."""
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
self.client.app.include_router(attempts.router, prefix="/attempts")
self.bank.user = self.bank.owner
self.store = {}
self.redis = fake_redis(self.store)
def tearDown(self):
self.bank.tearDown()
def test_answers_given_but_not_submitted_are_analysed(self):
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
with patch.dict(sys.modules, {"redis": self.redis}):
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=study").json()["id"]
# One answered, saved to progress; nothing submitted.
self.store[f"quiz_progress:{self.bank.owner.id}:{aid}"] = json.dumps({"answers": {"1": "yes"}})
with patch.dict(sys.modules, {"redis": self.redis}):
body = self.client.get(f"/attempts/{aid}/analysis").json()
# It used to report 0/0 with an empty table while the session list,
# which reads the same saved progress, said one had been answered.
self.assertEqual(body["answered"], 1)
self.assertGreater(body["total"], 1)
self.assertEqual(body["score"], 1)
statuses = {q["status"] for q in body["questions"]}
self.assertIn("correct", statuses)
self.assertIn("unanswered", statuses)
# Nothing was written: submitting is what records answers.
self.assertEqual(self.db.query(AttemptAnswer).filter_by(attempt_id=aid).count(), 0)
def test_an_exam_still_running_is_not_marked(self):
"""The integrity rule: a running exam reports progress, never a score.
Grading it live would let a learner answer, open the analysis to see
whether it was right, and go back and change it — the exam defeated
rather than analysed.
"""
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
with patch.dict(sys.modules, {"redis": self.redis}):
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=exam").json()["id"]
self.store[f"quiz_progress:{self.bank.owner.id}:{aid}"] = json.dumps({"answers": {"1": "yes"}})
with patch.dict(sys.modules, {"redis": self.redis}):
body = self.client.get(f"/attempts/{aid}/analysis").json()
self.assertFalse(body["graded"])
self.assertIsNone(body["score"])
self.assertIsNone(body["percent"])
# How far through, which is not a leak, and nothing about rightness.
self.assertEqual(body["answered"], 1)
statuses = {q["status"] for q in body["questions"]}
# "Skipped" would mean gone past; in a session still running it has
# not been reached.
self.assertEqual(statuses, {"answered", "unanswered"})
self.assertNotIn("correct", statuses)
self.assertNotIn("incorrect", statuses)
# And nothing to go back to yet: a recommendation is a verdict, and an
# exam still running has not reached one.
recs = self.client.get(f"/attempts/{aid}/recommendations")
self.assertEqual(recs.status_code, 200, recs.text)
self.assertEqual(recs.json()["rows"], [])
def test_a_finished_exam_is_marked(self):
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
with patch.dict(sys.modules, {"redis": self.redis}):
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=exam").json()["id"]
self.client.post(f"/attempts/{aid}/submit",
json={"answers": [{"question_id": 1, "user_answer": "yes"}]})
body = self.client.get(f"/attempts/{aid}/analysis").json()
# Once it is over the withholding stops: this is the "converts to
# study" moment, and it is what the clock running out does too.
self.assertTrue(body["graded"])
self.assertIsNotNone(body["percent"])
self.assertIn("correct", {q["status"] for q in body["questions"]})
def test_a_live_attempt_with_nothing_answered_reads_as_nothing_answered(self):
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
with patch.dict(sys.modules, {"redis": self.redis}):
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=study").json()["id"]
body = self.client.get(f"/attempts/{aid}/analysis").json()
self.assertEqual((body["answered"], body["score"]), (0, 0))
class HintRecordingTests(SessionLifecycleTests):
"""A tip opened before answering is remembered with the answer.
Both ways a session can end have to agree about it, or an exam that ran out
would quietly report a clean score the learner did not have.
"""
def test_a_submitted_answer_remembers_the_tip_that_was_opened(self):
quiz_id, aid = self.timed_quiz()
with patch.dict(sys.modules, {"redis": self.redis}):
response = self.client.post(f"/attempts/{aid}/submit", json={
"answers": [{"question_id": 1, "user_answer": "yes"},
{"question_id": 2, "user_answer": "yes"}],
"hints": [1],
})
self.assertEqual(response.status_code, 200, response.text)
rows = {row.question_id: row for row in
self.db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == aid)}
self.assertTrue(rows[1].used_hint)
self.assertFalse(rows[2].used_hint)
def test_an_answer_with_no_tips_reported_records_none(self):
quiz_id, aid = self.timed_quiz()
with patch.dict(sys.modules, {"redis": self.redis}):
self.client.post(f"/attempts/{aid}/submit", json={
"answers": [{"question_id": 1, "user_answer": "yes"}]})
rows = self.db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == aid).all()
self.assertTrue(rows)
self.assertFalse(any(row.used_hint for row in rows))
def test_an_exam_that_runs_out_carries_the_tips_it_was_submitted_with(self):
"""A tip opened before answering is recorded whoever pressed submit.
The clock running out used to hand the paper in from the server, which
read the tips out of the saved progress. It is the player that submits
now — from the Time's Up dialog — and it sends the same list.
"""
quiz_id, aid = self.timed_quiz()
with patch.dict(sys.modules, {"redis": self.redis}):
self.client.post(f"/attempts/{aid}/submit", json={
"answers": [{"question_id": 1, "user_answer": "yes"}], "hints": [1]})
rows = {row.question_id: row for row in
self.db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == aid)}
self.assertTrue(rows[1].used_hint)