此前监控页 6 张卡只有 2 个布尔有效(版本/运行时间/检查项三张死卡, 后端 /health 从未返回对应字段),与顶栏健康点重复。 后端: - /health 补 version(importlib.metadata,失败降级 None)与 uptime_seconds(monotonic,不受时钟跳变影响) —— 激活两张死卡 - 新增 GET /admin/monitoring/upstream:bzzoiro 可达性探针, 轻量 GET 根端点,不携带 Key、不消耗采集配额;HTTP<500 视为可达 (DNS/网络/TLS 层面),业务失败由死信与日志上报 前端: Monitoring 重排为三分区仪表 - 基础设施: 存活 / DB 就绪 / 版本+运行时间 / 上游探针(延迟) - 数据管线: 最近采集成功率 / 死信待处理 / 数据缺口 / 统计覆盖 - LLM: 预测总数 / 平均延迟 / 有效率(<80% 警示色) - 顶部「需要关注」聚合条: 任一异常亮红并直达处理页,全清显示状态行 - 各信号独立容错(Promise.allSettled),单项失败只降级对应卡片 测试: 新增 test_monitoring_endpoints.py 5 例(ASGITransport 真路由 + mock 探针外呼);全量 331 passed 8 skipped
115 lines
4.1 KiB
Python
115 lines
4.1 KiB
Python
"""监控增强端点测试:/health 扩展字段 + 上游探针。
|
|
|
|
沿用 test_api_critical.py 的模式:直接调用 handler,不启动完整 app
|
|
lifespan(异步 DB 引擎与同步 TestClient 不兼容);探针的外呼用 mock。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
class TestHealthExtended:
|
|
async def _get_health(self):
|
|
"""ASGITransport 走真实路由:/health 无鉴权、不依赖 DB,不触发 lifespan。"""
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from src.api.app import app
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
return await client.get("/health")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reports_version_and_uptime(self):
|
|
resp = await self._get_health()
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["status"] == "healthy"
|
|
assert isinstance(body["uptime_seconds"], int)
|
|
assert body["uptime_seconds"] >= 0
|
|
# 包已随 pip install . 安装,元数据可读;异常时为 None(前端降级隐藏)
|
|
assert body["version"] is None or isinstance(body["version"], str)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metadata_failure_degrades_to_none(self):
|
|
"""包元数据不可读时 version=None,不影响存活判定。"""
|
|
from importlib.metadata import PackageNotFoundError
|
|
|
|
with patch("importlib.metadata.version", side_effect=PackageNotFoundError("profeto")):
|
|
resp = await self._get_health()
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["status"] == "healthy"
|
|
assert body["version"] is None
|
|
assert isinstance(body["uptime_seconds"], int)
|
|
|
|
|
|
class TestUpstreamProbe:
|
|
async def _probe_with(self, mock_client_factory):
|
|
from src.api.routes import admin_monitoring
|
|
|
|
with patch.object(admin_monitoring.httpx, "AsyncClient", mock_client_factory), \
|
|
patch.object(
|
|
admin_monitoring, "get_runtime_value",
|
|
AsyncMock(return_value="https://sports.bzzoiro.com/api/v2"),
|
|
):
|
|
return await admin_monitoring.upstream_probe()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reachable_upstream(self):
|
|
"""HTTP 200 → ok=True 且带延迟与状态码。"""
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
client = MagicMock()
|
|
client.get = AsyncMock(return_value=resp)
|
|
client.__aenter__ = AsyncMock(return_value=client)
|
|
client.__aexit__ = AsyncMock(return_value=False)
|
|
factory = MagicMock(return_value=client)
|
|
|
|
out = await self._probe_with(factory)
|
|
|
|
assert out["ok"] is True
|
|
assert out["status_code"] == 200
|
|
assert out["latency_ms"] >= 0
|
|
assert out["endpoint"].endswith("/api/v2")
|
|
client.get.assert_awaited_once_with("https://sports.bzzoiro.com/api/v2")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_5xx_counts_as_unreachable(self):
|
|
resp = MagicMock()
|
|
resp.status_code = 502
|
|
client = MagicMock()
|
|
client.get = AsyncMock(return_value=resp)
|
|
client.__aenter__ = AsyncMock(return_value=client)
|
|
client.__aexit__ = AsyncMock(return_value=False)
|
|
factory = MagicMock(return_value=client)
|
|
|
|
out = await self._probe_with(factory)
|
|
|
|
assert out["ok"] is False
|
|
assert out["status_code"] == 502
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_network_error_degrades_gracefully(self):
|
|
"""连接失败不抛 500:返回 ok=False + error 摘要(探针失败不是故障)。"""
|
|
|
|
def factory(*_a, **_kw):
|
|
client = MagicMock()
|
|
client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
|
|
client.__aenter__ = AsyncMock(return_value=client)
|
|
client.__aexit__ = AsyncMock(return_value=False)
|
|
return client
|
|
|
|
out = await self._probe_with(factory)
|
|
|
|
assert out["ok"] is False
|
|
assert "connection refused" in out["error"]
|
|
assert out["latency_ms"] >= 0
|