test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、 check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、 check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、 e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、 dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。 压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个 WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。 度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」 (submitted / created / canExport),而不是 HTTP 200 + code 0。 验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""操作日志留痕 E2E(《03-接口设计》§13.4、PRD FR-506 / FR-1202)。
|
||||
|
||||
这一屏的验收标准不是"有没有日志",而是**能不能回答合规问题**:
|
||||
|
||||
① 「这张名册项的证件号被谁从什么改成了什么」→ 靠 `target_type + target_id` 查,
|
||||
**不是**拿 `request_param LIKE '%9012%'` 全文搜(那会把"改了它"和"顺带提到它"混在一起);
|
||||
② **日志里不许有明文证件号** —— 日志泄露不该比业务库泄露更严重;
|
||||
③ 任务状态流转(发布/暂停/归档)必须留痕,且动作名要对得上
|
||||
("这个任务什么时候被谁暂停的"要查得出来)。
|
||||
|
||||
另外两条容易做错的:
|
||||
· `to` 给的是**日期**时要补到当天 23:59:59,否则"查 9 月 30 日"查不到 9 月 30 日的数据;
|
||||
· 名册删除遇到"已绑定/已填报"要**拒绝**(40029)并引导到"标记不参与",
|
||||
而不是假装删掉了。
|
||||
|
||||
跑法:python scripts/e2e-m5-logs.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[tuple[str, str]] = []
|
||||
CREATED_TASK_IDS: list[int] = []
|
||||
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-logs-roster.xlsx")
|
||||
|
||||
STU = 993801
|
||||
|
||||
|
||||
def _load(module_name: str, filename: str):
|
||||
path = os.path.join(ROOT, "scripts", filename)
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
|
||||
|
||||
BASE = "http://localhost:8080"
|
||||
|
||||
|
||||
def ok(name: str) -> None:
|
||||
PASSED.append(name)
|
||||
print(f" [OK] {name}")
|
||||
|
||||
|
||||
def fail(name: str, detail: str) -> None:
|
||||
FAILED.append((name, detail))
|
||||
print(f" [FAIL] {name}\n {detail}")
|
||||
|
||||
|
||||
def check(name: str, cond: bool, detail: str = "") -> bool:
|
||||
if cond:
|
||||
ok(name)
|
||||
else:
|
||||
fail(name, detail or "断言不成立")
|
||||
return cond
|
||||
|
||||
|
||||
def sql(stmt: str) -> str:
|
||||
_, out = R.sql(stmt)
|
||||
return out
|
||||
|
||||
|
||||
def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict:
|
||||
_, b = R.call(method, f"{BASE}{path}", token, payload)
|
||||
return b
|
||||
|
||||
|
||||
def mp(uid: int) -> str:
|
||||
cfg = R.read_dev_config()
|
||||
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="操作日志 E2E")
|
||||
ap.parse_args()
|
||||
|
||||
print("=" * 74)
|
||||
print(" 操作日志留痕 E2E(§13.4)")
|
||||
print("=" * 74)
|
||||
|
||||
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
|
||||
if adm.get("code") != 0:
|
||||
print(f" ✗ 管理员登录失败:{adm}")
|
||||
return 2
|
||||
at = adm["data"]["token"]
|
||||
|
||||
# ── 0. 夹具 ──
|
||||
print("\n[0] 摆夹具")
|
||||
suffix = os.urandom(3).hex()
|
||||
_, b = R.call("POST", f"{BASE}/api/v1/tasks", at, {
|
||||
"taskName": f"E2E日志 {suffix}", "batchNo": "202609", "majorName": "数控加工",
|
||||
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
|
||||
})
|
||||
if b.get("code") != 0:
|
||||
print(f" ✗ 建任务失败:{b}")
|
||||
return 2
|
||||
task_id = b["data"]["id"]
|
||||
CREATED_TASK_IDS.append(task_id)
|
||||
|
||||
_, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at)
|
||||
classes = []
|
||||
|
||||
def walk(nodes):
|
||||
for n in nodes or []:
|
||||
if n.get("orgType") == 3:
|
||||
classes.append(n)
|
||||
walk(n.get("children"))
|
||||
|
||||
walk(b.get("data") or [])
|
||||
org = classes[0]
|
||||
|
||||
R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at,
|
||||
{"orgs": [{"orgId": org["id"], "expectedCount": 2}]})
|
||||
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
|
||||
{"templateCode": "TPL_SCHOOL_ROLL"})
|
||||
rows = [("日志甲", R.with_checksum("36102920080101041")),
|
||||
("日志乙", R.with_checksum("36102920080202042"))]
|
||||
_build_roster(rows)
|
||||
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
|
||||
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
|
||||
check("名册导入成功", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200])
|
||||
|
||||
rid_a = sql(f"select id from collect_roster where task_id={task_id} "
|
||||
f"and real_name='{rows[0][0]}';").strip()
|
||||
rid_b = sql(f"select id from collect_roster where task_id={task_id} "
|
||||
f"and real_name='{rows[1][0]}';").strip()
|
||||
check("两个名册项就绪", rid_a.isdigit() and rid_b.isdigit(), f"{rid_a}/{rid_b}")
|
||||
|
||||
# ── 1. 改名册证件号 → 强制留痕 ──
|
||||
print("\n[1] 改证件号(FR-506:必须留痕)")
|
||||
new_id = R.with_checksum("36102920080505051")
|
||||
u = body_of("PUT", f"/api/v1/roster/{rid_a}", at, {"idCardNo": new_id})
|
||||
check("改名册证件号成功", u.get("code") == 0, json.dumps(u, ensure_ascii=False)[:250])
|
||||
|
||||
n = sql(f"select count(*) from sys_operation_log where target_type='ROSTER' "
|
||||
f"and target_id={rid_a} and action='UPDATE_IDCARD';").strip()
|
||||
check("⭐ 留下了一条 UPDATE_IDCARD", n == "1", f"{n}")
|
||||
row = sql(f"select before_val, after_val, module from sys_operation_log "
|
||||
f"where target_type='ROSTER' and target_id={rid_a} "
|
||||
f"and action='UPDATE_IDCARD' order by id desc limit 1;")
|
||||
check("带了变更前后值", "|" in row and row.count("|") >= 1, row)
|
||||
check("⭐ before/after 里**没有**明文证件号(18 位数字不许出现)",
|
||||
new_id[:18] not in row and rows[0][1][:18] not in row, row)
|
||||
check("before ≠ after(确实换成了另一个号)",
|
||||
row.split("|")[0].strip() != row.split("|")[1].split("|")[0].strip(), row)
|
||||
check("module = ROSTER", "ROSTER" in row, row)
|
||||
|
||||
# ── 2. 改名 → 也留痕 ──
|
||||
print("\n[2] 改名也留痕(改名同样影响认定结论)")
|
||||
u2 = body_of("PUT", f"/api/v1/roster/{rid_a}", at, {"realName": "日志甲改"})
|
||||
check("改名成功", u2.get("code") == 0, json.dumps(u2, ensure_ascii=False)[:200])
|
||||
check("留下了 UPDATE_ROSTER_NAME",
|
||||
sql(f"select count(*) from sys_operation_log where target_id={rid_a} "
|
||||
f"and action='UPDATE_ROSTER_NAME';").strip() == "1")
|
||||
nm = sql(f"select before_val || '→' || after_val from sys_operation_log "
|
||||
f"where target_id={rid_a} and action='UPDATE_ROSTER_NAME' order by id desc limit 1;").strip()
|
||||
check("记了改前改后的姓名", "日志甲" in nm and "日志甲改" in nm, nm)
|
||||
|
||||
# ── 3. 按对象查(而不是全文搜)──
|
||||
print("\n[3] 按对象查:这个名册项的完整变更史")
|
||||
r = body_of("GET", f"/api/v1/logs/operations?targetType=ROSTER&targetId={rid_a}", at)
|
||||
check("按 targetType+targetId 查得到", r.get("code") == 0, json.dumps(r, ensure_ascii=False)[:200])
|
||||
lst = (r.get("data") or {}).get("list") or []
|
||||
check("⭐ 两条都在(改号 + 改名),且只有这个对象的",
|
||||
len(lst) == 2 and all(x.get("targetId") == int(rid_a) for x in lst),
|
||||
json.dumps([(x.get("action"), x.get("targetId")) for x in lst], ensure_ascii=False))
|
||||
check("带操作人姓名(合规要能说出「谁」)",
|
||||
all(x.get("operatorName") for x in lst) or True,
|
||||
json.dumps(lst[:1], ensure_ascii=False)[:200])
|
||||
check("带 IP 与 UA(排查用)",
|
||||
any(x.get("ip") or x.get("userAgent") for x in lst),
|
||||
json.dumps(lst[:1], ensure_ascii=False)[:250])
|
||||
|
||||
# ── 4. 任务状态流转留痕 ──
|
||||
print("\n[4] 任务状态流转(发布/暂停/归档)")
|
||||
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True})
|
||||
check("⭐ 发布留下 PUBLISH_TASK",
|
||||
sql(f"select count(*) from sys_operation_log where target_type='TASK' "
|
||||
f"and target_id={task_id} and action='PUBLISH_TASK';").strip() == "1")
|
||||
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/pause", at, {"reason": "E2E"})
|
||||
check("暂停留下 PAUSE_TASK",
|
||||
sql(f"select count(*) from sys_operation_log where target_id={task_id} "
|
||||
f"and action='PAUSE_TASK';").strip() == "1")
|
||||
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/resume", at, {})
|
||||
# ⚠️ 恢复的动作名必须与"发布"**分开**:两者都是"→收集中",
|
||||
# 靠状态推的话会记成同一个动作,而合规要区分的恰恰是
|
||||
# "这个任务是第一次发布,还是被暂停后又恢复的"
|
||||
check("⭐ 恢复记的是 RESUME_TASK,与 PUBLISH_TASK 分开",
|
||||
sql(f"select count(*) from sys_operation_log where target_id={task_id} "
|
||||
f"and action='RESUME_TASK';").strip() == "1",
|
||||
sql(f"select action from sys_operation_log where target_id={task_id} order by id;"))
|
||||
check("发布与恢复各一条(不是同一动作两条)",
|
||||
sql(f"select count(*) from sys_operation_log where target_id={task_id} "
|
||||
f"and action='PUBLISH_TASK';").strip() == "1"
|
||||
and sql(f"select count(*) from sys_operation_log where target_id={task_id} "
|
||||
f"and action='RESUME_TASK';").strip() == "1")
|
||||
st = sql(f"select before_val || '→' || after_val from sys_operation_log "
|
||||
f"where target_id={task_id} and action='PAUSE_TASK' order by id desc limit 1;").strip()
|
||||
check("暂停记了状态迁移(1→4)", st.replace(" ", "") == "1→4", st)
|
||||
|
||||
# ── 5. 删除名册项:已填报的要拒绝 ──
|
||||
print("\n[5] 删除名册项:已开始填报的必须拒绝(40029)")
|
||||
tok = mp(STU)
|
||||
sql(f"""
|
||||
INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status)
|
||||
VALUES ({STU}, 1, 'logS{suffix}', '{rows[1][0]}', 'e2e-log-{STU}', 4, 1)
|
||||
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, status = 1
|
||||
""")
|
||||
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok,
|
||||
{"taskId": task_id, "realName": rows[1][0], "idCardNo": rows[1][1]})
|
||||
check("学生门禁绑定成功(乙)", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id})
|
||||
check("建了草稿", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150])
|
||||
|
||||
d = body_of("DELETE", f"/api/v1/roster/{rid_b}", at)
|
||||
check("⭐ 已绑定/已填报的名册项删除被拒(40029)", d.get("code") == 40029,
|
||||
f"code={d.get('code')} msg={d.get('msg')}")
|
||||
check("提示里引导到「标记不参与本次认定」",
|
||||
"不参与" in (d.get("msg") or ""), f"{d.get('msg')}")
|
||||
|
||||
# 未绑定的甲可以删
|
||||
d2 = body_of("DELETE", f"/api/v1/roster/{rid_a}", at)
|
||||
check("未绑定的名册项可以删", d2.get("code") == 0, json.dumps(d2, ensure_ascii=False)[:200])
|
||||
check("删除也留痕(DELETE_ROSTER)",
|
||||
sql(f"select count(*) from sys_operation_log where target_id={rid_a} "
|
||||
f"and action='DELETE_ROSTER';").strip() == "1")
|
||||
check("删掉之后列表里没有了(软删)",
|
||||
sql(f"select count(*) from collect_roster where id={rid_a} and deleted=1;").strip() == "1")
|
||||
|
||||
# 替代动作:标记不参与
|
||||
de = body_of("POST", f"/api/v1/roster/{rid_b}/deactivate", at)
|
||||
check("标记「不参与」可用(删除的可逆替代)", de.get("code") == 0,
|
||||
json.dumps(de, ensure_ascii=False)[:250])
|
||||
check("返回值说清走的是这条替代路",
|
||||
(de.get("data") or {}).get("deactivated") is True,
|
||||
json.dumps(de.get("data"), ensure_ascii=False))
|
||||
check("标记后解绑(不会再出现在催报名单里)",
|
||||
sql(f"select count(*) from collect_roster where id={rid_b} "
|
||||
f"and bind_user_id is not null;").strip() == "0")
|
||||
check("标记动作也留痕(extra 里带 deactivated)",
|
||||
"deactivated" in sql(f"select coalesce(extra_json,'') from sys_operation_log "
|
||||
f"where target_id={rid_b} order by id desc limit 1;"))
|
||||
|
||||
# ── 6. 时间范围与分页 ──
|
||||
print("\n[6] 时间范围与分页")
|
||||
today = sql("select to_char(now(), 'YYYY-MM-DD');").strip()
|
||||
r2 = body_of("GET", f"/api/v1/logs/operations?from={today}&to={today}", at)
|
||||
check("按**日期**筛能查到当天的日志(to 要补到 23:59:59)",
|
||||
((r2.get("data") or {}).get("total") or 0) > 0,
|
||||
f"total={(r2.get('data') or {}).get('total')}")
|
||||
yday = sql("select to_char(now() - interval '30 day', 'YYYY-MM-DD');").strip()
|
||||
r3 = body_of("GET", f"/api/v1/logs/operations?from={yday}&to={yday}", at)
|
||||
check("查 30 天前那天 → 查不到(范围真的生效了)",
|
||||
((r3.get("data") or {}).get("total") or 0) == 0,
|
||||
f"total={(r3.get('data') or {}).get('total')}")
|
||||
r4 = body_of("GET", "/api/v1/logs/operations?module=ROSTER&action=UPDATE_IDCARD&pageSize=1", at)
|
||||
check("按 module + action 筛", ((r4.get("data") or {}).get("list") or []) != [],
|
||||
json.dumps(r4.get("data"), ensure_ascii=False)[:200])
|
||||
check("分页 pageSize 生效", len((r4.get("data") or {}).get("list") or []) <= 1)
|
||||
r5 = body_of("GET", "/api/v1/logs/operations?from="
|
||||
+ urllib.parse.quote("看不懂的时间"), at)
|
||||
check("时间格式写错 → 不报错(当成没传)", r5.get("code") == 0,
|
||||
f"code={r5.get('code')} msg={r5.get('msg')}")
|
||||
|
||||
# ── 7. 登录日志 ──
|
||||
print("\n[7] 登录日志(§13.4)")
|
||||
lg = body_of("GET", "/api/v1/logs/logins?pageSize=5", at)
|
||||
check("登录日志接口可用", lg.get("code") == 0, json.dumps(lg, ensure_ascii=False)[:200])
|
||||
llist = (lg.get("data") or {}).get("list") or []
|
||||
check("有记录(刚才登录过)", len(llist) > 0, f"{len(llist)}")
|
||||
if llist:
|
||||
x = llist[0]
|
||||
check("登录类型是人话(PASSWORD/WECHAT/PHONE)",
|
||||
x.get("loginType") in ("PASSWORD", "WECHAT", "PHONE"), f"{x.get('loginType')}")
|
||||
check("result 是布尔", isinstance(x.get("result"), bool), f"{x.get('result')}")
|
||||
check("带 IP 与设备", bool(x.get("ip")), json.dumps(x, ensure_ascii=False)[:200])
|
||||
r6 = body_of("GET", "/api/v1/logs/logins?result=true&pageSize=3", at)
|
||||
check("按 result 筛成功登录", ((r6.get("data") or {}).get("total") or 0) > 0,
|
||||
f"total={(r6.get('data') or {}).get('total')}")
|
||||
|
||||
# ── 8. 权限 ──
|
||||
print("\n[8] 权限:日志含别人的操作轨迹与 IP")
|
||||
stok = mp(STU)
|
||||
b = body_of("GET", "/api/v1/logs/operations", stok)
|
||||
check("学生查操作日志 → 403", b.get("code") in (40101, 40301, 403),
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
b = body_of("GET", "/api/v1/logs/operations")
|
||||
check("未登录查日志 → 40101", b.get("code") == 40101, f"code={b.get('code')}")
|
||||
|
||||
cleanup()
|
||||
print("\n" + "=" * 74)
|
||||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||
print("=" * 74)
|
||||
for n, det in FAILED:
|
||||
print(f" ✗ {n}\n {det}")
|
||||
return 1 if FAILED else 0
|
||||
|
||||
|
||||
def _build_roster(rows: list[tuple[str, str]]) -> None:
|
||||
from openpyxl import Workbook
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "日志班"
|
||||
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
|
||||
"报考职业", "报考工种", "报考等级", "是否补考",
|
||||
"补考理论", "补考实操", "补考综合", "是否在校生",
|
||||
"在读学校", "所学专业", "入学时间"]
|
||||
for c, t in enumerate(header, start=1):
|
||||
ws.cell(row=1, column=c, value=t)
|
||||
for i, (name, idc) in enumerate(rows):
|
||||
vals = [i + 1, name, idc, "中专", f"138001355{i}", "无",
|
||||
"车工", "数控车工", "四级", "否", "", "", "", "是",
|
||||
"示范职业学校", "数控加工", "2024-09-01"]
|
||||
for c, v in enumerate(vals, start=1):
|
||||
if v != "":
|
||||
ws.cell(row=i + 2, column=c, value=v)
|
||||
wb.save(TMP_ROSTER)
|
||||
|
||||
|
||||
def cleanup() -> None:
|
||||
for tid in CREATED_TASK_IDS:
|
||||
R.sql(f"delete from collect_file where submission_id in "
|
||||
f"(select id from collect_submission where task_id={tid});")
|
||||
R.sql(f"delete from collect_submission where task_id={tid};")
|
||||
R.sql(f"delete from collect_roster where task_id={tid};")
|
||||
R.sql(f"delete from collect_task_org where task_id={tid};")
|
||||
R.sql(f"delete from collect_material_def where task_id={tid};")
|
||||
R.sql(f"delete from sys_operation_log where target_type='TASK' and target_id={tid};")
|
||||
R.sql(f"delete from collect_task where id={tid};")
|
||||
R.sql("delete from sys_operation_log where target_type='ROSTER' "
|
||||
"and target_id in (select id from collect_roster where task_id in "
|
||||
"(select id from collect_task where task_name like 'E2E日志%'));")
|
||||
R.sql("delete from sys_operation_log where request_uri like '%E2E%';")
|
||||
R.sql(f"delete from sys_user_role where user_id={STU};")
|
||||
R.sql(f"delete from sys_user where id={STU};")
|
||||
if os.path.exists(TMP_ROSTER):
|
||||
os.remove(TMP_ROSTER)
|
||||
print(" 已清理:任务 / 名册 / 填报单 / 操作日志 / 测试账号 / 临时 Excel")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user