"""监控增强端点测试:/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