mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +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>
229 lines
8.6 KiB
Python
229 lines
8.6 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The guarantees !add_photo exists to keep, each asserted on its own.
|
|
|
|
Separate from test_pack_add.py because these are not "does the feature work"
|
|
-- they are the properties DOCS-photo-pack.md promises, and they should fail
|
|
loudly and by name when one of them stops holding.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
|
|
from signal_bot.animals import register_animal_commands
|
|
from signal_bot.dispatcher import Dispatcher
|
|
from signal_bot.http_client import ImageFetcher
|
|
from signal_bot.pack import TAGS_FILE
|
|
from signal_bot.pack_add import COMMAND_NAME, AddResolver
|
|
from signal_bot.pack_command import UNKNOWN
|
|
from signal_bot.pack_custom import CUSTOM_FILE
|
|
from signal_bot.pack_holder import PackHolder
|
|
from signal_bot.pack_load import load
|
|
from signal_bot.pack_replies import FAILED
|
|
from signal_bot.pack_store import ADDS_FILE
|
|
from signal_bot.registry import CommandRegistry
|
|
from signal_bot.registry_holder import RegistryHolder
|
|
from tests import pack_fixture
|
|
from tests.conftest import WATCHED_GROUP, envelope
|
|
from tests.pack_add_fixture import (
|
|
JPEG_ONE,
|
|
NOT_MEDIA,
|
|
FakeStripper,
|
|
UndeletableTransport,
|
|
Wiring,
|
|
_registry,
|
|
_run,
|
|
holder,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
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
|
|
|
|
__all__ = ["holder"]
|
|
|
|
|
|
async def test_a_typed_label_reaches_no_log_line_no_row_and_no_reply(
|
|
holder: PackHolder,
|
|
transport: FakeTransport,
|
|
store: Store,
|
|
config: Config,
|
|
directory: GroupDirectory,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The scrubbing seam, on all three surfaces, through the real dispatcher.
|
|
|
|
Driven end to end rather than by calling the handler: the log line that
|
|
names a command is written by the dispatcher, and the command_uses row is
|
|
written by the dispatcher, so a handler-level test could not see either.
|
|
"""
|
|
registry, _ = _registry(holder, FakeStripper())
|
|
dispatcher = Dispatcher(
|
|
config=config,
|
|
directory=directory,
|
|
holder=RegistryHolder(lambda: registry),
|
|
store=store,
|
|
transport=transport,
|
|
clock=lambda: dt.datetime(2026, 8, 31, 12, 0, tzinfo=dt.UTC),
|
|
)
|
|
transport.payloads["0"] = JPEG_ONE
|
|
frame = envelope(text="!dodaj sekretnaetykieta", attachments=("image/jpeg",))
|
|
|
|
with caplog.at_level(logging.DEBUG):
|
|
await dispatcher.handle(frame)
|
|
|
|
assert "dodano" in transport.texts[-1]
|
|
assert "sekretnaetykieta" not in caplog.text
|
|
assert all("sekretnaetykieta" not in text for text in transport.texts)
|
|
# The row exists and is keyed by the pseudonym, not by anything typed.
|
|
assert store.uses_today(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, "2026-08-31") == 1
|
|
assert "sekretnaetykieta" not in COMMAND_NAME
|
|
|
|
|
|
async def test_the_container_copy_is_deleted_whether_accepted_or_refused(
|
|
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
transport.payloads["a1"] = JPEG_ONE
|
|
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
|
assert transport.deleted == ["a1"]
|
|
|
|
transport.payloads["a2"] = NOT_MEDIA
|
|
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a2",))
|
|
assert transport.deleted == ["a1", "a2"]
|
|
|
|
|
|
async def test_the_adder_is_recorded_for_a_future_undo(
|
|
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
transport.payloads["a1"] = JPEG_ONE
|
|
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
|
document = json.loads((holder.pack.directory / ADDS_FILE).read_text(encoding="utf-8"))
|
|
entry = next(iter(document.values()))
|
|
assert entry == {"by": "uuid-alice", "at": 1756600000}
|
|
|
|
|
|
async def test_nothing_reads_adds_json_at_load_time(
|
|
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
"""A corrupt audit file must not be able to take the album down."""
|
|
transport.payloads["a1"] = JPEG_ONE
|
|
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
|
(holder.pack.directory / ADDS_FILE).write_text("{ corrupt", encoding="utf-8")
|
|
assert holder.reload() is True
|
|
|
|
|
|
async def test_a_swap_that_fails_rolls_everything_back_and_keeps_serving(
|
|
holder: PackHolder,
|
|
transport: FakeTransport,
|
|
store: Store,
|
|
config: Config,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The branch nobody remembers: written, unloadable, and must not raise."""
|
|
root = holder.pack.directory
|
|
before_pack = holder.pack
|
|
before_tags = (root / TAGS_FILE).read_text(encoding="utf-8")
|
|
before_media = sorted(p.name for p in (root / "media").iterdir())
|
|
|
|
monkeypatch.setattr(PackHolder, "reload", lambda _self: False)
|
|
transport.payloads["a1"] = JPEG_ONE
|
|
await _run(Wiring(holder, transport, store, config), "!dodaj alfa wakacje", attachments=("a1",))
|
|
|
|
assert transport.texts[-1] == FAILED
|
|
assert holder.pack is before_pack
|
|
assert (root / TAGS_FILE).read_text(encoding="utf-8") == before_tags
|
|
assert sorted(p.name for p in (root / "media").iterdir()) == before_media
|
|
assert not (root / CUSTOM_FILE).exists()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The resolver on its own
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_the_pending_mint_is_cleared_between_messages(holder: PackHolder) -> None:
|
|
"""Dispatch is sequential, so exactly one message's mints are outstanding."""
|
|
resolver = AddResolver(holder, CommandRegistry())
|
|
assert resolver(("wakacje",)) == ("t1",)
|
|
assert resolver.pending == {"t1": "wakacje"}
|
|
assert resolver(("alfa",)) == ("p1",)
|
|
assert resolver.pending == {}
|
|
|
|
|
|
def test_two_new_labels_in_one_message_get_different_keys(holder: PackHolder) -> None:
|
|
resolver = AddResolver(holder, CommandRegistry())
|
|
assert resolver(("wakacje", "gory")) == ("t1", "t2")
|
|
|
|
|
|
def test_a_known_custom_label_resolves_without_minting(tmp_path: Path) -> None:
|
|
"""A label the album already has is reused, never allocated a second key."""
|
|
root = pack_fixture.build(tmp_path / "pack")
|
|
(root / CUSTOM_FILE).write_text(json.dumps({"t1": ["wakacje"]}), encoding="utf-8")
|
|
holder = PackHolder(load(root))
|
|
|
|
resolver = AddResolver(holder, CommandRegistry())
|
|
assert resolver(("wakacje",)) == ("t1",)
|
|
assert resolver.pending == {}
|
|
|
|
|
|
async def test_a_delete_that_fails_does_not_lose_the_photo(
|
|
holder: PackHolder, store: Store, config: Config, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""The album write already succeeded; a stuck container copy is a log line."""
|
|
transport = UndeletableTransport()
|
|
transport.payloads["a1"] = JPEG_ONE
|
|
before = len(holder.pack.matching(frozenset({"p1"})))
|
|
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
|
assert len(holder.pack.matching(frozenset({"p1"}))) == before + 1
|
|
assert "could not delete" in caplog.text
|
|
|
|
|
|
def test_no_decorator_is_applied_so_there_is_no_cap(holder: PackHolder) -> None:
|
|
"""kuhy: NO CAP, NO LIMITS -- no cooldown, no size cap, no daily limit."""
|
|
registry, _ = _registry(holder, FakeStripper())
|
|
command = registry.commands[0]
|
|
assert command.name == COMMAND_NAME
|
|
assert command.groups == holder.pack.groups
|
|
assert command.arg_resolver is not None
|
|
|
|
|
|
def test_a_label_that_is_already_a_command_is_refused(holder: PackHolder) -> None:
|
|
"""Otherwise one message wedges the personal-data gate permanently.
|
|
|
|
scripts/personal_data_sources feeds custom labels to the gate. Minting
|
|
"kot" would put a word that is in aliases.json and DOCS-commands.md onto
|
|
the forbidden list, and every commit after that would be refused until
|
|
somebody hand-edited an album the committer cannot see.
|
|
"""
|
|
registry = CommandRegistry()
|
|
register_animal_commands(registry, _fetcher())
|
|
resolver = AddResolver(holder, registry)
|
|
|
|
assert resolver(("kot",)) == (UNKNOWN,)
|
|
assert resolver.pending == {}
|
|
|
|
|
|
def test_a_label_that_is_not_a_command_still_mints(holder: PackHolder) -> None:
|
|
registry = CommandRegistry()
|
|
register_animal_commands(registry, _fetcher())
|
|
resolver = AddResolver(holder, registry)
|
|
|
|
assert resolver(("wakacje",)) == ("t1",)
|
|
assert resolver.pending == {"t1": "wakacje"}
|
|
|
|
|
|
def _fetcher() -> ImageFetcher:
|
|
"""An image fetcher whose client is never called: only triggers matter."""
|
|
return ImageFetcher(httpx.AsyncClient())
|