mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
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>
221 lines
8.5 KiB
Python
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())
|