#!/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 # 只清理指定夹具 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,'') || '|' || coalesce(major,'') " 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,'') " 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 ("{}", ""), 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,'') " 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 —— 那里由 **nginx** 托管" "构建产物(deploy/nginx/xt.conf),不是 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())