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>
135 lines
5.6 KiB
Python
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
|