# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The deafness gate: silence plus a failing probe is not health.""" from __future__ import annotations import logging from typing import TYPE_CHECKING import httpx from signal_bot.liveness import Liveness if TYPE_CHECKING: from collections.abc import Callable import pytest from signal_bot.config import Config DEAD_BODY = {"error": "Failed to get linked devices: Closed unexpectedly"} class Clock: """A monotonic clock the test moves by hand.""" def __init__(self) -> None: self.seconds = 0.0 def __call__(self) -> float: return self.seconds def gate( config: Config, handler: Callable[[httpx.Request], httpx.Response], clock: Clock, *, slept: list[float] | None = None, ) -> Liveness: """A Liveness wired to a mock API and a hand-cranked clock.""" async def sleeper(seconds: float) -> None: if slept is not None: slept.append(seconds) return Liveness( config, httpx.AsyncClient(transport=httpx.MockTransport(handler)), ticker=clock, sleeper=sleeper, ) async def test_it_starts_healthy_without_asking_anybody(config: Config) -> None: """A cold start must not 503: the installer waits on /healthz.""" calls: list[str] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(str(request.url)) return httpx.Response(200, json=[]) assert gate(config, handler, Clock()).healthy is True assert calls == [] async def test_a_live_account_answers_the_probe(config: Config) -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/v1/devices/+15550000000" return httpx.Response(200, json=[{"id": 1}]) assert await gate(config, handler, Clock()).probe() is True async def test_the_real_outage_shape_fails_the_probe(config: Config) -> None: live = gate(config, lambda r: httpx.Response(400, json=DEAD_BODY), Clock()) assert await live.probe() is False assert "Closed unexpectedly" in live.reason async def test_a_hanging_api_fails_the_probe_rather_than_the_loop(config: Config) -> None: """/v1/devices was seen to hang past 20s during the outage.""" timed_out = "timed out" def handler(request: httpx.Request) -> httpx.Response: raise httpx.TimeoutException(timed_out, request=request) live = gate(config, handler, Clock()) assert await live.probe() is False assert "TimeoutException" in live.reason async def test_silence_alone_never_probes(config: Config) -> None: """A quiet group is normal; only long silence is worth a round trip.""" calls: list[str] = [] clock = Clock() slept: list[float] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(str(request.url)) return httpx.Response(200, json=[]) live = gate(config, handler, clock, slept=slept) await live.run(iterations=2) assert slept == [30.0, 30.0] assert calls == [] assert live.healthy is True async def test_a_successful_probe_resets_the_silence_clock(config: Config) -> None: """One probe per silence window, not one per tick.""" calls: list[str] = [] clock = Clock() def handler(request: httpx.Request) -> httpx.Response: calls.append(str(request.url)) clock.seconds += 30.0 return httpx.Response(200, json=[]) live = gate(config, handler, clock) async def tick() -> None: clock.seconds += 30.0 await live.run(iterations=1) for _ in range(8): await tick() assert len(calls) == 2 assert live.healthy is True async def test_three_failures_make_the_bot_admit_it_is_deaf( config: Config, caplog: pytest.LogCaptureFixture ) -> None: clock = Clock() live = gate(config, lambda r: httpx.Response(400, json=DEAD_BODY), clock) with caplog.at_level(logging.WARNING): for _ in range(3): clock.seconds += 200.0 await live.run(iterations=1) if live.healthy: assert "cannot reach Signal" in caplog.text assert live.healthy is False assert "nothing received for" in live.reason assert caplog.text.count("cannot reach Signal") == 3 # A fourth failure must not say "failure 4 of 3": the verdict is in. with caplog.at_level(logging.WARNING): clock.seconds += 200.0 await live.run(iterations=1) assert caplog.text.count("cannot reach Signal") == 3 assert "still deaf: 4 failed probe(s)" in caplog.text async def test_one_frame_clears_the_alarm(config: Config, caplog: pytest.LogCaptureFixture) -> None: clock = Clock() live = gate(config, lambda r: httpx.Response(400, json=DEAD_BODY), clock) for _ in range(3): clock.seconds += 200.0 await live.run(iterations=1) assert live.healthy is False with caplog.at_level(logging.WARNING): live.note_envelope() assert live.healthy is True assert "recovered after 3 failed probe(s)" in caplog.text assert live.silent_for == 0.0 async def test_a_frame_while_healthy_says_nothing( config: Config, caplog: pytest.LogCaptureFixture ) -> None: """The recovery line must mean something, so it cannot fire per frame.""" live = gate(config, lambda r: httpx.Response(200, json=[]), Clock()) with caplog.at_level(logging.WARNING): live.note_envelope() assert caplog.text == ""