Files
cma-management/backend/tests/test_notifications_pagination_stability_20260925.py
T
Hermes CI Fix 38787a006f fix(notifications): deterministic tiebreaker for paginated notification log list
Same-class gap as e369508 (alerts list): single-key ORDER BY created_at DESC plus offset/limit lets MySQL filesort return an unstable order inside same-second tie groups, so offset pagination duplicates and skips rows. Append secondary key id DESC, aligning with app/api/alerts.py, app/api/cash.py and app/api/data_quality.py.

Entity-scope caliber untouched: parent-key join attribution, alert_id IS NULL rows still excluded, policy/note wording unchanged. Local commit only - no push (release gated by ASK-20260925-04).
2026-09-25 16:34:52 +08:00

217 lines
9.9 KiB
Python
Raw 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.
# -*- coding: utf-8 -*-
"""`GET /api/cma/notifications/logs` 通知历史分页确定性回归(任务 0-P3-project-20260925-cma-same-class-pagination-determinism-fullstack)
缺陷(与 `app/api/alerts.py::list_alerts` 同构,2026-09-25 项目Bot 实读现件确认):
`app/api/notifications.py::list_logs` 用 `ORDER BY created_at DESC` **单键** + `offset/limit`
分页;`notification_logs` 由预警推送**批量写入**,同一秒多行 ⇒ MySQL filesort 在并列组内顺序
不受控,偏移分页会**重复并漏行**(同秒内后写入者可能被前页吞掉、或同一行跨页出现两次)。
修复:排序补**确定性次键** `id DESC` ⇒ `created_at DESC, id DESC`,偏移分页成为全序分页,
逐页遍历覆盖全部 total 行且零重复。先例(同族两键):`app/api/alerts.py`(e369508)、
`app/api/cash.py:165`、`app/api/data_quality.py:230`。
本套件判据(改前 RED / 改后 GREEN):
· T1 唯一性:逐页取满后 `唯一 id 数 == total` 且 `重复 == 0`;
· T2 确定性次键:并列 `created_at` 组内 id **严格降序**(改前同一夹具下为插入序 = 升序 → RED);
· T3 逐页序列 == 「一次性全量取」(无分页)按 (created_at desc, id desc) 的序列,逐项相同;
· T4 负对照(非回归):账套收口口径未动 —— 他账套(经父键归属)行不可见、
`alert_id IS NULL` 行仍不返回、`policy`/`note` 文案不变;
· T5 负对照(非回归):跨时间戳仍「新→旧」。
夹具口径照 `tests/test_alerts_pagination_stability_20260925.py`(同秒 60+ 行 + 跨时间戳若干行)。
"""
from datetime import datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import KPIAlert, NotificationLog
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
PAGE_SIZE = 20 # app/api/notifications.py 硬编码 limit(20)
#: 固定「同秒」时间戳 —— 全部并列行共用,复刻生产「预警推送批量写入、同秒 created_at」形态
SAME_SECOND = datetime(2026, 9, 20, 7, 0, 42)
POLICY_TEXT = "alert-linked-only"
def _alert(db: Session, kpi_id: int, entity_id: int = 1) -> KPIAlert:
a = KPIAlert(
kpi_id=kpi_id,
entity_id=entity_id,
alert_level="yellow",
alert_message="通知分页夹具",
status="pending",
created_at=SAME_SECOND,
)
db.add(a)
db.commit()
db.refresh(a)
return a
def _log(db: Session, alert_id, **kw) -> NotificationLog:
"""一条通知日志;`alert_id=None` = 无可归属父键(口径:不返回)"""
defaults = {
"alert_id": alert_id,
"channel": "wecom",
"recipient": "renzong",
"title": "预警推送",
"content": "分页夹具",
"status": "sent",
"created_at": SAME_SECOND,
}
defaults.update(kw)
row = NotificationLog(**defaults)
db.add(row)
db.commit()
db.refresh(row)
return row
def _walk(client: TestClient, token: str) -> dict:
"""逐页调用真实端点,直到某页不足 PAGE_SIZE 行(= 前端/调用方遍历姿势)。"""
url = "/api/cma/notifications/logs"
pages, seen, dups, rows = [], [], [], []
page = 1
total = None
policy = note = None
while True:
resp = client.get(f"{url}?page={page}", headers=auth_header(token))
assert resp.status_code == 200, resp.text
body = resp.json()
if total is None:
total = body["total"]
policy = body.get("policy")
note = body.get("note")
else:
assert body["total"] == total, "翻页过程中 total 漂移(口径/过滤不一致)"
ids = [r["id"] for r in body["data"]]
for pos, r in enumerate(body["data"]):
if r["id"] in seen:
dups.append({"id": r["id"], "page": page, "pos": pos})
else:
seen.append(r["id"])
rows.append(r)
pages.append(ids)
if len(ids) < PAGE_SIZE:
break
page += 1
assert page < 100, "分页遍历未终止(保险丝)"
return {"total": total, "pages": pages, "unique": seen, "dups": dups,
"rows": rows, "policy": policy, "note": note}
def _tie_groups(rows):
groups = {}
for r in rows:
groups.setdefault(str(r["created_at"]), []).append(r["id"])
return groups
class TestNotificationsLogsPaginationStability:
"""分页可及性 + 确定性次键(判据主体;改前 T2 应为 RED)"""
def test_t1_t2_t3_tie_heavy_batch_all_rows_reachable_and_deterministic(
self, client: TestClient, db: Session):
"""61 条同秒 + 5 条跨时间戳:逐页取满 ⇒ 唯一==total、零重复、并列组 id 降序、与全量序列一致"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
alert = _alert(db, kpi.id)
# 61 条同秒(>3 页,且每页必然出现「并列组跨界」)+ 5 条更早的(跨时间戳排序正对照)
tie_ids = [_log(db, alert.id).id for _ in range(61)]
older_ids = [_log(db, alert.id,
created_at=SAME_SECOND - timedelta(hours=1)).id for _ in range(5)]
total_expected = 66
w = _walk(client, token)
assert w["total"] == total_expected, w["total"]
# T1 唯一性与重复
assert len(set(w["unique"])) == w["total"], (
f"逐页遍历唯一 id 数 {len(set(w['unique']))} != total {w['total']};重复 {w['dups']}")
assert w["dups"] == [], f"偏移分页出现重复行:{w['dups']}"
assert len(w["unique"]) == w["total"], "逐页取到的行数应等于 total"
# T2 确定性次键:并列组内 id 严格降序
for ca, ids in _tie_groups(w["rows"]).items():
if len(ids) > 1:
assert ids == sorted(ids, reverse=True), (
f"created_at={ca} 并列组内未按 id 降序(分页顺序不确定):{ids}")
# T3 逐页拼接序列 == 一次性全量取(不同查询路径:单次全量 ORM 查询,显式两键排序)
full = (db.query(NotificationLog)
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
.filter(KPIAlert.entity_id == 1)
.order_by(NotificationLog.created_at.desc(), NotificationLog.id.desc())
.all())
assert [r["id"] for r in w["rows"]] == [l.id for l in full], "逐页序列与全量排序序列不一致"
# 全量序列 = 5 条跨时间戳(新→旧)+ 61 条同秒(id 降序)
assert [r["id"] for r in w["rows"]][:5] == sorted(tie_ids, reverse=True)[:5]
assert set(w["unique"]) == set(tie_ids) | set(older_ids)
def test_t4_entity_scope_and_null_parent_key_exclusion_unchanged(
self, client: TestClient, db: Session):
"""负对照:账套收口口径未动 —— 他账套行不可见、`alert_id IS NULL` 行不返回、policy/note 不变"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
mine_alert = _alert(db, kpi.id, entity_id=1)
other_alert = _alert(db, kpi.id, entity_id=2)
mine = [_log(db, mine_alert.id).id for _ in range(25)]
other = [_log(db, other_alert.id).id for _ in range(25)]
orphan = [_log(db, None).id for _ in range(5)] # 无父键(如 OKR 复盘提醒形态)
w = _walk(client, token)
assert w["total"] == len(mine), "账套收口被改:total 不再等于本账套可归属行数"
assert not (set(other) & set(w["unique"])), "他账套(经父键归属)的行泄漏进本账套列表"
assert not (set(orphan) & set(w["unique"])), "`alert_id IS NULL` 的行被返回(违反宁少勿错口径)"
assert set(w["unique"]) == set(mine)
assert w["policy"] == POLICY_TEXT, f"policy 文案被改:{w['policy']}"
assert w["note"] and "无父键的历史通知" in w["note"], f"note 文案被改:{w['note']}"
def test_t5_cross_timestamp_order_newest_first_unchanged(
self, client: TestClient, db: Session):
"""负对照:跨时间戳仍「新→旧」(时间戳互不相同 ⇒ 无并列,改前改后都应通过)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
alert = _alert(db, kpi.id)
older = [_log(db, alert.id, created_at=SAME_SECOND - timedelta(days=2)).id
for _ in range(10)]
newer = [_log(db, alert.id, created_at=SAME_SECOND + timedelta(hours=i + 1)).id
for i in range(6)]
expected_head = list(reversed(newer)) # 最新(+6h)在最前
w = _walk(client, token)
assert w["total"] == len(older) + len(newer)
assert [r["id"] for r in w["rows"]][:6] == expected_head, "跨时间戳未按新→旧排序"
assert set(w["unique"]) == set(older) | set(newer)
def test_t6_page_window_and_deep_page_reachable(self, client: TestClient, db: Session):
"""分页窗口本身未变:每页 ≤20 行、深页可读、同一行只在唯一一页出现"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
alert = _alert(db, kpi.id)
ids = [_log(db, alert.id).id for _ in range(45)]
w = _walk(client, token)
assert w["total"] == 45
assert [len(p) for p in w["pages"]] == [20, 20, 5], f"分页窗口异常:{[len(p) for p in w['pages']]}"
assert len(w["unique"]) == 45
assert set(w["unique"]) == set(ids)
seen_pages = {}
for idx, p in enumerate(w["pages"], start=1):
for row_id in p:
assert row_id not in seen_pages, f"行 {row_id} 同时出现在页 {seen_pages[row_id]} 与页 {idx}"
seen_pages[row_id] = idx