signal-bot/tests/test_selftest.py
Krzysztof kuhy Rudnicki 4af32f8bd5 Handle frames side by side, and budget the bot's own share of a reply
Bot.listen awaited every frame to completion, so one Commons fetch or one
attachment upload held up every text reply behind it (DOCS-latency.md).
Each frame now runs in its own task, never more than LIMIT at once; the
reader waits for a slot before spawning, so a burst applies back-pressure
at the websocket instead of fanning out upstream. One person's commands
stay serialised (user_lock.py): two quick !kot would otherwise both pass
the cooldown gate before either recorded a use.

Every served command reports its timings split into the bot's own time
and everybody else's (served.py); /_selftest carries that block and
tool/latency.py holds own_ms to 100 ms at p95, with the command's
cooldown switched off for the run and put back afterwards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 21:35:34 +02:00

232 lines
8.9 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Typing a command at the bot on its own behalf."""
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import TYPE_CHECKING
import httpx
import pytest
from fastapi import Response, status
from signal_bot import app as app_module
from signal_bot.app import SelfTestRequest
from signal_bot.app import selftest as selftest_route
from signal_bot.bot import Bot, build_bot
from signal_bot.liveness import Liveness
from signal_bot.selftest import (
SelfTestError,
allowed_groups,
build_frame,
resolve_group,
run,
)
from tests.conftest import WATCHED_GROUP, mock_client
if TYPE_CHECKING:
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
GROUP_ID = "group.internal-id"
OTHER = "+48000000000"
def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot:
"""The production object graph, so !papież resolves the way it really does."""
client = mock_client(_commons)
return build_bot(config, client, store, transport, directory, Liveness(config, client))
def _commons(request: httpx.Request) -> httpx.Response:
if "commons" in str(request.url):
return httpx.Response(
200,
json={"query": {"pages": [{"imageinfo": [{"thumburl": "https://cdn/portrait.jpg"}]}]}},
)
return httpx.Response(200, content=b"portrait-bytes")
def _client(entries: list[dict[str, object]]) -> httpx.AsyncClient:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=entries)
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
def test_no_groups_are_allowed_by_default() -> None:
"""Fail closed: a loopback POST must not reach the production group."""
assert allowed_groups({}) == frozenset()
def test_the_allowlist_is_parsed_and_trimmed() -> None:
assert allowed_groups({"SELFTEST_GROUPS": " testbot , catbot ,"}) == {"testbot", "catbot"}
def test_the_frame_is_the_shape_signal_cli_emits() -> None:
body = json.loads(build_frame(GROUP_ID, OTHER, "!papież"))["envelope"]
assert body["sourceUuid"] == OTHER
assert body["dataMessage"]["message"] == "!papież"
assert body["dataMessage"]["groupInfo"] == {"groupId": GROUP_ID, "type": "DELIVER"}
assert body["timestamp"] == body["dataMessage"]["timestamp"]
async def test_resolve_group_picks_a_member_who_is_not_the_bot(config: Config) -> None:
"""The reply quotes the author, so it has to be somebody Signal knows."""
entries = [
{"name": "testbot", "internal_id": GROUP_ID, "members": [config.phone_number, OTHER]}
]
assert await resolve_group(_client(entries), config, "testbot") == (GROUP_ID, OTHER)
async def test_resolve_group_rejects_an_unknown_name(config: Config) -> None:
with pytest.raises(SelfTestError, match="no group named"):
await resolve_group(_client([]), config, "nope")
async def test_resolve_group_rejects_a_group_with_nobody_else_in_it(config: Config) -> None:
entries = [{"name": "testbot", "internal_id": GROUP_ID, "members": [config.phone_number]}]
with pytest.raises(SelfTestError, match="no usable id or no other member"):
await resolve_group(_client(entries), config, "testbot")
async def test_run_refuses_a_group_outside_the_allowlist(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
bot = _bot(config, store, transport, directory)
with pytest.raises(SelfTestError, match="refusing to post"):
await run(bot, _client([]), "realgroup", "!papiez", env={"SELFTEST_GROUPS": "testbot"})
async def test_run_feeds_the_live_dispatcher(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
"""The point of the module: the real wiring, not a reconstruction.
build_bot is what production calls, so a command that resolves here is a
command the group can actually type -- which is exactly what could not
be checked before, Signal never delivering an account its own sends.
"""
bot = _bot(config, store, transport, directory)
entries = [
{"name": "testbot", "internal_id": WATCHED_GROUP, "members": [config.phone_number, OTHER]}
]
delivered, served = await run(
bot, _client(entries), "testbot", "!papiez", env={"SELFTEST_GROUPS": "testbot"}
)
assert delivered == WATCHED_GROUP
assert transport.sent[0].image_base64 is not None
assert "pl.wikipedia.org" in transport.sent[0].caption
# The cost came back with it, and it is the bot's own path that was timed.
assert served is not None
assert (served.command, served.outcome) == ("pope", "ok")
async def test_run_reports_no_cost_for_a_frame_that_was_not_a_command(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
bot = _bot(config, store, transport, directory)
entries = [
{"name": "testbot", "internal_id": WATCHED_GROUP, "members": [config.phone_number, OTHER]}
]
env = {"SELFTEST_GROUPS": "testbot"}
_, served = await run(bot, _client(entries), "testbot", "hej", env=env)
assert served is None
assert transport.sent == []
async def test_the_endpoint_reports_connecting_before_the_bot_is_up() -> None:
"""A self-test during startup is not a failure, it is "not yet"."""
app_module.app.state.starter = SimpleNamespace(runtime=None, reason="container down")
response = Response()
body = await selftest_route(SelfTestRequest(group="testbot", text="!papiez"), response)
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
assert body == {"status": "connecting", "reason": "container down"}
async def test_the_endpoint_refuses_a_group_outside_the_allowlist(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
bot = _bot(config, store, transport, directory)
app_module.app.state.starter = SimpleNamespace(runtime=SimpleNamespace(bot=bot), reason="")
app_module.app.state.client = _client([])
response = Response()
body = await selftest_route(SelfTestRequest(group="realgroup", text="!papiez"), response)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert body["status"] == "refused"
assert transport.sent == []
async def test_the_endpoint_delivers_and_says_where(
config: Config,
store: Store,
transport: FakeTransport,
directory: GroupDirectory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("SELFTEST_GROUPS", "testbot")
bot = _bot(config, store, transport, directory)
app_module.app.state.starter = SimpleNamespace(runtime=SimpleNamespace(bot=bot), reason="")
app_module.app.state.client = _client(
[{"name": "testbot", "internal_id": WATCHED_GROUP, "members": [config.phone_number, OTHER]}]
)
response = Response()
body = await selftest_route(SelfTestRequest(group="testbot", text="!papiez"), response)
assert body["served"]["command"] == "pope"
assert body["served"]["outcome"] == "ok"
assert body["served"]["own_ms"] >= 0
assert {k: v for k, v in body.items() if k != "served"} == {
"status": "delivered",
"group": WATCHED_GROUP,
"text": "!papiez",
}
assert transport.sent[0].image_base64 is not None
async def test_the_endpoint_carries_no_cost_for_text_that_ran_nothing(
config: Config,
store: Store,
transport: FakeTransport,
directory: GroupDirectory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("SELFTEST_GROUPS", "testbot")
bot = _bot(config, store, transport, directory)
app_module.app.state.starter = SimpleNamespace(runtime=SimpleNamespace(bot=bot), reason="")
app_module.app.state.client = _client(
[{"name": "testbot", "internal_id": WATCHED_GROUP, "members": [config.phone_number, OTHER]}]
)
body = await selftest_route(SelfTestRequest(group="testbot", text="hej"), Response())
assert body == {"status": "delivered", "group": WATCHED_GROUP, "text": "hej"}
assert transport.sent == []
async def test_resolve_group_rejects_an_entry_with_no_id(config: Config) -> None:
entries = [{"name": "testbot", "members": [config.phone_number, OTHER]}]
with pytest.raises(SelfTestError, match="no usable id or no other member"):
await resolve_group(_client(entries), config, "testbot")
async def test_resolve_group_skips_past_groups_with_other_names(config: Config) -> None:
"""The bot is in several groups; only the named one may be posted into."""
entries = [
{"name": "produkcja", "internal_id": "group.real", "members": [config.phone_number, OTHER]},
{"name": "testbot", "internal_id": GROUP_ID, "members": [config.phone_number, OTHER]},
]
assert await resolve_group(_client(entries), config, "testbot") == (GROUP_ID, OTHER)