"""Tests for the periodic health-check probe.""" from __future__ import annotations from datetime import UTC, datetime from pathlib import Path import pytest from pytest_httpx import HTTPXMock from cerbero_bite.config import golden_config from cerbero_bite.config.mcp_endpoints import load_endpoints from cerbero_bite.runtime import build_runtime from cerbero_bite.runtime.health_check import HealthCheck pytestmark = pytest.mark.httpx_mock(assert_all_responses_were_requested=False) def _now() -> datetime: return datetime(2026, 4, 27, 14, 0, tzinfo=UTC) def _ctx(tmp_path: Path): return build_runtime( cfg=golden_config(), endpoints=load_endpoints(env={}), token="t", db_path=tmp_path / "state.sqlite", audit_path=tmp_path / "audit.log", retry_max=1, clock=_now, ) def _wire_all_ok(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/environment_info", json={ "exchange": "deribit", "environment": "testnet", "source": "env", "env_value": "true", "base_url": "https://test.deribit.com/api/v2", "max_leverage": 3, }, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-macro:9013/tools/get_macro_calendar", json={"events": []}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding", json={"snapshot": {}}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-hyperliquid:9012/tools/get_funding_rate", json={"asset": "ETH", "current_funding_rate": 0.0001}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", json={"total_value_eur": 1000.0}, is_reusable=True, ) @pytest.mark.asyncio async def test_all_services_ok_emits_health_ok( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: ctx = _ctx(tmp_path) hc = HealthCheck(ctx, expected_environment="testnet") _wire_all_ok(httpx_mock) res = await hc.run() assert res.state == "ok" assert res.consecutive_failures == 0 @pytest.mark.asyncio async def test_environment_mismatch_counts_as_failure( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: ctx = _ctx(tmp_path) hc = HealthCheck(ctx, expected_environment="testnet") httpx_mock.add_response( url="http://mcp-deribit:9011/tools/environment_info", json={ "exchange": "deribit", "environment": "mainnet", "source": "env", "env_value": "false", "base_url": "https://www.deribit.com/api/v2", "max_leverage": 3, }, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-macro:9013/tools/get_macro_calendar", json={"events": []}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding", json={"snapshot": {}}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-hyperliquid:9012/tools/get_funding_rate", json={"asset": "ETH", "current_funding_rate": 0.0001}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", json={"total_value_eur": 1000.0}, is_reusable=True, ) res = await hc.run() assert res.state == "degraded" assert any("environment mismatch" in r for _s, r in res.failures) @pytest.mark.asyncio async def test_three_consecutive_failures_arm_kill_switch( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: ctx = _ctx(tmp_path) hc = HealthCheck(ctx, expected_environment="testnet", kill_after=3) httpx_mock.add_response( url="http://mcp-deribit:9011/tools/environment_info", status_code=500, text="boom", is_reusable=True, ) httpx_mock.add_response( url="http://mcp-macro:9013/tools/get_macro_calendar", json={"events": []}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding", json={"snapshot": {}}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-hyperliquid:9012/tools/get_funding_rate", json={"asset": "ETH", "current_funding_rate": 0.0001}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", json={"total_value_eur": 1000.0}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-telegram:9017/tools/notify_alert", json={"ok": True}, is_reusable=True, ) for _ in range(2): await hc.run() assert ctx.kill_switch.is_armed() is False res = await hc.run() assert res.consecutive_failures == 3 assert ctx.kill_switch.is_armed() is True @pytest.mark.asyncio async def test_recovered_run_resets_counter( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: ctx = _ctx(tmp_path) hc = HealthCheck(ctx, expected_environment="testnet", kill_after=10) # First run fails on deribit httpx_mock.add_response( url="http://mcp-deribit:9011/tools/environment_info", status_code=500, text="boom", ) # Other services OK httpx_mock.add_response( url="http://mcp-macro:9013/tools/get_macro_calendar", json={"events": []}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding", json={"snapshot": {}}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-hyperliquid:9012/tools/get_funding_rate", json={"asset": "ETH", "current_funding_rate": 0.0001}, is_reusable=True, ) httpx_mock.add_response( url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", json={"total_value_eur": 1000.0}, is_reusable=True, ) res = await hc.run() assert res.state == "degraded" assert res.consecutive_failures == 1 # Second run: deribit recovers httpx_mock.add_response( url="http://mcp-deribit:9011/tools/environment_info", json={ "exchange": "deribit", "environment": "testnet", "source": "env", "env_value": "true", "base_url": "https://test.deribit.com/api/v2", "max_leverage": 3, }, ) res = await hc.run() assert res.state == "ok" assert res.consecutive_failures == 0