Files
cma-management/backend/tests/test_alerts_pagination_stability_20260925.py
T
Hermes CI Fix e3695088a4 fix(alerts): deterministic tiebreaker for paginated alert list
list_alerts ordered by created_at DESC alone; kpi_alerts has many rows sharing the same second timestamp (07:00 batches), so MySQL tie order is unstable and offset pagination both duplicated and skipped rows (entity 1 read-only probe: total=281, 281 rows walked, only 268 unique ids, 13 duplicates; id 587 unreachable in the unfiltered list while visible in ?status=pending).

Add secondary key id DESC so pages form a total order; add regression suite test_alerts_pagination_stability_20260925.py (tie-heavy fixture: unique==total, zero duplicates, strict id DESC inside a tie group, page concat == single-shot ordered query, unfiltered vs status-filtered visibility, entity isolation negative control).
2026-09-25 14:06:38 +08:00

170 lines
8.5 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/alerts` 无过滤列表分页可及性回归(任务 fullstack-20260925-alerts-list-pagination-skip)
缺陷(改前既有,2026-09-25 生产只读实测):`app/api/alerts.py::list_alerts` 用
`ORDER BY created_at DESC` **单键** + `offset/limit` 分页;`kpi_alerts` 每批 07:00 生成,
**大量行同秒 created_at** ⇒ MySQL filesort 在并列组内顺序不确定,偏移分页会**重复并漏行**
(entity 1 实测:total=281、逐页取满 281 行、唯一 id 仅 **268**、重复 13;id=587 因此
不出现在无过滤列表,而同行的 `?status=pending` 列表首屏正常可见)。
修复:排序补**确定性次键** `id DESC` ⇒ 同一 `created_at` 内按 id 降序(= 同秒内后写入者在前),
偏移分页成为全序分页,逐页遍历可覆盖全部 total 行且零重复。
本套件判据(改前 RED / 改后 GREEN):
· T1 唯一性:逐页取满后 `唯一 id 数 == total` 且 `重复 == 0`;
· T2 确定性次键:并列 `created_at` 组内 id **严格降序**(改前同一夹具下为插入序 = 升序 → RED);
· T3 逐页序列 == 「一次性全量取」(无分页)按 (created_at desc, id desc) 的序列,逐项相同;
· T4 可见性一致:同一行在「无过滤遍历」与「?status=pending 遍历」中一致可见(587 型);
· T5 负对照(非回归):账套隔离未放宽 —— 他账套行不出现在本账套列表;跨时间戳仍「新→旧」。
"""
from datetime import datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import KPIAlert
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
PAGE_SIZE = 20 # app/api/alerts.py 硬编码 limit(20)
#: 固定「同秒」时间戳 —— 全部并列行共用,复刻生产「每批 07:00 生成、同秒 created_at」形态
SAME_SECOND = datetime(2026, 9, 20, 7, 0, 42)
def _alert(db: Session, kpi_id: int, **kw) -> KPIAlert:
defaults = {
"kpi_id": kpi_id,
"entity_id": 1,
"alert_level": "yellow",
"alert_message": "分页夹具",
"status": "pending",
"created_at": SAME_SECOND,
}
defaults.update(kw)
a = KPIAlert(**defaults)
db.add(a)
db.commit()
db.refresh(a)
return a
def _walk(client: TestClient, token: str, status: str = None) -> dict:
"""逐页调用真实端点,直到某页不足 PAGE_SIZE 行(= 前端/调用方遍历姿势)。"""
url = "/api/cma/alerts" + (f"?status={status}" if status else "")
pages, seen, dups, rows = [], [], [], []
page = 1
total = None
while True:
sep = "&" if "?" in url else "?"
resp = client.get(f"{url}{sep}page={page}", headers=auth_header(token))
assert resp.status_code == 200, resp.text
body = resp.json()
if total is None:
total = body["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}
def _tie_groups(rows):
groups = {}
for r in rows:
groups.setdefault(str(r["created_at"]), []).append(r["id"])
return groups
class TestAlertsListPaginationAccessibility:
"""分页可及性(判据主体;改前 T1/T2/T3 应为 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)
# 61 条同秒(>3 页,且每页必然出现「并列组跨界」)+ 5 条更早的(跨时间戳排序正对照)
tie_ids = [_alert(db, kpi.id).id for _ in range(61)]
older_ids = [_alert(db, kpi.id, created_at=SAME_SECOND - timedelta(hours=1)).id for _ in range(5)]
total_expected = 66
w = _walk(client, token, None)
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(KPIAlert)
.filter(KPIAlert.entity_id == 1)
.order_by(KPIAlert.created_at.desc(), KPIAlert.id.desc())
.all())
assert [r["id"] for r in w["rows"]] == [a.id for a 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_visibility_consistent_between_unfiltered_and_status_filtered(
self, client: TestClient, db: Session):
"""同一行在无过滤遍历与 ?status=pending 遍历中一致可见(587 型:待办不得翻页翻不到)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
# 65 条同秒 pending(>3 页)+ 3 条同秒 resolved(无过滤列表里夹在并列组中)
pending_ids = [_alert(db, kpi.id, status="pending").id for _ in range(65)]
_alert_ids = [_alert(db, kpi.id, status="resolved").id for _ in range(3)]
oldest_pending = min(pending_ids) # 深页行:改前最易被漏读
unf = _walk(client, token, None)
pend = _walk(client, token, "pending")
assert unf["total"] == 68 and pend["total"] == 65
assert len(set(unf["unique"])) == unf["total"], "无过滤列表漏读"
assert len(set(pend["unique"])) == pend["total"], "pending 列表漏读"
assert oldest_pending in unf["unique"], "深页 pending 行在无过滤列表不可达(改前 RED 形态)"
assert oldest_pending in pend["unique"], "深页 pending 行在 pending 列表不可达"
for aid in pending_ids:
assert aid in unf["unique"], f"pending 行 {aid} 在无过滤列表不可达"
assert set(unf["unique"]) == set(pending_ids) | set(_alert_ids)
def test_t5_entity_isolation_and_cross_timestamp_order_unchanged(
self, client: TestClient, db: Session):
"""负对照:账套隔离未放宽(他账套行不可见)+ 跨时间戳仍「新→旧」"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
mine = [_alert(db, kpi.id, created_at=SAME_SECOND - timedelta(days=2)).id for _ in range(30)]
other = [_alert(db, kpi.id, entity_id=2, created_at=SAME_SECOND).id for _ in range(30)]
# 本账套另有 6 条更新的行(**时间戳互不相同** → 无并列,纯跨时间戳正对照):
# 必须排在他账套「同秒组」之前,且按新→旧;改前改后都应通过(本用例是非回归负对照)
newer = [_alert(db, kpi.id,
created_at=SAME_SECOND + timedelta(hours=i + 1)).id for i in range(6)]
newer_expected = list(reversed(newer)) # 最新的一条(+6h)在最前
w = _walk(client, token, None)
assert w["total"] == len(mine) + len(newer), "账套隔离被放宽:他账套行进入本账套列表"
assert not (set(other) & set(w["unique"])), "他账套行泄漏进本账套列表"
assert [r["id"] for r in w["rows"]][:6] == newer_expected, "跨时间戳未按新→旧排序"
assert set(w["unique"]) == set(mine) | set(newer)