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:
2026-09-30 09:02:57 +08:00
parent 6945f194b5
commit 30c3714d35
26 changed files with 9190 additions and 1 deletions
+331
View File
@@ -0,0 +1,331 @@
# -*- coding: utf-8 -*-
"""学生端「我的任务 / 撤回 / 进度」E2E(《03-接口设计》§9.1、§9.11、§9.12)。
守的是三件最容易做错的事:
1. **可见范围 = 名册绑定关系**,不是"租户内全部任务"。
学生只该看到老师把他加进名单的任务;别的任务他点进去也只会撞门禁。
2. **撤回的边界**:待初审/待终审能撤;**已通过不能自己撤**(40031);
撤回要**同时把名册行退回草稿**,否则班主任的看板会一直显示他已提交。
3. **不能操作别人的填报单**:前端传来的 id 可以随便改,
所以查询必须带 submitter_id(越权访问是这类接口最容易漏的一步)。
跑法:python scripts/e2e-m3-mytask.py
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
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-mytask-roster.xlsx")
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")
S = _load("e2e_m3_submit", "e2e-m3-submit.py")
A = _load("e2e_m5_audit", "e2e-m5-audit.py")
BASE = "http://localhost:8080"
UID = 993401
OTHER_UID = 993402
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:
okk, 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")
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": 1}]})
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
{"templateCode": "TPL_SCHOOL_ROLL"})
my_name, my_id = R.NAME_A, R.ID_A
R.build_fixture(TMP_ROSTER)
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
if body.get("code") != 0:
print(f" ✗ 导名册失败:{body}")
return 2
# ⚠️ 不要用 SQL 改夹具里那个人的姓名:门禁是按 **证件号 HMAC** 查名册的,
# 改了姓名但证件号还是原来那个,门禁就永远匹配不上(E2E 第一版就是这么红的)。
# 直接用夹具里真实存在的那一行(NAME_A + ID_A)来当"我"。
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True})
tok = mp(UID)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": my_name, "idCardNo": my_id})
check("夹具:门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
# ── 1. 我的任务:可见范围 = 名册绑定 ──
print("\n[1] 我的任务列表")
d = body_of("GET", "/api/v1/mp/tasks", tok).get("data") or {}
check("能拿到我的任务列表", isinstance(d.get("list"), list),
json.dumps(d, ensure_ascii=False)[:200])
mine = next((x for x in (d.get("list") or []) if x.get("taskId") == task_id), None)
check("列表里有这个任务(因为名册里绑定了我)", mine is not None,
f"任务数={len(d.get('list') or [])}")
if mine:
check("带我在这份名册里的报考三项(学生要核对)",
mine.get("occupation") is not None, json.dumps(mine, ensure_ascii=False)[:250])
check("带我所在的班级名", bool(mine.get("orgName")), f"{mine.get('orgName')}")
check("还没填 → canFillNow=true", mine.get("canFillNow") is True, f"{mine.get('canFillNow')}")
check("还没填 → 状态文案说清「还没开始」", "还没开始" in (mine.get("statusText") or ""),
f"{mine.get('statusText')}")
check("还没填 → 计入 todoCount", (d.get("todoCount") or 0) >= 1, f"{d.get('todoCount')}")
other_tok = mp(OTHER_UID)
d2 = body_of("GET", "/api/v1/mp/tasks", other_tok).get("data") or {}
check("⭐ 没被绑定到名册的人看不到这个任务(可见范围=名册,不是租户)",
not any(x.get("taskId") == task_id for x in (d2.get("list") or [])),
f"他看到了 {[x.get('taskName') for x in (d2.get('list') or [])]}")
# ── 2. 进度:还没开始 ──
print("\n[2] 进度与问题清单(未提交)")
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id})
sid = b["data"]["id"]
p = body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", tok).get("data") or {}
check("进度接口可用", p.get("submissionId") == sid, json.dumps(p, ensure_ascii=False)[:200])
check("草稿状态:canEdit=true、canSubmit=true、canWithdraw=false",
p.get("canEdit") is True and p.get("canSubmit") is True and p.get("canWithdraw") is False,
json.dumps({k: p.get(k) for k in ("canEdit", "canSubmit", "canWithdraw")}))
mats = p.get("materials") or []
codes = {m.get("materialCode") for m in mats}
# ⚠️ 是 **3** 项不是 4 项:「工作证明」的 visibleWhen 是 is_student=false,
# 在校生根本不该被要求交它。若把不适用的材料也算进清单与分母,
# 进度会**永远到不了 100%**(E2E 第一版就是 75%,见 F-21)。
check("材料清单只列**本次适用**的材料(在校生 3 项,不含工作证明)",
codes == {"PHOTO1", "IDCARD", "SCHOOL_ROLL"}, f"{sorted(codes)}")
check("已交的 / 未交的都标清楚", all(m.get("done") is False for m in mats),
json.dumps([(m.get("materialCode"), m.get("done")) for m in mats], ensure_ascii=False))
check("必传材料标了 required", all(m.get("required") for m in mats),
json.dumps(mats[:2], ensure_ascii=False))
check("未交的必传材料带阻断级问题(issueLevel=3)",
all(m.get("issueLevel") == 3 for m in mats),
json.dumps([(m.get("materialCode"), m.get("issueLevel")) for m in mats],
ensure_ascii=False))
check("进度百分比是 0%(必传材料一项没交)", p.get("progressPercent") == "0%",
f"{p.get('progressPercent')}")
check("时间轴已有「创建草稿」", any(e.get("title") == "创建草稿" for e in (p.get("timeline") or [])),
json.dumps(p.get("timeline"), ensure_ascii=False)[:200])
check("⭐ 时间轴里**没有**「已提交」(没发生的事不许出现)",
not any(e.get("title") == "已提交" for e in (p.get("timeline") or [])),
json.dumps(p.get("timeline"), ensure_ascii=False)[:200])
# ── 3. 别人的填报单不能看/不能操作 ──
print("\n[3] 越权:不能看/不能撤别人的填报单")
b = body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", other_tok)
check("别人看我的进度 → 被拒", b.get("code") != 0, f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", other_tok)
check("别人撤回我的单 → 被拒", b.get("code") != 0, f"code={b.get('code')} msg={b.get('msg')}")
# ── 4. 填好并提交 ──
print("\n[4] 填好、提交、再看进度")
fill = {"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"}
res = A.prepare_student(tok, sid, task_id, fill, dup_first_file=False)
d = res.get("data") or {}
check("提交成功", d.get("submitted") is True, json.dumps(d, ensure_ascii=False)[:250])
p2 = body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", tok).get("data") or {}
check("提交后进度 100%", p2.get("progressPercent") == "100%", f"{p2.get('progressPercent')}")
check("提交后 canWithdraw=true、canEdit=false",
p2.get("canWithdraw") is True and p2.get("canEdit") is False,
json.dumps({k: p2.get(k) for k in ("canEdit", "canWithdraw")}))
check("时间轴出现「已提交」",
any(e.get("title") == "已提交" for e in (p2.get("timeline") or [])),
json.dumps(p2.get("timeline"), ensure_ascii=False)[:250])
d3 = body_of("GET", "/api/v1/mp/tasks", tok).get("data") or {}
m3 = next((x for x in (d3.get("list") or []) if x.get("taskId") == task_id), None)
check("我的任务里显示「等待初审」",
"初审" in (m3 or {}).get("statusText", ""), f"{(m3 or {}).get('statusText')}")
check("列表带 submissionId(学生端撤回要用它,不必再查一次)",
(m3 or {}).get("submissionId") == sid,
f"{(m3 or {}).get('submissionId')} 期望 {sid}")
check("审核中 → canFillNow=false 且给出原因",
m3.get("canFillNow") is False and "撤回" in (m3.get("fillBlockReason") or ""),
json.dumps({k: m3.get(k) for k in ("canFillNow", "fillBlockReason")}, ensure_ascii=False))
# ── 5. 撤回 ──
print("\n[5] 撤回(含名册状态同步)")
w = body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", tok)
check("撤回成功", w.get("code") == 0, json.dumps(w, ensure_ascii=False)[:200])
check("返回状态回到 0(草稿)", (w.get("data") or {}).get("status") == 0, f"{w.get('data')}")
check("填报单状态=0", sql(f"select status from collect_submission where id={sid};").strip() == "0")
check("提交时间被清空(否则界面会显示一个不存在的提交)",
sql(f"select coalesce(submit_time::text,'null') from collect_submission where id={sid};")
.strip() == "null")
check("⭐ 名册行的提交状态同步退回草稿(1)",
sql(f"select submit_status from collect_roster where bind_user_id={UID} "
f"and task_id={task_id};").strip() == "1",
sql(f"select submit_status from collect_roster where bind_user_id={UID} "
f"and task_id={task_id};"))
check("撤回后可以继续改(canEdit=true)",
(body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", tok).get("data") or {})
.get("canEdit") is True)
b = body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", tok)
check("草稿状态再撤 → 明确被拒(没有「撤回」这回事)", b.get("code") != 0,
f"code={b.get('code')} msg={b.get('msg')}")
# ── 6. 已通过不能自己撤回 ──
print("\n[6] 已终审通过 → 学生不能自己撤回")
# 重新提交 → 班主任初审 → 管理员终审
R.call("PUT", f"{BASE}/api/v1/mp/submissions/{sid}", tok, {"formData": fill})
S.call("POST", f"/api/v1/mp/submissions/{sid}/submit", tok, {"confirmWarnings": True})
teacher_id, teacher_name = 151, "演示班主任"
sql(f"update collect_task_org set head_user_id={teacher_id} where task_id={task_id};")
# 班主任账号可能不存在:直接用管理员走终审前先补初审(管理员有 audit:first 但范围可能不含)
# —— 这里用 SQL 把状态推到"已通过"是不可接受的(那会绕过真实链路),
# 所以改为:把 head_user_id 指向一个真实存在的班主任(种子脚本建的那个)。
row = sql("select id from sys_user where username='demot1' and deleted=0;").strip()
if row:
sql(f"update collect_task_org set head_user_id={row} where task_id={task_id};")
_, tea = R.login(BASE, "demot1", "Demo1234", "demo")
if tea.get("code") == 0:
tt = tea["data"]["token"]
b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", tt,
{"actionSource": "BUTTON"})
check("班主任初审通过", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", at,
{"actionSource": "BUTTON", "finalOpinion": "E2E"})
check("管理员终审通过", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
w2 = body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", tok)
check("⭐ 已通过 → 学生撤回被拒(40031)", w2.get("code") == 40031,
f"code={w2.get('code')} msg={w2.get('msg')}")
p3 = body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", tok).get("data") or {}
check("已通过 → canWithdraw=false", p3.get("canWithdraw") is False, f"{p3.get('canWithdraw')}")
d4 = body_of("GET", "/api/v1/mp/tasks", tok).get("data") or {}
m4 = next((x for x in (d4.get("list") or []) if x.get("taskId") == task_id), None)
check("我的任务显示「已通过」", "已通过" in (m4 or {}).get("statusText", ""),
f"{(m4 or {}).get('statusText')}")
check("已通过 → canFillNow=false 且原因是「无需再改」",
m4.get("canFillNow") is False and "通过" in (m4.get("fillBlockReason") or ""),
json.dumps({k: m4.get(k) for k in ("canFillNow", "fillBlockReason")},
ensure_ascii=False))
else:
print(f" · 跳过终审分支:班主任登录失败 {tea}")
else:
print(" · 跳过终审分支:没有 demot1 账号(先跑 dev-seed-audit.py)")
# ── 7. 任务状态对"能不能填"的影响 ──
print("\n[7] 任务截止后:不能填,且说清为什么")
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/close", at, {})
d5 = body_of("GET", "/api/v1/mp/tasks", tok).get("data") or {}
m5 = next((x for x in (d5.get("list") or []) if x.get("taskId") == task_id), None)
check("已截止 → canFillNow=false", m5.get("canFillNow") is False, f"{m5.get('canFillNow')}")
check("已截止 → 原因指向「联系班主任」",
"截止" in (m5.get("fillBlockReason") or "") or "班主任" in (m5.get("fillBlockReason") or ""),
f"{m5.get('fillBlockReason')}")
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 cleanup() -> None:
for tid in CREATED_TASK_IDS:
R.sql(f"delete from collect_audit_record where submission_id in "
f"(select id from collect_submission where task_id={tid});")
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_join_request where task_id={tid};")
R.sql(f"delete from collect_task where id={tid};")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 材料 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())