test(analytics): health 契约加深——ISO 序列化断言/空库6实体/诊断端点500有意注释

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-04 16:00:38 +08:00
parent 0b1d377db7
commit 4cfc2fa6a9
2 changed files with 32 additions and 5 deletions
+12 -3
View File
@@ -6,6 +6,7 @@ msg_daily_stats 当日有行 = 4h 消息链路活着。
from __future__ import annotations from __future__ import annotations
import datetime as dt import datetime as dt
from typing import Any
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,15 +23,23 @@ from app.db.models import (
async def data_as_of(session: AsyncSession) -> int | None: async def data_as_of(session: AsyncSession) -> int | None:
"""数据截至(ms-epoch)= MAX(msg_daily_stats.last_ts)。""" """数据截至(ms-epoch)= MAX(msg_daily_stats.last_ts)。
漏迁移导致表缺失时按 500 响亮报错是有意的——本模块是诊断端点,
要暴露部署问题而非掩盖(0ade58a 的空表静默守卫是业务端点的容错,
别好心搬到这里)。
"""
val = ( val = (
await session.execute(sa.select(sa.func.max(MsgDailyStats.last_ts))) await session.execute(sa.select(sa.func.max(MsgDailyStats.last_ts)))
).scalar_one_or_none() ).scalar_one_or_none()
return int(val) if val is not None else None return int(val) if val is not None else None
async def entity_freshness(session: AsyncSession) -> list[dict]: async def entity_freshness(session: AsyncSession) -> list[dict[str, Any]]:
"""各实体最后更新时间(UPDATED_AT 列 MAX;message 用 fetched_at)。""" """各实体最后更新时间(UPDATED_AT 列 MAX;message 用 fetched_at)。
同 data_as_of:表缺失按 500 响亮报错是有意的(诊断端点不容错)。
"""
async def _max(col) -> dt.datetime | None: async def _max(col) -> dt.datetime | None:
return (await session.execute(sa.select(sa.func.max(col)))).scalar_one_or_none() return (await session.execute(sa.select(sa.func.max(col)))).scalar_one_or_none()
+20 -2
View File
@@ -1,7 +1,12 @@
"""/api/analytics/health:E0-lite 冒烟。""" """/api/analytics/health:E0-lite 冒烟。"""
from __future__ import annotations from __future__ import annotations
from app.db.models import MsgDailyStats import datetime as dt
from app.db.models import Customer, MsgDailyStats
# naive:updated_at 列是无时区 DateTime;fromisoformat 构造不触发 DTZ 检查
_SEEN = "2026-09-04T12:00:00"
async def test_health_empty(make_client): async def test_health_empty(make_client):
@@ -13,7 +18,8 @@ async def test_health_empty(make_client):
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["data_as_of"] is None assert body["data_as_of"] is None
assert any(e["table"] == "message" for e in body["entities"]) assert len(body["entities"]) == 6
assert all(e["last_seen"] is None for e in body["entities"])
async def test_health_with_stats(make_client): async def test_health_with_stats(make_client):
@@ -31,9 +37,21 @@ async def test_health_with_stats(make_client):
last_is_send=False, last_is_send=False,
) )
) )
session.add(
Customer(
friend_id="f1",
channel="wechat",
updated_at=dt.datetime.fromisoformat(_SEEN),
)
)
await session.commit() await session.commit()
client = await make_client(seed) client = await make_client(seed)
r = await client.get("/api/analytics/health") r = await client.get("/api/analytics/health")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["data_as_of"] == 1756900000000 assert r.json()["data_as_of"] == 1756900000000
entities = {e["table"]: e["last_seen"] for e in r.json()["entities"]}
# 前端契约:last_seen 必须是 ISO 字符串(fromisoformat 可解析)
assert dt.datetime.fromisoformat(entities["customer"]) == dt.datetime.fromisoformat(
_SEEN
)