131 lines
4.3 KiB
Python
131 lines
4.3 KiB
Python
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
import agentci.api.app as app_module
|
|
import agentci.api.lifespan as lifespan_module
|
|
|
|
|
|
class BlockingWorker:
|
|
def __init__(self, events: list[str]) -> None:
|
|
self.events = events
|
|
self.started = asyncio.Event()
|
|
|
|
async def run(self, stop: asyncio.Event) -> None:
|
|
self.events.append("worker-started")
|
|
self.started.set()
|
|
try:
|
|
await stop.wait()
|
|
except asyncio.CancelledError:
|
|
self.events.append(f"worker-cancelled:{stop.is_set()}")
|
|
raise
|
|
|
|
|
|
class FailingWorker:
|
|
def __init__(self) -> None:
|
|
self.started = asyncio.Event()
|
|
|
|
async def run(self, _stop: asyncio.Event) -> None:
|
|
self.started.set()
|
|
raise RuntimeError("worker failed")
|
|
|
|
|
|
class FakeRuntime:
|
|
def __init__(self, worker: object, events: list[str]) -> None:
|
|
self.worker = worker
|
|
self.events = events
|
|
self.closed = False
|
|
|
|
async def close(self) -> None:
|
|
self.events.append("runtime-closed")
|
|
self.closed = True
|
|
|
|
|
|
async def test_lifespan_starts_worker_cancels_it_and_closes_runtime(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
events: list[str] = []
|
|
worker = BlockingWorker(events)
|
|
runtime = FakeRuntime(worker, events)
|
|
selected_settings = SimpleNamespace(name="selected")
|
|
built_with: list[object] = []
|
|
configured: list[bool] = []
|
|
|
|
async def build(settings: object) -> FakeRuntime:
|
|
built_with.append(settings)
|
|
return runtime
|
|
|
|
monkeypatch.setattr(lifespan_module, "build_runtime", build)
|
|
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
|
|
application = app_module.create_app(selected_settings) # type: ignore[arg-type]
|
|
|
|
async with application.router.lifespan_context(application):
|
|
await worker.started.wait()
|
|
assert application.state.runtime is runtime
|
|
assert not runtime.closed
|
|
|
|
assert built_with == [selected_settings]
|
|
assert configured == [True]
|
|
assert events == ["worker-started", "worker-cancelled:True", "runtime-closed"]
|
|
|
|
|
|
async def test_lifespan_closes_runtime_when_worker_task_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
events: list[str] = []
|
|
worker = FailingWorker()
|
|
runtime = FakeRuntime(worker, events)
|
|
|
|
async def build(_settings: object) -> FakeRuntime:
|
|
return runtime
|
|
|
|
monkeypatch.setattr(lifespan_module, "build_runtime", build)
|
|
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: None)
|
|
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
|
|
|
with pytest.raises(RuntimeError, match="worker failed"):
|
|
async with application.router.lifespan_context(application):
|
|
await worker.started.wait()
|
|
|
|
assert runtime.closed
|
|
assert events == ["runtime-closed"]
|
|
|
|
|
|
async def test_lifespan_propagates_runtime_startup_failure_without_starting_worker(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
configured: list[bool] = []
|
|
|
|
async def fail_build(_settings: object) -> None:
|
|
raise RuntimeError("database unavailable")
|
|
|
|
monkeypatch.setattr(lifespan_module, "build_runtime", fail_build)
|
|
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
|
|
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
|
|
|
with pytest.raises(RuntimeError, match="database unavailable"):
|
|
async with application.router.lifespan_context(application):
|
|
pytest.fail("startup failure must prevent serving requests")
|
|
|
|
assert configured == [True]
|
|
assert not hasattr(application.state, "runtime")
|
|
|
|
|
|
async def test_global_exception_handler_returns_safe_json() -> None:
|
|
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
|
|
|
@application.get("/explode")
|
|
async def explode() -> None:
|
|
raise RuntimeError("sensitive provider detail")
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=application, raise_app_exceptions=False),
|
|
base_url="http://test",
|
|
) as client:
|
|
response = await client.get("/explode")
|
|
|
|
assert response.status_code == 500
|
|
assert response.json() == {"detail": "Internal server error. See service logs for diagnostics."}
|