"""Integration tests for the FastAPI REST adapter (spec §5). Uses ``fastapi.testclient.TestClient`` against a real DB. Ollama / OCR probes are stubbed via the DI hooks the routes expose for testing — in Chunk 4 the production probes swap in. """ from __future__ import annotations from collections.abc import Iterator from uuid import uuid4 import pytest from fastapi.testclient import TestClient from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from ix.adapters.rest.routes import Probes, get_probes, get_session_factory_dep from ix.app import create_app def _factory_for_url(postgres_url: str): # type: ignore[no-untyped-def] """Build a TestClient-compatible session factory. TestClient runs the ASGI app on its own dedicated event loop (the one it creates in its sync wrapper), distinct from the per-test loop pytest-asyncio gives direct tests. Session factories must therefore be constructed from an engine that was itself created on that inner loop. We do this lazily: each dependency resolution creates a fresh engine + factory on the current running loop, which is the TestClient's loop at route-invocation time. Engine reuse would drag the cross-loop futures that asyncpg hates back in. """ def _factory(): # type: ignore[no-untyped-def] eng = create_async_engine(postgres_url, pool_pre_ping=True) return async_sessionmaker(eng, expire_on_commit=False) return _factory @pytest.fixture def app(postgres_url: str) -> Iterator[TestClient]: """Spin up the FastAPI app wired to the test DB + stub probes.""" app_obj = create_app(spawn_worker=False) app_obj.dependency_overrides[get_session_factory_dep] = _factory_for_url( postgres_url ) app_obj.dependency_overrides[get_probes] = lambda: Probes( ollama=lambda: "ok", ocr=lambda: "ok", ) with TestClient(app_obj) as client: yield client def _valid_request_body(client_id: str = "mammon", request_id: str = "r-1") -> dict: return { "use_case": "bank_statement_header", "ix_client_id": client_id, "request_id": request_id, "context": {"texts": ["hello world"]}, } def test_post_jobs_creates_pending(app: TestClient) -> None: resp = app.post("/jobs", json=_valid_request_body()) assert resp.status_code == 201, resp.text body = resp.json() assert body["status"] == "pending" assert len(body["ix_id"]) == 16 assert body["job_id"] def test_post_jobs_idempotent_returns_200(app: TestClient) -> None: first = app.post("/jobs", json=_valid_request_body("m", "dup")) assert first.status_code == 201 first_body = first.json() second = app.post("/jobs", json=_valid_request_body("m", "dup")) assert second.status_code == 200 second_body = second.json() assert second_body["job_id"] == first_body["job_id"] assert second_body["ix_id"] == first_body["ix_id"] def test_get_job_by_id(app: TestClient) -> None: created = app.post("/jobs", json=_valid_request_body()).json() resp = app.get(f"/jobs/{created['job_id']}") assert resp.status_code == 200 body = resp.json() assert body["job_id"] == created["job_id"] assert body["request"]["use_case"] == "bank_statement_header" assert body["status"] == "pending" def test_get_job_404(app: TestClient) -> None: resp = app.get(f"/jobs/{uuid4()}") assert resp.status_code == 404 def test_get_by_correlation_query(app: TestClient) -> None: created = app.post("/jobs", json=_valid_request_body("mammon", "corr-1")).json() resp = app.get("/jobs", params={"client_id": "mammon", "request_id": "corr-1"}) assert resp.status_code == 200 assert resp.json()["job_id"] == created["job_id"] missing = app.get("/jobs", params={"client_id": "mammon", "request_id": "nope"}) assert missing.status_code == 404 def test_healthz_all_ok(app: TestClient) -> None: resp = app.get("/healthz") assert resp.status_code == 200, resp.text body = resp.json() assert body["postgres"] == "ok" assert body["ollama"] == "ok" assert body["ocr"] == "ok" def test_healthz_503_on_postgres_fail(postgres_url: str) -> None: """Broken postgres probe → 503. Ollama/OCR still surface in the body.""" app_obj = create_app(spawn_worker=False) def _bad_factory(): # type: ignore[no-untyped-def] def _raise(): # type: ignore[no-untyped-def] raise RuntimeError("db down") return _raise app_obj.dependency_overrides[get_session_factory_dep] = _bad_factory app_obj.dependency_overrides[get_probes] = lambda: Probes( ollama=lambda: "ok", ocr=lambda: "ok" ) with TestClient(app_obj) as client: resp = client.get("/healthz") assert resp.status_code == 503 body = resp.json() assert body["postgres"] == "fail" def test_healthz_degraded_ollama_is_503(postgres_url: str) -> None: """Per spec §5: degraded flips HTTP to 503 (only all-ok yields 200).""" app_obj = create_app(spawn_worker=False) app_obj.dependency_overrides[get_session_factory_dep] = _factory_for_url( postgres_url ) app_obj.dependency_overrides[get_probes] = lambda: Probes( ollama=lambda: "degraded", ocr=lambda: "ok" ) with TestClient(app_obj) as client: resp = client.get("/healthz") assert resp.status_code == 503 assert resp.json()["ollama"] == "degraded" def test_metrics_shape(app: TestClient) -> None: # Submit a couple of pending jobs to populate counters. app.post("/jobs", json=_valid_request_body("mm", "a")) app.post("/jobs", json=_valid_request_body("mm", "b")) resp = app.get("/metrics") assert resp.status_code == 200 body = resp.json() for key in ( "jobs_pending", "jobs_running", "jobs_done_24h", "jobs_error_24h", "by_use_case_seconds", ): assert key in body assert body["jobs_pending"] == 2 assert body["jobs_running"] == 0 assert isinstance(body["by_use_case_seconds"], dict)