Files
ProductionDataBaseSync_Data…/tests/test_health.py
Misaka_Company 79a95465e7 refactor(web): API 路由统一 /api 前缀 + 完善 OpenAPI 文档
- 路由挂 APIRouter(prefix="/api"),健康检查路径 /health -> /api/health,
  后期加接口(/api/compare、/api/queue 等)自动共享前缀。
- 文档路径归到 /api 下:/api/docs、/api/redoc、/api/openapi.json
  (原 /docs、/redoc、/openapi.json 已 404)。
- 新增 Pydantic 响应模型 schemas.py,并在 /api/health 挂 response_model,
  使 /api/docs 展示完整响应结构。
- 为接口和每个响应字段补详细英文 description(覆盖范围、status 三态判定、
  HTTP 码含义、各字段语义如 cycle_lag 阈值/dead 含义等)。

不动业务逻辑与同步主循环,仅 Web 层。
2026-08-05 12:55:45 +08:00

184 lines
6.9 KiB
Python

"""Tests for the health-check layer: ServiceState, HealthChecker status logic,
and the /health endpoint via FastAPI TestClient."""
import datetime as _dt
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from sync.config import (
AccessConfig, FileMapping, HealthConfig, RuntimeConfig,
SqlServerConfig, SyncConfig,
)
from sync.health import CYCLE_LAG_MULTIPLIER, HealthChecker, ServiceState
from sync.web.app import create_app
def _cfg(**health_kw):
return SyncConfig(
sql_server=SqlServerConfig(conn_str="x"),
access=AccessConfig(driver="d", roots={"2026": "r"}),
runtime=RuntimeConfig(poll_interval_seconds=10),
files=[FileMapping(file="f.accdb", root="2026", schema="s", year_suffix="_Y")],
health=HealthConfig(**health_kw),
)
def _state(**kw):
s = ServiceState(started_at=_dt.datetime.now(), pid=1)
# Force last_cycle_at to "recent" by default so cycle_lag is small.
s.last_cycle_at = _dt.datetime.now()
for k, v in kw.items():
setattr(s, k, v)
return s
# ---------------------------------------------------------------- ServiceState
def test_state_snapshot_is_independent_copy():
s = ServiceState(started_at=_dt.datetime.now(), pid=1)
s.update_cycle(active=True, duration_s=1.5)
snap = s.snapshot()
snap["capture_enqueued"] = 999 # mutate the copy
assert s.snapshot()["capture_enqueued"] == 0 # original untouched
def test_state_update_cycle_accumulates_capture_totals():
from sync.capture import CaptureStats
s = ServiceState(started_at=_dt.datetime.now(), pid=1)
s.update_cycle(True, 1.0, capture=CaptureStats(enqueued=5, deferred=2, aged_out=1))
s.update_cycle(True, 1.0, capture=CaptureStats(enqueued=3, deferred=1, aged_out=0))
snap = s.snapshot()
assert snap["capture_enqueued"] == 8
assert snap["capture_deferred"] == 3
assert snap["capture_aged_out"] == 1
# --------------------------------------------------------------- status logic
def _checker_with(state, cfg, queue=None, compare_mismatch=False):
"""Build a checker whose SQL queries are stubbed (no real DB)."""
c = HealthChecker(state, cfg)
c._queue_status = lambda: queue or {}
c._drift_tables = lambda: []
c._dead_rows_sample = lambda: []
c._last_compare = lambda: {"mismatch": compare_mismatch}
return c
def test_status_healthy_when_recent_cycle_clean_queue():
cfg = _cfg()
state = _state()
c = _checker_with(state, cfg, queue={"pending": 0, "dead": 0, "error": 0})
assert c._derive_status(c._service_health(state.snapshot(), _dt.datetime.now()),
c._data_health()) == "healthy"
def test_status_unhealthy_when_cycle_lag_exceeds_threshold():
cfg = _cfg() # poll_interval=10 -> threshold = 30s
# last cycle was 60s ago
state = ServiceState(started_at=_dt.datetime.now() - _dt.timedelta(seconds=120), pid=1)
state.last_cycle_at = _dt.datetime.now() - _dt.timedelta(seconds=60)
c = _checker_with(state, cfg)
assert c._derive_status(c._service_health(state.snapshot(), _dt.datetime.now()),
c._data_health()) == "unhealthy"
def test_status_degraded_when_dead_rows():
cfg = _cfg()
state = _state()
c = _checker_with(state, cfg, queue={"dead": 2})
assert c._derive_status(c._service_health(state.snapshot(), _dt.datetime.now()),
c._data_health()) == "degraded"
def test_status_degraded_when_error_rows():
cfg = _cfg()
state = _state()
c = _checker_with(state, cfg, queue={"error": 1})
assert c._derive_status(c._service_health(state.snapshot(), _dt.datetime.now()),
c._data_health()) == "degraded"
def test_status_degraded_when_aged_out_accumulated():
cfg = _cfg()
state = _state()
state.update_cycle(True, 1.0) # no capture arg
state.capture_aged_out = 3 # simulate prior aged-out incidents
c = _checker_with(state, cfg, queue={})
snap = c.snapshot()
assert snap["status"] == "degraded"
def test_status_degraded_when_compare_mismatch():
cfg = _cfg()
state = _state()
c = _checker_with(state, cfg, queue={}, compare_mismatch=True)
assert c._derive_status(c._service_health(state.snapshot(), _dt.datetime.now()),
c._data_health()) == "degraded"
# -------------------------------------------------------------- snapshot shape
def test_snapshot_has_expected_top_level_keys():
cfg = _cfg()
state = _state()
c = _checker_with(state, cfg, queue={"pending": 0})
snap = c.snapshot()
for k in ("service", "status", "checked_at", "pid", "started_at",
"uptime_seconds", "service_health", "data_health"):
assert k in snap
assert "cycle_lag_seconds" in snap["service_health"]
assert "last_compare" in snap["data_health"]
# ------------------------------------------------------------- HTTP endpoint
def test_health_endpoint_returns_200_when_healthy():
cfg = _cfg()
state = _state()
with patch.object(HealthChecker, "_queue_status", return_value={}), \
patch.object(HealthChecker, "_drift_tables", return_value=[]), \
patch.object(HealthChecker, "_dead_rows_sample", return_value=[]), \
patch.object(HealthChecker, "_last_compare",
return_value={"available": False}):
app = create_app(state, cfg)
client = TestClient(app)
r = client.get("/api/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
assert body["service"] == "DataMacroSync"
def test_health_endpoint_returns_503_when_unhealthy():
cfg = _cfg()
# stale cycle -> unhealthy
state = ServiceState(started_at=_dt.datetime.now() - _dt.timedelta(seconds=120), pid=1)
state.last_cycle_at = _dt.datetime.now() - _dt.timedelta(seconds=60)
with patch.object(HealthChecker, "_queue_status", return_value={}), \
patch.object(HealthChecker, "_drift_tables", return_value=[]), \
patch.object(HealthChecker, "_dead_rows_sample", return_value=[]), \
patch.object(HealthChecker, "_last_compare",
return_value={"available": False}):
app = create_app(state, cfg)
client = TestClient(app)
r = client.get("/api/health")
assert r.status_code == 503
assert r.json()["status"] == "unhealthy"
def test_health_endpoint_returns_503_when_degraded():
cfg = _cfg()
state = _state()
with patch.object(HealthChecker, "_queue_status",
return_value={"dead": 1}), \
patch.object(HealthChecker, "_drift_tables", return_value=[]), \
patch.object(HealthChecker, "_dead_rows_sample", return_value=[]), \
patch.object(HealthChecker, "_last_compare",
return_value={"available": False}):
app = create_app(state, cfg)
client = TestClient(app)
r = client.get("/api/health")
assert r.status_code == 503
assert r.json()["status"] == "degraded"