Files
AI/ontology_platform/tests/integration/test_api_smoke.py

277 lines
9.9 KiB
Python
Raw Normal View History

2026-05-13 19:57:34 +09:00
"""FastAPI smoke tests for the Phase 0 endpoints.
These tests bypass real LLM/Toolbox initialization by injecting a mocked
``AppContext`` via FastAPI's ``dependency_overrides``. They cover:
- /health 200 / 503
- /info shape
- /flush confirmation token enforcement
- /process input validation (JSON / multipart / unsupported)
- /process happy path with a fake workflow (no real LLM call)
A separate file (`test_api_real_workflow.py`) exercises the real OntoCast
pipeline behind a marker that is skipped unless an LLM key is configured.
"""
from __future__ import annotations
import importlib
import json
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi.testclient import TestClient
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
VENDORED_ONTOCAST = REPO_ROOT / "vendored" / "ontocast"
if str(VENDORED_ONTOCAST) not in sys.path:
sys.path.insert(0, str(VENDORED_ONTOCAST))
main_module = importlib.import_module("platform.api.main")
deps_module = importlib.import_module("platform.api.deps")
from ontocast.onto.enum import RenderMode # noqa: E402
# ─── Mocked AppContext factory ────────────────────────────────────────────
def _make_mock_context(workflow_chunks: list[dict[str, Any]]) -> SimpleNamespace:
"""Build an AppContext stand-in.
``workflow_chunks`` is the sequence of state snapshots that the mock
workflow.astream will yield. The last chunk becomes the response state.
"""
# Triple store and LLM are MagicMocks because /health/info just inspect them.
triple_store = MagicMock()
triple_store.clean = AsyncMock()
tools = SimpleNamespace(
llm=MagicMock(),
llm_provider="openai",
triple_store_manager=triple_store,
update_dataset=AsyncMock(),
)
async def fake_astream(initial_state, *, stream_mode, config): # noqa: ARG001
for chunk in workflow_chunks:
yield chunk
workflow = SimpleNamespace(astream=fake_astream)
server_config = SimpleNamespace(
render_mode=RenderMode.ONTOLOGY_AND_FACTS,
max_visits_per_node=1,
ontology_max_triples=1000,
)
settings = SimpleNamespace(
phase=0,
storage_backend="filesystem",
working_directory=REPO_ROOT / "data" / "working",
)
return SimpleNamespace(
settings=settings,
tools=tools,
workflow=workflow,
server_config=server_config,
recursion_limit=100,
)
def _client_with_context(ctx: SimpleNamespace) -> TestClient:
"""Return a TestClient whose ``get_app_context`` returns the given ctx.
Bypassing the lifespan keeps tests fast and deterministic. We use
``raise_server_exceptions=True`` (the default) so unexpected errors
surface in test output.
"""
app = main_module.create_app()
app.dependency_overrides[deps_module.get_app_context] = lambda: ctx
# `TestClient` runs the lifespan by default; disable it because we're
# providing the context manually.
return TestClient(app, raise_server_exceptions=True, backend="asyncio")
# ─── /health ──────────────────────────────────────────────────────────────
def test_health_healthy() -> None:
ctx = _make_mock_context([])
with _client_with_context(ctx) as client:
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "healthy"
assert body["llm_provider"] == "openai"
assert body["phase"] == 0
assert body["storage_backend"] == "filesystem"
def test_health_unhealthy_when_llm_missing() -> None:
ctx = _make_mock_context([])
ctx.tools.llm = None
with _client_with_context(ctx) as client:
response = client.get("/health")
assert response.status_code == 503
assert response.json()["status"] == "unhealthy"
# ─── /info ────────────────────────────────────────────────────────────────
def test_info_shape() -> None:
ctx = _make_mock_context([])
with _client_with_context(ctx) as client:
response = client.get("/info")
assert response.status_code == 200
body = response.json()
for key in ("name", "platform_version", "capabilities", "input_types", "output_types"):
assert key in body, f"missing key: {key}"
assert "text-to-triples" in body["capabilities"]
# ─── /flush ───────────────────────────────────────────────────────────────
def test_flush_requires_confirmation_token() -> None:
ctx = _make_mock_context([])
with _client_with_context(ctx) as client:
response = client.post("/flush")
assert response.status_code == 400
assert "confirm" in response.json()["detail"].lower()
def test_flush_with_correct_confirmation_succeeds() -> None:
ctx = _make_mock_context([])
with _client_with_context(ctx) as client:
response = client.post(
"/flush",
params={"confirm": "YES-I-WANT-TO-DELETE-EVERYTHING"},
)
assert response.status_code == 200
ctx.tools.triple_store_manager.clean.assert_awaited_once_with(dataset=None)
# ─── /process — input validation ──────────────────────────────────────────
def test_process_rejects_unsupported_content_type() -> None:
ctx = _make_mock_context([])
with _client_with_context(ctx) as client:
response = client.post(
"/process",
content="raw text",
headers={"Content-Type": "text/plain"},
)
assert response.status_code == 415
def test_process_rejects_empty_json_body() -> None:
ctx = _make_mock_context([])
with _client_with_context(ctx) as client:
response = client.post(
"/process",
content=b"",
headers={"Content-Type": "application/json"},
)
assert response.status_code == 400
# ─── /process — happy path with fake workflow ─────────────────────────────
def _fake_workflow_state() -> dict[str, Any]:
"""Build a minimal workflow state that /process can serialize."""
onto_graph = MagicMock()
onto_graph.serialize = MagicMock(return_value="@prefix ex: <urn:ex#> .")
facts_graph = MagicMock()
facts_graph.serialize = MagicMock(return_value="@prefix ex: <urn:ex#> . ex:a ex:b ex:c .")
budget_tracker = MagicMock()
budget_tracker.model_dump = MagicMock(
return_value={
"chars_sent": 42,
"chars_received": 24,
"calls_count": 3,
"ontology_triples_generated": 5,
"facts_triples_generated": 7,
"ontology_operations_count": 1,
"facts_operations_count": 2,
}
)
current_ontology = MagicMock()
current_ontology.graph = onto_graph
return {
"status": "success",
"content_units": [MagicMock(), MagicMock()],
"parallel_facts_units": [MagicMock(), MagicMock()],
"render_mode": RenderMode.ONTOLOGY_AND_FACTS,
"current_ontology": current_ontology,
"aggregated_facts": facts_graph,
"budget_tracker": budget_tracker,
}
def test_process_json_envelope_returns_ontology_and_facts(
sample_json_path: Path,
) -> None:
workflow_state = _fake_workflow_state()
ctx = _make_mock_context([workflow_state])
payload = json.loads(sample_json_path.read_text(encoding="utf-8"))
with _client_with_context(ctx) as client:
response = client.post(
"/process",
content=json.dumps(payload),
headers={"Content-Type": "application/json"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["status"] == "success"
assert body["data"]["ontology"].startswith("@prefix")
assert body["data"]["facts"].startswith("@prefix")
assert body["metadata"]["chunks_processed"] == 2
# Budget tracker must surface, otherwise Acceptance Gate 0 fails.
assert body["metadata"]["budget"]["calls_count"] == 3
assert body["metadata"]["budget"]["facts_triples_generated"] == 7
def test_process_multipart_upload(sample_json_path: Path) -> None:
workflow_state = _fake_workflow_state()
ctx = _make_mock_context([workflow_state])
with _client_with_context(ctx) as client:
with sample_json_path.open("rb") as fh:
response = client.post(
"/process",
files={"file": (sample_json_path.name, fh, "application/octet-stream")},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["status"] == "success"
assert body["data"]["ontology"]
def test_process_workflow_exception_returns_500() -> None:
"""If the workflow raises, /process must return 500 with error details."""
async def boom(initial_state, *, stream_mode, config): # noqa: ARG001
if False: # pragma: no cover — makes this a generator function
yield {}
raise RuntimeError("simulated failure")
ctx = _make_mock_context([])
ctx.workflow = SimpleNamespace(astream=boom)
with _client_with_context(ctx) as client:
response = client.post(
"/process",
content=json.dumps({"text": "anything"}),
headers={"Content-Type": "application/json"},
)
assert response.status_code == 500
body = response.json()
assert body["status"] == "error"
assert "simulated failure" in body["error"]