api/tests/test_exam_bank.py
Kevin Carter (via Claude) df827b990c Mode-3: question bank + custom-paper assembly endpoints
GET /api/exam/bank — leaf questions across the caller's institute templates
(RLS-scoped) with source-paper context + spec_ref/subject filters and facets;
the spec-planning surface for build-your-own.

POST /api/exam/custom-papers {title, subject?, question_ids[]} — copy-on-assemble:
create a new exam_template owned by the caller and insert COPIES of the selected
leaf questions (new ids, requested order, carrying marks/answer_type/spec_ref/
bounds + their response areas), then project to Neo4j. A custom paper is a normal
template, so it flows through setup/marking/results/projection unchanged. The
shared-question decouple is Phase-2 (design: ~/cc/ideas/2026-07-02-mode3-...).

Tests (4, pass in-container): bank lists leaves + facets (excludes containers),
spec_ref filter, custom-paper copies in order with fresh ids, 400/404 guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:05:39 +00:00

135 lines
5.6 KiB
Python

"""Tests for the question-bank + custom-paper assembly router (/api/exam/bank, /custom-papers) — mode 3.
Same FakeSupabase + dependency-override pattern as test_exam_templates.py; the as-user RLS itself is
verified against the live dev DB (.94), not here.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routers.exam.bank as bank_mod
from routers.exam.bank import router
from routers.exam.dependencies import ExamContext, get_exam_context
TEACHER = "00000000-0000-0000-0000-000000000001"
INST_A = "10000000-0000-0000-0000-000000000001"
@pytest.fixture(autouse=True)
def _stub_projection(monkeypatch):
monkeypatch.setattr(bank_mod, "project_template_safe", lambda tid: None)
class FakeResult:
def __init__(self, data):
self.data = data
class FakeQuery:
def __init__(self, store, table):
self.store = store
self.table = table
self.rows = list(store.get(table, []))
self._op = None
self._payload = None
def select(self, *_a, **_k):
self._op = "select"
return self
def insert(self, payload):
self._op = "insert"
self._payload = payload
return self
def eq(self, key, value):
self.rows = [r for r in self.rows if r.get(key) == value]
return self
def in_(self, key, values):
values = set(values)
self.rows = [r for r in self.rows if r.get(key) in values]
return self
def execute(self):
backing = self.store.setdefault(self.table, [])
if self._op == "insert":
payloads = self._payload if isinstance(self._payload, list) else [self._payload]
inserted = []
for p in payloads:
row = dict(p)
row.setdefault("id", f"gen-{self.table}-{len(backing)}")
backing.append(row)
inserted.append(row)
return FakeResult(inserted)
return FakeResult(self.rows)
class FakeSupabase:
def __init__(self, store):
self.store = store
def table(self, name):
return FakeQuery(self.store, name)
def make_client(store, user_id=TEACHER, institute_ids=(INST_A,)):
app = FastAPI()
app.include_router(router, prefix="/api/exam")
app.dependency_overrides[get_exam_context] = lambda: ExamContext(user_id, "tok", FakeSupabase(store), list(institute_ids))
return TestClient(app), store
def test_bank_lists_leaf_questions_with_facets_excluding_containers():
store = {"exam_questions": [
{"id": "c1", "template_id": "t1", "label": "Q1", "is_container": True, "spec_ref": None,
"exam_templates": {"id": "t1", "title": "Physics 1H", "subject": "physics", "exam_code": "AQA-PHYS-8463"}},
{"id": "q1", "template_id": "t1", "label": "01.1", "is_container": False, "max_marks": 3, "spec_ref": "4.2",
"exam_templates": {"id": "t1", "title": "Physics 1H", "subject": "physics", "exam_code": "AQA-PHYS-8463"}},
{"id": "q2", "template_id": "t1", "label": "01.2", "is_container": False, "max_marks": 2, "spec_ref": "4.2",
"exam_templates": {"id": "t1", "title": "Physics 1H", "subject": "physics", "exam_code": "AQA-PHYS-8463"}},
]}
c, _ = make_client(store)
body = c.get("/api/exam/bank").json()
labels = {q["label"] for q in body["questions"]}
assert labels == {"01.1", "01.2"} # container excluded
assert body["facets"]["spec_ref"] == {"4.2": 2}
assert body["questions"][0]["paper"]["exam_code"] == "AQA-PHYS-8463"
def test_bank_filters_by_spec_ref():
store = {"exam_questions": [
{"id": "q1", "template_id": "t1", "label": "a", "is_container": False, "spec_ref": "4.2", "exam_templates": {"subject": "physics"}},
{"id": "q2", "template_id": "t1", "label": "b", "is_container": False, "spec_ref": "4.5", "exam_templates": {"subject": "physics"}},
]}
c, _ = make_client(store)
body = c.get("/api/exam/bank?spec_ref=4.2").json()
assert [q["id"] for q in body["questions"]] == ["q1"]
def test_custom_paper_copies_selected_questions_in_order():
store = {"exam_questions": [
{"id": "q1", "template_id": "t1", "label": "01.1", "is_container": False, "max_marks": 3, "spec_ref": "4.2", "answer_type": "short"},
{"id": "q2", "template_id": "t2", "label": "05.1", "is_container": False, "max_marks": 6, "spec_ref": "4.5", "answer_type": "written"},
]}
c, store = make_client(store)
r = c.post("/api/exam/custom-papers", json={"title": "Electricity mini-test", "subject": "physics", "question_ids": ["q2", "q1"]})
assert r.status_code == 200
body = r.json()
assert body["n_questions"] == 2
# a new template row was created, owned + institute-scoped
new_tpls = [t for t in store["exam_templates"] if t["id"] == body["id"]]
assert new_tpls and new_tpls[0]["teacher_id"] == TEACHER and new_tpls[0]["institute_id"] == INST_A
# two COPIES exist under the new template, order preserving the requested [q2, q1], with fresh ids
copies = sorted([q for q in store["exam_questions"] if q["template_id"] == body["id"]], key=lambda q: q["order"])
assert [q["label"] for q in copies] == ["05.1", "01.1"]
assert all(q["id"] not in ("q1", "q2") for q in copies) # not the originals
assert copies[0]["max_marks"] == 6 and copies[0]["spec_ref"] == "4.5"
def test_custom_paper_requires_accessible_questions():
c, _ = make_client({"exam_questions": []})
assert c.post("/api/exam/custom-papers", json={"title": "x", "question_ids": ["nope"]}).status_code == 404
assert c.post("/api/exam/custom-papers", json={"title": "x", "question_ids": []}).status_code == 400