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