Implements all P0 deliverables for the Lazarus Pit epic. - docs/lazarus-pit/mission-cell-spec.md: Canonical `/var/missions/<uuid>/` directory layout, file schemas (cell.json, events.jsonl, heartbeat, daemon state), lifecycle state machine, and isolation guarantees. - bin/lazarus_pit.py: Daemon skeleton with config loading, cell discovery, per-agent heartbeat polling, stale detection, resurrection stub (P3 placeholder), graceful signal handling, PID file management, and CLI subcommands (--status, --list-cells). - config/lazarus-pit.toml: Documented config file with all tunable knobs: cells root, heartbeat thresholds, revive policy, gateway coordinates, Gitea notification settings. - server.py: Added HTTP health heartbeat endpoint on NEXUS_HEALTH_PORT (default 8766). GET /health returns JSON with status, uptime, connected_clients, ws_port, and timestamp. Consumed by lazarus-pit to verify gateway liveness. Runs in a background daemon thread — zero impact on existing WS logic. - .gitea/ISSUE_TEMPLATE/mission-proposal.md: Gitea issue template for proposing new mission cells (agents, scope, isolation requirements, cell config, success criteria). - tests/test_lazarus_pit.py: 25 unit tests covering config, cell discovery, heartbeat reading, health polling, resurrection stub, and CLI. - tests/test_gateway_health.py: 6 unit tests covering health endpoint schema, 404 for unknown paths, uptime, and client count reflection. All 31 new tests pass. Pre-existing test failures unchanged. Fixes #879 Refs #878 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
130 lines
4.1 KiB
Python
130 lines
4.1 KiB
Python
"""Tests for the gateway health heartbeat endpoint (#879 — M6-P0 Foundation).
|
|
|
|
Validates:
|
|
- /health returns 200 with correct JSON schema
|
|
- /health reports connected_clients count
|
|
- Non-/health paths return 404
|
|
- Uptime is non-negative
|
|
"""
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
import time
|
|
from http.server import HTTPServer
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
# ── Load server module directly ──────────────────────────────────────────────
|
|
PROJECT_ROOT = Path(__file__).parent.parent
|
|
|
|
# Patch websockets import so we don't need the package installed to test health
|
|
_ws_mock = MagicMock()
|
|
sys.modules.setdefault("websockets", _ws_mock)
|
|
|
|
_srv_spec = importlib.util.spec_from_file_location(
|
|
"nexus_server_test",
|
|
PROJECT_ROOT / "server.py",
|
|
)
|
|
_srv = importlib.util.module_from_spec(_srv_spec)
|
|
sys.modules["nexus_server_test"] = _srv
|
|
_srv_spec.loader.exec_module(_srv)
|
|
|
|
_HealthHandler = _srv._HealthHandler
|
|
|
|
|
|
# ── Fake request helper ───────────────────────────────────────────────────────
|
|
|
|
class _FakeRequest:
|
|
"""Minimal socket-like object for BaseHTTPRequestHandler testing."""
|
|
|
|
def __init__(self, raw_bytes: bytes):
|
|
self._buf = BytesIO(raw_bytes)
|
|
self.sent = BytesIO()
|
|
|
|
def makefile(self, mode, **kwargs):
|
|
if "r" in mode:
|
|
return self._buf
|
|
return self.sent
|
|
|
|
def sendall(self, data: bytes):
|
|
self.sent.write(data)
|
|
|
|
|
|
def _invoke_handler(path: str) -> tuple[int, dict]:
|
|
"""Call the health handler for a GET request and return (status_code, body_dict)."""
|
|
raw = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode()
|
|
request = _FakeRequest(raw)
|
|
|
|
handler = _HealthHandler.__new__(_HealthHandler)
|
|
handler.rfile = BytesIO(raw)
|
|
handler.wfile = request.sent
|
|
handler.client_address = ("127.0.0.1", 9999)
|
|
handler.server = MagicMock()
|
|
handler.request_version = "HTTP/1.1"
|
|
handler.command = "GET"
|
|
handler.path = path
|
|
handler.headers = {}
|
|
|
|
# Capture response
|
|
responses: list[tuple] = []
|
|
handler.send_response = lambda code, *a: responses.append(("status", code))
|
|
handler.send_header = lambda k, v: None
|
|
handler.end_headers = lambda: None
|
|
|
|
body_parts: list[bytes] = []
|
|
handler.wfile = MagicMock()
|
|
handler.wfile.write = lambda b: body_parts.append(b)
|
|
|
|
handler.do_GET()
|
|
|
|
status = responses[0][1] if responses else None
|
|
body = {}
|
|
if body_parts:
|
|
try:
|
|
body = json.loads(b"".join(body_parts))
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
return status, body
|
|
|
|
|
|
# ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
class TestHealthEndpoint:
|
|
def test_health_returns_200(self):
|
|
status, _ = _invoke_handler("/health")
|
|
assert status == 200
|
|
|
|
def test_health_body_schema(self):
|
|
_, body = _invoke_handler("/health")
|
|
assert body.get("status") == "ok"
|
|
assert body.get("service") == "nexus-gateway"
|
|
assert "uptime_seconds" in body
|
|
assert "connected_clients" in body
|
|
assert "ws_port" in body
|
|
assert "ts" in body
|
|
|
|
def test_uptime_is_non_negative(self):
|
|
_, body = _invoke_handler("/health")
|
|
assert body["uptime_seconds"] >= 0
|
|
|
|
def test_unknown_path_returns_404(self):
|
|
status, _ = _invoke_handler("/notfound")
|
|
assert status == 404
|
|
|
|
def test_root_path_returns_404(self):
|
|
status, _ = _invoke_handler("/")
|
|
assert status == 404
|
|
|
|
def test_connected_clients_reflects_module_state(self):
|
|
original = _srv.clients.copy()
|
|
try:
|
|
_srv.clients.clear()
|
|
_, body = _invoke_handler("/health")
|
|
assert body["connected_clients"] == 0
|
|
finally:
|
|
_srv.clients.update(original)
|