signal-bot/tests/test_bot.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

171 lines
5.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Command wiring, the daily report, and the listen loop."""
from __future__ import annotations
import datetime as dt
from dataclasses import replace
from typing import TYPE_CHECKING
import httpx
import pytest
from signal_bot.bot import Bot, build_bot, now_local
from signal_bot.groups import ConfigError
from signal_bot.liveness import Liveness
from signal_bot.stats.events import MessageEvent
from signal_bot.stats.periods import WARSAW
from tests import pack_fixture
from tests.conftest import (
WATCHED_GROUP,
envelope,
mock_client,
)
if TYPE_CHECKING:
from pathlib import Path
from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
# The report covers the day it runs on, so the tests freeze "now" and write
# their events into it.
NOW = dt.datetime(2026, 9, 2, 21, 37, tzinfo=WARSAW)
YESTERDAY = "2026-09-01"
def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot:
client = mock_client(lambda r: httpx.Response(200))
return build_bot(
config, client, store, transport, directory, Liveness(config, client), clock=lambda: NOW
)
def wrote(
store: Store, group_id: str, uuid: str, name: str, times: int, *, day: str = YESTERDAY
) -> None:
"""Put ``times`` messages from one person into one group on one day."""
for i in range(times):
store.events.record(
MessageEvent(
group_id=group_id,
uuid=uuid,
display_name=name,
sent_at_ms=i + 1,
day=day,
local_hour=12,
char_len=5,
)
)
store.events.note_person(group_id, uuid, name, "", day)
def test_now_local_is_timezone_aware() -> None:
assert now_local().tzinfo is not None
def test_build_bot_registers_the_animal_commands(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
bot = _bot(config, store, transport, directory)
assert bot.dispatcher.registry.resolve("!cat") is not None
assert bot.dispatcher.registry.resolve("!pies") is not None
def test_build_bot_registers_the_culture_commands(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
"""!sztuka and !wiersz reach the live registry, off the shipped packs.
Both read a committed data file at registration time rather than at the
moment somebody types the trigger, so a missing or malformed pack is a
startup failure. This is the test that would catch one.
"""
bot = _bot(config, store, transport, directory)
assert bot.dispatcher.registry.resolve("!sztuka") is not None
assert bot.dispatcher.registry.resolve("!wiersz") is not None
async def test_listen_dispatches_every_frame(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
transport.queue([envelope(text="hello", group_id=WATCHED_GROUP)])
await _bot(config, store, transport, directory).listen()
row = store.events.connection.execute(
"SELECT COUNT(*) AS n FROM message_events WHERE group_id = ?", (WATCHED_GROUP,)
).fetchone()
assert row["n"] == 1
def test_an_album_is_wired_up_only_where_there_is_one(
tmp_path: Path,
config: Config,
store: Store,
transport: FakeTransport,
directory: GroupDirectory,
) -> None:
"""Absent by default; present, and scoped, when the config names a pack."""
client = mock_client(lambda r: httpx.Response(200))
liveness = Liveness(config, client)
plain = build_bot(config, client, store, transport, directory, liveness)
assert plain.dispatcher.registry.resolve("!alfa") is None
root = pack_fixture.build(tmp_path / "pack")
with_album = build_bot(
replace(config, photo_pack_dirs=(root,)),
client,
store,
transport,
directory,
liveness,
)
command = with_album.dispatcher.registry.resolve("!alfa")
assert command is not None
assert command.name == "pack:p1"
assert command.groups == frozenset({WATCHED_GROUP})
def test_reload_commands_picks_up_a_person_added_to_the_album(
tmp_path: Path,
config: Config,
store: Store,
transport: FakeTransport,
directory: GroupDirectory,
) -> None:
"""A person tagged after startup is a command after a reload, not a restart."""
root = pack_fixture.build(tmp_path / "pack")
bot = _bot(replace(config, photo_pack_dirs=(root,)), store, transport, directory)
before = bot.command_count
assert bot.dispatcher.registry.resolve("!delta") is None
people = [*pack_fixture.PEOPLE, {"key": "p4", "triggers": ["!delta"]}]
pack_fixture.build(root, people=people, tags={**pack_fixture.TAGS, "delta.png": ["p4"]})
assert bot.reload_commands() is True
assert bot.dispatcher.registry.resolve("!delta") is not None
assert bot.command_count == before + 1
def test_reload_commands_keeps_serving_when_the_album_breaks(
tmp_path: Path,
config: Config,
store: Store,
transport: FakeTransport,
directory: GroupDirectory,
) -> None:
"""A half-edited manifest must not take the working commands down with it."""
root = pack_fixture.build(tmp_path / "pack")
bot = _bot(replace(config, photo_pack_dirs=(root,)), store, transport, directory)
(root / "people.json").write_text("{not json", encoding="utf-8")
assert bot.reload_commands() is False
assert bot.dispatcher.registry.resolve("!alfa") is not None
assert bot.dispatcher.holder.last_error
# And the real loader is what said no -- the same error a restart would die on.
with pytest.raises(ConfigError):
_bot(replace(config, photo_pack_dirs=(root,)), store, transport, directory)