30c3714d35
新增: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。
332 lines
17 KiB
Python
332 lines
17 KiB
Python
# -*- 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())
|