signal-bot/tests/test_app.py
Krzysztof kuhy Rudnicki 0717df646a
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Rebuild the command table on demand, and survive a tag whose file is gone
The registry used to be built once, inline in build_bot, so every change to
the data behind it -- a person tagged in the web UI, an alias, soon a command
switched off from the management page -- meant restarting the unit. That is
cheap and lossless, but it is a deploy step somebody has to remember, and a
21:37 report can land in the middle of it.

Now the registration sequence lives in wiring.build_registry, and
RegistryHolder runs it again on POST /_reload: the new table is built whole
and swapped in with one assignment, never patched in place, so a duplicate
trigger is found before anything the dispatcher reads has changed. Bad data
(ConfigError, DuplicateTriggerError) keeps the previous table and answers
409 with the reason; a programming error still surfaces as a 500.
scripts/reload.sh wraps it, with --code for the restart a Python change
needs, and an optional command to type in testbot so "reloaded" means
"answered". No Python hot-reload on purpose.

The loader change is what makes deploying this safe: two rows in the live
tags.json name files that are no longer in media/, and load_pack refused
them, so the next restart would have looped on ConfigError. A stale tag is
drift in a file a page edits mid-session, not a broken album -- it is now
skipped with a warning naming it. A tag that reaches outside the album is
still refused. pack_load.py split at the 250-line cap; pack_tags.py is the
half whose checks are not all fatal.

Proven offline in scripts/e2e.sh: !delta is unknown, a fifth person is
tagged into the synthetic pack, scripts/reload.sh runs, and the same
process posts delta's photo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 10:14:39 +02:00

133 lines
4.8 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.groups import ConfigError
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
def test_reload_is_refused_while_still_connecting(monkeypatch: pytest.MonkeyPatch) -> None:
with _serving(monkeypatch, mock_client(_offline)) as client:
response = client.post("/_reload")
assert response.status_code == httpx.codes.SERVICE_UNAVAILABLE
assert response.json()["status"] == "connecting"
def _connected(client: TestClient) -> None:
"""Spin the loop until the connecting task has produced a runtime."""
for _ in range(50):
if client.get("/healthz").json()["status"] == "ok":
return
msg = "the bot never connected"
raise AssertionError(msg)
def test_reload_rebuilds_the_command_table(monkeypatch: pytest.MonkeyPatch) -> None:
with _serving(monkeypatch, signal_client(WATCHED_GROUP)) as client:
_connected(client)
response = client.post("/_reload")
assert response.status_code == httpx.codes.OK
body = response.json()
assert body["status"] == "reloaded"
assert body["commands"] > 0
def test_reload_keeps_the_old_table_when_the_data_is_bad(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""409, not 500: the bot is fine, the file somebody edited is not."""
with _serving(monkeypatch, signal_client(WATCHED_GROUP)) as client:
_connected(client)
before = client.post("/_reload").json()["commands"]
complaint = ConfigError("people.json: !alfa claimed twice")
def broken(*args: object) -> object:
raise complaint
monkeypatch.setattr("signal_bot.bot.build_registry", broken)
response = client.post("/_reload")
assert response.status_code == httpx.codes.CONFLICT
body = response.json()
assert body["status"] == "kept"
assert body["commands"] == before
assert "claimed twice" in body["reason"]
# And the old table still answers.
assert client.get("/healthz").json()["status"] == "ok"