# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """ASGI startup: the app must serve before it can talk to Signal, not after.""" from __future__ import annotations from typing import TYPE_CHECKING import httpx from fastapi.testclient import TestClient from signal_bot import app as app_module from signal_bot.app import lifespan from signal_bot.versioning import __version__ from tests.conftest import WATCHED_GROUP, mock_client, signal_client if TYPE_CHECKING: from collections.abc import Iterator import pytest BASE_ENV = { "PHONE_NUMBER": "+15550000000", "GROUP_IDS": "", "API_BASE_URL": "http://localhost:9922", "DATABASE_PATH": ":memory:", } DEAD = httpx.ConnectError("Server disconnected without sending a response.") def _offline(request: httpx.Request) -> httpx.Response: raise DEAD def _serving(monkeypatch: pytest.MonkeyPatch, client: httpx.AsyncClient) -> Iterator[TestClient]: for key, value in BASE_ENV.items(): monkeypatch.setenv(key, value) monkeypatch.setattr(app_module.httpx, "AsyncClient", lambda **kwargs: client) return TestClient(app_module.app) def test_healthz_serves_while_signal_cli_is_unreachable(monkeypatch: pytest.MonkeyPatch) -> None: """The 2026-08-30 reboot: startup used to fail outright and stay failed.""" with _serving(monkeypatch, mock_client(_offline)) as client: response = client.get("/healthz") assert response.status_code == httpx.codes.SERVICE_UNAVAILABLE body = response.json() # Not "deaf": a bot that has never connected is a different state # from one that could hear and then stopped. assert body["status"] == "connecting" assert "attempt" in body["reason"] assert body["version"] == __version__ def test_healthz_turns_green_once_the_container_answers( monkeypatch: pytest.MonkeyPatch, ) -> None: """The old app never finished startup, so no route was ever reachable.""" with _serving(monkeypatch, signal_client(WATCHED_GROUP)) as client: body: dict[str, str] = {} # Each request hands the event loop back to the connecting task. for _ in range(50): body = client.get("/healthz").json() if body["status"] == "ok": break assert body["status"] == "ok" assert body["version"] == __version__ async def test_lifespan_is_an_async_context_manager(monkeypatch: pytest.MonkeyPatch) -> None: for key, value in BASE_ENV.items(): monkeypatch.setenv(key, value) monkeypatch.setattr( app_module.httpx, "AsyncClient", lambda **kwargs: signal_client(WATCHED_GROUP), ) async with lifespan(app_module.app): # Present immediately, before anything has reached Signal: /healthz # reads it, and an absent attribute would be a 500 rather than a 503. assert app_module.app.state.starter is not None