signal-bot/tests/test_pack_add_invariants.py
Krzysztof kuhy Rudnicki da016e1b94
Some checks failed
CI / check (push) Has been cancelled
CI / web (push) Has been cancelled
!dodaj lands in a queue; the management page approves it into the album
A photo sent to the chat no longer enters the album by itself. pack_add
strips it, checks it against the album and the queue, and writes it to
pending/ with a sidecar naming the people and labels the sender proposed;
the reply is "czeka na zatwierdzenie". The management page lists the queue
above the tagger, with the proposal pre-ticked, and Approve moves the file
in, mints the labels, records approved_at and asks the bot to rebuild its
table through /_reload -- the same publish path every other data change
takes, so PackHolder.reload is gone. A reload the bot refuses is reported
on the page, not rolled back: the photo is in the album either way.

The personal-data gate now reads pending/*.json too, so a label that is
still waiting for approval is forbidden in the tree already. The e2e run
boots the management page beside the bot with the same environment,
proves the queue is stripped and never reaches media/, approves it through
the real routes, and has !losowe answer with the photo afterwards without
a restart; exiftool is found through media_strip.locate() there rather
than PATH, which lacks vendor_perl on Arch.

!gadaj is dropped from !odcisz: it was within a typo of !dodaj, and the
nearest-command suggestion must never be a coin toss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 22:55:39 +02:00

221 lines
8.5 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_add import COMMAND_NAME, NEW_PREFIX, PENDING_LIMIT, 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_pending import listing
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 "czeka na zatwierdzenie" 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
# The word went exactly one place: the sidecar, inside the album's 0700.
(entry,) = listing(holder.pack.directory)
assert entry.labels == ("sekretnaetykieta",)
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_a_delete_that_fails_does_not_lose_the_photo(
holder: PackHolder, store: Store, config: Config, caplog: pytest.LogCaptureFixture
) -> None:
"""The queue write already succeeded; a stuck container copy is a log line."""
transport = UndeletableTransport()
transport.payloads["a1"] = JPEG_ONE
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
assert len(listing(holder.pack.directory)) == 1
assert "could not delete" in caplog.text
async def test_the_sender_and_group_ride_along_for_the_approver_and_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",))
(sidecar,) = (holder.pack.directory / "pending").glob("*.json")
document = json.loads(sidecar.read_text(encoding="utf-8"))
assert document["by"] == "uuid-alice"
assert document["group"] == WATCHED_GROUP
assert document["at"] == 1756600000
def test_no_decorator_is_applied_so_there_is_no_cap(holder: PackHolder) -> None:
"""kuhy: NO CAP, NO LIMITS -- no cooldown, no size cap and 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
# --------------------------------------------------------------------------
# The resolver on its own
# --------------------------------------------------------------------------
def test_a_new_label_travels_as_a_token_and_is_claimed_by_its_own_message(
holder: PackHolder,
) -> None:
"""Frames are handled concurrently, so message A must not claim message B's label."""
resolver = AddResolver(holder, CommandRegistry())
first = resolver(("wakacje",))
second = resolver(("gory",))
assert first == (f"{NEW_PREFIX}1",)
assert second == (f"{NEW_PREFIX}2",)
assert resolver.take(second) == ((), ("gory",))
assert resolver.take(first) == ((), ("wakacje",))
# Claimed once: a second take of the same token is not a label any more.
assert resolver.take(first) == (first, ())
def test_two_new_labels_in_one_message_get_different_tokens(holder: PackHolder) -> None:
resolver = AddResolver(holder, CommandRegistry())
keys = resolver(("wakacje", "gory"))
assert keys == (f"{NEW_PREFIX}1", f"{NEW_PREFIX}2")
assert resolver.take(keys) == ((), ("wakacje", "gory"))
def test_the_same_new_label_twice_in_one_message_is_one_token(holder: PackHolder) -> None:
"""Spelt two ways that fold to one word, so the dedupe is by the folded form."""
resolver = AddResolver(holder, CommandRegistry())
assert resolver(("Wakacje", "wakacje")) == (f"{NEW_PREFIX}1", f"{NEW_PREFIX}1")
def test_unclaimed_tokens_are_capped(holder: PackHolder) -> None:
"""A muted sender resolves but never handles; those must not accrue for ever."""
resolver = AddResolver(holder, CommandRegistry())
oldest = resolver(("etykieta0",))
for n in range(1, PENDING_LIMIT + 1):
resolver((f"etykieta{n}",))
assert resolver.take(oldest) == (oldest, ())
newest = (f"{NEW_PREFIX}{PENDING_LIMIT + 1}",)
assert resolver.take(newest) == ((), (f"etykieta{PENDING_LIMIT}",))
def test_a_known_custom_label_resolves_without_a_token(tmp_path: Path) -> None:
"""A label the album already has is reused, never proposed a second time."""
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.take(("t1",)) == (("t1",), ())
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 proposed labels to the gate. Queuing
"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.take((UNKNOWN,)) == ((UNKNOWN,), ())
def test_a_label_that_is_not_a_command_still_gets_a_token(holder: PackHolder) -> None:
registry = CommandRegistry()
register_animal_commands(registry, _fetcher())
resolver = AddResolver(holder, registry)
assert resolver(("wakacje",)) == (f"{NEW_PREFIX}1",)
def _fetcher() -> ImageFetcher:
"""An image fetcher whose client is never called: only triggers matter."""
return ImageFetcher(httpx.AsyncClient())