"""Tests for `api.graph` — the `/graph` (Neo4j) and `/reasoning-graph`
(SQLite) endpoints. Neo4j itself is not exercised here; `/graph` is covered
by the integration tests under `tests/neo4j/`. This file focuses on the
SQLite-backed reasoning endpoint and the error paths.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.graph import router
from domain.models import AnalysisJob
FIXTURE = {
"pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}},
"body": {"self_ref": "#/body", "children": [{"$ref": "#/texts/0"}]},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Hello",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 0, "r": 10, "b": 10}}],
}
],
"tables": [],
"pictures": [],
"groups": [],
}
def _job_with_doc_json() -> AnalysisJob:
job = AnalysisJob(document_id="doc-1")
job.document_filename = "hello.pdf"
job.mark_running()
job.mark_completed(
markdown="# Hello",
html="
Hello
",
pages_json="[]",
document_json=json.dumps(FIXTURE),
chunks_json="[]",
)
return job
@pytest.fixture
def mock_analysis_repo() -> AsyncMock:
repo = AsyncMock()
repo.find_latest_completed_by_document.return_value = _job_with_doc_json()
return repo
@pytest.fixture
def client(mock_analysis_repo: AsyncMock) -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.analysis_repo = mock_analysis_repo
app.state.neo4j = None # /reasoning-graph must not need Neo4j
return TestClient(app)
class TestReasoningGraph:
def test_returns_payload_built_from_sqlite_json(self, client: TestClient) -> None:
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 200
data = resp.json()
assert data["doc_id"] == "doc-1"
assert data["page_count"] == 1
assert data["truncated"] is False
groups = {n["group"] for n in data["nodes"]}
assert groups == {"document", "page", "element"}
edge_types = {e["type"] for e in data["edges"]}
# HAS_ROOT + ON_PAGE expected; NEXT absent (single element so no chain).
assert edge_types == {"HAS_ROOT", "ON_PAGE"}
def test_404_when_no_completed_analysis(
self, client: TestClient, mock_analysis_repo: AsyncMock
) -> None:
mock_analysis_repo.find_latest_completed_by_document.return_value = None
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 404
def test_404_when_analysis_has_no_document_json(
self, client: TestClient, mock_analysis_repo: AsyncMock
) -> None:
job = AnalysisJob(document_id="doc-1")
job.mark_running()
job.mark_completed(
markdown="", html="", pages_json="[]", document_json=None, chunks_json="[]"
)
mock_analysis_repo.find_latest_completed_by_document.return_value = job
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 404
def test_does_not_need_neo4j(self, client: TestClient) -> None:
# `app.state.neo4j = None` and the endpoint still serves — proves the
# reasoning graph is fully decoupled from the Neo4j provider.
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 200
class TestPrimeEndpointRemoved:
def test_graph_prime_endpoint_is_gone(self, client: TestClient) -> None:
# Guardrail — if someone reintroduces /graph/prime we want a failing test.
resp = client.post("/api/documents/doc-1/graph/prime")
assert resp.status_code in (404, 405)