# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The bbernhard adapter: sends, attachments, mode check and reconnect.""" from __future__ import annotations from collections.abc import AsyncIterator, Callable from typing import TYPE_CHECKING import httpx import pytest from websockets.exceptions import ConnectionClosedError from signal_bot.quoting import Quote from signal_bot.transport_rest import RestTransport, TransportError if TYPE_CHECKING: from types import TracebackType from signal_bot.config import Config # The callable httpx.MockTransport expects -- not a bot command handler. MockHandler = Callable[[httpx.Request], httpx.Response] class FakeSocket: """Yields a canned list of frames, optionally failing partway through.""" def __init__(self, frames: list[str], *, fail_after: bool = False) -> None: self._frames = frames self._fail_after = fail_after async def __aiter__(self) -> AsyncIterator[str]: for frame in self._frames: yield frame if self._fail_after: raise ConnectionClosedError(None, None) class FakeConnect: """Stands in for websockets.connect, handing out sockets in order.""" def __init__(self, sockets: list[FakeSocket]) -> None: self._sockets = sockets self.urls: list[str] = [] def __call__(self, url: str) -> FakeConnect: self.urls.append(url) return self async def __aenter__(self) -> FakeSocket: if not self._sockets: msg = "no more sockets" raise OSError(msg) return self._sockets.pop(0) async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: return None def _transport(config: Config, handler: MockHandler, **kwargs: object) -> RestTransport: client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) return RestTransport(config, client, **kwargs) async def test_send_text_posts_expected_payload(config: Config) -> None: seen: list[dict] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(__import__("json").loads(request.content)) return httpx.Response(201) await _transport(config, handler).send_text("hi", "group==") assert seen[0] == { "message": "hi", "number": config.phone_number, "recipients": ["group=="], } async def test_a_quoted_text_send_carries_the_quote_keys(config: Config) -> None: """This is what makes the bot answer a message rather than the room.""" seen: list[dict] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(__import__("json").loads(request.content)) return httpx.Response(201) quote = Quote(1719177917717, "uuid-alice", "!cat") await _transport(config, handler).send_text("hi", "group==", quote) assert seen[0]["quote_timestamp"] == 1719177917717 assert seen[0]["quote_author"] == "uuid-alice" assert seen[0]["quote_message"] == "!cat" async def test_a_quoted_image_send_carries_the_quote_keys(config: Config) -> None: seen: list[dict] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(__import__("json").loads(request.content)) return httpx.Response(201) quote = Quote(17, "uuid-bob", "!dog") await _transport(config, handler).send_image("Zm9v", "group==", quote) assert seen[0]["base64_attachments"] == ["Zm9v"] assert seen[0]["quote_timestamp"] == 17 assert seen[0]["quote_author"] == "uuid-bob" async def test_send_image_posts_base64_attachment(config: Config) -> None: seen: list[dict] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(__import__("json").loads(request.content)) return httpx.Response(200) await _transport(config, handler).send_image("BASE64", "group==") assert seen[0]["base64_attachments"] == ["BASE64"] async def test_send_raises_on_rejection(config: Config) -> None: transport = _transport(config, lambda r: httpx.Response(400, text="nope")) with pytest.raises(TransportError, match="400"): await transport.send_text("hi", "g") async def test_verify_mode_accepts_json_rpc(config: Config) -> None: transport = _transport(config, lambda r: httpx.Response(200, json={"mode": "json-rpc"})) assert await transport.verify_mode() == "json-rpc" async def test_verify_mode_rejects_the_native_build(config: Config) -> None: """It serves the same WebSocket and then fails every attachment send.""" transport = _transport(config, lambda r: httpx.Response(200, json={"mode": "json-rpc-native"})) with pytest.raises(TransportError, match="awt"): await transport.verify_mode() @pytest.mark.parametrize("body", [{"mode": "normal"}, {"mode": "native"}, {}, ["nope"]]) async def test_verify_mode_rejects_non_websocket_modes(config: Config, body: object) -> None: """In normal/native mode /v1/receive silently degrades to a polling GET.""" transport = _transport(config, lambda r: httpx.Response(200, json=body)) with pytest.raises(TransportError, match="not 'json-rpc'"): await transport.verify_mode() async def test_receive_yields_frames(config: Config) -> None: connector = FakeConnect([FakeSocket(["a", "b"])]) transport = _transport( config, lambda r: httpx.Response(200), connector=connector, sleeper=_no_sleep, max_reconnects=0, ) assert [f async for f in transport.receive()] == ["a", "b"] async def test_receive_decodes_binary_frames(config: Config) -> None: connector = FakeConnect([FakeSocket([b"bytes-frame"])]) transport = _transport( config, lambda r: httpx.Response(200), connector=connector, sleeper=_no_sleep, max_reconnects=0, ) assert [f async for f in transport.receive()] == ["bytes-frame"] async def test_receive_reconnects_after_a_drop(config: Config) -> None: """A container restart used to kill the bot silently.""" slept: list[float] = [] async def sleeper(seconds: float) -> None: slept.append(seconds) connector = FakeConnect([FakeSocket(["a"], fail_after=True), FakeSocket(["b"])]) transport = _transport( config, lambda r: httpx.Response(200), connector=connector, sleeper=sleeper, max_reconnects=1, ) assert [f async for f in transport.receive()] == ["a", "b"] assert slept == [1.0] async def test_receive_backs_off_exponentially(config: Config) -> None: slept: list[float] = [] async def sleeper(seconds: float) -> None: slept.append(seconds) connector = FakeConnect([]) transport = _transport( config, lambda r: httpx.Response(200), connector=connector, sleeper=sleeper, max_reconnects=3, ) assert [f async for f in transport.receive()] == [] assert slept == [1.0, 2.0, 4.0] async def _no_sleep(seconds: float) -> None: return None def _groups(*entries: object) -> MockHandler: """Answer /v1/groups with an arbitrary payload.""" return lambda r: httpx.Response(200, json=list(entries))