"""埋点整批校验的纯逻辑用例(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"}