Files
meida_backend/tests/unit/test_events_validation.py
xsl 64175a1ea1 feat(analytics): POST /events/batch with fail-close validation
ob_event 落库通道:形状校验 + props 字节上限 + client_ts 合理性 +
PII 键剔除,整批原子(FR-SYS-06 服务端部分)。

实现:packages/analytics/schemas.py(EventIn/EventBatchRequest/
EventBatchResponse + 事件名形状正则常量);service.py 拆出纯函数
validate_batch(批量/props 字节/事件名形状/client_ts 窗口/PII 剔除,
任一不过整批 40020),AnalyticsService.record 组装 ObEvent 逐条
add + 一次 flush;routers/events.py 提供 POST /events/batch
(/api/v1 与 /v1 别名,认证必需)。

测试:单测 tests/unit/test_events_validation.py 8 例(合法批、超批量、
props 超字节/不可序列化、事件名形状含尾随换行、client_ts 双边界、
naive 时区按 UTC、PII 键含大小写变体剔除);集成
tests/integration/test_events.py 3 例(两前缀可用+落库断言、
非法批 40020 且库无半条、无 token 40100)。门禁全绿:ruff
check/format、mypy(T2 列表 + domain strict)、lint-imports 4 契约、
单测 177 通过、集成 6 通过。

偏差说明:event_name 正则未写成 Field(pattern=...),而是作为
EVENT_NAME_PATTERN 常量放 schemas.py、由服务层 validate_batch 强制——
若挂在请求体校验上,非法事件名会先被 RequestValidationError 拦截、
返回通用 40001/422 信封,无法满足任务契约 §4「大写 event_name →
40020」的集成行为。正则本体与文档一致。
2026-08-14 16:16:26 +08:00

137 lines
4.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""埋点整批校验的纯逻辑用例(FR-SYS-06,不连库)。
校验入口是 packages.analytics.service.validate_batch —— 模块级纯函数,
时间与上限全部注入,便于钉死边界值逐条断言。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from packages.analytics.schemas import EventIn
from packages.analytics.service import validate_batch
from packages.shared.errors import AppError, ErrorCode
#: 钉死的"服务端当前时间",所有 client_ts 边界都相对它构造。
NOW = datetime(2026, 8, 14, 12, 0, 0, tzinfo=UTC)
USER_ID = 7_100_000_000_000_000_001
MAX_BATCH = 50
MAX_BYTES = 8192
def _ev(
name: str = "page_view",
props: dict[str, Any] | None = None,
client_ts: datetime | None = None,
) -> EventIn:
return EventIn(event_name=name, props=props if props is not None else {}, client_ts=client_ts)
def _run(events: list[EventIn]) -> list[Any]:
return validate_batch(
events, user_id=USER_ID, max_batch=MAX_BATCH, max_bytes=MAX_BYTES, now=NOW
)
def _assert_rejected(events: list[EventIn]) -> None:
"""fail-close:任一规则不过 → 整批 40020。"""
with pytest.raises(AppError) as excinfo:
_run(events)
assert excinfo.value.code == ErrorCode.EVENTS_BATCH_REJECTED
assert int(excinfo.value.code) == 40020
def test_valid_batch_passes_and_assembles_rows() -> None:
rows = _run(
[
_ev("outfit_card.tap", {"card_id": 42}, NOW - timedelta(minutes=1)),
_ev("page_view"),
]
)
assert len(rows) == 2
assert all(row.user_id == USER_ID for row in rows)
# server_ts 用注入的 now,与 client_ts 窗口判定同一时刻
assert all(row.server_ts == NOW for row in rows)
assert rows[0].event_name == "outfit_card.tap"
assert rows[0].props == {"card_id": 42}
assert rows[0].client_ts == NOW - timedelta(minutes=1)
assert rows[1].client_ts is None # 缺省 client_ts 允许
# 雪花 id 逐条独立分配
assert len({row.event_id for row in rows}) == 2
def test_empty_and_oversized_batch_are_rejected() -> None:
_assert_rejected([]) # schema 的 min_length=1 之外,服务层同样兜底
_assert_rejected([_ev() for _ in range(MAX_BATCH + 1)])
# 恰好等于上限是合法的
assert len(_run([_ev() for _ in range(MAX_BATCH)])) == MAX_BATCH
def test_oversize_props_are_rejected() -> None:
# 默认上限 8192 字节;8KB 的值序列化后必然超限
big = {"blob": "x" * 8192}
assert len(json.dumps(big, ensure_ascii=False).encode()) > MAX_BYTES
_assert_rejected([_ev(props=big)])
# 与合法事件混在同一批也要整批拒绝(fail-close,不部分落库)
_assert_rejected([_ev(), _ev(props=big)])
def test_unserializable_props_are_rejected() -> None:
# set 不可 JSON 序列化 → fail-close 拒收,而不是落库时炸事务
_assert_rejected([_ev(props={"bad": {1, 2}})]) # type: ignore[dict-item]
def test_event_name_shape_is_enforced() -> None:
for bad_name in (
"PageView", # 大写
"1page", # 数字开头
"p", # 太短(正则要求 >= 2 位)
"page view", # 空格
"page_view!", # 非法字符
"a" * 65, # 超长(正则上限 64)
"page_view\n", # $ 可被尾随换行骗过,fullmatch 不行
):
_assert_rejected([_ev(bad_name)])
# 64 位以内、含点与下划线、数字开头之外的位置都是合法的
assert len(_run([_ev("a" + "b" * 63)])) == 1
assert len(_run([_ev("wardrobe.item_2.sync_ok")])) == 1
def test_client_ts_window_boundaries() -> None:
# 不晚于 now+5min:恰好 +5min 合法,再多 1 秒拒收
assert len(_run([_ev(client_ts=NOW + timedelta(minutes=5))])) == 1
_assert_rejected([_ev(client_ts=NOW + timedelta(minutes=5, seconds=1))])
# 不早于 7 天前:恰好 7 天前合法,再早 1 秒拒收
assert len(_run([_ev(client_ts=NOW - timedelta(days=7))])) == 1
_assert_rejected([_ev(client_ts=NOW - timedelta(days=7, seconds=1))])
# 同批一条合法 + 一条越界 → 整批拒
_assert_rejected([_ev(client_ts=NOW), _ev(client_ts=NOW + timedelta(hours=1))])
def test_naive_client_ts_is_interpreted_as_utc() -> None:
# 客户端漏发时区按 UTC 解释,窗口内仍可落库
rows = _run([_ev(client_ts=datetime(2026, 8, 14, 11, 0, 0))])
assert rows[0].client_ts == datetime(2026, 8, 14, 11, 0, 0, tzinfo=UTC)
def test_pii_keys_are_stripped_from_props() -> None:
rows = _run(
[
_ev(
"report.share",
{
"phone": "13800001234",
"Token": "Bearer abc", # 大小写变体也拦
"authorization": "Basic xyz",
"scene": "home",
},
)
]
)
assert rows[0].props == {"scene": "home"}