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
211 lines
10 KiB
Python
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)
|