import asyncio from types import SimpleNamespace import pytest from httpx import ASGITransport, AsyncClient import agentci.app as app_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(app_module, "build_runtime", build) monkeypatch.setattr(app_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(app_module, "build_runtime", build) monkeypatch.setattr(app_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(app_module, "build_runtime", fail_build) monkeypatch.setattr(app_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."}