mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +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>
110 lines
3.3 KiB
Python
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()
|