# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Wiring, the background loops, and ASGI startup.""" from __future__ import annotations import asyncio from typing import TYPE_CHECKING import httpx import pytest from fastapi.testclient import TestClient from signal_bot import app as app_module from signal_bot.app import Bot, Runtime, build_bot, create_runtime, lifespan, now_local from signal_bot.transport_rest import TransportError from tests.conftest import REPLY_GROUP, envelope if TYPE_CHECKING: from signal_bot.config import Config from signal_bot.registry import Handler from signal_bot.store import Store from signal_bot.transport_fake import FakeTransport BASE_ENV = { "PHONE_NUMBER": "+15550000000", "GROUP_ID": "watched==", "GROUP_ID_SEND": "reply==", "DATABASE_PATH": ":memory:", } # Captured before any monkeypatching, so a patched httpx.AsyncClient cannot # recurse into this helper. _REAL_ASYNC_CLIENT = httpx.AsyncClient def _client(handler: Handler) -> httpx.AsyncClient: return _REAL_ASYNC_CLIENT(transport=httpx.MockTransport(handler)) def _bot(config: Config, store: Store, transport: FakeTransport) -> Bot: return build_bot(config, _client(lambda r: httpx.Response(200)), store, transport) def test_now_local_is_timezone_aware() -> None: assert now_local().tzinfo is not None def test_build_bot_registers_the_animal_commands( config: Config, store: Store, transport: FakeTransport ) -> None: bot = _bot(config, store, transport) assert bot.dispatcher.registry.resolve("!cat") is not None assert bot.dispatcher.registry.resolve("!pies") is not None async def test_publish_report_posts_and_resets( config: Config, store: Store, transport: FakeTransport ) -> None: store.increment("u1", "Alice") await _bot(config, store, transport).publish_report() assert transport.texts == ["Alice 1"] assert transport.sent[0].recipient == REPLY_GROUP assert store.tallies() == [] async def test_publish_report_falls_back_to_uuid_when_unnamed( config: Config, store: Store, transport: FakeTransport ) -> None: store.increment("u1", "") await _bot(config, store, transport).publish_report() assert transport.texts == ["u1 1"] async def test_listen_dispatches_every_frame( config: Config, store: Store, transport: FakeTransport ) -> None: transport.queue([envelope(text="hello", group_id=config.group_id)]) await _bot(config, store, transport).listen() assert store.tallies()[0].count == 1 async def test_create_runtime_verifies_the_container_mode(config: Config) -> None: runtime = await create_runtime( config, _client(lambda r: httpx.Response(200, json={"mode": "json-rpc-native"})) ) assert isinstance(runtime, Runtime) await runtime.stop() async def test_create_runtime_refuses_a_polling_container(config: Config) -> None: with pytest.raises(TransportError): await create_runtime( config, _client(lambda r: httpx.Response(200, json={"mode": "normal"})) ) async def test_runtime_start_and_stop_cancels_tasks( config: Config, store: Store, transport: FakeTransport ) -> None: client = _client(lambda r: httpx.Response(200)) runtime = Runtime(bot=_bot(config, store, transport), client=client, store=store) runtime.start() assert len(runtime.tasks) == 2 await asyncio.sleep(0) await runtime.stop() assert all(task.done() for task in runtime.tasks) def test_healthz_serves_and_startup_completes(monkeypatch: pytest.MonkeyPatch) -> None: """The old app never finished startup, so no route was ever reachable.""" for key, value in BASE_ENV.items(): monkeypatch.setenv(key, value) def fake_client(**kwargs: object) -> httpx.AsyncClient: return _client(lambda r: httpx.Response(200, json={"mode": "json-rpc-native"})) monkeypatch.setattr(app_module.httpx, "AsyncClient", fake_client) with TestClient(app_module.app) as client: assert client.get("/healthz").json() == {"status": "ok"} async def test_lifespan_is_an_async_context_manager( monkeypatch: pytest.MonkeyPatch, ) -> None: for key, value in BASE_ENV.items(): monkeypatch.setenv(key, value) monkeypatch.setattr( app_module.httpx, "AsyncClient", lambda **kwargs: _client(lambda r: httpx.Response(200, json={"mode": "json-rpc"})), ) async with lifespan(app_module.app): assert app_module.app.state.runtime is not None