There will be no courses. What was there: one draft called "jk" with two empty lessons, and 4,000 lines of code around it — courses, modules, lessons, enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates, three React pages, a router, two models. Its real cost was everywhere else. Every query that measured practice had to remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have silently mixed course attempts into a learner's analytics; the bank predicate carried a subquery to exclude a course's own questions from every search, recommendation and share; quiz access had a second, parallel rule about enrolment. All of that is gone, so the remaining rules say what they mean. `quizzes.allow_review` goes with it. It was only ever enforced for a course quiz, so it had become a promise nothing keeps — the public session page was still offering "no answer review" about sessions that review fine. The fixtures' question 5 lived in a course quiz and stood for "a question that exists but is not in your bank". There is no such thing now — a question is in the bank unless it is deleted — so the counts it kept out of the numbers are back in, and the tests that turned on it now turn on deletion or on the attempt that actually holds a question. Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in app/utils/upload_access.py is what keeps them unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
111 lines
5 KiB
Python
111 lines
5 KiB
Python
"""Exams are selectable data, and the choice scopes the bank.
|
|
|
|
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
|
|
"""
|
|
import os
|
|
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
|
|
|
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
|
|
|
|
from app.database import Base, get_db
|
|
from app.models.exam import Exam, QuestionExamLink
|
|
from app.models.question import Question
|
|
from app.models.user import User
|
|
from app.routers import exams, questions
|
|
from app.utils.auth import get_current_user
|
|
|
|
|
|
class ExamTests(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.user = User(id=1, name="Learner", email="learner@example.test", hashed_password="unused")
|
|
self.mod = User(id=2, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
|
|
self.admin = User(id=4, name="Admin", email="admin@example.test", hashed_password="unused", role="admin")
|
|
self.db.add_all([self.user, self.mod, self.admin])
|
|
self.db.add_all([
|
|
Exam(id=1, slug="pediatrics-boards", name="Pediatrics Boards", sort_order=10),
|
|
Exam(id=2, slug="usmle-step-2-ck", name="USMLE Step 2 CK", sort_order=20),
|
|
Exam(id=3, slug="retired", name="Retired exam", sort_order=30, is_active=0),
|
|
])
|
|
self.db.flush()
|
|
for qid in (1, 2, 3):
|
|
self.db.add(Question(id=qid, user_id=1, question_text=f"Question {qid}",
|
|
question_type="mcq", options=["yes", "no"], correct_answer="yes"))
|
|
self.db.flush()
|
|
# 1 → boards, 2 → step 2, 3 → unlinked (unclassified, not excluded)
|
|
self.db.add_all([QuestionExamLink(question_id=1, exam_id=1),
|
|
QuestionExamLink(question_id=2, exam_id=2)])
|
|
self.db.commit()
|
|
|
|
app = FastAPI()
|
|
app.include_router(exams.router, prefix="/exams")
|
|
app.include_router(questions.router, prefix="/questions")
|
|
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 bank_ids(self):
|
|
return {q["id"] for q in self.client.get("/questions/bank").json()["questions"]}
|
|
|
|
def test_lists_active_exams_with_their_question_counts(self):
|
|
body = self.client.get("/exams/").json()
|
|
self.assertEqual([(e["name"], e["question_count"]) for e in body["exams"]],
|
|
[("Pediatrics Boards", 1), ("USMLE Step 2 CK", 1)])
|
|
self.assertNotIn("Retired exam", [e["name"] for e in body["exams"]])
|
|
self.assertIsNone(body["active_exam_id"])
|
|
|
|
def test_the_choice_is_stored_on_the_user(self):
|
|
response = self.client.put("/exams/active", json={"exam_id": 2})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(self.db.get(User, 1).active_exam_id, 2)
|
|
self.assertEqual(self.client.get("/exams/").json()["active_exam_id"], 2)
|
|
|
|
# And can be cleared again.
|
|
self.client.put("/exams/active", json={"exam_id": None})
|
|
self.assertIsNone(self.db.get(User, 1).active_exam_id)
|
|
|
|
def test_an_unknown_or_inactive_exam_is_refused(self):
|
|
self.assertEqual(self.client.put("/exams/active", json={"exam_id": 999}).status_code, 404)
|
|
self.assertEqual(self.client.put("/exams/active", json={"exam_id": 3}).status_code, 404)
|
|
|
|
def test_the_active_exam_scopes_the_bank(self):
|
|
self.assertEqual(self.bank_ids(), {1, 2, 3}) # no selection yet: everything
|
|
|
|
self.user.active_exam_id = 1
|
|
self.db.commit()
|
|
# Question 2 belongs to another exam; 3 is unlinked so stays visible.
|
|
self.assertEqual(self.bank_ids(), {1, 3})
|
|
|
|
self.user.active_exam_id = 2
|
|
self.db.commit()
|
|
self.assertEqual(self.bank_ids(), {2, 3})
|
|
|
|
def test_creating_an_exam_is_an_administrator_s(self):
|
|
# An objective appears in everyone's picker and scopes the whole bank,
|
|
# so it is site configuration rather than content — and it lives with
|
|
# the other site switches, which a moderator cannot reach either.
|
|
body = {"name": "USMLE Step 1", "slug": "usmle-step-1"}
|
|
self.assertEqual(self.client.post("/exams/", json=body).status_code, 403)
|
|
self.user = self.mod
|
|
self.assertEqual(self.client.post("/exams/", json=body).status_code, 403)
|
|
self.user = self.admin
|
|
self.assertEqual(self.client.post("/exams/", json=body).status_code, 201)
|
|
self.assertEqual(self.client.post("/exams/", json=body).status_code, 409)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|