"""Tests for the worker-side heartbeat loop (decnet.agent.heartbeat).""" from __future__ import annotations import asyncio from typing import Any import httpx import pytest from decnet.agent import heartbeat as hb @pytest.fixture(autouse=True) def _reset_module_task(monkeypatch: pytest.MonkeyPatch): # Each test gets a fresh _task slot so start()/stop() state doesn't # leak between cases. monkeypatch.setattr(hb, "_task", None) yield monkeypatch.setattr(hb, "_task", None) class _StubTransport(httpx.AsyncBaseTransport): """Record each POST and respond according to ``responder(req)``.""" def __init__(self, responder): self.calls: list[dict[str, Any]] = [] self._responder = responder async def handle_async_request(self, request: httpx.Request) -> httpx.Response: body = request.read() self.calls.append({"url": str(request.url), "body": body}) return self._responder(request) @pytest.mark.asyncio async def test_tick_posts_status_snapshot_and_accepts_204(monkeypatch) -> None: async def fake_status() -> dict: return {"deployed": False, "deckies": []} monkeypatch.setattr(hb._exec, "status", fake_status) transport = _StubTransport(lambda req: httpx.Response(204)) async with httpx.AsyncClient(transport=transport) as client: await hb._tick(client, "https://m/swarm/heartbeat", "uuid-a", "1.2.3") assert len(transport.calls) == 1 import json payload = json.loads(transport.calls[0]["body"]) assert payload["host_uuid"] == "uuid-a" assert payload["agent_version"] == "1.2.3" assert payload["status"]["deployed"] is False @pytest.mark.asyncio async def test_tick_logs_on_non_204_response(monkeypatch, caplog) -> None: async def fake_status() -> dict: return {"deployed": False} monkeypatch.setattr(hb._exec, "status", fake_status) transport = _StubTransport(lambda req: httpx.Response(403, text="mismatch")) async with httpx.AsyncClient(transport=transport) as client: with caplog.at_level("WARNING", logger="agent.heartbeat"): await hb._tick(client, "https://m/swarm/heartbeat", "uuid-a", "1.2.3") assert any("rejected" in rec.getMessage() for rec in caplog.records) def test_start_is_noop_when_identity_missing(monkeypatch) -> None: # Neither DECNET_HOST_UUID nor DECNET_MASTER_HOST set → start() must # return None, never raise. Dev runs exercise this path every time. import decnet.env as env monkeypatch.setattr(env, "DECNET_HOST_UUID", None) monkeypatch.setattr(env, "DECNET_MASTER_HOST", None) assert hb.start() is None assert hb._task is None @pytest.mark.asyncio async def test_start_is_noop_when_ssl_context_unavailable( monkeypatch, tmp_path ) -> None: # Identity plumbed, but worker bundle missing on disk → start() logs # and bails instead of crashing the FastAPI app. import decnet.env as env monkeypatch.setattr(env, "DECNET_HOST_UUID", "uuid-a") monkeypatch.setattr(env, "DECNET_MASTER_HOST", "master.lan") monkeypatch.setattr(env, "DECNET_SWARMCTL_PORT", 8770) monkeypatch.setenv("DECNET_AGENT_DIR", str(tmp_path / "empty")) assert hb.start() is None @pytest.mark.asyncio async def test_loop_keeps_ticking_after_5xx_failures(monkeypatch) -> None: # Simulates a flapping master: first two ticks raise/5xx, third succeeds. # The loop must not crash — it must sleep and retry. call_count = {"n": 0} def _responder(req): call_count["n"] += 1 if call_count["n"] < 3: return httpx.Response(503, text="unavailable") return httpx.Response(204) async def fake_status() -> dict: return {"deployed": False} monkeypatch.setattr(hb._exec, "status", fake_status) monkeypatch.setattr(hb, "INTERVAL_S", 0.01) # fast-forward the sleep transport = _StubTransport(_responder) async def _run(): async with httpx.AsyncClient(transport=transport) as client: while call_count["n"] < 3: try: await hb._tick(client, "https://m/swarm/heartbeat", "uuid-a", "1.2.3") except Exception: pass await asyncio.sleep(0.01) await asyncio.wait_for(_run(), timeout=2.0) assert call_count["n"] >= 3