"""Creating an objective, and putting things into it in bulk. Two gaps this pins shut: create used to write four fields and silently drop family, description and article views, so an objective never arrived as asked for; and membership was one link row at a time, which nobody would do for three thousand questions. """ 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 import test_quiz_builder # noqa: F401 — imports every model from app.database import Base, get_db from app.models.article import Article from app.models.exam import ArticleExamLink, Exam, QuestionExamLink from app.models.question import Question from app.models.question_category import QuestionCategory, QuestionCategoryLink from app.models.user import User from app.routers import exams from app.utils.auth import get_current_user class ExamAdminTests(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.admin = User(id=1, name="Admin", email="a@example.test", hashed_password="unused", role="admin") self.learner = User(id=2, name="Learner", email="l@example.test", hashed_password="unused", role="user") self.db.add_all([self.admin, self.learner]) self.db.add_all([QuestionCategory(id=1, name="Cardiology", user_id=1), QuestionCategory(id=2, name="Congenital", parent_id=1, user_id=1), QuestionCategory(id=3, name="Dermatology", user_id=1)]) self.db.flush() for qid, category in [(1, 1), (2, 2), (3, 3)]: self.db.add(Question(id=qid, question_category_id=category, user_id=1, question_text=f"Q{qid}", question_type="mcq", options=["a", "b"], correct_answer="a")) self.db.add(Question(id=4, question_category_id=2, user_id=1, question_text="Gone", question_type="mcq", options=["a"], correct_answer="a", deleted_at=__import__("datetime").datetime(2026, 1, 1))) self.db.add_all([Article(id=1, title="Tetralogy", slug="tetralogy", category_id=2), Article(id=2, title="Eczema", slug="eczema", category_id=3)]) self.db.commit() self.user = self.admin app = FastAPI() app.include_router(exams.router, prefix="/exams") 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 create(self, **overrides): body = {"name": "USMLE Step 1", "slug": "usmle-step-1", "family": "USMLE", "description": "Basic science.", "article_views": ["long"]} body.update(overrides) return self.client.post("/exams/", json=body) def test_create_keeps_every_field_it_was_given(self): body = self.create().json() self.assertEqual(body["family"], "USMLE") self.assertEqual(body["description"], "Basic science.") self.assertEqual(body["article_views"], ["long"]) def test_an_objective_that_would_show_nothing_shows_everything_instead(self): # A view list naming nothing real would leave every article blank, # which is a mistake rather than a preference worth honouring. body = self.create(article_views=["bedside"]).json() self.assertEqual(body["article_views"], ["short", "long", "clinical"]) def test_a_repeated_slug_is_refused(self): self.assertEqual(self.create().status_code, 201) self.assertEqual(self.create(name="Another").status_code, 409) def test_only_an_administrator_may_create_one(self): self.user = self.learner self.assertEqual(self.create().status_code, 403) def test_editing_changes_what_was_named_and_nothing_else(self): exam_id = self.create().json()["id"] body = self.client.patch(f"/exams/{exam_id}", json={"family": "Steps"}).json() self.assertEqual(body["family"], "Steps") self.assertEqual(body["description"], "Basic science.") self.assertEqual(body["slug"], "usmle-step-1") def test_assigning_a_category_takes_everything_beneath_it(self): exam_id = self.create().json()["id"] body = self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [1]}).json() # Questions 1 and 2 — the root and its child. Not 3, and not the # deleted one, which is not in the bank any more. self.assertEqual(body["questions_added"], 2) self.assertEqual(body["articles_added"], 1) linked = {row.question_id for row in self.db.query(QuestionExamLink).all()} self.assertEqual(linked, {1, 2}) def test_not_taking_descendants_takes_only_what_was_named(self): exam_id = self.create().json()["id"] body = self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [1], "include_descendants": False}).json() self.assertEqual(body["questions_added"], 1) def test_assigning_twice_adds_nothing_the_second_time(self): exam_id = self.create().json()["id"] self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [1]}) again = self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [1]}).json() self.assertEqual(again["questions_added"], 0) self.assertEqual(again["articles_added"], 0) def test_articles_can_belong_to_two_objectives_at_once(self): step = self.create().json()["id"] ck = self.create(name="Step 2 CK", slug="usmle-step-2", article_views=["clinical"]).json()["id"] for exam_id in (step, ck): self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [2]}) rows = self.db.query(ArticleExamLink).filter(ArticleExamLink.article_id == 1).all() self.assertEqual(sorted(row.exam_id for row in rows), sorted([step, ck])) def test_unassigning_removes_membership_and_leaves_the_questions(self): exam_id = self.create().json()["id"] self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [1]}) body = self.client.request("DELETE", f"/exams/{exam_id}/assign", json={"category_ids": [1]}).json() self.assertEqual(body["questions_removed"], 2) self.assertEqual(self.db.query(QuestionExamLink).count(), 0) self.assertEqual(self.db.query(Question).filter(Question.deleted_at.is_(None)).count(), 3) def test_a_category_that_does_not_exist_is_refused_by_name(self): exam_id = self.create().json()["id"] response = self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [1, 99]}) self.assertEqual(response.status_code, 400) self.assertIn("99", response.json()["detail"]) self.assertEqual(self.db.query(QuestionExamLink).count(), 0, "nothing written on refusal") if __name__ == "__main__": unittest.main()