- 路由挂 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 层。
184 lines
6.9 KiB
Python
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"
|