signal-bot/tests/manage_fixture.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

125 lines
4.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""A whole management app, pointed entirely at a temp directory.
Every path the app touches is a field on ManageDeps, so nothing here can
reach the real .env or the real album. The bot at the other end is an
httpx.MockTransport, so no test needs a container, a network or a phone.
Deliberately a builder rather than a fixture: the tests want apps whose bot
is up, down, or answering nonsense, and saying which belongs in the test.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import httpx
from fastapi.testclient import TestClient
from signal_bot.config import Config
from signal_bot.manage.app import build_app
from signal_bot.manage.deps import ManageDeps
from signal_bot.pack import MEDIA_DIR, PEOPLE_FILE, TAGS_FILE
from signal_bot.pack_album import Album
from tests.conftest import WATCHED_GROUP
from tests.pack_fixture import PIXEL
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
PORT = 8140
# The guard refuses anything that is not loopback, so every client in the
# suite has to say so -- which is also the only way the refusal gets tested.
BASE_URL = f"http://127.0.0.1:{PORT}"
# Invented, and short on purpose: the personal-data gate blocks anything
# matching group.<16+ base64 chars>, so a realistic-looking fixture id would
# make this repository refuse every future commit.
FIXTURE_GROUP = "group.FIXTURE-ALPHA"
ENV_TEXT = """\
# The account, in +E.164 form.
PHONE_NUMBER=+15550000000
# Empty means every group this account is in.
GROUP_IDS=
COOLDOWN_SECONDS=10
"""
HEALTHY = {"status": "ok", "version": "0.0.0", "pope": "Petrus", "papacy": "nr 1"}
def album_at(root: Path) -> Album:
"""A two-item album with one person, on disk under ``root``."""
media = root / MEDIA_DIR
media.mkdir(parents=True)
(media / "one.png").write_bytes(PIXEL)
(media / "two.png").write_bytes(PIXEL)
(root / PEOPLE_FILE).write_text(
json.dumps({"groups": [WATCHED_GROUP], "people": [{"key": "p1", "triggers": ["!alfa"]}]}),
encoding="utf-8",
)
(root / TAGS_FILE).write_text(json.dumps({"one.png": ["p1"]}), encoding="utf-8")
return Album(root)
def bot_is_up(request: httpx.Request) -> httpx.Response:
"""A container and a bot that both answer normally."""
if request.url.path == "/healthz":
return httpx.Response(200, json=HEALTHY)
if request.url.path.endswith("/_selftest"):
return httpx.Response(200, json={"status": "delivered", "group": FIXTURE_GROUP})
return httpx.Response(200, json=[{"id": FIXTURE_GROUP, "name": "alfa"}, {"name": "no id here"}])
def bot_is_down(_request: httpx.Request) -> httpx.Response:
"""Nothing at the other end at all."""
msg = "connection refused"
raise httpx.ConnectError(msg)
def answers_junk(_request: httpx.Request) -> httpx.Response:
"""Something is listening, but it is not speaking JSON."""
return httpx.Response(200, text="<html>not json</html>")
def deps_at(
tmp_path: Path,
handler: Callable[[httpx.Request], httpx.Response] = bot_is_up,
*,
built: bool = False,
) -> ManageDeps:
"""Everything a management app needs, all of it under ``tmp_path``."""
env_path = tmp_path / ".env"
env_path.write_text(ENV_TEXT, encoding="utf-8")
env_path.chmod(0o600)
dist = tmp_path / "dist"
if built:
(dist / "assets").mkdir(parents=True)
(dist / "assets" / "app.js").write_text("// built", encoding="utf-8")
(dist / "index.html").write_text("<title>built</title>", encoding="utf-8")
return ManageDeps(
config=Config(phone_number="+15550000000"),
env_path=env_path,
dist_dir=dist,
album=album_at(tmp_path / "pack"),
# Both under tmp_path, and neither has a default. Config's own
# database_path is the *relative* "signal_bot.sqlite3" and pytest
# runs from the repo root, so a defaulted field here would point
# the suite at the operator's real database -- which Store would
# then open and, on a version mismatch, partly drop.
database_path=tmp_path / "signal_bot.sqlite3",
export_path=tmp_path / "export" / "DOCS-suggestions.md",
aliases_path=tmp_path / "aliases_custom.json",
client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
port=PORT,
)
def client_for(deps: ManageDeps) -> TestClient:
"""A test client that talks to the app the way a browser on this box does."""
return TestClient(build_app(deps), base_url=BASE_URL)