infoxtractor/tests/integration/test_rest_adapter.py
Dirk Riemann e46c44f1e0
All checks were successful
tests / test (push) Successful in 1m7s
tests / test (pull_request) Successful in 1m5s
feat(rest): FastAPI adapter + /jobs, /healthz, /metrics routes (spec §5)
Routes:
- POST /jobs: 201 on first insert, 200 on idempotent re-submit.
- GET /jobs/{id}: full Job envelope or 404.
- GET /jobs?client_id=&request_id=: correlation lookup or 404.
- GET /healthz: {postgres, ollama, ocr}; 200 iff all ok (degraded counts
  as non-200 per spec). Postgres probe guarded by a 2 s wait_for.
- GET /metrics: pending/running counts + 24h done/error counters +
  per-use-case avg seconds. Plain JSON, no Prometheus.

create_app(spawn_worker=bool) parameterises worker spawning so tests that
only need REST pass False. Worker spawn is tolerant of the loop module not
being importable yet (Task 3.5 fills it in).

Probes are a DI bundle — production wiring swaps them in at startup
(Chunk 4); tests inject canned ok/fail callables. Session factory is also
DI'd so tests can point at a per-loop engine and sidestep the async-pg
cross-loop future issue that bit the jobs_repo fixture.

9 new integration tests; unit suite unchanged. Forgejo Actions trigger is
flaky; local verification is the gate (unit + integration green locally).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:47:04 +02:00

179 lines
6 KiB
Python

"""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)