# 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.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_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"