pdf-quiz-generator/backend/tests/test_draft_questions.py
Daniel 8614792bdf fix: the other registration form, our own transcriber first, and a rail
that stops answering the wrong question

Invite-only was set and the sign-up form had nowhere to type a code.
There are two registration forms — /register and the modal on the landing
page — and only the first had been taught about invite codes. The modal
is the one most people meet, so turning the gate on failed everybody with
"an invite code is required" and no field to satisfy it. It now asks the
same signup-policy question and shows the same field.

Dictation records to our own transcriber first and falls back to the
browser's recogniser only where recording is unavailable. It was the
other way round for speed, but the browser's speech stack announces
itself to the user in ways we do not control — Firefox interrupts the
page with a warning about a missing Speech Dispatcher library, which is
alarming and is not about us.

A draft question could be promoted into the bank with no category. That
question would reach nothing: no discipline, no organ system, no
relevance, no row on any tab of the analysis — in the bank and invisible
to every page that counts. Promotion now refuses, before an id is spent.

And "Your overall analysis" is out of the session rail. It put lifetime
figures one click away while you were standing in front of a single
session, which is the thing that was supposed to have moved to the
Performance tab.

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

211 lines
10 KiB
Python

"""Extracted questions are staged, and only acceptance takes a question id.
The property the whole table exists for: a machine's first attempt must not
consume a permanent id. Ids come from a sequence and are never reissued, so
rejecting a draft that had already taken one burns it, and a draft being
corrected would be sitting in the bank while it was wrong.
"""
import unittest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
import test_quiz_builder as fixtures # noqa: F401 — imports every model
from app.database import Base, get_db
from app.models.draft_question import DraftBatch, DraftQuestion
from app.models.question import Question
from app.models.question_category import QuestionCategory
from app.models.user import User
from app.routers import drafts
from app.services import draft_questions
from app.utils.auth import get_current_user
class DraftTests(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="m@example.test",
hashed_password="unused", role="moderator")
self.learner = User(id=2, name="Learner", email="l@example.test",
hashed_password="unused", role="user")
self.db.add_all([self.mod, self.learner])
self.db.add(QuestionCategory(id=5, name="Cardiology", user_id=1))
self.batch = DraftBatch(id=1, title="Nelson ch. 12", category_id=5, created_by=1, status="open")
self.db.add(self.batch)
self.db.flush()
self.db.add_all([
DraftQuestion(id=1, batch_id=1, position=0, question_text="A neonate with cyanosis…",
question_type="mcq", options=["TGA", "ASD"], correct_answer="TGA",
explanation="Because."),
DraftQuestion(id=2, batch_id=1, position=1, question_text="Incomplete one",
question_type="mcq", options=["only one"], correct_answer=None),
DraftQuestion(id=3, batch_id=1, position=2, question_text="Answer not an option",
question_type="mcq", options=["a", "b"], correct_answer="c"),
])
self.db.commit()
self.user = self.mod
app = FastAPI()
app.include_router(drafts.router, prefix="/drafts")
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 test_a_draft_holds_no_question_id_until_it_is_accepted(self):
self.assertEqual(self.db.query(Question).count(), 0)
body = self.client.get("/drafts/batches/1").json()
self.assertEqual(len(body["drafts"]), 3)
self.assertTrue(all(d["question_id"] is None for d in body["drafts"]))
def test_accepting_creates_the_question_and_records_what_it_became(self):
body = self.client.post("/drafts/accept", json={"ids": [1]}).json()
self.assertEqual(body["accepted"], 1)
question_id = body["questions"][0]["question_id"]
question = self.db.get(Question, question_id)
self.assertEqual(question.question_text, "A neonate with cyanosis…")
# Filed where the batch said, without anyone choosing again.
self.assertEqual(question.question_category_id, 5)
self.db.expire_all()
self.assertEqual(self.db.get(DraftQuestion, 1).question_id, question_id)
self.assertEqual(self.db.get(DraftQuestion, 1).status, "accepted")
def test_rejecting_leaves_the_bank_untouched(self):
self.assertEqual(self.client.post("/drafts/reject",
json={"ids": [2], "note": "not a question"}).status_code, 200)
self.assertEqual(self.db.query(Question).count(), 0)
self.db.expire_all()
self.assertEqual(self.db.get(DraftQuestion, 2).note, "not a question")
def test_a_draft_that_is_not_ready_is_refused_by_name(self):
response = self.client.post("/drafts/accept", json={"ids": [2]})
self.assertEqual(response.status_code, 400)
self.assertIn("two options", response.json()["detail"])
self.assertEqual(self.db.query(Question).count(), 0)
def test_an_answer_that_is_not_one_of_the_options_is_caught(self):
response = self.client.post("/drafts/accept", json={"ids": [3]})
self.assertEqual(response.status_code, 400)
self.assertIn("not one of the options", response.json()["detail"])
def test_accepting_a_batch_is_all_or_nothing(self):
# Half a batch accepted because the second draft was broken is worse
# than a refusal that names it.
response = self.client.post("/drafts/accept", json={"ids": [1, 2]})
self.assertEqual(response.status_code, 400)
# Names the one that stopped it, so it can be fixed without guessing.
self.assertIn("draft 2", response.json()["detail"])
self.assertEqual(self.db.query(Question).count(), 0)
self.db.expire_all()
self.assertEqual(self.db.get(DraftQuestion, 1).status, "pending")
def test_problems_are_named_before_anyone_opens_a_draft(self):
body = self.client.get("/drafts/batches/1").json()
by_id = {d["id"]: d for d in body["drafts"]}
self.assertEqual(by_id[1]["problems"], [])
self.assertTrue(by_id[2]["problems"])
def test_editing_a_draft_fixes_it_without_anything_being_in_the_bank(self):
response = self.client.patch("/drafts/2", json={
"options": ["one", "two"], "correct_answer": "two"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["problems"], [])
self.assertTrue(response.json()["edited"])
self.assertEqual(self.db.query(Question).count(), 0)
self.assertEqual(self.client.post("/drafts/accept", json={"ids": [2]}).status_code, 200)
def test_an_accepted_draft_cannot_be_edited_or_accepted_again(self):
self.client.post("/drafts/accept", json={"ids": [1]})
self.assertEqual(self.client.patch("/drafts/1", json={"explanation": "x"}).status_code, 409)
self.assertEqual(self.client.post("/drafts/accept", json={"ids": [1]}).status_code, 409)
self.assertEqual(self.db.query(Question).count(), 1, "no second question")
def test_a_rejection_can_be_undone_but_an_acceptance_cannot(self):
self.client.post("/drafts/reject", json={"ids": [2]})
self.assertEqual(self.client.post("/drafts/2/reopen").status_code, 200)
self.db.expire_all()
self.assertEqual(self.db.get(DraftQuestion, 2).status, "pending")
self.client.post("/drafts/accept", json={"ids": [1]})
self.assertEqual(self.client.post("/drafts/1/reopen").status_code, 409)
def test_a_batch_closes_itself_when_nothing_is_left_to_decide(self):
self.client.post("/drafts/reject", json={"ids": [2, 3]})
self.db.expire_all()
self.assertEqual(self.db.get(DraftBatch, 1).status, "open")
self.client.post("/drafts/accept", json={"ids": [1]})
self.db.expire_all()
self.assertEqual(self.db.get(DraftBatch, 1).status, "closed")
def test_deleting_a_batch_leaves_the_questions_it_produced(self):
self.client.post("/drafts/accept", json={"ids": [1]})
self.assertEqual(self.client.delete("/drafts/batches/1").status_code, 204)
self.assertEqual(self.db.query(DraftQuestion).count(), 0)
# The question is in the bank and is nothing to do with the batch now.
self.assertEqual(self.db.query(Question).count(), 1)
def test_none_of_this_is_a_learner_s(self):
self.user = self.learner
self.assertEqual(self.client.get("/drafts/batches").status_code, 403)
self.assertEqual(self.client.post("/drafts/accept", json={"ids": [1]}).status_code, 403)
if __name__ == "__main__":
unittest.main()
class UnfiledDraftTests(unittest.TestCase):
"""A question filed nowhere is invisible to every page that counts it.
No category means no discipline, no organ system, no relevance and no row
on any tab of the analysis — it would sit in the bank and be seen by
nobody. The batch carries the category so saying it once covers the whole
extraction.
"""
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.db = self.bank.db
def tearDown(self):
self.bank.tearDown()
def draft_in(self, category_id):
batch = DraftBatch(title="Unfiled batch", category_id=category_id,
created_by=1, status="open")
self.db.add(batch)
self.db.flush()
draft = DraftQuestion(
batch_id=batch.id, question_text="A 2-year-old with a barking cough.",
question_type="mcq", options=["Croup", "Epiglottitis"],
correct_answer="Croup", status="pending")
self.db.add(draft)
self.db.commit()
return draft, batch
def test_an_unfiled_draft_is_refused_and_takes_no_id(self):
draft, batch = self.draft_in(None)
before = self.db.query(Question).count()
with self.assertRaises(HTTPException) as caught:
draft_questions.accept(self.db, draft, batch, self.bank.owner)
self.assertEqual(caught.exception.status_code, 400)
self.assertIn("category", caught.exception.detail)
# Nothing was created, so no permanent id was spent on a refusal.
self.assertEqual(self.db.query(Question).count(), before)
self.assertEqual(draft.status, "pending")
def test_a_filed_draft_carries_the_batch_s_category_through(self):
draft, batch = self.draft_in(1)
question = draft_questions.accept(self.db, draft, batch, self.bank.owner)
self.assertEqual(question.question_category_id, 1)