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

110 lines
3.3 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The registry holder: rebuilt whole, swapped once, never left half-built."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from signal_bot.groups import ConfigError
from signal_bot.registry import CommandRegistry, DuplicateTriggerError
from signal_bot.registry_holder import RegistryHolder
if TYPE_CHECKING:
from signal_bot.context import CommandContext
async def _noop(ctx: CommandContext) -> None:
"""A handler that does nothing; only the table's shape matters here."""
def _table(*names: str) -> CommandRegistry:
registry = CommandRegistry()
for name in names:
registry.register(name, [f"!{name}"], _noop, f"{name} description")
return registry
class _Builder:
"""A builder whose answer can be changed between calls, like data on disk."""
def __init__(self, *names: str) -> None:
self.names = names
self.error: Exception | None = None
self.calls = 0
def __call__(self) -> CommandRegistry:
self.calls += 1
if self.error is not None:
raise self.error
return _table(*self.names)
def test_the_first_table_is_built_at_construction() -> None:
builder = _Builder("cat")
holder = RegistryHolder(builder)
assert builder.calls == 1
assert holder.registry.resolve("!cat") is not None
assert holder.last_error == ""
def test_a_bad_table_at_startup_is_fatal() -> None:
"""No previous table to keep, so the unit must fail rather than answer nothing."""
builder = _Builder("cat")
builder.error = ConfigError("tags.json names a file that is not there")
with pytest.raises(ConfigError):
RegistryHolder(builder)
def test_reload_publishes_the_new_table() -> None:
builder = _Builder("cat")
holder = RegistryHolder(builder)
builder.names = ("cat", "dog")
assert holder.reload() is True
assert holder.registry.resolve("!dog") is not None
assert {command.name for command in holder.registry.commands} == {"cat", "dog"}
@pytest.mark.parametrize(
"error",
[
ConfigError("people.json is not valid JSON"),
DuplicateTriggerError("!cat is claimed twice"),
],
)
def test_bad_data_keeps_the_previous_table(
error: Exception, caplog: pytest.LogCaptureFixture
) -> None:
"""The table that was answering keeps answering; the caller learns why."""
builder = _Builder("cat")
holder = RegistryHolder(builder)
before = holder.registry
builder.error = error
assert holder.reload() is False
assert holder.registry is before
assert holder.last_error == str(error)
assert "keeping the previous one" in caplog.text
def test_a_later_good_reload_clears_the_error() -> None:
builder = _Builder("cat")
holder = RegistryHolder(builder)
builder.error = ConfigError("broken")
holder.reload()
builder.error = None
assert holder.reload() is True
assert holder.last_error == ""
def test_a_programming_error_is_not_swallowed() -> None:
"""Only bad *data* is survivable; a bug must surface, not become a 409."""
builder = _Builder("cat")
holder = RegistryHolder(builder)
builder.error = TypeError("register() got an unexpected keyword")
with pytest.raises(TypeError):
holder.reload()