4e68d0e5da
既有 37 个脚本是按模块切片的(门禁/草稿/提交/审核各一个),有三处固定盲区: 跨步骤的状态轨迹没人断言、切片之间那条缝(驳回后改了字段再交)、以及脚本 docstring 自述与实际断言不符(e2e-m3-draft.py:11 自述的那条断言全文没有)。 docs/11 给出 A~J 十组覆盖矩阵与判据口径(含用 grep 核对实际断言、不读自述的 规程),scripts/e2e-m3-flow.py 只补这三处、其余交给既有脚本,并钉住 F-125 / F-128 的现状(XFAIL)。verify-all 的脚本数口径随之变成 38。 配套 scripts/dev-reveal-roster-idcard.py:L3-B 走查要在门禁界面手输身份证, 而明文只在建夹具那一刻存在过(库里是 AES 密文 + 脱敏 + HMAC 索引), 此脚本只读解密打印,不入库;用完连同夹具任务一起 --clean。
1123 lines
64 KiB
Python
1123 lines
64 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
填报流程端到端 E2E(M3 主链路 + 错误矩阵)。
|
||
|
||
配套文档:docs/11-填报流程端到端测试计划.md(覆盖矩阵与判据纪律的唯一出处)
|
||
|
||
────────────────────────────────────────────────────────────────────────
|
||
这个脚本**只写"没人覆盖"的用例**,不重复既有脚本。
|
||
────────────────────────────────────────────────────────────────────────
|
||
|
||
仓库里已有 37 个 e2e 脚本(1617 条断言),它们是**按模块切片的**:
|
||
门禁一个、草稿一个、提交一个、审核一个。切片测试有三个固定的盲区,
|
||
本脚本只补这三个盲区,其余一律交给既有脚本(矩阵里逐行标了"覆盖者"):
|
||
|
||
1. **跨步骤的状态一致性没人管** —— 「名册行 submit_status 从 0 走到 3」
|
||
被四个脚本各断言一格(e2e-m5-audit.py:308/436/517/537),
|
||
但**没有任何一条断言这条轨迹是连续的**。分散断言发现不了"中间跳了一格"。
|
||
→ J1
|
||
|
||
2. **切片之间那条缝** —— 驳回后「能重新提交」被 e2e-m5-audit.py:534 证明了,
|
||
但它在重交前只改了库里的 reject_count,**没有改任何业务字段** ——
|
||
而驳回的全部意义就是"改了再交"。切片把这一格填成绿色,缝里没人看。
|
||
→ E9/F10/I9(F-125)
|
||
|
||
3. **脚本自述 ≠ 实际断言** —— e2e-m3-draft.py:11 的 docstring 自述
|
||
「已提交的不能直接改」,该文件全文却**没有这条断言**
|
||
(全仓唯一涉及 40030 的是 e2e-m5-proxy.py:277,测的是代传路径)。
|
||
→ E8
|
||
|
||
用法:
|
||
python scripts/e2e-m3-flow.py # 全部(chain + errors)
|
||
python scripts/e2e-m3-flow.py --group chain # 只跑链路
|
||
python scripts/e2e-m3-flow.py --group errors # 只跑错误矩阵
|
||
python scripts/e2e-m3-flow.py --fixture-only # 只建夹具并打印 id,不清理、不跑用例
|
||
python scripts/e2e-m3-flow.py --keep # 跑完不清理(配合人工走查)
|
||
python scripts/e2e-m3-flow.py --h5-url # 打印可直接打开的 H5 地址
|
||
python scripts/e2e-m3-flow.py --clean <taskId> # 只清理指定夹具
|
||
python scripts/e2e-m3-flow.py --full # 连慢用例一起跑(默认跳过)
|
||
|
||
前提:后端 8080 在跑、xt-pg 容器在跑、backend/ 目录是启动工作目录。
|
||
"""
|
||
|
||
import argparse
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import importlib.util
|
||
import io
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
import uuid
|
||
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
PG = "xt-pg"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# 复用既有 e2e 脚本的工具(**不自己再写一份** call/upload/mint)
|
||
#
|
||
# 理由见 scripts/dev-seed-audit.py:48-65:两边各写一份,一改一漏,
|
||
# 就会变成"单独跑得过、和 E2E 一起跑就红",而根因极难找。
|
||
# ⚠️ e2e-m3-submit.mint 是 4 参(学生侧写死 STUDENT/userType=4),
|
||
# 而 e2e-m3-draft 里的 mint 是 6 参 —— 签名不同,改一处漏一处。
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
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
|
||
|
||
|
||
S = _load("e2e_m3_submit", "e2e-m3-submit.py") # 学生侧:call / upload / sub / mint / form_values
|
||
R = _load("e2e_m2_roster", "e2e-m2-roster.py") # 服务端:login / upload_roster / with_checksum
|
||
A = _load("e2e_m5_audit", "e2e-m5-audit.py") # big_jpeg(长边够长,免得被判"清晰度偏低")
|
||
|
||
PASSED: list[str] = []
|
||
FAILED: list[tuple[str, str]] = []
|
||
XFAILED: list[str] = []
|
||
XPASSED: list[str] = []
|
||
CREATED_TASK_IDS: list[int] = []
|
||
CREATED_USER_IDS: list[int] = []
|
||
TMP_FILES: list[str] = []
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# 已知缺陷登记(XFAIL)
|
||
#
|
||
# 为什么不直接让它红:`verify-all --with-e2e` 长期红,红着红着就没人看了,
|
||
# 那比不测更糟(一个恒红的套件等于没有套件)。
|
||
# 为什么不直接删掉用例:删掉之后**缺陷修好了也没有任何信号**。
|
||
# 所以两个方向都要报:失败 → [XFAIL] 不计失败;**通过 → [XPASS] 计入失败**。
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
KNOWN_DEFECTS = {
|
||
"EP-E09": "F-125(驳回后 PUT 被 40030 拒;契约 §2.4 期望 4 → 1 可整改)",
|
||
"EP-F10": "F-125(驳回后上传材料被 40030 拒)",
|
||
"EP-F10b": "F-125(驳回后删除材料被 40030 拒 —— 与上传是两条独立的守卫)",
|
||
"EP-I09": "F-125(驳回后整改提交走不通)",
|
||
"EP-D09": "F-128(扩展字段存进去却回显不出来:assemble 从不把 extrasJson 合进 values)",
|
||
"EP-J02e": "F-128(必填扩展字段永远满足不了 → 该任务对所有人永久阻断)",
|
||
}
|
||
|
||
|
||
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 check_case(cid: str, name: str, cond: bool, detail: str = "") -> bool:
|
||
"""带用例编号的断言:编号在 KNOWN_DEFECTS 里就走 XFAIL/XPASS 口径。"""
|
||
label = f"{cid} {name}"
|
||
if cid in KNOWN_DEFECTS:
|
||
if cond:
|
||
XPASSED.append(label)
|
||
FAILED.append((label, f"缺陷似乎已修({KNOWN_DEFECTS[cid]})—— "
|
||
f"请把 {cid} 从 KNOWN_DEFECTS 里删掉"))
|
||
print(f" [XPASS] {label}\n 缺陷似乎已修 —— 请删掉 KNOWN_DEFECTS 里的登记")
|
||
else:
|
||
XFAILED.append(label)
|
||
print(f" [XFAIL] {label} (已知缺陷 {KNOWN_DEFECTS[cid]})")
|
||
return cond
|
||
return check(label, cond, detail)
|
||
|
||
|
||
def sql(stmt: str) -> str:
|
||
r = subprocess.run(
|
||
["docker", "exec", "-e", "PGPASSWORD=fill_dev_pwd", PG,
|
||
"psql", "-U", "fill", "-d", "filldb", "-t", "-A", "-c", stmt],
|
||
capture_output=True, text=True, timeout=40)
|
||
return (r.stdout or r.stderr).strip()
|
||
|
||
|
||
def pc(base: str, method: str, path: str, token: str | None = None,
|
||
body: dict | None = None) -> dict:
|
||
"""PC 侧调用:R.call 返回 (status, body),这里只要 body。"""
|
||
_, b = R.call(method, f"{base}{path}", token, body)
|
||
return b
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# 夹具
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
def build_roster_xlsx(path: str, rows: list[tuple]) -> None:
|
||
"""名册:姓名/证件号/是否在校生/在读学校/所学专业/入学时间 逐行给。
|
||
|
||
⚠️ 两个必须踩对的点(照 scripts/e2e-m5-audit.py:100-119):
|
||
1. 表头文字要与名册导入的列名对得上(报考三项要写全,否则 VAL-13 会红);
|
||
2. 「是否在校生」要**逐行不同** —— 条件可见(D8)与 VAL-16 都靠它分叉。
|
||
"""
|
||
from openpyxl import Workbook
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "24中技数控1班"
|
||
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
|
||
"报考职业", "报考工种", "报考等级", "是否补考",
|
||
"补考理论", "补考实操", "补考综合", "是否在校生",
|
||
"在读学校", "所学专业", "入学时间"]
|
||
for c, t in enumerate(header, start=1):
|
||
ws.cell(row=1, column=c, value=t)
|
||
for i, (name, idc, is_stu, school, major, enroll) in enumerate(rows):
|
||
vals = [i + 1, name, idc, "中专", f"138001388{i}", "无",
|
||
"车工", "数控车工", "四级", "否", "", "", "", is_stu,
|
||
school, major, enroll]
|
||
for c, v in enumerate(vals, start=1):
|
||
if v != "":
|
||
ws.cell(row=i + 2, column=c, value=v)
|
||
wb.save(path)
|
||
|
||
|
||
def make_task(base: str, admin: str, name: str) -> int:
|
||
body = pc(base, "POST", "/api/v1/tasks", admin, {
|
||
"taskName": name, "collectMode": 1, "batchNo": "2007",
|
||
"majorName": "数控加工", "jobName": "车工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-12-31",
|
||
})
|
||
if body.get("code") != 0:
|
||
raise SystemExit(f" ✗ 建任务失败:{body}")
|
||
tid = body["data"]["id"]
|
||
CREATED_TASK_IDS.append(tid)
|
||
return tid
|
||
|
||
|
||
def prepare_task(base: str, admin: str, tid: int, org_id: int, head_uid: int,
|
||
xlsx: str, expect_rows: int) -> None:
|
||
"""名册 → 覆盖班级 + 指定初审人 → 必传材料 → 发布。顺序不能换。"""
|
||
# ⚠️ 必须显式带 orgId:留空则名册行没有 org_id → 提交单没有班级 →
|
||
# 审核范围判不出来(40001/40301)。见 scripts/e2e-m5-audit.py:240-241。
|
||
body = R.upload_roster(base, admin, tid, xlsx,
|
||
{"dryRun": "false", "orgId": str(org_id)})
|
||
if body.get("code") != 0:
|
||
raise SystemExit(f" ✗ 名册导入失败:{body}")
|
||
got = (body.get("data") or {}).get("created")
|
||
if got != expect_rows:
|
||
raise SystemExit(f" ✗ 名册导入了 {got} 行,期望 {expect_rows}:{body}")
|
||
|
||
body = pc(base, "PUT", f"/api/v1/tasks/{tid}/orgs", admin, {
|
||
"orgs": [{"orgId": org_id, "headUserId": head_uid, "expectedCount": expect_rows}],
|
||
})
|
||
if body.get("code") != 0:
|
||
raise SystemExit(f" ✗ 覆盖班级失败:{body}")
|
||
|
||
# ⚠️ 下限必须调到 1KB / 长边 100:否则测试图会被材料目录的默认下限判成
|
||
# 「体积偏小 / 清晰度偏低」——那是**黄色警告**,于是"绿色单"这个前提不成立,
|
||
# 后面所有断言跟着错,而失败的样子像功能坏了。
|
||
# 见 scripts/e2e-m5-audit.py:255-263 的实测注释。
|
||
body = pc(base, "POST", f"/api/v1/tasks/{tid}/materials", admin, {
|
||
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
|
||
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 1024, "maxCount": 2,
|
||
"minSizeKb": 1, "minLongSide": 100,
|
||
})
|
||
if body.get("code") != 0:
|
||
raise SystemExit(f" ✗ 配材料失败:{body}")
|
||
|
||
body = pc(base, "POST", f"/api/v1/tasks/{tid}/publish", admin, {"force": True})
|
||
if body.get("code") != 0:
|
||
raise SystemExit(f" ✗ 发布失败:{body}")
|
||
|
||
|
||
def build_fixture(base: str, args) -> dict:
|
||
print("[夹具] 管理员 / 班级 / 班主任 / 两个任务 / 三人名册")
|
||
_, b = R.login(base, "admin", args.password, args.tenant)
|
||
if b.get("code") != 0:
|
||
raise SystemExit(f" ✗ 管理员登录失败:{b}")
|
||
admin = b["data"]["token"]
|
||
ok("管理员登录")
|
||
|
||
_, tree = R.call("GET", f"{base}/api/v1/orgs/tree", admin)
|
||
classes = []
|
||
|
||
def walk(nodes):
|
||
for n in nodes or []:
|
||
if n.get("orgType") == 3:
|
||
classes.append(n)
|
||
walk(n.get("children"))
|
||
|
||
walk((tree.get("data") or []))
|
||
if not classes:
|
||
raise SystemExit(" ✗ demo 租户里没有 orgType=3 的班级")
|
||
org_id, org_name = classes[0]["id"], classes[0]["orgName"]
|
||
ok(f"班级:{org_name}(id={org_id})")
|
||
|
||
suffix = args.suffix or uuid.uuid4().hex[:6]
|
||
uname = f"e2eflow{suffix}"
|
||
body = pc(base, "POST", "/api/v1/users", admin, {
|
||
"username": uname, "realName": f"流程师{suffix}",
|
||
# 手机号要过唯一约束:用 suffix 算出来,别用 hash()(进程随机)
|
||
"phone": "13" + str(int(suffix, 16) % 10 ** 8).zfill(9),
|
||
"roleCodes": ["HEAD_TEACHER"], "userType": 3,
|
||
})
|
||
if body.get("code") != 0:
|
||
raise SystemExit(f" ✗ 建班主任失败:{body}")
|
||
head_uid, head_pwd = body["data"]["id"], body["data"]["initialPassword"]
|
||
CREATED_USER_IDS.append(head_uid)
|
||
_, lb = R.login(base, uname, head_pwd, args.tenant)
|
||
if lb.get("code") != 0:
|
||
raise SystemExit(f" ✗ 班主任登录失败:{lb}")
|
||
head = lb["data"]["token"]
|
||
ok(f"班主任(id={head_uid})已建并登录,被指定为初审人")
|
||
|
||
task_main = make_task(base, admin, f"E2E流程 {suffix}")
|
||
task_state = make_task(base, admin, f"E2E流程状态 {suffix}")
|
||
|
||
id_jia = R.with_checksum("361029" + "20080101" + "011")
|
||
id_yi = R.with_checksum("361029" + "20080202" + "022")
|
||
id_bing = R.with_checksum("361029" + "20080303" + "033")
|
||
rows = [
|
||
(f"流程甲{suffix}", id_jia, "是", "示范职业学校", "数控加工", "2024-09-01"),
|
||
(f"流程乙{suffix}", id_yi, "否", "", "", ""),
|
||
(f"流程丙{suffix}", id_bing, "是", "示范职业学校", "机电一体化", "2023-09-01"),
|
||
]
|
||
fix = os.path.join(ROOT, "scripts", f"_tmp_flow_{suffix}.xlsx")
|
||
TMP_FILES.append(fix)
|
||
build_roster_xlsx(fix, rows)
|
||
prepare_task(base, admin, task_main, org_id, head_uid, fix, len(rows))
|
||
prepare_task(base, admin, task_state, org_id, head_uid, fix, len(rows))
|
||
ok(f"两个任务已发布:taskMain={task_main}(跑链路)、taskState={task_state}(跑状态机)")
|
||
|
||
pairs = [ln.split("|") for ln in
|
||
sql(f"SELECT id, real_name FROM collect_roster WHERE task_id={task_main} "
|
||
f"AND deleted=0 ORDER BY id").splitlines() if ln]
|
||
by_name = {p[1]: int(p[0]) for p in pairs}
|
||
rid = {k: by_name[f"流程{k}{suffix}"] for k in ("甲", "乙", "丙")}
|
||
|
||
cfg = S.read_cfg()
|
||
uid_base = 960000000 + int(suffix, 16) % 10000000
|
||
uid = {"甲": uid_base, "乙": uid_base + 1, "丙": uid_base + 2, "丁": uid_base + 3}
|
||
tok = {k: S.mint(cfg["jwt"], cfg["issuer"], v, 1) for k, v in uid.items()}
|
||
ok(f"手搓三个学生 token(假 uid {uid_base}~,库里没有 sys_user —— 与既有脚本同法)")
|
||
|
||
return {
|
||
"base": base, "admin": admin, "head": head, "head_uid": head_uid,
|
||
"org_id": org_id, "suffix": suffix, "args": args,
|
||
"task_main": task_main, "task_state": task_state,
|
||
"rid": rid, "uid": uid, "tok": tok,
|
||
"id": {"甲": id_jia, "乙": id_yi, "丙": id_bing},
|
||
"name": {k: f"流程{k}{suffix}" for k in ("甲", "乙", "丙")},
|
||
}
|
||
|
||
|
||
def bind_via_gate(fx: dict, who: str, task_id: int) -> dict:
|
||
"""走**真实门禁**绑定(不是 SQL 直接改)—— 链路保真度靠这一步。"""
|
||
return S.call("POST", "/api/v1/mp/roster/match", fx["tok"][who], {
|
||
"realName": fx["name"][who], "idCardNo": fx["id"][who], "taskId": task_id,
|
||
})
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# 小工具
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
def roster_row(task_id: int, who: str) -> int:
|
||
"""按 (task_id, 姓名) 查名册行 id。
|
||
|
||
⚠️ 不能用 FX["rid"] —— 那是**taskMain**的名册行 id。名册行是
|
||
(task_id, 人) 维度的,同一个学生在两个任务里是两行,拿错行去改绑定的后果是
|
||
「改了半天没改着,而断言以另一种形式失败」。
|
||
"""
|
||
v = sql(f"SELECT id FROM collect_roster WHERE task_id={task_id} AND deleted=0 "
|
||
f"AND real_name='{FX['name'][who]}'")
|
||
if not v.strip().isdigit():
|
||
raise SystemExit(f" ✗ 找不到名册行:task={task_id} name={FX['name'][who]!r} → {v!r}")
|
||
return int(v.strip())
|
||
|
||
|
||
def roster_status(task_id: int, who: str) -> str:
|
||
return sql(f"SELECT submit_status FROM collect_roster WHERE id={roster_row(task_id, who)}")
|
||
|
||
|
||
def sub_status(sid: int) -> str:
|
||
return sql(f"SELECT status FROM collect_submission WHERE id={sid}")
|
||
|
||
|
||
def issues_of(r: dict) -> list:
|
||
return ((r.get("data") or {}).get("issues") or [])
|
||
|
||
|
||
def rules(r: dict) -> set:
|
||
return {i.get("ruleCode") for i in issues_of(r)}
|
||
|
||
|
||
def red_count(r: dict) -> int:
|
||
return ((r.get("data") or {}).get("redCount") or 0)
|
||
|
||
|
||
def yellow_count(r: dict) -> int:
|
||
return ((r.get("data") or {}).get("yellowCount") or 0)
|
||
|
||
|
||
def submitted(r: dict) -> bool:
|
||
"""判据纪律(F-84):业务结果看 data.submitted,不看 code==0。
|
||
|
||
校验不通过也是 HTTP 200 + code:0,只给一份问题清单 ——
|
||
所以 `code == 0` 恒真,拿它当"提交成功"判据的测试会**静默变绿**。
|
||
"""
|
||
return (r.get("data") or {}).get("submitted") is True
|
||
|
||
|
||
def new_draft(fx: dict, who: str, task_id: int) -> int:
|
||
r = S.call("POST", "/api/v1/mp/submissions", fx["tok"][who], {"taskId": task_id})
|
||
if r.get("code") != 0:
|
||
raise SystemExit(f" ✗ 建草稿失败({who}):{r}")
|
||
return r["data"]["id"]
|
||
|
||
|
||
def fill_materials(fx: dict, who: str, sid: int, task_id: int,
|
||
img: bytes | None = None, dup_first: bool = False) -> dict:
|
||
"""按**表单里当前必传且条件成立**的材料逐项上传(不写死材料码)。"""
|
||
img = img or A.big_jpeg()
|
||
values, mats = S.form_values(fx["tok"][who], task_id)
|
||
need = [m for m in mats if m.get("required") and S.visible(m.get("visibleWhen"), values)]
|
||
for i, m in enumerate(need):
|
||
code = m.get("materialCode")
|
||
up = S.upload(fx["tok"][who], sid, code, f"{m.get('materialName') or code}.jpg", img)
|
||
if up.get("code") != 0:
|
||
return {"_upload_error": up}
|
||
S.call("POST", f"/api/v1/mp/files/{up['data']['id']}/process", fx["tok"][who])
|
||
if dup_first and i == 0:
|
||
up2 = S.upload(fx["tok"][who], sid, code, "重复.jpg", img)
|
||
if up2.get("code") == 0:
|
||
S.call("POST", f"/api/v1/mp/files/{up2['data']['id']}/process", fx["tok"][who])
|
||
return {"uploaded": len(need)}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# chain 组:完整链路(甲)
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
def group_chain(fx: dict) -> None:
|
||
base, tok = fx["base"], fx["tok"]
|
||
task = fx["task_main"]
|
||
sid_jia = None
|
||
|
||
# ── 1. 门禁:真实核验绑定 + 明文不落任何地方 ─────────────────
|
||
print("\n[C] 门禁核验与「明文不落地」")
|
||
b = bind_via_gate(fx, "甲", task)
|
||
check_case("EP-C07a", "走真实门禁绑定成功(姓名 + 证件号与名册一致,非 SQL 直改)",
|
||
b.get("code") == 0, f"{b}")
|
||
mask = sql(f"SELECT coalesce(id_card_mask,'') FROM collect_roster WHERE id={fx['rid']['甲']}")
|
||
check_case("EP-C07b", "库里的证件号是脱敏值(带 *)", "*" in mask, f"mask={mask!r}")
|
||
check_case("EP-C07e", "门禁响应全文搜不到明文证件号(不是「只回脱敏字段」就算,要全文扫)",
|
||
fx["id"]["甲"] not in json.dumps(b, ensure_ascii=False),
|
||
f"响应里出现了明文:{b}")
|
||
raw = sql(f"SELECT id_card_no FROM collect_roster WHERE id={fx['rid']['甲']}")
|
||
check_case("EP-C07c", "明文证件号**没有**落进 collect_roster(id_card_no 是 AES 密文)",
|
||
fx["id"]["甲"] not in raw, f"id_card_no={raw!r}")
|
||
leak = sql(f"SELECT count(*) FROM sys_operation_log "
|
||
f"WHERE create_time > now() - interval '30 minutes' "
|
||
f"AND coalesce(request_param,'') LIKE '%{fx['id']['甲']}%'")
|
||
check_case("EP-C07d", "操作日志里也没有明文证件号(入库前必须脱敏)",
|
||
leak == "0", f"命中 {leak} 行")
|
||
logged = sql(f"SELECT count(*) FROM sys_operation_log "
|
||
f"WHERE create_time > now() - interval '30 minutes' "
|
||
f"AND request_uri LIKE '%roster/match%'")
|
||
print(f" [INFO] 近 30 分钟 /roster/match 的操作日志行数:{logged}")
|
||
|
||
bind_via_gate(fx, "乙", task)
|
||
bind_via_gate(fx, "丙", task)
|
||
ok("乙(在校生=否)与丙(在校生=是)也已绑定")
|
||
|
||
# ── 2. 名册轨迹起点 + 表单结构 ──────────────────────────────
|
||
print("\n[J1] 名册行 submit_status 全轨迹(本脚本独有:单点断言发现不了「中间跳了一格」)")
|
||
check_case("EP-J01a", "轨迹起点:绑定后 submit_status=0(未提交)",
|
||
roster_status(task, "甲") == "0", roster_status(task, "甲"))
|
||
|
||
form = S.call("GET", f"/api/v1/mp/tasks/{task}/form", tok["甲"])
|
||
d = form.get("data") or {}
|
||
flat = {}
|
||
for sec in d.get("sections") or []:
|
||
for f in sec.get("fields") or []:
|
||
flat[f.get("key")] = f
|
||
raw_json = json.dumps(d, ensure_ascii=False)
|
||
check_case("EP-D07", "表单响应全文搜不到明文证件号(只有脱敏值)",
|
||
fx["id"]["甲"] not in raw_json, "响应里出现了明文")
|
||
# 夹具前提(不是用例:21 个内置字段已由 e2e-m3-draft.py:153 断言过,不重复计分)
|
||
check("夹具前提:表单含 21 个内置字段(D8 的条件可见依赖这份字段目录)",
|
||
len(flat) == 21, f"实际 {len(flat)}:{sorted(flat)}")
|
||
|
||
# ── 3. D8 条件可见(字段侧,逐学生分叉)─────────────────────
|
||
print("\n[D8] 条件可见性(字段侧):在校生 / 非在校生两条分支")
|
||
vals_jia, _ = S.form_values(tok["甲"], task)
|
||
vals_yi, _ = S.form_values(tok["乙"], task)
|
||
stu_keys = ["school", "major", "enroll_date"]
|
||
check_case("EP-D08a", "在校生(is_student=1)→ 学籍三项可见",
|
||
all(S.visible(flat[k].get("visibleWhen"), vals_jia) for k in stu_keys),
|
||
f"is_student={vals_jia.get('is_student')!r} "
|
||
f"when={[flat[k].get('visibleWhen') for k in stu_keys]}")
|
||
check_case("EP-D08b", "非在校生(is_student=0)→ 学籍三项不可见",
|
||
not any(S.visible(flat[k].get("visibleWhen"), vals_yi) for k in stu_keys),
|
||
f"is_student={vals_yi.get('is_student')!r}")
|
||
retake_keys = ["retake_theory", "retake_practice", "retake_overall"]
|
||
check_case("EP-D08c", "未勾补考(is_retake=0)→ 补考三科不可见",
|
||
not any(S.visible(flat[k].get("visibleWhen"), vals_jia) for k in retake_keys),
|
||
f"is_retake={vals_jia.get('is_retake')!r}")
|
||
|
||
# ── 4. 草稿:幂等 / 缺 taskId / 越权 / 扩展字段白名单 ────────
|
||
print("\n[E] 草稿:建单守卫与字段白名单")
|
||
r = S.call("POST", "/api/v1/mp/submissions", tok["甲"], {})
|
||
check_case("EP-E12", "建草稿不带 taskId → 40001「缺少 taskId」(不静默当成 0)",
|
||
r.get("code") == 40001, f"{r}")
|
||
sid_jia = new_draft(fx, "甲", task)
|
||
check_case("EP-J01b", "建草稿后名册行 → 1(草稿)",
|
||
roster_status(task, "甲") == "1", roster_status(task, "甲"))
|
||
r = S.call("POST", "/api/v1/mp/submissions", tok["甲"], {"taskId": task})
|
||
# 夹具前提(幂等建单已由 e2e-m3-draft.py:161-171 断言过,不重复计分)
|
||
check("夹具前提:重复建单幂等(后续步骤都靠这个 sid)",
|
||
r.get("code") == 0 and r["data"]["id"] == sid_jia, f"{r}")
|
||
r = S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["乙"],
|
||
{"formData": {"work_years": "9"}})
|
||
check_case("EP-E10", "越权:用乙的 token 改甲的单 → 40001(前端传的 id 可以随便改)",
|
||
r.get("code") == 40001, f"{r}")
|
||
r = S.call("PUT", "/api/v1/mp/submissions/99999999", tok["甲"], {"formData": {}})
|
||
check_case("EP-E11", "不存在的 submissionId → 40001", r.get("code") == 40001, f"{r}")
|
||
r = S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"extra_not_declared": "x"}})
|
||
check_case("EP-E14a", "扩展字段白名单:没声明过的 extra_* → 40001(不静默忽略)",
|
||
r.get("code") == 40001, f"{r}")
|
||
r = S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"school": "示范职业学校", "major": "数控加工",
|
||
"enroll_date": "2024-09-01"}})
|
||
check_case("EP-E14b", "声明过的内置字段正常保存", r.get("code") == 0, f"{r}")
|
||
|
||
# ── 5. 「无」是合法值(契约 §2.6)───────────────────────────
|
||
print("\n[G9] 「无」是合法业务值(契约 §2.6:学历为「无」要存字符串,不是 null)")
|
||
r = S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"education": "无", "major": "无"}})
|
||
check_case("EP-G09a", "「无」能存进去(不被当成空值丢掉)", r.get("code") == 0, f"{r}")
|
||
stored = sql(f"SELECT coalesce(education,'<NULL>') || '|' || coalesce(major,'<NULL>') "
|
||
f"FROM collect_submission WHERE id={sid_jia}")
|
||
check_case("EP-G09b", "库里存的是字符串「无」,不是 NULL(契约 §2.6)",
|
||
stored == "无|无", f"{stored}")
|
||
|
||
# ── 6. 材料:上传 / 空文件 / 错材料码 / 越权 / 秒传 / 预览 / 批量 ──
|
||
print("\n[F] 材料端点:边界与越权")
|
||
r = S.upload(tok["甲"], sid_jia, "IDCARD", "空.jpg", b"")
|
||
check_case("EP-F07", "空文件 → 40001「文件是空的」", r.get("code") == 40001, f"{r}")
|
||
r = S.upload(tok["甲"], sid_jia, "NOT_EXIST", "x.jpg", A.big_jpeg())
|
||
check_case("EP-F08", "不存在的 materialCode → 40001",
|
||
r.get("code") == 40001 and "NOT_EXIST" in str(r.get("msg")), f"{r}")
|
||
|
||
img = A.big_jpeg()
|
||
fp = os.path.join(ROOT, "scripts", f"_tmp_flow_img_{fx['suffix']}.jpg")
|
||
TMP_FILES.append(fp)
|
||
with open(fp, "wb") as f:
|
||
f.write(img)
|
||
pre = S.call("POST", "/api/v1/mp/files/upload-token", tok["甲"], {
|
||
"taskId": task, "materialCode": "IDCARD", "fileName": "身份证.jpg",
|
||
"fileSize": len(img), "contentType": "image/jpeg",
|
||
})
|
||
pd = pre.get("data") or {}
|
||
check_case("EP-F13a", "§9.5 预检通过:给 uploadUrl、existed=false、direct=false(一期后端代理接收)",
|
||
pre.get("code") == 0 and pd.get("ok") is True and pd.get("uploadUrl")
|
||
and pd.get("existed") is False and pd.get("direct") is False, f"{pre}")
|
||
|
||
up = S.upload(tok["甲"], sid_jia, "IDCARD", "身份证.jpg", img)
|
||
check_case("EP-F01", "正常上传成功", up.get("code") == 0, f"{up}")
|
||
fid = (up.get("data") or {}).get("id")
|
||
S.call("POST", f"/api/v1/mp/files/{fid}/process", tok["甲"])
|
||
fhash = sql(f"SELECT file_hash FROM collect_file WHERE id={fid}")
|
||
check_case("EP-F13b", "库里算了 SHA-256(查重靠它)", len(fhash) == 64, f"{fhash!r}")
|
||
pre2 = S.call("POST", "/api/v1/mp/files/upload-token", tok["甲"], {
|
||
"taskId": task, "materialCode": "IDCARD", "fileName": "身份证.jpg",
|
||
"fileSize": len(img), "contentType": "image/jpeg", "fileHash": fhash,
|
||
})
|
||
pd2 = pre2.get("data") or {}
|
||
check_case("EP-F13c", "§9.5 秒传:同 hash 第二次 existed=true 且**不给 uploadUrl**(前端据此跳过上传)",
|
||
pre2.get("code") == 0 and pd2.get("existed") is True and not pd2.get("uploadUrl"),
|
||
f"{pre2}")
|
||
|
||
r = S.call("GET", f"/api/v1/mp/files/{fid}/url", tok["乙"])
|
||
check_case("EP-F12a", "越权取别人的文件链接 → 40001", r.get("code") == 40001, f"{r}")
|
||
r = S.call("GET", f"/api/v1/mp/files/{fid}/url", tok["甲"])
|
||
check_case("EP-F12b", "本人取自己的文件链接 → code=0 且 url 非空",
|
||
r.get("code") == 0 and bool((r.get("data") or {}).get("url")), f"{r}")
|
||
r = S.call("GET", f"/api/v1/mp/files/{fid}?prefer=original", tok["甲"])
|
||
vd = r.get("data") or {}
|
||
check_case("EP-F15", "§9.8 请求原始件但原始件不存在 → 退到规范件,且 actualKind **如实告知**"
|
||
"(keep-raw=false 时它不该说 ORIGINAL)",
|
||
r.get("code") == 0 and vd.get("actualKind") not in (None, "", "ORIGINAL"),
|
||
f"actualKind={vd.get('actualKind')!r} {r}")
|
||
print(f" [INFO] prefer=original 时 actualKind={vd.get('actualKind')!r}"
|
||
f"(这一格就是「你以为看的是原图」那个坑)")
|
||
|
||
r = S.call("POST", "/api/v1/mp/files/batch-process", tok["甲"], {
|
||
"submissionId": sid_jia, "fileIds": list(range(1, 300)),
|
||
})
|
||
check_case("EP-F14", "§9.7 批量超过 200 条 → 40011(不是静默截断)",
|
||
r.get("code") == 40011, f"{r}")
|
||
|
||
r = S.upload(tok["乙"], sid_jia, "IDCARD", "越权.jpg", A.big_jpeg())
|
||
check_case("EP-F11a", "越权:上传到别人的单 → 40001", r.get("code") == 40001, f"{r}")
|
||
r = S.call("DELETE", f"/api/v1/mp/files/{fid}", tok["乙"])
|
||
check_case("EP-F11b", "越权:删别人的文件 → 40001", r.get("code") == 40001, f"{r}")
|
||
r = S.call("POST", f"/api/v1/mp/files/{fid}/process", tok["乙"])
|
||
check_case("EP-F17", "越权:对别人的文件调 process → 40001(不靠前端隐藏按钮)",
|
||
r.get("code") == 40001, f"{r}")
|
||
r = S.call("GET", f"/api/v1/mp/files/submissions/{sid_jia}", tok["乙"])
|
||
check_case("EP-F18", "越权:列别人填报单的材料 → 40001",
|
||
r.get("code") == 40001, f"{r}")
|
||
|
||
# ── 7. 校验:VAL-17 / VAL-15+VAL-22 / VAL-01 / 黄级 ──────────
|
||
print("\n[G] 提交校验:VAL-17 / VAL-15 / VAL-22 / VAL-01 / 黄级")
|
||
# ⚠️ 先制造一个**稳定的红**(清空在校生的「在读学校」→ VAL-16)。
|
||
# 否则下面每一次"探针式提交"都可能在「红=0、黄=0」时**真的提交成功**,
|
||
# 于是后面每条断言都拿到 40030 + 空 issues —— 而空集合会让
|
||
# "VAL-17 not in rules(r)" 这类断言**静默通过**。
|
||
# 第一版就踩了这个坑:10 条失败里有 6 条是它的连带反应。
|
||
S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"school": ""}})
|
||
blk = S.sub(tok["甲"], sid_jia)
|
||
check("夹具前提:清空在读学校 → VAL-16 红(探针提交不会误成功)",
|
||
"VAL-16" in rules(blk) and submitted(blk) is False, f"{sorted(rules(blk))}")
|
||
# 契约明写「只对黄色有效;红色是阻断级,这个参数绕不过去」(SubmitDtos.java:100-101)
|
||
blk2 = S.sub(tok["甲"], sid_jia, True)
|
||
check_case("EP-G18", "红色**不能**靠 confirmWarnings 强提绕过(它只对黄色有效)",
|
||
submitted(blk2) is False and red_count(blk2) > 0,
|
||
f"submitted={submitted(blk2)} red={red_count(blk2)}")
|
||
|
||
put = S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"work_years": "三年"}})
|
||
back = sql(f"SELECT coalesce(work_years::text,'') FROM collect_submission WHERE id={sid_jia}")
|
||
check_case("EP-G11a", "VAL-17 的非阿拉伯数字分支**不可达**:work_years 是 smallint,"
|
||
"「三年」根本存不进去(PUT 被拒或落空)→ 那段校验没有入口",
|
||
put.get("code") != 0 or back == "",
|
||
f"PUT code={put.get('code')} 回读={back!r}")
|
||
print(f" [INFO] PUT「三年」→ code={put.get('code')};回读 work_years={back!r}"
|
||
f" —— 这就是那段校验从来没有触发过的原因(契约有、数据模型到不了)")
|
||
|
||
S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"work_years": "80"}})
|
||
r = S.sub(tok["甲"], sid_jia)
|
||
check_case("EP-G11b", "VAL-17:工作年限填「80」→ 红(不在 0~60;80 能进 smallint)",
|
||
"VAL-17" in rules(r) and submitted(r) is False,
|
||
f"{sorted(rules(r))} red={red_count(r)}")
|
||
# 复位成合法值:不复位的话后面每一条都会带着这个红,最终提交会失败
|
||
S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"work_years": "3"}})
|
||
|
||
# 条件必填:不可见字段不参与 VAL-22;可见且必填才参与
|
||
S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"is_retake": False}})
|
||
r = S.sub(tok["甲"], sid_jia)
|
||
check_case("EP-G10a", "补考三科不可见时**不参与**必填(不报 VAL-22/VAL-15)",
|
||
"VAL-22" not in rules(r) and "VAL-15" not in rules(r) and submitted(r) is False,
|
||
f"{sorted(rules(r))}")
|
||
S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"is_retake": True}})
|
||
r = S.sub(tok["甲"], sid_jia)
|
||
check_case("EP-G10b", "勾了补考且三科空 → VAL-22(必填缺失,红)+ VAL-15(黄)同时出现",
|
||
"VAL-22" in rules(r) and "VAL-15" in rules(r),
|
||
f"{sorted(rules(r))} red={red_count(r)} yellow={yellow_count(r)}")
|
||
S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"is_retake": False}})
|
||
|
||
# VAL-01 正向:名册行证件号为空的**正向**分支(既有脚本只测了"不该误报")
|
||
saved_mask = sql(f"SELECT coalesce(id_card_mask,'') FROM collect_roster WHERE id={fx['rid']['甲']}")
|
||
sql(f"UPDATE collect_roster SET id_card_mask=NULL WHERE id={fx['rid']['甲']}")
|
||
r = S.sub(tok["甲"], sid_jia)
|
||
check_case("EP-G12", "VAL-01 正向:名册行没有证件号 → 红「名册里没有你的身份证号」",
|
||
"VAL-01" in rules(r) and red_count(r) > 0,
|
||
f"{sorted(rules(r))}:{[(i.get('ruleCode'), i.get('title')) for i in issues_of(r)]}")
|
||
sql(f"UPDATE collect_roster SET id_card_mask='{saved_mask}' WHERE id={fx['rid']['甲']}")
|
||
S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"school": "示范职业学校"}})
|
||
|
||
# 黄级:体积偏小 / 清晰度偏低(既有脚本都没测黄级的两条)
|
||
small = S.small_jpeg() # 900x1200:长边不足 IDCARD 档位默认的 1400
|
||
ups = S.upload(tok["甲"], sid_jia, "IDCARD", "小图.jpg", small)
|
||
if ups.get("code") == 0:
|
||
S.call("POST", f"/api/v1/mp/files/{ups['data']['id']}/process", tok["甲"])
|
||
r = S.sub(tok["甲"], sid_jia)
|
||
got = rules(r)
|
||
check_case("EP-G14", "VAL-08 清晰度偏低 / VAL-20 体积偏小 → **黄**(不阻断,但也不许直接提交)",
|
||
("VAL-08" in got or "VAL-20" in got) and red_count(r) == 0
|
||
and submitted(r) is False,
|
||
f"{sorted(got)} red={red_count(r)} yellow={yellow_count(r)}")
|
||
yd = ((r.get("data") or {}).get("yellowCount") or 0)
|
||
# 链路的必经一步(「只黄无红要学生确认」已由 e2e-m3-submit.py 断言过,不重复计分)
|
||
check("链路前提:有黄无红时不带 confirmWarnings → submitted=false(要学生确认)",
|
||
submitted(r) is False and yd > 0, f"yellow={yd}")
|
||
|
||
# ── 8. 提交成功(按黄确认)→ 名册 2 ─────────────────────────
|
||
print("\n[J1] 提交成功 → 名册行 2")
|
||
r = S.sub(tok["甲"], sid_jia, True)
|
||
check_case("EP-G17", "带 confirmWarnings=true 重提交 → data.submitted=true(判据是 submitted,不是 code==0)",
|
||
submitted(r) is True, f"{r}")
|
||
check_case("EP-J01c", "提交后名册行 → 2(待审)", roster_status(task, "甲") == "2",
|
||
roster_status(task, "甲"))
|
||
st = sql(f"SELECT status || '|' || red_count || '|' || yellow_count "
|
||
f"FROM collect_submission WHERE id={sid_jia}")
|
||
check_case("EP-G07", "红黄计数落库(班主任端排序与「可否批量通过」靠它,不能审核时现算)",
|
||
st.startswith("1|") and st.split("|")[2] != "0", f"{st}")
|
||
|
||
# ── 9. 驳回 → 4,然后看学生侧的四路信号 ────────────────────
|
||
print("\n[J1][I8][H10] 驳回 → 4:学生侧四处说「能改」,写接口说「不能改」")
|
||
rej = pc(base, "POST", f"/api/v1/audit/submissions/{sid_jia}/reject", fx["head"],
|
||
{"reasonCode": "R002"})
|
||
check_case("EP-I05", "班主任驳回成功(带原因码)", rej.get("code") == 0, f"{rej}")
|
||
check_case("EP-J01d", "驳回后名册行 → 4(已驳回)", roster_status(task, "甲") == "4",
|
||
roster_status(task, "甲"))
|
||
|
||
mt = S.call("GET", "/api/v1/mp/tasks", tok["甲"])
|
||
mine = next((x for x in ((mt.get("data") or {}).get("list") or [])
|
||
if x.get("taskId") == task), None) or {}
|
||
check_case("EP-I08a", "驳回后学生侧文案:「被驳回,请修改后重新提交」",
|
||
"被驳回" in (mine.get("statusText") or "") and "重新提交" in (mine.get("statusText") or ""),
|
||
f"statusText={mine.get('statusText')!r}")
|
||
check_case("EP-I08b", "驳回后 canFillNow=true(underReview 只算 1/2/3,4 不算)",
|
||
mine.get("canFillNow") is True, f"canFillNow={mine.get('canFillNow')!r}")
|
||
check_case("EP-I08c", "驳回后计入 todoCount(不是已完成)",
|
||
(mt.get("data") or {}).get("todoCount", 0) >= 1, f"{mt.get('data')}")
|
||
|
||
pr = S.call("GET", f"/api/v1/mp/submissions/{sid_jia}/progress", tok["甲"])
|
||
pdd = pr.get("data") or {}
|
||
check_case("EP-H10a", "progress.canEdit=true —— 读模型说「能改」",
|
||
pdd.get("canEdit") is True, f"canEdit={pdd.get('canEdit')!r}")
|
||
check_case("EP-H10b", "progress.canSubmit=true —— 读模型说「能交」",
|
||
pdd.get("canSubmit") is True, f"canSubmit={pdd.get('canSubmit')!r}")
|
||
print(" [INFO] 下面三条对照写模型:同一个类文件里,读模型说能改,写模型(saveDraft/storeFile)")
|
||
print(" 却以 40030 拒绝 —— 这四条信号加起来就是 F-125。")
|
||
|
||
r = S.call("PUT", f"/api/v1/mp/submissions/{sid_jia}", tok["甲"],
|
||
{"formData": {"work_years": "7"}})
|
||
check_case("EP-E09", "驳回后 PUT 改字段:契约 §2.4 期望 4 → 1 可整改(改成 7,便于下面回读验证)",
|
||
r.get("code") == 0, f"{r}")
|
||
r = S.upload(tok["甲"], sid_jia, "IDCARD", "整改.jpg", A.big_jpeg())
|
||
check_case("EP-F10", "驳回后上传材料:契约期望可改", r.get("code") == 0, f"{r}")
|
||
|
||
# ── 10. 原样重交(这一格既有脚本已经证明过,这里只作为轨迹的一环)──
|
||
print("\n[J1][H11] 原样重交 → 名册 2,reject_count 不因撤回/重交而清零")
|
||
rc_before = sql(f"SELECT reject_count FROM collect_submission WHERE id={sid_jia}")
|
||
r = S.sub(tok["甲"], sid_jia, True)
|
||
# ⚠️ 这条**不能**只断言"重交成功" —— 原样重交是能过的(e2e-m5-audit.py:534 已证明),
|
||
# 那样断言会让本用例在 F-125 下**假绿**(第一版就是,报了个 XPASS)。
|
||
# 要断言的是**整改真的落库了**:EP-E09 想把 work_years 改成 7,看它到底改没改成。
|
||
wy = sql(f"SELECT coalesce(work_years::text,'') FROM collect_submission WHERE id={sid_jia}")
|
||
check_case("EP-I09", "驳回后「先整改再提交」:改动的值要真的落库(work_years 应为 7)",
|
||
r.get("code") == 0 and submitted(r) is True and wy == "7",
|
||
f"重交={r.get('code')} submitted={submitted(r)} work_years={wy!r}(期望 7)")
|
||
check_case("EP-J01e", "重交后名册行回到 2(不回退的话班主任会一直以为他还欠一份)",
|
||
roster_status(task, "甲") == "2", roster_status(task, "甲"))
|
||
rc_after = sql(f"SELECT reject_count FROM collect_submission WHERE id={sid_jia}")
|
||
check_case("EP-H11", "reject_count 保留(驳回历史不能被重交抹掉)",
|
||
rc_after == rc_before and rc_before not in ("", "0"), f"{rc_before} → {rc_after}")
|
||
|
||
# ── 11. 初审 → 终审:名册 3(轨迹终点)──────────────────────
|
||
print("\n[J1] 初审 → 终审:名册行 3(轨迹终点)")
|
||
ap = pc(base, "POST", f"/api/v1/audit/submissions/{sid_jia}/approve", fx["head"],
|
||
{"actionSource": "SWIPE", "remark": "材料齐全"})
|
||
check_case("EP-J01f", "初审通过 → 填报单 2,名册行仍是 2(名册的 3 是终审后才写)",
|
||
ap.get("code") == 0 and sub_status(sid_jia) == "2"
|
||
and roster_status(task, "甲") == "2",
|
||
f"{ap} sub={sub_status(sid_jia)} roster={roster_status(task, '甲')}")
|
||
fin = pc(base, "POST", f"/api/v1/audit/submissions/{sid_jia}/approve", fx["admin"],
|
||
{"finalOpinion": "同意认定", "needArchive": False})
|
||
check_case("EP-J01g", "终审通过 → 3 且名册行 → 3(这条轨迹 0→1→2→4→2→3 到此走完)",
|
||
fin.get("code") == 0 and sub_status(sid_jia) == "3"
|
||
and roster_status(task, "甲") == "3",
|
||
f"{fin} sub={sub_status(sid_jia)} roster={roster_status(task, '甲')}")
|
||
|
||
# ── 12. J3 并发提交 ─────────────────────────────────────────
|
||
print("\n[J3] 并发提交(一期明确不做并发保护,这里把它钉成可观测事实)")
|
||
sid_yi = new_draft(fx, "乙", task)
|
||
S.call("PUT", f"/api/v1/mp/submissions/{sid_yi}", tok["乙"],
|
||
{"formData": {"work_years": "2"}})
|
||
fill_materials(fx, "乙", sid_yi, task)
|
||
results: list = []
|
||
lock = threading.Lock()
|
||
|
||
def hammer():
|
||
rr = S.sub(tok["乙"], sid_yi, True)
|
||
with lock:
|
||
results.append(rr)
|
||
|
||
threads = [threading.Thread(target=hammer) for _ in range(2)]
|
||
for t in threads:
|
||
t.start()
|
||
for t in threads:
|
||
t.join()
|
||
codes = [(x.get("code"), submitted(x)) for x in results]
|
||
print(f" [INFO] 两个并发请求的结论:{codes}")
|
||
print(f" [INFO] SubmissionService.java:280-282 明确写着「一期不做并发保护」"
|
||
f"(学生的真实场景是一人一机)")
|
||
check_case("EP-J03", "并发提交后落库状态自洽(名册行 2、填报单 1)——把「无保护」写成已知事实",
|
||
roster_status(task, "乙") == "2" and sub_status(sid_yi) == "1",
|
||
f"roster={roster_status(task, '乙')} sub={sub_status(sid_yi)} codes={codes}")
|
||
|
||
# ── 13. E8 已提交后不能再改(自述缺口:既有脚本从没断言过 40030)──
|
||
print("\n[E8] 已提交后不能再改(e2e-m3-draft.py:11 自述覆盖了这条,实际没有)")
|
||
r = S.call("PUT", f"/api/v1/mp/submissions/{sid_yi}", tok["乙"],
|
||
{"formData": {"work_years": "4"}})
|
||
check_case("EP-E08", "已提交(status=1)后 PUT → 40030", r.get("code") == 40030, f"{r}")
|
||
r = S.upload(tok["乙"], sid_yi, "IDCARD", "追传.jpg", A.big_jpeg())
|
||
check_case("EP-F09", "已提交后上传材料 → 40030", r.get("code") == 40030, f"{r}")
|
||
|
||
# ── 14. J2 表单升版 → 旧草稿按**当前** schema 校验 ──────────
|
||
print("\n[J2] 表单升版后提交旧草稿:不报 40025,按当前 schema 校验,form_version 是快照")
|
||
sid_bing = new_draft(fx, "丙", task)
|
||
S.call("PUT", f"/api/v1/mp/submissions/{sid_bing}", tok["丙"],
|
||
{"formData": {"school": "示范职业学校", "major": "数控加工",
|
||
"enroll_date": "2023-09-01"}})
|
||
fill_materials(fx, "丙", sid_bing, task)
|
||
fv_before = sql(f"SELECT form_version FROM collect_submission WHERE id={sid_bing}")
|
||
|
||
sch = pc(base, "GET", f"/api/v1/tasks/{task}/form-schema", fx["admin"])
|
||
sd = sch.get("data") or {}
|
||
base_ver = sd.get("latestVersion")
|
||
fields = sd.get("fields") or []
|
||
extras = list(sd.get("extras") or [])
|
||
extras.append({"key": "extra_upgrade_note", "label": "升版新增必填", "type": "text",
|
||
"required": True, "builtin": False})
|
||
sv = pc(base, "PUT", f"/api/v1/tasks/{task}/form-schema", fx["admin"],
|
||
{"baseVersion": base_ver, "fields": fields, "extras": extras})
|
||
check_case("EP-J02a", "表单升版成功(新增一个必填扩展字段)", sv.get("code") == 0,
|
||
f"baseVersion={base_ver} {sv}")
|
||
sch2 = pc(base, "GET", f"/api/v1/tasks/{task}/form-schema", fx["admin"])
|
||
keys2 = [e.get("key") for e in ((sch2.get("data") or {}).get("extras") or [])]
|
||
check("夹具前提:升版后 schema 里确实声明了新增扩展字段 extra_upgrade_note",
|
||
"extra_upgrade_note" in keys2, f"extras keys={keys2}")
|
||
|
||
r = S.sub(tok["丙"], sid_bing, True)
|
||
check_case("EP-J02b", "旧草稿提交**不报** 40025(submit 不比对 form_version,不拒绝旧版本)",
|
||
r.get("code") == 0, f"{r}")
|
||
check_case("EP-J02c", "但按**当前** schema 校验:新增的必填扩展字段缺失 → VAL-22 拦下",
|
||
"VAL-22" in rules(r) and submitted(r) is False,
|
||
f"{sorted(rules(r))} submitted={submitted(r)}")
|
||
fv_after = sql(f"SELECT form_version FROM collect_submission WHERE id={sid_bing}")
|
||
check_case("EP-J02d", "collect_submission.form_version 保持建单时的快照(不随升版被改写)",
|
||
fv_before == fv_after, f"{fv_before} → {fv_after}")
|
||
|
||
put2 = S.call("PUT", f"/api/v1/mp/submissions/{sid_bing}", tok["丙"],
|
||
{"formData": {"extra_upgrade_note": "塞错口袋"}})
|
||
ev_wrong = sql(f"SELECT coalesce(extras_json::text,'<NULL>') "
|
||
f"FROM collect_submission WHERE id={sid_bing}")
|
||
check_case("EP-E16", "把**已声明**的扩展字段塞进 formData(而不是 extrasJson):"
|
||
"code=0 但值被**静默丢弃** —— 与本文件「不静默忽略」的注释自相矛盾"
|
||
"(未声明的键会 40001,已声明的却无声消失)",
|
||
put2.get("code") == 0 and ev_wrong in ("{}", "<NULL>"),
|
||
f"PUT code={put2.get('code')} extras_json={ev_wrong!r}(期望被丢弃,即本用例在钉现状)")
|
||
print(f" [INFO] formData 里塞扩展字段 → code={put2.get('code')},extras_json={ev_wrong!r}"
|
||
f" —— 契约要求用 extrasJson(DraftDtos.SaveReq:44),塞错口袋不会报错,"
|
||
f"学生填的东西就没了")
|
||
|
||
put3 = S.call("PUT", f"/api/v1/mp/submissions/{sid_bing}", tok["丙"],
|
||
{"extrasJson": {"extra_upgrade_note": "已补填"}})
|
||
ev = sql(f"SELECT coalesce(extras_json::text,'<NULL>') "
|
||
f"FROM collect_submission WHERE id={sid_bing}")
|
||
check("夹具前提:用 extrasJson 提交扩展字段值 → 落库成功",
|
||
put3.get("code") == 0 and "已补填" in ev, f"PUT={put3.get('code')} {ev!r}")
|
||
|
||
form2 = S.call("GET", f"/api/v1/mp/tasks/{task}/form", tok["丙"])
|
||
fx_val = None
|
||
for sec in ((form2.get("data") or {}).get("sections") or []):
|
||
for f in sec.get("fields") or []:
|
||
if f.get("key") == "extra_upgrade_note":
|
||
fx_val = f.get("value")
|
||
check_case("EP-D09", "存过的扩展字段要在表单里**回显**(否则学生重开一看是空的,"
|
||
"以为没保存上,再填一遍)",
|
||
fx_val == "已补填", f"表单里 extra_upgrade_note 的 value={fx_val!r},"
|
||
f"而库里是 {ev!r}")
|
||
r = S.sub(tok["丙"], sid_bing, True)
|
||
check_case("EP-J02e", "补填必填扩展字段后能提交(否则**必填扩展字段永远满足不了**,"
|
||
"整个任务被永久锁死)",
|
||
submitted(r) is True,
|
||
f"extras_json={ev!r} sub={r}")
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# errors 组:错误矩阵(大部分在 taskState 上,不破坏主链路)
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
def group_errors(fx: dict) -> None:
|
||
base, tok = fx["base"], fx["tok"]
|
||
task = fx["task_main"]
|
||
tstate = fx["task_state"]
|
||
|
||
# ── 1. 认证负向(既有脚本只证明"有效 token 能用")──────────
|
||
print("\n[B] 认证负向:无 token / 篡改 / 过期")
|
||
r = pc(base, "GET", "/api/v1/mp/tasks", None)
|
||
check_case("EP-B04", "不带 Authorization → 40101", r.get("code") == 40101, f"{r}")
|
||
good = tok["甲"]
|
||
bad_sig = good[:-6] + ("aaaaaa" if not good.endswith("aaaaaa") else "bbbbbb")
|
||
r = pc(base, "GET", "/api/v1/mp/tasks", bad_sig)
|
||
check_case("EP-B05", "签名被篡改的 token → 40101", r.get("code") == 40101, f"{r}")
|
||
cfg = S.read_cfg()
|
||
key = base64.b64decode(cfg["jwt"])
|
||
now = int(time.time())
|
||
signing = (S.b64u(json.dumps({"alg": "HS512"}, separators=(",", ":")).encode()) + "."
|
||
+ S.b64u(json.dumps({"iss": cfg["issuer"], "sub": str(fx["uid"]["甲"]),
|
||
"tenantId": 1, "orgIds": [], "roles": ["STUDENT"],
|
||
"userType": 4, "iat": now - 7200,
|
||
"exp": now - 3600}, separators=(",", ":")).encode()))
|
||
expired = signing + "." + S.b64u(hmac.new(key, signing.encode(), hashlib.sha512).digest())
|
||
r = pc(base, "GET", "/api/v1/mp/tasks", expired)
|
||
check_case("EP-B06", "exp 已过期的 token → 40101", r.get("code") == 40101, f"{r}")
|
||
|
||
# ── 2. 门禁不判任务状态(钉住一个刻意的设计)───────────────
|
||
print("\n[C8] 门禁与任务状态**解耦**(有意为之,钉住它防止后来者顺手加校验)")
|
||
tb = pc(base, "GET", f"/api/v1/tasks/{tstate}", fx["admin"])
|
||
# 先把 taskState 推到"已截止"
|
||
pc(base, "POST", f"/api/v1/tasks/{tstate}/publish", fx["admin"], {"force": True})
|
||
cl = pc(base, "POST", f"/api/v1/tasks/{tstate}/close", fx["admin"], {})
|
||
check_case("EP-C08a", "夹具:taskState 已截止", cl.get("code") == 0, f"{cl}")
|
||
r = bind_via_gate(fx, "甲", tstate)
|
||
check_case("EP-C08b", "任务已截止时核验身份**仍然成功**(绑定与任务状态解耦,学生回来核对身份是对的)",
|
||
r.get("code") == 0, f"{r}")
|
||
r = S.call("GET", f"/api/v1/mp/tasks/{tstate}/form", tok["甲"])
|
||
check_case("EP-C08c", "紧随其后的取表单 → 40008(这才该拦的地方拦住了)",
|
||
r.get("code") == 40008, f"{r}")
|
||
|
||
# ── 3. 任务状态机 → 40008(逐状态)────────────────────────
|
||
print("\n[D3][D4] 任务状态非「收集中」→ 40008(逐状态各一次)")
|
||
task_draft = make_task(base, fx["admin"], f"E2E流程草稿 {fx['suffix']}")
|
||
xlsx = [p for p in TMP_FILES if p.endswith(".xlsx")][0]
|
||
prepare_task(base, fx["admin"], task_draft, fx["org_id"], fx["head_uid"], xlsx, 3)
|
||
# 造完立刻退回草稿:直接改 status(没有"取消发布"接口,publish 是不可逆的)
|
||
sql(f"UPDATE collect_task SET status=0 WHERE id={task_draft}")
|
||
# ⚠️ 用「甲」而不是第四个人:每份名册是**独立**的三行,甲在 task_draft 里的那一行是未绑定的
|
||
# (绑定是按 task_id 的)。门禁限流是 10 次/小时/uid,甲用掉 3 次,安全。
|
||
bind_via_gate(fx, "甲", task_draft)
|
||
r = S.call("GET", f"/api/v1/mp/tasks/{task_draft}/form", tok["甲"])
|
||
check_case("EP-D03", "任务为草稿(0) → 取表单 40008", r.get("code") == 40008, f"{r}")
|
||
|
||
# ⚠️ 这一串不只是"逐状态都 40008":中间那一步「恢复」必须**重新可填**(0),
|
||
# 否则这个断言退化成"永远 40008",而那掩盖了"状态机卡死"这类故障。
|
||
seq = [("暂停", f"/api/v1/tasks/{task_draft}/pause", 40008),
|
||
("恢复", f"/api/v1/tasks/{task_draft}/resume", 0),
|
||
("截止", f"/api/v1/tasks/{task_draft}/close", 40008),
|
||
("归档", f"/api/v1/tasks/{task_draft}/archive", 40008)]
|
||
sql(f"UPDATE collect_task SET status=1 WHERE id={task_draft}")
|
||
for label, path, want in seq:
|
||
rr = pc(base, "POST", path, fx["admin"], {})
|
||
if rr.get("code") != 0:
|
||
fail(f"夹具:{label} 任务", f"{rr}")
|
||
continue
|
||
r = S.call("GET", f"/api/v1/mp/tasks/{task_draft}/form", tok["甲"])
|
||
if want == 0:
|
||
check_case(f"EP-D04-{label}", f"任务{label}(回到收集中)→ 取表单又能用了(code=0)",
|
||
r.get("code") == 0, f"{r}")
|
||
else:
|
||
check_case(f"EP-D04-{label}", f"任务{label} → 取表单 40008",
|
||
r.get("code") == 40008, f"{r}")
|
||
|
||
# ── 4. 不存在的 taskId ────────────────────────────────────
|
||
print("\n[D2] 不存在的 taskId")
|
||
r = S.call("GET", "/api/v1/mp/tasks/99999999/form", tok["甲"])
|
||
check_case("EP-D02", "不存在的 taskId → 40001(与「未过门禁」的 40007 区分开)",
|
||
r.get("code") == 40001, f"{r}")
|
||
|
||
# ── 5. 暂停后存草稿 / 提交 → 40008;解绑门禁 → 40007 ───────
|
||
print("\n[E13][G16] 任务被暂停后:存草稿与提交都要 40008;门禁被解绑要 40007")
|
||
sql(f"UPDATE collect_task SET status=1 WHERE id={tstate}")
|
||
sid = new_draft(fx, "甲", tstate)
|
||
S.call("PUT", f"/api/v1/mp/submissions/{sid}", tok["甲"], {"formData": {"work_years": "1"}})
|
||
pc(base, "POST", f"/api/v1/tasks/{tstate}/pause", fx["admin"], {})
|
||
r = S.call("PUT", f"/api/v1/mp/submissions/{sid}", tok["甲"],
|
||
{"formData": {"work_years": "2"}})
|
||
check_case("EP-E13", "任务暂停后存草稿 → 40008", r.get("code") == 40008, f"{r}")
|
||
r = S.sub(tok["甲"], sid)
|
||
check_case("EP-G16a", "任务暂停后提交 → 40008", r.get("code") == 40008, f"{r}")
|
||
pc(base, "POST", f"/api/v1/tasks/{tstate}/resume", fx["admin"], {})
|
||
# ⚠️ 解绑要用 (task_id=tstate, 姓名) 查出来的**那一行** —— 名册行是按任务分开的
|
||
sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_type=NULL "
|
||
f"WHERE id={roster_row(tstate, '甲')}")
|
||
r = S.sub(tok["甲"], sid)
|
||
check_case("EP-G16b", "门禁被解绑后提交 → 40007(不是 40008,两件事要说清是哪一件)",
|
||
r.get("code") == 40007, f"{r}")
|
||
|
||
# ── 8. 撤回 → 再提交(闭环最后一段)────────────────────────
|
||
# ⚠️ 这一对**必须**用一份真的已经提交过的单:丙那条路被 F-128(必填扩展字段永远满足不了)
|
||
# 堵死,拿它当夹具会让本用例以「撤回被拒」的形式失败,而原因完全在别处。
|
||
print("\n[H12] 撤回 → 再提交:既有脚本只覆盖了「撤回」那一半,没有一段把它闭合")
|
||
bind_via_gate(fx, "甲", tstate)
|
||
fill_materials(fx, "甲", sid, tstate)
|
||
r = S.sub(tok["甲"], sid, True)
|
||
check("夹具前提:甲在 taskState 上提交成功(下面要撤回它)", submitted(r) is True, f"{r}")
|
||
w = S.call("POST", f"/api/v1/mp/submissions/{sid}/withdraw", tok["甲"])
|
||
check_case("EP-H12a", "撤回待初审的单 → 填报单回 0、名册行回 1(同时清提交时间,"
|
||
"否则界面会显示一个不存在的提交)",
|
||
w.get("code") == 0 and sub_status(sid) == "0"
|
||
and roster_status(tstate, "甲") == "1",
|
||
f"{w} sub={sub_status(sid)} roster={roster_status(tstate, '甲')}")
|
||
r = S.sub(tok["甲"], sid, True)
|
||
check_case("EP-H12b", "撤回后再提交 → 回到 1、名册行回 2(两个方向都要能走通)",
|
||
submitted(r) is True and sub_status(sid) == "1"
|
||
and roster_status(tstate, "甲") == "2",
|
||
f"submitted={submitted(r)} sub={sub_status(sid)} "
|
||
f"roster={roster_status(tstate, '甲')}")
|
||
|
||
# ── 6. 越权提交 / 无 token 提交 ────────────────────────────
|
||
print("\n[G15] 越权与未登录提交")
|
||
sql(f"UPDATE collect_roster SET bind_user_id={fx['uid']['乙']} WHERE id={fx['rid']['乙']}")
|
||
sid_yi = new_draft(fx, "乙", task)
|
||
r = S.sub(tok["甲"], sid_yi, True)
|
||
check_case("EP-G15a", "用甲的 token 提交乙的单 → 40001(查询必须带 submitter_id)",
|
||
r.get("code") == 40001, f"{r}")
|
||
r = pc(base, "POST", f"/api/v1/mp/submissions/{sid_yi}/submit", None,
|
||
{"confirmWarnings": True})
|
||
check_case("EP-G15b", "无 token 提交 → 40101", r.get("code") == 40101, f"{r}")
|
||
|
||
# ── 7. 驳回后删除材料(F-125 的第三个面孔)─────────────────
|
||
print("\n[F10b] 驳回后的删材料守卫(与上传是两条独立的守卫)")
|
||
sql(f"UPDATE collect_submission SET status=0 WHERE id={sid_yi}")
|
||
up = S.upload(tok["乙"], sid_yi, "IDCARD", "先传一张.jpg", A.big_jpeg())
|
||
check("夹具前提:草稿态能上传(下面要把状态改成已驳回再删)", up.get("code") == 0, f"{up}")
|
||
if up.get("code") == 0:
|
||
fdel = up["data"]["id"]
|
||
sql(f"UPDATE collect_submission SET status=4 WHERE id={sid_yi}")
|
||
r = S.call("DELETE", f"/api/v1/mp/files/{fdel}", tok["乙"])
|
||
check_case("EP-F10b", "驳回后删除材料:契约期望可改(F-125 的第三个面孔)",
|
||
r.get("code") == 0, f"{r}")
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# main
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
FX: dict = {}
|
||
|
||
|
||
def cleanup() -> None:
|
||
print("\n[清理]")
|
||
for tid in CREATED_TASK_IDS:
|
||
for tbl in ("collect_audit_record", "collect_file", "collect_submission",
|
||
"collect_roster", "collect_material_def", "collect_task_org",
|
||
"collect_export_task"):
|
||
sql(f"DELETE FROM {tbl} WHERE task_id={tid}")
|
||
sql(f"DELETE FROM collect_task WHERE id={tid}")
|
||
for uid in CREATED_USER_IDS:
|
||
sql(f"DELETE FROM sys_user_role WHERE user_id={uid}")
|
||
sql(f"DELETE FROM sys_user WHERE id={uid}")
|
||
for p in TMP_FILES:
|
||
try:
|
||
os.remove(p)
|
||
except OSError:
|
||
pass
|
||
left = sql("SELECT count(*) FROM collect_task WHERE task_name LIKE 'E2E流程%' "
|
||
"OR task_name LIKE 'E2E流程状态%' OR task_name LIKE 'E2E流程草稿%'")
|
||
ok(f"已清理(残留夹具任务 {left} 个)")
|
||
|
||
|
||
def main() -> int:
|
||
global FX
|
||
ap = argparse.ArgumentParser(description="填报流程端到端 E2E(M3 主链路 + 错误矩阵)")
|
||
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
|
||
ap.add_argument("--password", default="Admin@123")
|
||
ap.add_argument("--group", choices=["chain", "errors", "all"], default="all")
|
||
ap.add_argument("--fixture-only", action="store_true",
|
||
help="只建夹具并打印 id,不跑用例、不清理")
|
||
ap.add_argument("--keep", action="store_true", help="跑完不清理")
|
||
ap.add_argument("--h5-url", action="store_true", help="打印可直接打开的 H5 地址")
|
||
ap.add_argument("--full", action="store_true", help="连慢用例一起跑(默认跳过)")
|
||
ap.add_argument("--clean", type=int, default=None, metavar="TASKID",
|
||
help="只清理指定的夹具任务")
|
||
ap.add_argument("--suffix", default=None, help="固定夹具后缀(重跑同一批时用)")
|
||
args = ap.parse_args()
|
||
base = args.base.rstrip("/")
|
||
|
||
print("=" * 78)
|
||
print(f"填报流程端到端 E2E(M3 主链路 + 错误矩阵) base={base} group={args.group}")
|
||
print("配套:docs/11-填报流程端到端测试计划.md(覆盖矩阵 / XFAIL / 判据纪律)")
|
||
print("=" * 78)
|
||
|
||
if args.clean:
|
||
CREATED_TASK_IDS.append(args.clean)
|
||
cleanup()
|
||
return 0
|
||
|
||
try:
|
||
FX = build_fixture(base, args)
|
||
except SystemExit as e:
|
||
print(f"\n{e}")
|
||
print("\n夹具前提不满足就**不要**继续 —— 否则后面每一条断言失败都会"
|
||
"看起来像功能坏了,而真正的原因是夹具没建起来。")
|
||
return 1
|
||
|
||
if args.h5_url:
|
||
scene = f"t1-k{FX['task_main']}"
|
||
req = urllib.request.Request(
|
||
f"{base}/api/v1/mp/h5/stub-login?openid=e2e_flow_{FX['suffix']}&scene={scene}")
|
||
try:
|
||
opener = urllib.request.build_opener(NoRedirect)
|
||
with opener.open(req, timeout=20) as resp:
|
||
loc = resp.headers.get("Location", "")
|
||
except urllib.error.HTTPError as e:
|
||
loc = e.headers.get("Location", "")
|
||
m = re.search(r"token=([^&]+)", loc)
|
||
tkn = m.group(1) if m else ""
|
||
print("\n[H5] 走查地址(**不要**直接打开 stub-login 的 302 落点)")
|
||
print(f" 302 落点 = {loc}")
|
||
print(" ⚠️ 落点默认是穿透域名 WX_H5_BASE_URL(后端托管的**旧构建**),"
|
||
"不是 dev server。")
|
||
print(f" dev server(pnpm dev:h5 → 5174):")
|
||
print(f" http://127.0.0.1:5174/#/pages/index/index?token={tkn}&scene={scene}")
|
||
print(" ⚠️ 名册行必须**未绑定**,由界面自己走门禁 —— "
|
||
"本脚本的 token 是手搓假 uid,与 stub-login 真建的 sys_user 不是同一个人。")
|
||
sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_type=NULL "
|
||
f"WHERE task_id={FX['task_main']}")
|
||
|
||
if not args.fixture_only:
|
||
try:
|
||
if args.group in ("chain", "all"):
|
||
group_chain(FX)
|
||
if args.group in ("errors", "all"):
|
||
group_errors(FX)
|
||
finally:
|
||
if not args.keep:
|
||
cleanup()
|
||
else:
|
||
print("\n[夹具就绪] 下面这些 id 可直接用于手工排查:")
|
||
print(f" taskMain = {FX['task_main']} taskState = {FX['task_state']}")
|
||
print(f" 名册行 = {FX['rid']}")
|
||
print(f" token 甲 = {FX['tok']['甲']}")
|
||
|
||
print("\n" + "=" * 78)
|
||
total = len(PASSED) + len(FAILED)
|
||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {total}")
|
||
if XFAILED:
|
||
print(f"(另有 {len(XFAILED)} 条已知缺陷 XFAIL,不计入失败)")
|
||
for n in XFAILED:
|
||
print(f" · {n}")
|
||
if XPASSED:
|
||
print(f"⚠️ {len(XPASSED)} 条 XPASS —— 缺陷似乎已修,请删掉 KNOWN_DEFECTS 里的登记")
|
||
for n, dd in FAILED:
|
||
print(f" · {n}\n {dd}")
|
||
print("=" * 78)
|
||
return 1 if FAILED else 0
|
||
|
||
|
||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||
return None
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|