This commit is contained in:
LASTA_DEV01\lasta
2026-05-13 19:57:34 +09:00
parent 2e9204243d
commit 9e88f4c7ad
4310 changed files with 48538 additions and 905279 deletions

View File

@@ -0,0 +1,33 @@
"""Shared fixtures for integration tests.
Sets up the Python path so `import platform.api.main` resolves the local
package (not the stdlib `platform` module) and exposes helpers that turn
the FastAPI app into a controllable test harness.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
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))
@pytest.fixture
def fixtures_dir() -> Path:
return REPO_ROOT / "tests" / "fixtures"
@pytest.fixture
def sample_json_path(fixtures_dir: Path) -> Path:
path = fixtures_dir / "sample_cryogenics.json"
assert path.exists(), f"Missing fixture: {path}"
return path

View File

@@ -0,0 +1,276 @@
"""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"]