signal-bot/tests/test_app.py
Krzysztof kuhy Rudnicki 72482629d9 fix: a signal-api that is not up yet no longer kills the bot at boot
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:23:42 +02:00

82 lines
2.9 KiB
Python

# 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