"""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 contextlib import asynccontextmanager 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("ont_platform.api.main") deps_module = importlib.import_module("ont_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, ) @asynccontextmanager async def _noop_lifespan(app): # noqa: ARG001 yield 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.router.lifespan_context = _noop_lifespan app.dependency_overrides[deps_module.get_app_context] = lambda: ctx 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: .") facts_graph = MagicMock() facts_graph.serialize = MagicMock(return_value="@prefix 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"]