# 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 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, registry=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())