mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 21:37:03 +01:00
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>
133 lines
4.8 KiB
Python
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"
|