signal-bot/tests/test_pack_people.py
Krzysztof kuhy Rudnicki 72e90acb47
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Let the operator add aliases from the Commands tab
A custom alias is data the command table is built from, so it cannot be a
live switch like the ones already on the tab. Instead the management page
writes aliases_custom.json in the bot's state directory and then asks the
bot to reload, in one step: whenever the answer is anything but `reloaded`
-- a word another command has, a name no command has, a bot still
connecting -- the file is restored byte for byte and the bot's own reason is
shown next to the field. What is on disk is therefore always what the bot
serves, and a typo cannot sit in the file poisoning every later reload.

The registry appends the operator's words to the shipped list at
registration, which keeps `!pomoc` on the shipped spelling and routes the
new words through the same clash check as aliases.json. An alias for a name
no command has fails the whole build (ConfigError -> `kept`), because a
silently ignored entry is how a file drifts. The tagger treats custom
aliases as reserved words too, so a person cannot be given one.

registry.py crossed the 250-line cap and now re-exports the record and the
error types from command.py; the bot's state directory lookup, previously
duplicated in pack.py and the suggestions export, is state_dir.py.

Proven end to end: !e2ekot is unknown, the file is written and reload.sh
runs, !e2ekot posts a cat, a file naming a command that does not exist is
refused with 409 and the previous table still answers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 16:20:05 +02:00

154 lines
6.3 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Appending to a roster that already exists, without renumbering it."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
import pytest
from signal_bot.custom_aliases import ENV_VAR as CUSTOM_ALIASES_ENV
from signal_bot.groups import ConfigError
from signal_bot.pack import PEOPLE_FILE
from signal_bot.pack_custom import CUSTOM_FILE
from signal_bot.pack_people import add_person, merge, next_key, normalise, reserved
from tests.conftest import WATCHED_GROUP
if TYPE_CHECKING:
from pathlib import Path
def _roster(tmp_path: Path, people: list[dict[str, Any]]) -> Path:
(tmp_path / PEOPLE_FILE).write_text(
json.dumps({"groups": [WATCHED_GROUP], "people": people}), encoding="utf-8"
)
return tmp_path
def test_the_next_key_is_one_past_the_highest_not_the_count() -> None:
# p2 deleted by hand: a count-based key would hand out p3 again and the
# new person would silently inherit the old p3's photos.
assert next_key([{"key": "p1"}, {"key": "p3"}]) == "p4"
def test_the_next_key_on_an_empty_roster() -> None:
assert next_key([]) == "p1"
def test_the_next_key_ignores_entries_that_are_not_keys() -> None:
assert next_key([{"key": "pX"}, {"key": 7}, {}, {"key": "p2"}]) == "p3"
def test_a_trigger_may_be_typed_with_or_without_the_bang() -> None:
assert normalise([" alfa ", "!beta", "", " "]) == ["!alfa", "!beta"]
def test_the_bot_s_own_commands_are_known() -> None:
# Folded, and folding keeps the "!" -- which is why add_person compares
# against normalise()'s "!"-prefixed spelling and not the bare word.
assert "!kot" in reserved()
assert not any(word.startswith("_") for word in reserved())
def test_the_operators_own_aliases_are_reserved_too(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
custom = tmp_path / "aliases_custom.json"
custom.write_text(json.dumps({"cat": ["!zeta"]}), encoding="utf-8")
monkeypatch.setenv(CUSTOM_ALIASES_ENV, str(custom))
assert "!zeta" in reserved()
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
with pytest.raises(ConfigError, match="already a command"):
add_person(directory, ["zeta"])
def test_a_person_is_appended_with_a_stable_key(tmp_path: Path) -> None:
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
assert add_person(directory, ["delta"]) == "p2"
document = json.loads((directory / PEOPLE_FILE).read_text(encoding="utf-8"))
assert document["people"][0] == {"key": "p1", "triggers": ["!alfa"]}
assert document["people"][1] == {"key": "p2", "triggers": ["!delta"]}
def test_a_person_can_be_added_to_an_album_with_no_roster_yet(tmp_path: Path) -> None:
(tmp_path / PEOPLE_FILE).write_text(json.dumps({"groups": [WATCHED_GROUP]}), encoding="utf-8")
assert add_person(tmp_path, ["!alfa"]) == "p1"
def test_a_person_with_no_triggers_is_refused(tmp_path: Path) -> None:
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
with pytest.raises(ConfigError, match="at least one trigger"):
add_person(directory, [" "])
def test_a_trigger_that_is_already_a_command_is_refused(tmp_path: Path) -> None:
# !kot would make personal_data_sources find "kot" in aliases.json and
# block every commit forever, fixable only by editing a private album.
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
with pytest.raises(ConfigError, match="already a command"):
add_person(directory, ["kot"])
def test_a_trigger_with_a_digit_is_refused_and_nothing_is_written(tmp_path: Path) -> None:
# Folding maps 0134578 onto letters, so a digit is ambiguous by design.
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
before = (directory / PEOPLE_FILE).read_bytes()
with pytest.raises(ConfigError):
add_person(directory, ["!alfa2"])
assert (directory / PEOPLE_FILE).read_bytes() == before
def test_a_trigger_colliding_with_an_existing_person_is_refused(tmp_path: Path) -> None:
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
before = (directory / PEOPLE_FILE).read_bytes()
with pytest.raises(ConfigError):
add_person(directory, ["!alfa"])
assert (directory / PEOPLE_FILE).read_bytes() == before
def test_a_trigger_colliding_with_a_custom_label_is_refused(tmp_path: Path) -> None:
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
(directory / CUSTOM_FILE).write_text(json.dumps({"t1": ["wakacje"]}), encoding="utf-8")
with pytest.raises(ConfigError):
add_person(directory, ["wakacje"])
def test_a_failed_add_restores_a_roster_that_did_not_exist(tmp_path: Path) -> None:
with pytest.raises(ConfigError):
add_person(tmp_path, ["!alfa"])
assert not (tmp_path / PEOPLE_FILE).exists()
def test_a_roster_that_is_not_json_is_refused_rather_than_overwritten(tmp_path: Path) -> None:
# Strict, unlike the ledger: appending to a document that failed to parse
# would write a roster that silently dropped everyone already in it.
(tmp_path / PEOPLE_FILE).write_text("{oops", encoding="utf-8")
with pytest.raises(ConfigError, match="not valid JSON"):
add_person(tmp_path, ["!alfa"])
def test_a_roster_that_is_not_an_object_is_refused(tmp_path: Path) -> None:
(tmp_path / PEOPLE_FILE).write_text("[]", encoding="utf-8")
with pytest.raises(ConfigError, match="not an object"):
add_person(tmp_path, ["!alfa"])
def test_merge_keeps_existing_keys_where_they_are() -> None:
# The parseSetup bug, pinned: positional keys renumber everyone after an
# insertion and repoint every tags.json entry at the wrong person.
document = {"people": [{"key": "p1", "triggers": ["!alfa"]}, {"key": "p2", "triggers": ["!b"]}]}
merged = merge(document, [{"key": "p2", "triggers": ["!b", "!beta"]}])
assert [person["key"] for person in merged] == ["p1", "p2"]
assert merged[1]["triggers"] == ["!b", "!beta"]
def test_merge_appends_someone_new_with_a_fresh_key() -> None:
document = {"people": [{"key": "p1", "triggers": ["!alfa"]}]}
merged = merge(document, [{"triggers": ["!delta"]}])
assert merged[1]["key"] == "p2"
def test_merge_on_an_empty_document() -> None:
assert merge({}, [{"triggers": ["!alfa"]}])[0]["key"] == "p1"