signal-bot/tests/test_fake_server.py
Krzysztof kuhy Rudnicki 558dd297a4 release: 0.0.7 «Telesfor» — !papież, and the bot can test itself
Adds !papież / !papiez / !pope: a random pope as a photo, captioned with his
Latin name, reign and pl.wikipedia link. popes.json already carried a Commons
portrait filename for all 267 and nothing had ever consumed it.

The Latin name comes from the Latin column of Poczet papieży, not from the
la.wikipedia sitelink. The sitelink resolved for all 267 and was still wrong:
la.wikipedia numbers several popes differently, so #154 came out as
"Stefan IX — Stephanus X" in a single caption, which reads as an off-by-one.
The table's own column says "Stephanus Nonus" and cannot disagree with the
name beside it. LATIN_OVERRIDES ships empty; it is a tripwire, not a backlog.

Portraits are cached on disk under CacheDirectory=signal-bot, named from the
ordinal so no upstream string ever reaches a path, and bounded at 267 entries
so nothing needs evicting. Verified that CacheDirectory wins against
ProtectHome=read-only before relying on it.

Also adds POST /_selftest and scripts/selftest.sh. Signal never delivers an
account its own sends, so no command has ever been exercised end-to-end
without asking a human to type it. This injects a real frame into the live
dispatcher; it is loopback-only and refuses any group not in SELFTEST_GROUPS,
which is empty by default.

Verified against the real endpoints, not just the fake: 267/267 portraits
resolve on Commons, regeneration is byte-identical on every previously
shipped field, and !papież was driven 7 times through the live dispatcher in
testbot, returning distinct popes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 17:06:25 +02:00

115 lines
4.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The fake Signal server's own behaviour."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from fake_signal.server import create_app, default_state
from fake_signal.state import PIXEL_PNG, State
@pytest.fixture
def state() -> State:
return State(base_url="http://testserver")
@pytest.fixture
def client(state: State) -> TestClient:
return TestClient(create_app(state))
def test_about_reports_a_websocket_mode(client: TestClient) -> None:
body = client.get("/v1/about").json()
assert body["mode"] == "json-rpc"
def test_qrcodelink_returns_a_png(client: TestClient) -> None:
"""The real endpoint returns an image, which is why the runbook opens it."""
response = client.get("/v1/qrcodelink", params={"device_name": "bot"})
assert response.headers["content-type"] == "image/png"
assert response.headers["X-Device-Name"] == "bot"
assert response.content == PIXEL_PNG
def test_send_is_captured_and_mints_attachment_ids(client: TestClient, state: State) -> None:
client.post("/v2/send", json={"message": "hi", "recipients": ["g"]})
client.post("/v2/send", json={"base64_attachments": ["A"], "recipients": ["g"]})
assert len(state.sent) == 2
assert state.attachments == ["attach-2-0"]
def test_attachments_can_be_listed_and_deleted(client: TestClient) -> None:
client.post("/v2/send", json={"base64_attachments": ["A"], "recipients": ["g"]})
listed = client.get("/v1/attachments").json()
assert listed == ["attach-1-0"]
assert client.delete(f"/v1/attachments/{listed[0]}").status_code == 204
assert client.get("/v1/attachments").json() == []
def test_deleting_an_unknown_attachment_is_a_noop(client: TestClient) -> None:
assert client.delete("/v1/attachments/nope").status_code == 204
def test_reset_clears_captured_state(client: TestClient, state: State) -> None:
client.post("/v2/send", json={"base64_attachments": ["A"], "recipients": ["g"]})
client.post("/_control/reset")
assert state.sent == []
assert state.attachments == []
def test_websocket_subscribes_and_receives_injected_frames(client: TestClient) -> None:
with client.websocket_connect("/v1/receive/+15550000000") as socket:
assert socket.receive_json()["envelope"]["subscribed"] == "+15550000000"
assert client.get("/_control/ready").json() == {"sockets": 1}
assert client.post("/_control/inject", json={"raw": "FRAME"}).json() == {"delivered": 1}
assert socket.receive_text() == "FRAME"
def test_socket_is_forgotten_after_disconnect(client: TestClient) -> None:
with client.websocket_connect("/v1/receive/+1") as socket:
socket.receive_json()
assert client.get("/_control/ready").json() == {"sockets": 0}
def test_inject_with_no_listeners_delivers_nothing(client: TestClient) -> None:
assert client.post("/_control/inject", json={"raw": "x"}).json() == {"delivered": 0}
def test_fixture_apis_mirror_the_real_response_shapes(client: TestClient) -> None:
cat = client.get("/_fixtures/cat-api").json()
dog = client.get("/_fixtures/dog-api").json()
gecko = client.get("/_fixtures/gecko-api").json()
assert cat[0]["url"].endswith("/_fixtures/image.png")
assert dog["message"].endswith("/_fixtures/image.png")
assert gecko["results"][0]["photos"][0]["url"].endswith("/_fixtures/photos/square.png")
commons = client.get("/_fixtures/commons-api", params={"titles": "File:8-St.jpg"}).json()
page = commons["query"]["pages"][0]
# thumburl only, never url: the bot must ask for a bounded thumbnail.
# One committed portrait is 37 MB, which is not a Signal attachment.
assert page["imageinfo"][0]["thumburl"].endswith("/_fixtures/image.png")
assert "url" not in page["imageinfo"][0]
assert page["title"] == "File:8-St.jpg"
assert client.get("/_fixtures/image.png").content == PIXEL_PNG
# The square URL is deliberately absent: the bot must rewrite it.
assert client.get("/_fixtures/photos/large.png").content == PIXEL_PNG
assert client.get("/_fixtures/photos/square.png").status_code == 404
def test_create_app_makes_its_own_state_when_none_given() -> None:
with TestClient(create_app()) as client:
assert client.get("/v1/about").status_code == 200
def test_default_state_reads_the_port_from_the_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FAKE_PORT", "9123")
assert default_state().base_url.endswith(":9123")
def test_control_sent_exposes_captured_payloads(client: TestClient) -> None:
client.post("/v2/send", json={"message": "hi", "recipients": ["R=="]})
assert client.get("/_control/sent").json() == [{"message": "hi", "recipients": ["R=="]}]