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

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())