mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 14:27:50 +01:00
!dodaj is unwired: register_add_command is no longer called, its aliases
are gone from aliases.json, and the word is an unknown one like any
other -- answered with "Nie znam !dodaj", storing nothing. The ingest
path itself (pack_add, pack_ingest, pack_store, the stripper) stays,
tested at 100%, because the approval queue that replaces the direct
write will reuse every piece of it: !dodaj lands in pending/, and the
tagger view gets approve / reject.
The eight e2e scenarios that proved the direct write, the media
generation step and prove_the_add_photo_invariants leave with it;
64852ae has them for the queue to bring back in their new shape. One
scenario takes their place and pins the disabled state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
6.9 KiB
Python
193 lines
6.9 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Two albums at once, each scoped to its own groups.
|
|
|
|
The reason this exists: a test group wants working `!add_photo` and `!losowe`
|
|
without the *real* album being readable there. One album cannot do that -- its
|
|
`groups` list is the whole privacy guarantee -- so the bot loads several, and
|
|
a command from one album simply does not exist in the other's groups.
|
|
|
|
Which makes trigger collisions legal for the first time. Two albums are two
|
|
different sets of people, and both may reasonably call somebody "!alfa"; no
|
|
message can ever mean both, because a command a group is not scoped to is not
|
|
a command there. A collision is only fatal where the group sets *overlap*.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import replace
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from signal_bot.bot import build_bot
|
|
from signal_bot.liveness import Liveness
|
|
from signal_bot.registry import DuplicateTriggerError
|
|
from tests import pack_fixture
|
|
from tests.conftest import SECOND_GROUP, WATCHED_GROUP
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from signal_bot.config import Config
|
|
from signal_bot.discovery import GroupDirectory
|
|
from signal_bot.registry import Command, CommandRegistry
|
|
from signal_bot.store import Store
|
|
from signal_bot.transport_fake import FakeTransport
|
|
|
|
|
|
def _album(root: Path, group: str, *, key: str = "p1", trigger: str = "!alfa") -> Path:
|
|
"""A one-person album scoped to exactly one group."""
|
|
return pack_fixture.build(
|
|
root,
|
|
people=[{"key": key, "triggers": [trigger]}],
|
|
combos=[],
|
|
tags={"alfa.png": [key]},
|
|
groups=[group],
|
|
)
|
|
|
|
|
|
def _registry(
|
|
config: Config,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
directory: GroupDirectory,
|
|
albums: tuple[Path, ...],
|
|
) -> CommandRegistry:
|
|
"""Build the production object graph over these albums."""
|
|
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200)))
|
|
bot = build_bot(
|
|
replace(config, photo_pack_dirs=albums),
|
|
client,
|
|
store,
|
|
transport,
|
|
directory,
|
|
Liveness(config, client),
|
|
)
|
|
return bot.dispatcher.registry
|
|
|
|
|
|
def _claim(registry: CommandRegistry, text: str, group: str) -> Command | None:
|
|
resolved = registry.resolve_with_args(text, group)
|
|
return resolved.command if resolved is not None else None
|
|
|
|
|
|
def test_each_album_answers_only_in_its_own_group(
|
|
tmp_path: Path,
|
|
config: Config,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
directory: GroupDirectory,
|
|
) -> None:
|
|
real = _album(tmp_path / "real", WATCHED_GROUP, key="p1", trigger="!alfa")
|
|
test = _album(tmp_path / "test", SECOND_GROUP, key="p9", trigger="!zeta")
|
|
registry = _registry(config, store, transport, directory, (real, test))
|
|
|
|
assert _claim(registry, "!alfa", WATCHED_GROUP) is not None
|
|
assert _claim(registry, "!alfa", SECOND_GROUP) is None
|
|
assert _claim(registry, "!zeta", SECOND_GROUP) is not None
|
|
assert _claim(registry, "!zeta", WATCHED_GROUP) is None
|
|
|
|
|
|
def test_two_albums_may_share_a_trigger_when_their_groups_do_not_overlap(
|
|
tmp_path: Path,
|
|
config: Config,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
directory: GroupDirectory,
|
|
) -> None:
|
|
"""Two different people, both called "!alfa". No message can mean both."""
|
|
real = _album(tmp_path / "real", WATCHED_GROUP, key="p1", trigger="!alfa")
|
|
test = _album(tmp_path / "test", SECOND_GROUP, key="p1", trigger="!alfa")
|
|
registry = _registry(config, store, transport, directory, (real, test))
|
|
|
|
first = _claim(registry, "!alfa", WATCHED_GROUP)
|
|
second = _claim(registry, "!alfa", SECOND_GROUP)
|
|
assert first is not None
|
|
assert second is not None
|
|
assert first is not second
|
|
|
|
|
|
def test_random_exists_separately_in_each_album(
|
|
tmp_path: Path,
|
|
config: Config,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
directory: GroupDirectory,
|
|
) -> None:
|
|
"""!losowe carries the same triggers in both, by construction.
|
|
|
|
!dodaj used to be asserted here too. It is unwired until an approval
|
|
queue exists (2026-09-04), and a word nothing registers is the same in
|
|
every group: the scoping this test guards is the album's, not its.
|
|
"""
|
|
real = _album(tmp_path / "real", WATCHED_GROUP)
|
|
test = _album(tmp_path / "test", SECOND_GROUP, key="p9", trigger="!zeta")
|
|
registry = _registry(config, store, transport, directory, (real, test))
|
|
|
|
for group in (WATCHED_GROUP, SECOND_GROUP):
|
|
assert _claim(registry, "!losowe", group) is not None
|
|
assert _claim(registry, "!dodaj alfa", group) is None
|
|
|
|
|
|
def test_two_albums_sharing_a_group_are_refused_at_startup(
|
|
tmp_path: Path,
|
|
config: Config,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
directory: GroupDirectory,
|
|
) -> None:
|
|
"""Fail loudly rather than let one message mean two things.
|
|
|
|
Overlapping scopes make "which album does !dodaj write to?" a coin toss,
|
|
and a coin toss about which private album gets a photo is the one outcome
|
|
this whole feature exists to prevent.
|
|
"""
|
|
one = _album(tmp_path / "one", WATCHED_GROUP)
|
|
two = _album(tmp_path / "two", WATCHED_GROUP, key="p9", trigger="!zeta")
|
|
with pytest.raises(DuplicateTriggerError):
|
|
_registry(config, store, transport, directory, (one, two))
|
|
|
|
|
|
def test_an_album_added_second_does_not_disturb_the_first(
|
|
tmp_path: Path,
|
|
config: Config,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
directory: GroupDirectory,
|
|
) -> None:
|
|
"""The real group's photos must answer exactly as they did before."""
|
|
real = _album(tmp_path / "real", WATCHED_GROUP)
|
|
alone = _registry(config, store, transport, directory, (real,))
|
|
before = {c.name for c in alone.commands if c.allowed_in(WATCHED_GROUP)}
|
|
|
|
test = _album(tmp_path / "test", SECOND_GROUP, key="p9", trigger="!zeta")
|
|
both = _registry(config, store, transport, directory, (real, test))
|
|
after = {c.name for c in both.commands if c.allowed_in(WATCHED_GROUP)}
|
|
|
|
assert before == after
|
|
|
|
|
|
def test_a_custom_tag_minted_in_one_album_is_invisible_in_the_other(
|
|
tmp_path: Path,
|
|
config: Config,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
directory: GroupDirectory,
|
|
) -> None:
|
|
"""Albums share no data at all, only the process they run in."""
|
|
real = _album(tmp_path / "real", WATCHED_GROUP)
|
|
test = _album(tmp_path / "test", SECOND_GROUP, key="p9", trigger="!zeta")
|
|
(test / "custom.json").write_text(json.dumps({"t1": ["wakacje"]}), encoding="utf-8")
|
|
registry = _registry(config, store, transport, directory, (real, test))
|
|
|
|
# Resolved to a key in the album that has it, and to the "no such thing"
|
|
# sentinel in the one that does not.
|
|
in_test = registry.resolve_with_args("!losowe wakacje", SECOND_GROUP)
|
|
in_real = registry.resolve_with_args("!losowe wakacje", WATCHED_GROUP)
|
|
assert in_test is not None
|
|
assert in_real is not None
|
|
assert in_test.args == ("t1",)
|
|
assert in_real.args == ("?",)
|