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>
179 lines
6 KiB
Python
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)
|