feat(exam): Neo4j projection on template save + neo4j-sync (S4-7)
modules/database/services/exam_projection.py projects a saved template into
cc.public.exams: ExamPaper -> Question/Part -> Region + Part-[:ASSESSES]->
SpecPoint, joined by shared UUIDs (exam_questions.id, exam_response_areas.id,
exam_code, spec_code). Full re-sync per exam_code (idempotent). Reads via
service role + writes via system Neo4j driver (R3.5.1 documented graph-writer).
Wiring (R3.5.4/R5.3):
- PUT /templates/{id} enqueues project_template_safe via BackgroundTasks
(swallows failures so a graph hiccup never fails the canvas save).
- POST /templates/{id}/neo4j-sync — manual trigger, as-user auth + owner check,
runs synchronously and returns projection counts.
Unit tests: projection scheduled on PUT; neo4j-sync owner/403/404.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
98be55ab57
commit
77bb0766ff
@@ -5,9 +5,11 @@ ExamContext dependency is overridden with an in-memory fake, so these tests exer
|
||||
router's auth/ownership/institute logic without a live Supabase — the as-user RLS itself is
|
||||
verified separately against .94 (see the evidence note).
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routers.exam.templates as templates_mod
|
||||
from routers.exam.templates import router
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context
|
||||
|
||||
@@ -18,6 +20,15 @@ INST_A = "10000000-0000-0000-0000-000000000001"
|
||||
INST_B = "10000000-0000-0000-0000-000000000002"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_projection(monkeypatch):
|
||||
"""Record projection scheduling and never touch Neo4j/service-role in unit tests."""
|
||||
calls = []
|
||||
monkeypatch.setattr(templates_mod, "project_template_safe", lambda tid: calls.append(tid))
|
||||
monkeypatch.setattr(templates_mod, "project_template", lambda tid: {"exam_code": "X", "questions": 1})
|
||||
return calls
|
||||
|
||||
|
||||
# ─── in-memory fake supabase ─────────────────────────────────────────────────
|
||||
|
||||
class FakeResult:
|
||||
@@ -262,3 +273,31 @@ def test_patch_question_empty_body_is_400():
|
||||
store = {"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01"}]}
|
||||
client, _ = make_client(store=store)
|
||||
assert client.patch("/api/exam/questions/q1", json={}).status_code == 400
|
||||
|
||||
|
||||
# ─── Neo4j projection (S4-7) ─────────────────────────────────────────────────
|
||||
|
||||
def test_put_schedules_projection(_stub_projection):
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||
client, _ = make_client(store=store)
|
||||
client.put("/api/exam/templates/t1", json={"questions": []})
|
||||
assert _stub_projection == ["t1"] # projection enqueued for the saved template
|
||||
|
||||
|
||||
def test_neo4j_sync_owner_runs():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||
client, _ = make_client(store=store)
|
||||
r = client.post("/api/exam/templates/t1/neo4j-sync")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["projection"]["exam_code"] == "X"
|
||||
|
||||
|
||||
def test_neo4j_sync_non_owner_403():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": OTHER_TEACHER}]}
|
||||
client, _ = make_client(user_id=TEACHER, institute_ids=(INST_A,), store=store)
|
||||
assert client.post("/api/exam/templates/t1/neo4j-sync").status_code == 403
|
||||
|
||||
|
||||
def test_neo4j_sync_404():
|
||||
client, _ = make_client(store={"exam_templates": []})
|
||||
assert client.post("/api/exam/templates/does-not-exist/neo4j-sync").status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user