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