diff --git a/docs/11-填报流程端到端测试计划.md b/docs/11-填报流程端到端测试计划.md new file mode 100644 index 0000000..19f20cc --- /dev/null +++ b/docs/11-填报流程端到端测试计划.md @@ -0,0 +1,724 @@ +# 填报流程端到端测试计划 + +> 版本:V1.0 | 日期:2026-10 +> 读者:测试、开发(自测)、项目经理(验收) +> 配套:《03-接口设计.md》§9 / §11、《06-验收测试清单.md》、《04-核心模块设计说明.md》 +> 执行入口:`python scripts/e2e-m3-flow.py` +> 核心原则:**能用真实数据断言的不靠人眼;能自动化的一次写、长期跑**(与《06》同口径) + +--- + +## 一、目标与范围 + +### 1.1 一句话 + +把「学生扫码 → 门禁 → 填报 → 传材料 → 提交 → 我的任务 → 撤回 → 审核 → 驳回 → 重交」这条**完整链路**做成一张**覆盖全、责任清**的用例矩阵,并只把**没人覆盖**的那些用例写成可执行脚本。 + +### 1.2 为什么不能只靠既有脚本 + +本仓库已有 **37 个 `scripts/e2e-*.py` / 1617 条断言**,且 **37 个全绿**(见《交接摘要》§一·四十的验证一节)。但它们是**按模块切片的**:门禁一个脚本、草稿一个脚本、提交一个脚本、审核一个脚本。切片测试有三个固定的盲区,本计划就是为它们而写: + +| 盲区 | 具体表现 | 本计划的对策 | +|---|---|---| +| **跨步骤的状态一致性没人管** | 「名册行的 `submit_status` 从 0 走到 3」被四个脚本各断言一格,但**没有人断言这条轨迹是连续的** | §3.11 J 组 | +| **切片之间那条缝** | 驳回后「能重新提交」被 `e2e-m5-audit.py:534` 证明了,但「能**先改再**提交」没有人证明 —— 而后者才是驳回的意义 | §3.10 I 组、§6 | +| **脚本自述 ≠ 实际断言** | `e2e-m3-draft.py:11` 自述覆盖「已提交的不能直接改」,实际**全文没有这条断言**(无 `40030` 出现) | §3.1 的核对规程 | + +> **规律**:切片测试的通过率越高,「缝里那一块」越安全 —— 因为没有人会去看缝里。F-84(「HTTP 200 + code 0」不等于动作成功,`docs/08-完成度盘点与问题清单.md:452`)就是同一个规律的产物。 + +### 1.3 分层与执行方式 + +``` +┌──────────────────────────────────────────────────────────┐ +│ L3-B 人工/浏览器走查(加分项) │ +│ H5 真机形态:门禁 → 填报 → 传图 → 提交 → 进度 │ +├──────────────────────────────────────────────────────────┤ +│ L3-A 端到端接口链路(自动 · 本计划主证据) │ +│ scripts/e2e-m3-flow.py + 既有 37 个 e2e 脚本 │ +│ 真实接口 + docker exec xt-pg 查库断言 │ +├──────────────────────────────────────────────────────────┤ +│ L2 机械校验(自动 · 不需要后端) │ +│ check-api-parity / check-entity-columns / verify-docs │ +│ check-manual / check-wiring │ +└──────────────────────────────────────────────────────────┘ +``` + +### 1.4 成功判据(DoD) + +| # | 判据 | 怎么验 | +|:--:|---|---| +| 1 | 覆盖矩阵每行都有「覆盖者」,**没有一行是空的** | 人眼过一遍 §三 | +| 2 | `scripts/e2e-m3-flow.py` 退出码 0 | 跑它 | +| 3 | 该脚本 **至少 90 条断言** | 脚本末尾的 `通过 X 失败 Y 共 Z`(**实测 95**) | +| 4 | `python scripts/verify-all.py --with-e2e` 全绿,脚本数 37 → 38 | 跑它 | +| 5 | 缺陷有 XFAIL 登记 + F 编号,**且缺陷修好后 XPASS 会报红** | §4.5(**实测 6 条 XFAIL**) | +| 6 | 演示态(任务 17 / 2097 / 2215)与名册绑定**不被本脚本破坏** | 跑完查库(**实测残留 0**) | + +--- + +## 二、权威口径与判据来源 + +### 2.1 期望从哪来(裁决顺序) + +1. **契约**:《03-接口设计.md》§9.1~§9.12(学生填报)、§11(审核)、§2.4 提交单状态、§2.5 红黄蓝级别、§2.6「无」的语义。 +2. **实现**:`backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/*.java`、`module/file/service/*.java`、`module/mp/service/*.java`。 +3. **常量**:`CollectSubmission.java:122-127`、`CollectRoster.java:87-99`、`xt-common/.../api/ErrorCode.java`。 + +⚠️ **契约与实现不一致时,以契约为期望**,把差异写成 XFAIL + 缺陷记录。**不许**按「实现更合理」把期望改成实现的样子 —— 那等于让测试给实现背书(AGENTS.md 铁律:文档是需求源)。 + +### 2.2 明确**不测**的部分 + +| 不测 | 原因 | +|---|---| +| `40004` / `40005` / `40006` / `40043` / `50002` / `50003` / `50004` | M4(OCR、归属校验、串档检测)**一期缓做**,等供应商选型 —— 这些码零引用,见 `scripts/check-wiring.py:130-149` 的例外表 | +| `collectMode=2`(社会收集)/ `certType=2,3` | 2026-10 需求收敛,一律 `40044` | +| 微信真实授权(非打桩) | 需要真人在微信里点「允许」;打桩路径已覆盖同一段发 token 的代码 | +| 导出(M6)/ 通知(M7)/ 压测 | 各自有专门的脚本与文档,不在填报链路 | + +### 2.3 判据速查 + +**提交单状态**(`CollectSubmission.java:122-127`,契约 §2.4) + +| 值 | 含义 | 契约允许流转到 | +|:--:|---|---| +| 0 | 草稿 | 1 | +| 1 | 待初审 | 2 / 4 | +| 2 | 待终审 | 3 / 4 | +| 3 | 已通过 | 5 | +| 4 | 已驳回 | **1(学生重提交)** | +| 5 | 已归档 | — | + +**名册行 `submit_status`**(`CollectRoster.java:87-99`):`0` 未提交 / `1` 草稿 / `2` 待审 / `3` 已通过 / `4` 已驳回。 + +**任务状态**(`collect_task.status`):`0` 草稿 / `1` 收集中 / `2` 已截止 / `3` 已归档 / `4` 暂停。**只有 `1` 能填报**(`SubmissionService.java:339-347`、`FillService.java:71-81`)。 + +**问题级别**(契约 §2.5):`1` 信息(蓝)/ `2` 警告(黄,可确认后提交)/ `3` 错误(红,必须改)。 + +--- + +## 三、覆盖矩阵 + +### 3.1 怎么读这张表 + +`覆盖者` 一列只有四种取值: + +| 取值 | 含义 | +|---|---| +| `既有脚本 文件:行` | 该断言**已经存在**且已在跑(`verify-all.py --with-e2e`) | +| **本脚本** | 既有脚本**没有**覆盖,由 `scripts/e2e-m3-flow.py` 实现 | +| `人工走查` | 只能靠浏览器/真机看的(本计划的 L3-B 部分) | +| `M4 缓做` | 一期不做,**不写用例** | + +⚠️ **「既有脚本」这一列是核对出来的,不是抄脚本自述的。** 核对规程: + +```bash +grep -n 'check(\|ok(' scripts/e2e-m3-*.py scripts/e2e-m5-audit.py +``` + +一条一条对照实际断言文案。**这条规程本身就是必要的**:`e2e-m3-draft.py:11` 的 docstring 自述「已提交的不能直接改」,而该文件全文没有这条断言 —— 自述覆盖了一个它没测的东西。 + +### 3.2 A 组 · 扫码落地页(匿名) + +| 编号 | 场景 | 期望 | 覆盖者 | +|---|---|---|---| +| A1 | 合法 scene,返回租户名/任务名/能否填报 | `valid=true`,`canSubmit` 与任务状态一致 | 既有脚本 `scripts/e2e-m1-scene.py` | +| A2 | 非法 scene / 不存在的租户 / 不存在的任务 | 三种**不同的**提示,不泄漏是否存在 | 既有脚本 `scripts/e2e-m1-scene.py` | +| A3 | 跨租户:A 租户的 scene 拼 B 租户的 taskId | 当作「不存在」 | 既有脚本 `scripts/e2e-m1-scene.py` | +| A4 | 带班级的 scene:班级名只在**该班确实在覆盖范围内**时才返回 | 否则不返回 | 既有脚本 `scripts/e2e-m1-scene.py` | +| A5 | 限流(scene 可穷举) | 能挡住批量扫描 | 既有脚本 `scripts/e2e-m1-scene.py` | + +### 3.3 B 组 · 登录与鉴权 + +| 编号 | 场景 | 期望 | 覆盖者 | +|---|---|---|---| +| B1 | `GET /mp/h5/stub-login?openid&scene` | 302;`Location` 是 **hash 路由**格式 `...#/pages/index/index?token=..&scene=..`,token **不在** query | 既有脚本 `scripts/e2e-m3-h5-auth.py:163` | +| B2 | stub-login 不带 scene | 302 + `authError=打桩登录需要带合法的 scene`(不猜租户) | 既有脚本 `scripts/e2e-m3-h5-auth.py` | +| B3 | 拿到的 token 真的能用 | `GET /mp/tasks` → `code=0` | 既有脚本 `scripts/e2e-m3-h5-auth.py` | +| B4 | **不带 `Authorization`** 调 `/mp/tasks` | `40101` | **本脚本** | +| B5 | **签名被篡改**的 token | `40101` | **本脚本** | +| B6 | **`exp` 已过期**的 token | `40101` | **本脚本** | + +> B4~B6 的意义:既有脚本都在证明「有效 token 能用」(`e2e-m3-mytask.py:140`、`e2e-m3-gate.py:175`),**没有一条证明「无效 token 不能用」**。认证的负向路径从来没人走过。 + +### 3.4 C 组 · 门禁核验 + +| 编号 | 场景 | 期望 | 覆盖者 | +|---|---|---|---| +| C1 | 姓名 + 证件号与名册一致 | `code=0`,绑定该行,返回姓名与**脱敏**证件号 | 既有脚本 `scripts/e2e-m3-gate.py:339` | +| C2 | 号码不在名册 | `40007` | 既有脚本 `scripts/e2e-m3-gate.py:366` | +| C3 | 姓名不对 | `40007`,且提示与 C2 **逐字相同**(防枚举) | 既有脚本 `scripts/e2e-m3-gate.py:364` | +| C4 | 已绑自己的人再核验 | `code=0`(幂等,刷新页面不该出现「已被登记」) | 既有脚本 `scripts/e2e-m3-gate.py:356` | +| C5 | 另一个账号核同一名册行 | `40072` | 既有脚本 `scripts/e2e-m3-gate.py:379` | +| C6 | 号码格式乱 / 前 10 次失败 / 第 11 次限流 | `40007` × 10 → `40071` | 既有脚本 `scripts/e2e-m3-gate.py:417,430-431` | +| C7 | **明文证件号不落任何地方** | 门禁**响应全文**无明文;库里 `collect_roster` 只有 `id_card_no`(密文)/`id_card_hash`/`id_card_mask`;`sys_operation_log.request_param` 也无明文 | 响应字段侧:既有脚本 `scripts/e2e-m3-gate.py:339`;**全文扫 + 库 + 日志侧:本脚本**(5 条断言,全绿) | +| C8 | **任务已截止/已暂停时核验** | **仍 `code=0`**(`MpRosterService.java:107-175` 不判任务状态,这是**有意的解耦**);但紧随其后的 `GET /mp/tasks/{id}/form` 必须 `40008` | **本脚本** | + +> C8 是「钉住一个刻意的设计」。它看起来像 bug(截止了还能核验?),其实绑定与任务状态解耦是对的:学生可能只是回来核对身份。**必须有用例把它钉住**,否则后来者「顺手加个校验」就把它改了,而且没有任何东西会红。 + +### 3.5 D 组 · 表单结构与三态 + +| 编号 | 场景 | 期望 | 覆盖者 | +|---|---|---|---| +| D1 | 未过门禁取表单 | `40007` | 既有脚本 `scripts/e2e-m3-draft.py:139` | +| D2 | **不存在的 taskId** | `40001「任务不存在或已删除」` | **本脚本** | +| D3 | **任务为草稿(0)** | `40008` | **本脚本** | +| D4 | **任务已暂停(4)/ 已截止(2)/ 已归档(3)** | `40008`(逐状态各一次) | **本脚本** | +| D5 | 收集中:内置字段 **21 项**、分区结构完整 | `len(fields)==21` | 既有脚本 `scripts/e2e-m3-draft.py:153` | +| D6 | 三态:`occupation/worktype/job_level` 锁定且值来自名册;`education` 可改且 `prefilled=true` | 见断言 | 既有脚本 `scripts/e2e-m3-draft.py:154-157` | +| D7 | **表单响应里搜不到明文证件号**(全文扫一遍) | 只有 `******` 形式的脱敏值 | **本脚本** | +| D8 | **条件可见性(字段侧)**:`is_student` 真/假切换后,学籍三项与工作证明类字段的可见性 | 与 `SubmissionValidator.visible()` 同口径(未知运算符 **fail-open**) | **本脚本** | +| D9 | 有草稿时取表单带出 `submissionId`,草稿值覆盖名册值 | 见断言 | 既有脚本 `scripts/e2e-m3-draft.py:184-192` | +| D10 | **扩展字段要能回显**:用 `extrasJson` 存进去的值,下次取表单要在字段上带出来 | 见期望 → 实测恒为 `null`(**XFAIL · F-128**) | **本脚本**(`EP-D09`) | + +> D8 只做**字段侧**:条件**材料**侧的排除已被 `e2e-m3-submit.py:297` 与 `e2e-m3-mytask.py:179` 覆盖,不要重复。 + +### 3.6 E 组 · 草稿 + +| 编号 | 场景 | 期望 | 覆盖者 | +|---|---|---|---| +| E1 | `POST /mp/submissions` 幂等 | 首次 `created=true`,再次 `created=false` 且 id 相同,库里只有 1 份 | 既有脚本 `scripts/e2e-m3-draft.py:161-171` | +| E2 | `PUT` 合并式保存:只传变化字段 | 其它字段不受影响 | 既有脚本 `scripts/e2e-m3-draft.py:175-192` | +| E3 | 改锁定字段 | `40001`,提示含「老师」「班主任」;库里没被改 | 既有脚本 `scripts/e2e-m3-draft.py:197` | +| E4 | 锁定字段改成**原值** | `code=0`(不算改动) | 既有脚本 `scripts/e2e-m3-draft.py:206` | +| E5 | 传不认识的字段 | `40001「表单里没有这个字段」`(不静默忽略) | 既有脚本 `scripts/e2e-m3-draft.py:211` | +| E6 | 清空预填字段 | 库里真变 `NULL`;回显为空,**不被名册值顶回** | 既有脚本 `scripts/e2e-m3-draft.py:226-234` | +| E7 | `changed_fields` 留痕 | 只含真改过的 | 既有脚本 `scripts/e2e-m3-draft.py:216-221` | +| E8 | **已提交(status=1)后 `PUT`** | `40030` | **本脚本** | +| E9 | **已驳回(status=4)后 `PUT`** | 契约 §2.4 期望可改 → 实测 `40030`(**XFAIL · F-125**) | **本脚本** | +| E10 | 越权:用 B 的 token 改 A 的单 | `40001` | **本脚本** | +| E11 | 不存在的 submissionId | `40001` | **本脚本** | +| E12 | 建草稿不带 `taskId` | `40001「缺少 taskId」` | **本脚本** | +| E13 | **建草稿后把任务暂停,再 `PUT`** | `40008` | **本脚本** | +| E14 | **扩展字段白名单**:未声明的 `extra_*` → 拒;已声明的 → 落 `extras_json` | `40001` / 落库 | **本脚本** | +| E15 | **扩展字段塞错口袋**:已声明的扩展键放进 `formData`(而不是 `extrasJson`) | `code=0` 但值被**静默丢弃**(**F-129**,钉住现状) | **本脚本**(`EP-E16`) | + +> E8 是本计划**第一个被核对出来的自述缺口**:`scripts/e2e-m3-draft.py:11` 的 docstring 写着「已提交的不能直接改」,而全仓**没有任何断言涉及 `40030`**(唯一相关的是 `scripts/e2e-m5-proxy.py:277`,测的是**代传**那条路,不是学生自己的 `PUT`)。 + +### 3.7 F 组 · 材料 + +| 编号 | 场景 | 期望 | 覆盖者 | +|---|---|---|---| +| F1 | 上传 → 落库 → `process` | `final_size ≤ maxSizeKb*1024`;`standard_name` 按模板含姓名 + **脱敏号(用 X 不用 `*`)** | 既有脚本 `scripts/e2e-m3-file.py:314-336` | +| F2 | 档位生效(P2 证件翻拍 → 长边限 1600)/ 原始件保留 | 见断言 | 既有脚本 `scripts/e2e-m3-file.py` | +| F3 | `process` 幂等:重复处理不产生第二份规范文件 | 见断言 | 既有脚本 `scripts/e2e-m3-file.py:364` | +| F4 | 格式白名单外的格式 | `40002` | 既有脚本 `scripts/e2e-m3-file.py:374` | +| F5 | 超过份数上限 | `40001` | 既有脚本 `scripts/e2e-m3-file.py:376` | +| F6 | **按字节判格式**:`.txt` 改名 `.jpg` → 拒;`.jpg` 里装 PNG → 收 | 不信后缀 | 既有脚本 `scripts/e2e-m3-file.py:423,429` | +| F7 | **空文件** | `40001「文件是空的」` | **本脚本** | +| F8 | **不存在的 materialCode** | `40001「这个任务里没有该材料项:xxx」` | **本脚本** | +| F9 | **已提交(status=1)后上传 / 删除** | `40030` | **本脚本**(⚠️ **代传**侧的 `40030` 已由 `scripts/e2e-m5-proxy.py:277` 覆盖,学生自己那条路没有) | +| F10 | **已驳回(status=4)后上传 / 删除** | 契约期望可改 → 实测 `40030`(**XFAIL · F-125**) | **本脚本** | +| F11 | 越权:上传到 A 的单 / 删 A 的文件 | `40001` | **本脚本** | +| F12 | `/mp/files/{id}/url`:本人 `code=0` + 非空 url;别人 `40001` | 见期望 | **本脚本** | +| F13 | **§9.5 `upload-token` 预检 + 秒传**:同 hash 第二次 → `existed=true` 且**不给 uploadUrl** | 秒传是真的 | **本脚本** | +| F14 | **§9.7 `batch-process`**:>200 个 fileId → `40011`;正常批量 → 逐份走同一 `process` | 见期望 | **本脚本** | +| F15 | **§9.8 预览**:`prefer=original` 但原始件不存在 → 退到规范件,且 `actualKind` **如实告知** | 不假装是原图 | **本脚本** | +| F16 | 条件材料:在校生不被要求工作证明 | 不阻断提交 | 既有脚本 `scripts/e2e-m3-submit.py:297` | +| F17 | 越权:对**别人的文件**调 `process` → `40001` | 不靠前端隐藏按钮 | **本脚本** | +| F18 | 越权:列**别人的填报单**的材料 → `40001` | 见期望 | **本脚本** | + +### 3.8 G 组 · 提交与校验(红黄蓝全覆盖) + +| 编号 | 场景 | 期望 | 覆盖者 | +|---|---|---|---| +| G1 | 缺必传材料 | `VAL-05` 红 | 既有脚本 `scripts/e2e-m3-submit.py:229` | +| G2 | 缺必填字段 | `VAL-22` 红 | 既有脚本 `scripts/e2e-m3-submit.py:230` | +| G3 | `VAL-01` **不该误报**(`id_card_no` 是密文,不能当明文算校验位) | 不出现 `VAL-01` | 既有脚本 `scripts/e2e-m3-submit.py:231` | +| G4 | 在校生三项缺项 | `VAL-16` 红;缺项要**列全**;补齐后消失 | 既有脚本 `scripts/e2e-m3-submit.py:244,248,252` | +| G5 | `VAL-15`:四级勾 2 科 → 红;勾了补考但 0 科 → **黄** | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py:258,268,279` | +| G6 | 只黄无红 → 先 `submitted=false`;带 `confirmWarnings=true` 再提交 → `submitted=true` | 两次调用 | 既有脚本 `scripts/e2e-m3-submit.py` | +| G7 | 提交后落库 + 名册行 → 2;重复提交被拒 | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py`、`scripts/e2e-m5-audit.py:308` | +| G8 | **一次给全**:多类问题同时返回(不是改一条再拒一次) | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py` | +| G9 | **「无」是合法值**:填「无」不算缺失(契约 §2.6,`docs/03-接口设计.md:258-265`) | 不报 `VAL-22` | **本脚本** | +| G10 | **`VAL-22` 只对「可见 + 必填 + 非锁定」字段报** | 不要求在校生填「补考理论」 | **本脚本** | +| G11 | **`VAL-17`**:工作年限「80」→ 红(0~60 阿拉伯数字) | 红 | **本脚本** | +| G12 | **`VAL-17` 的非阿拉伯数字分支**:「三年」根本存不进去(`work_years` 是 `smallint`)→ 那段校验**没有入口** | 钉住「死分支」这一事实 | **本脚本**(`EP-G11a`) | +| G12 | **`VAL-01` 正向**:名册行证件号为空 → 红「名册里没有你的身份证号」 | 红 | **本脚本**(G3 只测了不该误报) | +| G13 | **`VAL-09`** 同一张图传到两个材料项 → 黄 | — | 既有脚本 `scripts/e2e-m3-submit.py:319` | +| G14 | **`VAL-08` 清晰度偏低 / `VAL-20` 体积偏小** → 黄(不阻断) | 见期望 | **本脚本** | +| G15 | 越权提交别人的单 → `40001`;无 token 提交 → `40101` | 见期望 | **本脚本** | +| G16 | 任务暂停 / 已截止时提交 → `40008`;解绑门禁后提交 → `40007` | 见期望 | **本脚本** | +| G17 | **判据纪律**:断言 `data.submitted` 而非 `code==0`(F-84) | 失败信息带红黄计数与 issues | 全脚本约定 | +| G18 | **红色不能靠 `confirmWarnings` 强提绕过**(契约:它只对黄色有效,`SubmitDtos.java:100-101`) | `submitted=false` 且 `redCount>0` | **本脚本** | + +### 3.9 H 组 · 我的任务 / 进度 / 撤回 + +| 编号 | 场景 | 期望 | 覆盖者 | +|---|---|---|---| +| H1 | 可见范围 = 名册绑定(没绑的人看不到该任务) | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:161` | +| H2 | 草稿态:`canFillNow=true`、状态文案「还没开始」、计入 `todoCount` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:154-157` | +| H3 | 审核中 / 已通过 / 已截止:`canFillNow=false` + `fillBlockReason` 各自不同 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:228,287,301-302` | +| H4 | **`progressPercent` 是字符串**(`"0%"` → `"100%"`),分母只算**可见的必传材料** | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:189,213` | +| H5 | 材料清单只列本次适用的(在校生 3 项,不含工作证明);`done/required/issueLevel` 正确 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:179-185` | +| H6 | 时间轴只列**确实发生过**的节点 | 「没发生的事不许出现」 | 既有脚本 `scripts/e2e-m3-mytask.py:193,217` | +| H7 | 撤回 status=1 → `status=0`、`submit_time` 清空、**名册行回 1** | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:235-241` | +| H8 | 撤回边界:草稿态再撤 → 拒;已通过 → `40031` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:251,279` | +| H9 | 越权:别人看我的进度 / 别人撤回我的单 → 拒 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:200,202` | +| H10 | **status=4 时 `progress.canEdit` / `canSubmit`** | 实测 `true` —— 与 E9/F10 的 `40030` **直接矛盾**(**F-125 的运行时证据**) | **本脚本** | +| H11 | **撤回后再改再提交**,且 `reject_count` 不因撤回增加 | 见期望 | **本脚本** | +| H12 | **撤回 → 再提交**这一对(既有脚本只覆盖了「撤回」那一半,没有一段把它闭合) | 撤回回 0/名册 1;重交回 1/名册 2 | **本脚本** | + +> H10 的证据来自 `StudentTaskService.java:311-313`:`setCanEdit(st == null \|\| st == 0 \|\| st == 4)`、`setCanSubmit(同)`。**读模型说「能改能交」,写模型(`saveDraft`/`storeFile`)说「`40030`」** —— 同一个类文件里的两个方法互相矛盾。 + +### 3.10 I 组 · 审核闭环与驳回重交 + +| 编号 | 场景 | 期望 | 覆盖者 | +|---|---|---|---| +| I1 | 班主任待办:被指定的初审人看得到、同角色但没被指定的看不到;跨班不可见 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:316,336` | +| I2 | 初审通过 → `status=2`、名册行 3、审核记录 `action=1` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:406-436` | +| I3 | 有红的单不允许通过 → `40032` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:385` | +| I4 | 批量通过只带无问题项;红黄一律跳过并说明原因 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:452,485` | +| I5 | 驳回:必填原因码、`R999` 必须带说明 → `status=4`、`reject_count`、名册行 4、审核记录 `action=2` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:496-517` | +| I6 | 驳回达上限 → `escalated=true`(升级管理员,**不是**错误码) | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:528` | +| I7 | **驳回后学生能重新提交** → `status=1`、名册行回 2 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:534,537` | +| I8 | **驳回后学生侧的提示**:「被驳回,请修改后重新提交」+ `canFillNow=true` + 计入 todo | 见期望 | **本脚本** | +| I9 | **驳回后先整改再提交**(改一个字段 + 换一份材料,再提交) | 契约 §2.4 期望可改 → 实测改不动(**XFAIL · F-125**) | **本脚本** | +| I10 | 已通过后撤回 → `40031`;导出后 → `40033`;归档后 → `40034` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:279`、`scripts/e2e-m5-audit.py:557,565` | + +> ⚠️ **I7 与 I9 的区别就是本计划存在的理由**:`scripts/e2e-m5-audit.py:531-538` 在重交前**只改了库里的 `reject_count`**(`UPDATE collect_submission SET reject_count=3`),**没有改任何业务字段** —— 它证明的是「原样重交能过」,而驳回的全部意义在于「改了再交」。切片测试把这一格填成了绿色,缝里的东西没人看。 + +### 3.11 J 组 · 跨步骤一致性(本脚本独有价值) + +| 编号 | 场景 | 期望 | 覆盖者 | +|---|---|---|---| +| J1 | **名册行 `submit_status` 全轨迹**:`0` →建草稿`1` →提交`2` →驳回`4` →重交`2` →终审`3`,每一步查库 | 五步全对且**连续** | **本脚本** | +| J2 | **表单升版后旧草稿提交**:`PUT /tasks/{id}/form-schema` 升版并加一个必填 `extra_*`,再提交旧草稿 | **不报 `40025`**;按**当前** schema 校验(新必填项会拦);`collect_submission.form_version` 保持创建时的快照值 | **本脚本** | +| J3 | **并发提交**(两个连接同时 `POST /submit`) | 如实钉住当前行为(`SubmissionService.java:280-282` 明写一期不做并发保护),把「无保护」写成**已知事实**而不是假装有 | **本脚本** | + +> **J1 的价值**:`scripts/e2e-m5-audit.py:308,436,517,537` 分别断言过 `2`、`3`、`4`、`2`,但**分散在四个上下文里**,没有任何一条断言「这一行的状态是连续走过来的」。分散断言无法发现「中间跳了一格」。 +> **J2 的顺序依赖**:它会改夹具 schema,必须排在 G 组之后(见 §4.2)。 +> **J3 的态度**:不做并发保护的**决定是书面的**(代码注释里写了为什么,以及将来要改的话该怎么改)。测试的职责是把这个决定**钉成可观测事实**,而不是替它加锁。 + +### 3.12 小结(实测口径,2026-10) + +| 覆盖者 | 用例数 | +|---|---| +| 既有脚本(已在跑) | 约 55 | +| **本脚本** | **45** | +| 人工走查(L3-B / L3-C,加分项) | L3-B 4 条里走了 3 条(第 4 条未走,见 §5.3);**L3-C 小程序端另走了 10 步**(第 2 条由它补上) | +| M4 缓做(不写用例) | 7 个错误码 | + +**实测结果**: + +| 跑法 | 通过 | 失败 | XFAIL | +|---|:--:|:--:|:--:| +| `--group chain` | 74 | 0 | 5 | +| `--group errors` | 27 | 0 | 1 | +| 无参数(chain + errors) | **95** | **0** | **6** | + +(三种跑法都通过;`共 95` 已超过 DoD 要求的 90 条。) + +--- + +## 四、脚本设计(`scripts/e2e-m3-flow.py`) + +### 4.1 夹具 + +**两个任务**,都带随机 suffix: + +| 任务 | 用途 | 状态 | +|---|---|---| +| `taskMain` | 跑 chain 组(完整链路) | 全程保持**收集中** | +| `taskState` | 跑 errors 组里的**状态机边界**(草稿/暂停/截止/归档 → `40008`) | 按用例切换 | + +**构造顺序**(每一步都走真实接口,与 `scripts/e2e-m5-audit.py:177-267` 同形): + +1. `admin` / `Admin@123` + 验证码 `demo` 登录(学校管理员) +2. 取一个 `orgType=3` 的班级 +3. 建初审班主任(`HEAD_TEACHER`、`userType=3`,拿返回的 `initialPassword` 登录) +4. 建两个任务(`collectMode=1`、`certType=1`) +5. 生成 xlsx 名册(2 人,`with_checksum` 补校验位)→ `upload_roster(..., {"orgId": ...})` +6. `PUT /tasks/{id}/orgs`(覆盖班级 + 指定初审人) +7. `POST /tasks/{id}/materials`:必传材料,**`minSizeKb=1`、`minLongSide=100`** +8. `POST /tasks/{id}/publish` + +⚠️ 第 7 步的下限必须调小。理由见 `scripts/e2e-m5-audit.py:255-263` 的实测注释:E2E 用的小图会被材料目录的默认下限判成「体积偏小 / 清晰度偏低」→ **黄色警告**,于是「绿色单」这个前提不成立,后面所有断言跟着错。**断言失败的样子像功能坏了,其实只是夹具不满足前提。** + +⚠️ **绝不触碰**演示任务 17 / 2097 / 2215 及其名册绑定。跑 `scripts/e2e-m3-*.py` 会清掉 `collect_roster.bind_user_id`(AGENTS.md 点过),本脚本只操作自建任务。 + +### 4.2 用例顺序依赖 + +| 依赖 | 说明 | +|---|---| +| `chain` 组必须整体跑 | J1 的轨迹断言依赖前序步骤真的发生过 | +| **J2 必须在 G 组之后** | J2 会升版夹具的表单 schema;提前跑会让后续所有 `VAL-22` 断言多一个必填项 | +| D3/D4/E13/G16 用 `taskState` | 它们会把任务切到暂停/截止/归档;用 `taskMain` 会毁掉 chain 组 | +| `errors` 组内的越权用例需要**两个学生** | 夹具准备 2 行名册即可 | + +### 4.3 命令行参数 + +```bash +python scripts/e2e-m3-flow.py # 全部(chain + errors) +python scripts/e2e-m3-flow.py --group chain # 只跑链路(J1 轨迹在这里) +python scripts/e2e-m3-flow.py --group errors # 只跑错误矩阵 +python scripts/e2e-m3-flow.py --fixture-only # 只建夹具,打印 id/token,不清理、不跑用例 +python scripts/e2e-m3-flow.py --keep # 跑完不清理(配合人工走查) +python scripts/e2e-m3-flow.py --h5-url # 打印可直接打开的 H5 地址(见 §5.3) +python scripts/e2e-m3-flow.py --clean # 只清理指定的夹具任务 +python scripts/e2e-m3-flow.py --full # 连限流类慢用例一起跑(默认跳过) +``` + +### 4.4 清理 + +放 `finally`,**失败路径也要跑** —— F-85 的教训是「清理只在正常结束时跑,于是失败路径才是残留的来源」。 + +按 `task_id` 依次删:`collect_audit_record` → `collect_file` → `collect_submission` → `collect_roster` → `collect_material_def` → `collect_task_org` → `collect_export_task` → `collect_task`;再删 `sys_user_role` / `sys_user`(自建班主任)与临时 xlsx。最后**断言残留任务数为 0**。 + +### 4.5 XFAIL 机制 + +```python +KNOWN_DEFECTS = { + "EP-E09": "F-125", # 驳回后 PUT 被 40030 拒 + "EP-F10": "F-125", # 驳回后上传被 40030 拒 + "EP-F10b": "F-125", # 驳回后删除被 40030 拒(与上传是两条独立的守卫) + "EP-I09": "F-125", # 驳回后整改不落库 + "EP-D09": "F-128", # 扩展字段存进去却回显不出来 + "EP-J02e": "F-128", # 必填扩展字段永远满足不了 +} +``` + +⚠️ **登记之后,用例的断言要写「契约期望」而不是「现状」** —— 否则 XFAIL 会变成一条永远通过的断言,等于把缺陷写成了规格。`EP-I09` 第一版就写错了:它只断言「原样重交能成功」,而那本来就成功(`scripts/e2e-m5-audit.py:534` 已证明),于是它报了个 `XPASS` 说"缺陷已修"。改成断言「整改的值真的落库了」之后才如实变成 XFAIL。 + +| 情形 | 输出 | 计入失败 | +|---|---|:--:| +| 该用例按契约期望**失败** | `[XFAIL] EP-E09(已知缺陷 F-125)` | 否 | +| 该用例**通过**了 | `[XPASS] EP-E09 缺陷似乎已修 —— 请删掉 KNOWN_DEFECTS 里的登记` | **是** | + +为什么要 XPASS 报红:否则缺陷修好之后没有任何信号,登记会永远留在那里,而下一个人会以为它还在。 +为什么可以 XFAIL:否则 `verify-all --with-e2e` 长期红,红着红着就没人看了 —— 那比不测更糟。 + +### 4.6 判据纪律(每一条都有血) + +| # | 纪律 | 血的来源 | +|:--:|---|---| +| 1 | 业务错误也是 **HTTP 200**,判 `code`;只有 `401/403/429/5xxxx` 看 HTTP 状态码 | 项目约定,`ErrorCode.httpStatus()` | +| 2 | **「HTTP 200 + code 0」≠ 动作成功**:提交/发布/导入/批量审核一律断 `data` 里的结论字段(`submitted`/`created`…),失败信息带红黄计数与 issues | **F-84**(`docs/08-完成度盘点与问题清单.md:452`) | +| 3 | 每个接口级断言配一条**查库断言** | 「接口说成功了」与「数据库真的变了」是两件事 | +| 4 | 软删字段是 `smallint`:写 `deleted=0`,**不是** `=false` | PG 会报 `operator does not exist: smallint = boolean` | +| 5 | `Map` 结果的列别名会被**折成小写**,查库别用驼峰别名 | **F-54 / F-83** | +| 6 | 清理放 `finally`,并断言残留数 | **F-85** | +| 7 | 末尾必须打印 `通过 X 失败 Y 共 Z` | `scripts/verify-all.py:143` 靠这行正则统计;格式不对会被判成「无汇总」而算失败 | +| 8 | 门禁限流按 openid 计(10 次/小时):学生 uid 每次现造;要清 Redis 键必须带 `-a redis_dev_pwd --no-auth-warning` **并检查返回码** | `scripts/dev-seed-audit.py:123-141`(不看返回码 → 「清了但没清掉」) | + +--- + +## 五、执行步骤 + +### 5.1 前置 + +```bash +cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379) + # ⚠️ 不要动本机别的 pg-verify/redis-local/sp-redis/vastbase-pg +cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev +``` + +⚠️ **工作目录必须是 `backend/`**:`application-local.yml` 与 H5 静态目录两处相对路径依赖它。 +⚠️ **「jar 比源码新」不算证据**。启动后必须先探活,再跑一条已知脚本冒烟: + +```bash +curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/v1/auth/captcha # 期望 200 +python scripts/e2e-m3-draft.py # 期望 27/27 +``` + +### 5.2 L3-A 主流程 + +```bash +python scripts/e2e-m3-flow.py --fixture-only # 夹具前提不满足时立刻退出并打印清单 +python scripts/e2e-m3-flow.py --group chain # 实测:通过 74 失败 0 XFAIL 5 +python scripts/e2e-m3-flow.py --group errors # 实测:通过 27 失败 0 XFAIL 1 +python scripts/e2e-m3-flow.py # 实测:通过 95 失败 0 XFAIL 6 +python scripts/verify-all.py --with-e2e # 全量回归(38 个脚本,约 10 分钟) +``` + +⚠️ **跑完确认没有残留**(本脚本自己在 `finally` 里清,但失败路径值得再看一眼): + +```bash +docker exec -e PGPASSWORD=fill_dev_pwd xt-pg psql -U fill -d filldb -t -A \ + -c "SELECT count(*) FROM collect_task WHERE task_name LIKE 'E2E流程%'" # 期望 0 +ls scripts/_tmp_flow_* # 期望没有 +``` + +### 5.3 L3-B 浏览器走查(加分项) + +```bash +cd frontend-mp && pnpm dev:h5 # → http://localhost:5174 +python scripts/e2e-m3-flow.py --keep --h5-url # 打印带 token + scene 的 H5 地址 +``` + +⚠️ **不要直接打开 stub-login 的 302 落点**:它默认跳到 `WX_H5_BASE_URL`(穿透域名 `http://394nb68658le.vicp.fun`),那里是**后端托管的构建产物**(`frontend-mp/dist/build/h5`,2026-09-29 的旧构建),不是 dev server。`--h5-url` 的做法是:Python 侧请求 stub-login 但**不跟随跳转**,从 `Location` 里取出 `token` 与 `scene`,拼成 `http://127.0.0.1:5174/#/pages/index/index?token=..&scene=..`。 + +⚠️ 走查时**名册行必须未绑定**,由界面自己走一遍门禁。原因:L3-A 用的是 `mint()` 手搓的**假 uid**(库里没有对应 `sys_user`),而 stub-login 会**真建** `sys_user` 并拿到真 uid —— 两者不是同一个人,混用会得到「门禁说你不在名册里」。 + +⚠️ **门禁要手动输入身份证号,而库里只有密文**。`scripts/_reveal-roster-idcard.py` 能解,但它把 task id **写死在 `:59`(1719)**;查别的任务用: + +```bash +python scripts/dev-reveal-roster-idcard.py # 只读,只打印;解密方式见 AesGcmCipher +``` + +⚠️ `--fixture-only` 不会触发清理(那是给手工排查用的),走查完要自己收尾: +删任务(`--clean `,或按 `docs/11` §4.4 的顺序逐个表删)、 +删 stub-login 建出来的 `sys_user`(它不在脚本的夹具清单里)、删 `scripts/_tmp_flow_*.xlsx`。 + +走查看这四件**接口测不出来**的事(**2026-10 已实测前 3 条,见下表**): + +| # | 看什么 | 为什么接口测不出来 | 实测结果 | +|:--:|---|---|---| +| 1 | 无 scene 但有 token 时,是否跳到「我的填报」而不是报错 | `frontend-mp/src/pages/index/index.vue:156-169` 的分支 | ✅ 直达「我的填报」。⚠️ 但**不能靠"去掉 URL 里的 scene"来复现** —— scene 已进本地缓存,落地页仍会用它;要直接开 `#/pages/tasks/tasks?token=..` | +| 2 | 有黄色警告时**弹确认框**,点「仍然提交」才真的提交 | `fill.vue:706-743` 的两次调用;`doSubmit` 不能把事件对象当 `confirmWarnings` 传(`:688-693` 的注释记过这个坑) | ✅ **已在 L3-C 小程序端走到**(H5 侧本轮只造出红色;小程序端用 72×72 的 logo 触发 `VAL-08` 黄单 → 页内「仍然提交」→ 提交成功) | +| 3 | 驳回状态下每次失焦都提示「保存失败:…请先撤回」 | `fill.vue:378-380` —— **这就是 F-125 在学生眼里的样子** | ✅ **复现**(原文见下) | +| 4 | 传图后结果卡显示**命名前后对比**与压缩后体积 | 只能看 | 未走(浏览器自动化无法选本地文件,见下) | + +**第 3 条实测到的原话**(把 `status` 改成 4 后改任意字段并失焦): + +``` +保存失败:已提交的填报单不能直接修改,请先撤回 +``` + +而**同一屏上没有任何「撤回」入口**;退到「我的填报」,卡片上写着 +**「被驳回,请修改后重新提交」**、按钮写着**「修改后重新提交」**, +点进去却每一次修改都失败 —— 界面自己的行动号召把学生带进死胡同。 +这正是 F-125 的完整用户侧证据链(接口侧是 4 条 XFAIL)。 + +⚠️ **第 4 条为什么没走**:材料上传要选本地文件,而本轮可用的浏览器自动化**没有文件选择能力**。 +所以"传图 → 命名对比 → 压缩体积"这条只能人工走查;接口侧的等价断言在 L3-A(`EP-F05` 系列与 `EP-F15`)。 + +### 5.4 L3-C 微信小程序端走查(2026-10 补) + +> 起因:需求方明确要求「**学生提交的过程要测微信小程序端**」。小程序与 H5 是同一套源码 +> (`frontend-mp/`,`#ifdef` 条件编译),但**小程序端此前从未被任何自动化跑过** —— AGENTS.md 的 +> 节奏是"先做 H5(可自动化测试、不依赖小程序资质)"。 + +**环境**(一次性的,`wmp_doctor` 可自检):微信开发者工具已装且已登录、自动化端口 9420、 +项目 = `frontend-mp/dist/dev/mp-weixin`(`pnpm dev:mp-weixin` 的产物;`project.config.json` 里 +`urlCheck:false`、appid `wxf01d3fa60f87f0b7` **与后端 `fill.mini-program.app-id` 逐字相同**)。 + +**前置**:① `python scripts/e2e-m3-flow.py --fixture-only --keep` 建夹具(名册行必须**未绑定**); +② `python scripts/dev-reveal-roster-idcard.py ` 取门禁要手输的明文姓名/身份证。 + +**实测步骤与结果**(iPhone 12/13,SDK 3.17.3,开发者工具 8.0.5): + +| # | 操作(自动化动作) | 结果 | +|:--:|---|---| +| 1 | `reLaunch /pages/index/index?scene=t1-k2313` | 落地页正常:示范职业学校 / E2E流程 / **收集中** / scene 回显 —— **匿名接口在小程序里也是通的** | +| 2 | `tap .land__btn`(开始填报)→ `pages/verify/verify` | 正常 | +| 3 | 两个 `input` 填姓名 + 身份证 → `tap button` | ❌ **「未登录或登录已失效」**(后端 40101)→ **F-130** | +| 4 | 用 `evaluate` 在页面上下文里调 `wx.login()` + `wx.request` 打 `/api/v1/mp/login` | ❌ scene 带 taskId → **HTTP 500 / code 50000**;同秒换 `scene=t1` → ✅ **200 + token** → **F-130** | +| 5 | `wx.setStorageSync('token', …)`(桥接,见下)后重走 1~3 | ✅ 门禁通过:流程甲 / 脱敏号 / 班级 / 报考三项锁定;库里 `bind_user_id=1390`、`bind_type=1` | +| 6 | 进 `pages/fill/fill?taskId=2313` | ✅ 表单 68 个元素全渲染,锁定项标注正确 | +| 7 | `mockWx chooseImage` → `tap`「+ 选择文件」 | ✅ 控制台 `[材料压缩] IDCARD P2 证件翻拍:4KB → 2KB(q88)` → `collect_file` 落库,规范名 `流程甲…-3610292008XXXXXX13-身份证.jpg` | +| 8 | `tap`「提交审核」 | ✅ 页内红单:`VAL-05 缺少必传材料:身份证` + 「知道了,去修改」 | +| 9 | 传材料后再提交 | ✅ 页内黄单:`VAL-08「身份证」清晰度偏低(72×72)` + **「仍然提交」**(= L3-B 第 2 条,H5 那轮没走到的确认框) | +| 10 | `tap`「仍然提交」 | ✅ 提交成功:`collect_submission.status=1`、`submit_time` 写入、`collect_roster.submit_status=2` | + +**结论**:小程序端**除登录外整条链路都是好的** —— 落地页、门禁、动态表单、前端压缩上传、 +红黄两档校验与确认框、提交后的名册流转,全部在真机模拟器里跑通。 +唯一的断点是登录(**F-130**),而且它断在两处、任一处都足以让小程序不可用。 + +⚠️ **第 5 步是"桥接",不是正路**:直接把 token 塞进 storage 只是为了把「登录坏了」与 +「其余链路好不好」分开验证。**修好 F-130 之后这一步必须删掉。** + +⚠️ **为什么没有做成 `.flow.mjs` 固化**:本轮可选的小程序自动化动词(`reLaunch`/`tap`/`input`/ +`evaluate`/`mockWx`)都实测过,但**流程文件的字段形状**(带 `index` 的 `input`、带选择器的 +`assert.text`、设 storage 的 `wx` 动词)没有可验证的样例 —— **宁可留一份可照着做的步骤表, +也不往仓库里放一个跑不起来、却看起来像能跑的脚本**(这与 F-126 的教训是同一条)。 +等 F-130 修好、登录不再需要桥接时,再把它固化成 flow 文件更划算。 + +⚠️ **别忘了收尾**:`--fixture-only` 不触发清理;**登录成功那一次**(本轮是 `scene=t1` 那次)会真建 +`sys_user`(本轮 1 个:uid 1390,并被门禁绑到名册行 21695),它不在脚本的夹具清单里,要手工删。 +失败的登录**不建账号**(事务回滚,见 F-130 的副作用一节)。 + +--- + +## 六、已知缺陷(已定案) + +### F-130【P0 · 功能不可用】微信小程序端的学生**完全进不来** —— 登录链路断成两截 + +**症状**(真机模拟器实测):扫任务码进小程序 → 落地页正常 → 点「开始填报」→ 填姓名 + 身份证 → +点「核对并继续」→ 屏幕显示 **「未登录或登录已失效」**。 + +**断点 A(前端):小程序端从来没有调用过 `/mp/login`。** + +- 全仓 `frontend-mp/src` 里**唯一**写 token 的地方是 `pages/index/index.vue:222` + (从 **H5 授权回调**的 `options.token` 取),而这条路径是 H5 专有的; +- 9 个页面(`fill` / `verify` / `tasks` / `progress` / `messages` / `dashboard` / `audit` / `audit-detail` / `subscribe`) + 的 `token()` 都读 `uni.getStorageSync('token')`; +- 全仓搜不到 `wx.login` / `uni.login` / `/mp/login` 的**调用**(只有 `utils/request.ts:22` 一句注释 + 「登录流程见契约 §5.x 的 /mp/login,**属 M3**」);也**没有 `web-view` 兜底**。 +- ⇒ 小程序里 token **永远是空串**,`POST /mp/roster/match` 不带 Authorization → 后端 40101。 + +**断点 B(后端):`/mp/login` 只要 scene 带 taskId 就必然 500。** + +- `MpLoginService.login` 在 `:126-130` 有个"顺带把名册绑定带回去"的分支: + `rosterMapper.selectOne(... collect_roster ...)`。`rosterMapper` 是**普通 `BaseMapper`**, + 而 `/mp/login` 是 **`@PublicApi`(匿名)** → **租户拦截器默认拒绝** → `BizException(40101)` + → MyBatis 包成 `PersistenceException` → `GlobalExceptionHandler` → **HTTP 500 / code 50000「系统繁忙,请稍后再试」**。 +- 日志铁证(本轮实测):`MpH5AuthService : H5 首次登录,已建学生账号:tenant=1 uid=1389` 成功后紧接着 + `### Error querying database. Cause: cn.tianbaotong.fill.common.exception.BizException: 未登录或登录已失效`, + 栈顶 `at cn.tianbaotong.fill.module.mp.service.MpLoginService.login(MpLoginService.java:127)`。 +- **对照实验**(同秒、同端点、只改 scene): + + | scene | 结果 | + |---|---| + | `t1`(不带任务) | **HTTP 200 + token**(`needBindPhone:true`,`user{id,realName:"微信用户",userType:4}`) | + | `t1-k2313`(带任务) | **HTTP 500 / code 50000** | + +- ⇒ `/mp/login` **只在"永远用不到的那个场景"下能用** —— 学生扫的码必然带 taskId(不然没有任务可填)。 +- **副作用(已核实,不要写成"留下一堆半成品账号")**:日志里明明打着 + `MpH5AuthService : H5 首次登录,已建学生账号:tenant=1 uid=1389`,但 `login()` 是 `@Transactional` 的, + 账号与 `sys_user_role` 都随异常**回滚**了 —— + `SELECT id FROM sys_user WHERE id IN (1389,1390)` 只有 **1390**(那是本轮用 `scene=t1` 成功调出来的那个)。 + 所以症状是「学生每次点都 500,什么也没留下」,**不是**「攒下一堆孤儿账号」; + 唯一残留是**自增 id 被消耗**(1389 成了空号)。 +- 修法方向(**未实施,属改代码**):该分支改成走**专用 Mapper + `@InterceptorIgnore(tenantLine="true")` + 显式带 tenant_id** + —— 这正是 AGENTS.md 里"匿名接口必须走专用 Mapper"那条铁律,`MpH5Mapper` 就是这么做的 + (`selectByOpenId(tenantId, openId)`),而这里漏了。另外**异常不该以"系统繁忙"对外**: + 40101 被包成 50000,把"没登录"讲成了"服务器坏了"。 + +**为什么至今没暴露**: + +- `/mp/login` **没有任何 E2E 覆盖** —— `scripts/e2e-m3-gate.py:18` 明确写着「登录(`/mp/login`)需要真实的 + `wx.login` code —— 脚本拿不到」。**本轮证明它拿得到**:开发者工具自动化里可以在页面上下文调 `wx.login()` + 拿到**真的** code(后端真换到了 openid),所以这条链路是可以自动化的。 +- 文档**互相矛盾**:`docs/08:147` 写着「~~P1-14~~ 学生端 `/mp/login` + 手机号绑定 → ✅ **已完成**」 + (指的是**后端**),而 `docs/交接摘要.md:563` 还留着「`/mp/login`(小程序登录)仍未做」。 + 两边都不算错,但**没有任何一处说"前端没接"** —— 与 F-126 同一类:"宣布完成"与"界面上能用"之间的落差, + `check-wiring.py` 的十项检查**没有一项会发现**(它查权限码/错误码/模板/菜单键,不查"契约端点有没有前端调用方")。 + +### F-125【P0 · 阻断闭环】学生被驳回后**改不了任何东西** + +**现象**:填报单进入 `status=4`(已驳回)后,学生: + +| 动作 | 结果 | 代码 | +|---|---|---| +| 改字段(`PUT /mp/submissions/{id}`) | `40030` | `SubmissionService.java:158-161` | +| 传材料(`POST /mp/files/upload`) | `40030` | `MaterialFileService.java:103-105` | +| 删材料(`DELETE /mp/files/{id}`) | `40030` | `MaterialFileService.java:338-341` | +| 撤回(`POST /mp/submissions/{id}/withdraw`) | `40001`(只放行 1/2) | `StudentTaskService.java:190-196` | +| **重新提交**(`POST .../submit`) | **允许** | `SubmissionService.java:293-297` | + +**于是闭环断了**:学生只能**原样重交**,而驳回的意义正是「改了再交」。 + +**而且系统在四处同时告诉他「你可以改」**: + +| 信号 | 值 | 代码 | +|---|---|---| +| `GET /mp/tasks` 的 `statusText` | 「被驳回,请修改后重新提交」 | `StudentTaskService.java:142` | +| `GET /mp/tasks` 的 `canFillNow` | `true`(`underReview` 只算 1/2/3,**4 不算**) | `StudentTaskService.java:105-106` | +| `GET /mp/submissions/{id}/progress` 的 `canEdit` | `true`(`st == 0 \|\| st == 4`) | `StudentTaskService.java:311` | +| 同一个接口的 `canSubmit` | `true` | `StudentTaskService.java:313` | + +**为什么 1617 条断言没抓到**:`scripts/e2e-m5-audit.py:531-538` 证明过「驳回后能重新提交」,但它在重交前只改了库里的 `reject_count`,**没有改任何业务字段**。切片测试把这一格填成了绿色。 + +**契约站在哪一边**:`docs/03-接口设计.md:239-248` 明确写 `4 已驳回 → 可流转到 1(学生重提交)`;`docs/03-接口设计.md:2040` 的撤回约束只写 `status ∈ {1,2}`,没有为「驳回后怎么改」留出口。**所以按契约,改不动就是实现缺一条路径。** + +**建议的最小改法**(**改业务口径,须用户确认后再动**):把三处写守卫从 `!= STATUS_DRAFT` 放宽为 `∉ {STATUS_DRAFT, STATUS_REJECTED}`,与 `submit`(`SubmissionService.java:293-297`)和 `progress.canEdit`(`:311`)的口径对齐。涉及:`SubmissionService.java:158-161`、`MaterialFileService.java:103-105`、`MaterialFileService.java:338-341`。 + +**备选方案**(不动写守卫,改撤回守卫):让 `withdraw` 也接受 `status=4`(驳回 → 草稿 → 改 → 重交)。缺点是学生要先点一次「撤回」,而界面在驳回态**不显示撤回按钮**(`StudentTaskService.java:109` 的 `canWithdraw` 只算 1/2)。 + +### F-128【P0 · 阻断功能】扩展字段存进去却读不出来 —— 必填扩展字段会让整个任务**永久无法提交** + +**现象**(`EP-D09` / `EP-J02e`,已实测): + +``` +PUT /mp/submissions/{id} {"extrasJson": {"extra_upgrade_note": "已补填"}} + → code=0,库里 extras_json = {"extra_upgrade_note": "已补填"} ← 写成功了 +GET /mp/tasks/{task}/form + → 该字段的 value = null ← 读不出来 +POST /mp/submissions/{id}/submit + → {"submitted": false, "redCount": 1, + "issues":[{"ruleCode":"VAL-22","title":"还有必填项没填:升版新增必填"}]} ← 填了也说没填 +``` + +**根因**:`FillService.assemble`(`FillService.java:106-159`)构造取值表 `values` 时只有两个来源 —— + +1. `rosterValuesOf(roster)`(名册列) +2. 遍历 `schema.getFields()` 中 `SubValues.hasColumn(key)` 成立的键,读 `collect_submission` 的**列** + +**它从来没有把 `draft.extrasJson` 合进 `values`。** 而扩展字段的值只存在 `extras_json` 里,于是: + +- `withValue(f, values)` 对扩展字段恒取到 `null` → **学生填的扩展字段重开表单就没了**(以为没保存上,再填一遍); +- `SubmissionValidator.validate` 的 `values`(`SubmissionValidator.java:104-109`)来自同一份装配 → `checkRequiredFields` 的 `blank(values.get(key))` **恒为真** → **必填扩展字段永远满足不了**。 + +**影响**: + +| # | 影响 | 严重度 | +|:--:|---|:--:| +| 1 | 表单里加了**必填**扩展字段 → 该任务对**所有人**永久阻断,没有任何办法满足它 | **P0(功能不可用)** | +| 2 | 学生填的扩展字段保存后在界面上消失(数据在库里,但读不出来) | P1 | +| 3 | 依赖 `values` 的下游(校验、导出前置判断)都看不到扩展字段的值 | P1 | + +**为什么至今没暴露**:内置表单(`BuiltinFields.java`)**一个扩展字段都没有**(`extras` 为空), +而既有 37 个脚本用的都是内置表单 —— 所以这条路径**从来没有数据走过**。它是"表单设计器给任务加了扩展字段"这个真实用法才会踩到的。 + +**建议的最小改法**(**改业务口径/数据读取口径,须确认后再动**):在 `assemble` 里把 `extrasJson` 合进 `values`, +即对 `schema.getExtras()` 的每个键,从 `draft.getExtrasJson()` 解析后 `values.put(key, v)`。 +位置在 `FillService.java:144-158` 之后(那段循环之后、构造 sections 之前)。 +⚠️ 注意 `changed_fields` 的语义:扩展字段没有列,不要顺手把它们塞进 `changed_fields`(那是给内置列用的)。 + +### F-129【P2 · 静默丢数据】把已声明的扩展字段放进 `formData` 不会报错,值直接没了 + +`EP-E16` 实测:`{"formData": {"extra_upgrade_note": "塞错口袋"}}` → `code=0`,`extras_json` 仍是 `{}`。 + +对照:**未声明**的键放进 `formData` 会明确 `40001`(`SubmissionService.java:178-182`,注释写着"不静默忽略")。 +而**已声明**的扩展键放进 `formData` 时,`defs` 里查得到(`:169-171` 把 extras 也放进 `defs`), +于是躲过了那道检查,接着 `SubValues.apply(sub, key, value)` 对它**没有对应列,静默什么也不做**。 + +契约要求扩展字段走 `extrasJson`(`DraftDtos.SaveReq.java:44`),所以这属于**用错参数**; +但同一个方法里"不静默忽略"的原则对两种塞错口袋的待遇不一致,前端写错一次就是学生白填一遍。 +**建议**:`SubValues.apply` 遇到既不是列、又确实是声明过的扩展键时,抛出 40001 并提示"扩展字段请放在 extrasJson"。 + +### F-126【P2 · 脚本自述与断言不符】(已处理) + +`scripts/e2e-m3-draft.py:11` 的 docstring 自述 `[x] 未过门禁不能建草稿;已提交的不能直接改`,但该文件**全文没有这条断言**(全仓唯一涉及 `40030` 的断言是 `scripts/e2e-m5-proxy.py:277`,测的是代传路径)。 + +**本轮已补**:`EP-E08`(已提交后 `PUT` → `40030`)与 `EP-F09`(已提交后上传 → `40030`)已落地并通过。 +**建议**:把 `scripts/e2e-m3-draft.py:11` 的自述改准(去掉那半句,或指向本脚本)。 + +**为什么值得记**:docstring 的 `[x]` 清单在本仓库被当作覆盖率的**唯一账本**(`scripts/verify-all.py` 的汇总、多份文档的「37 个脚本 / N 条断言」都建立在它上面)。自述虚高与 F-84 同类,而且**更隐蔽** —— 断言缺失不会让任何东西变红,反而让覆盖率看起来更好。 +**教训**:本计划 §3.1 的 `grep 'check(\|ok('` 核对规程**不是走形式** —— 它一上来就抓到了这一条。 + +### F-127【P2 · 文档口径未登记】§9.10 的「校验失败返回 40001」与实现不一致 + +- **契约**(`docs/03-接口设计.md:1988,1997-2019`):错误码 `40001 阻断`,`data` 里给 `blocking[]` / `warnings[]` / `canForceSubmit`。 +- **实现**(`FillController.java:59-70`):**HTTP 200 + `code:0`**,`data` 是 `{submitted:false, status, statusText, redCount, yellowCount, infoCount, issues[]}`。代码注释给了理由:校验不通过是**正常业务结果**,前端要一次拿到完整清单才能渲染红黄绿。 + +实现的口径与项目约定(业务错误不占 HTTP 状态码)一致,也已被 37 个脚本按 `data.submitted` 判定 —— **但 `§17.2 有意偏离契约的地方` 里没有它**(D-1~D-12,`docs/03-接口设计.md:2972-2988`)。后果是文档读者会写出 `code != 0` 的判据,而那是**恒假的**,测试会静默变绿。 + +**建议**:在 §17.2 补一行 **D-13** 记录该偏离(表格仍是 5 列,不影响 `verify-docs.py`)。本轮只提议,不在测试轮里动契约。 + +### 待定案 → 已定案(实测结论,2026-10) + +| 问题 | 定案 | 证据 | +|---|---|---| +| 驳回后**重新提交**能否过校验(内容未变时) | **能过** —— 这也正是 F-125 的欺骗性所在:交得上去,但改不了 | 既有 `scripts/e2e-m5-audit.py:534` + 本脚本 `EP-I09` | +| 表单升版后旧草稿提交是否报 `40025` | **不报**。`submit` 不比对 `form_version`;按**当前** schema 校验;库里 `form_version` 保持建单时快照 | 本脚本 `EP-J02b/J02c/J02d` | +| 并发提交的实际行为 | **无保护**,两个请求都能拿到 `submitted=true`,落库状态仍自洽 | 本脚本 `EP-J03`(钉成已知事实) | +| 任务已截止/暂停时能否核验身份 | **能**(门禁与任务状态**有意解耦**),但紧随其后的取表单会被 `40008` 拦住 | 本脚本 `EP-C08b/C08c` | +| `VAL-17` 的「非阿拉伯数字」分支能否触发 | **触发不了**:`collect_submission.work_years` 是 `smallint`,「三年」在写库前就被类型挡住 → 该分支是死代码 | 本脚本 `EP-G11a`(钉住事实) | +| 扩展字段能不能存、能不能读 | **存得进、读不出**(F-128) | 本脚本 `EP-D09/EP-J02e`(XFAIL) | + +--- + +## 七、与《06-验收测试清单》的映射 + +《06》是**客户向**清单,含 M4 与已取消二期的条目,本计划**不改它**,只在此建立对应关系。 + +| 《06》条目 | 本计划 | 说明 | +|---|---|---| +| §3.1 主流程 A(A1~A23) | A/B/C 组 + chain 组 | 其中 A10/A11(材料移动)、A6(`changedFields` 记 phone)依赖 M4,**暂无自动验收** | +| §3.4 异常边界 E1~E27 | §3 各组的错误用例 | E5/E16/E17/E18/E24/E25/E26/E27 属 M4(OCR/归属),**缓做**;E21~E23 随「加入申请」撤回一并作废 | +| §3.2 主流程 B(社会收集) | — | 已作废(`40044`) | +| §3.3 主流程 C(历史整理器) | — | 已取消 | +| §4 功能验收 F-500 / F-800 | §3.9 / §3.10 | 名册与审核侧 | +| §2 Golden Assertions(G-*) | 不在本计划 | 那是引擎级纯函数,用 112 个真实文件做断言,与链路无关 | + +--- + +## 八、变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| V1.0 | 2026-10 | 首版。覆盖矩阵 40 条本脚本用例 + 约 55 条既有脚本用例;登记 F-125 / F-126 / F-127 | +| V1.1 | 2026-10 | 按实测结果回填:本脚本用例 40 → **45**;新增 **F-128**(扩展字段存得进读不出,必填扩展字段永久阻断,P0)与 **F-129**(扩展字段塞进 `formData` 被静默丢弃);F-126 已补断言;「待定案」六项**全部定案**;三种跑法的实测数字回填 §3.12 / §5.2 | +| V1.2 | 2026-10 | 补 L3-B 真实浏览器走查结论(§5.3):走了 4 条里的 3 条,**F-125 的用户侧证据链拿到**(「保存失败:…请先撤回」+ 同一屏没有撤回入口 + 列表页按钮「修改后重新提交」);记下两个走查坑(scene 会进本地缓存、`--fixture-only` 不触发清理)与新增工具 `scripts/dev-reveal-roster-idcard.py` | +| V1.3 | 2026-10 | **新增 §5.4 L3-C 微信小程序端走查**(需求方要求「学生提交的过程要测微信小程序端」):在开发者工具模拟器里把 10 步走完,**除登录外整条链路都通**(落地页 / 门禁 / 表单 / 前端压缩上传 / 红单 / 黄单确认 / 提交后名册流转);登记 **F-130(P0,小程序端登录断成两截:前端从不调 `/mp/login` + 后端 `MpLoginService.java:127` 匿名查租户表被拦截 → 50000)**;§5.3 第 2 条由 L3-C 补上。**本轮不改业务代码** | diff --git a/scripts/dev-reveal-roster-idcard.py b/scripts/dev-reveal-roster-idcard.py new file mode 100644 index 0000000..445cb66 --- /dev/null +++ b/scripts/dev-reveal-roster-idcard.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""临时脚本(只读):解出本轮走查夹具(e2e-m3-flow.py 建的 taskMain)名册行身份证明文。 + +用途:L3-B 浏览器走查要在门禁界面里**手动输入姓名 + 身份证号**, +而明文只在建夹具的那一刻存在过(库里是 AES 密文 / 脱敏 / HMAC 索引)。 +dev 的 AES 密钥在 application-dev.yml 里(提交进仓库的开发专用密钥),格式见 AesGcmCipher: +Base64( IV(12) || ciphertext || tag(16) )。 + +**只读,不写库。** 走查完请连同夹具任务一起清掉(`python scripts/e2e-m3-flow.py --clean `)。 +""" +from __future__ import annotations + +import base64 +import re +import subprocess +import sys + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +TASK_ID = int(sys.argv[1]) if len(sys.argv) > 1 else 2311 + +DEV_YML = r"D:\IDEA_Project\xiaotutongxue\backend\xt-server\src\main\resources\application-dev.yml" +TEXT = open(DEV_YML, encoding="utf-8").read() +AES_KEY = base64.b64decode(re.search(r"aes-key:\s*(\S+)", TEXT).group(1)) +CIPHER = AESGCM(AES_KEY) + + +def psql(sql: str) -> list[list[str]]: + p = subprocess.run( + ["docker", "exec", "xt-pg", "psql", "-U", "fill", "-d", "filldb", + "-A", "-F", "\t", "-P", "pager=off", "-t", "-c", sql], + capture_output=True, + ) + if p.returncode != 0: + raise SystemExit(p.stderr.decode("utf-8", "replace")) + # ⚠️ 不能对整段输出 .strip() —— psql 的 -A 模式在**末尾字段为空**时会留下结尾制表符, + # 整段 strip 会把它吃掉,于是"未绑定"的那一行少一列、解包直接报 + # `ValueError: not enough values to unpack`(而这个报错看起来像解密失败)。 + out = p.stdout.decode("utf-8", "replace") + return [ln.split("\t") for ln in out.splitlines() if ln.strip()] + + +def decrypt(cipher_b64: str) -> str: + raw = base64.b64decode(cipher_b64.strip()) + return CIPHER.decrypt(raw[:12], raw[12:], None).decode("utf-8") + + +rows = psql(f""" +SELECT id, real_name, COALESCE(id_card_no,''), COALESCE(id_card_mask,''), + COALESCE(bind_user_id::text,'') +FROM collect_roster +WHERE deleted = 0 AND task_id = {TASK_ID} +ORDER BY id +""") + +print(f"任务 {TASK_ID} 的名册(走查用;只打印,不入库)") +print("名册行 | 姓名 | 身份证(明文) | 脱敏 | 绑定用户") +for r in rows: + rid, name, enc = r[0], r[1], r[2] + mask = r[3] if len(r) > 3 else "" + bind = r[4] if len(r) > 4 else "" + try: + plain = decrypt(enc) + except Exception as e: # noqa: BLE001 + print(f"{rid} | {name} | [解不开:{type(e).__name__}] | {mask} | {bind or '未绑定'}") + continue + print(f"{rid} | {name} | {plain} | {mask} | {bind or '未绑定'}") diff --git a/scripts/e2e-m3-flow.py b/scripts/e2e-m3-flow.py new file mode 100644 index 0000000..dad780d --- /dev/null +++ b/scripts/e2e-m3-flow.py @@ -0,0 +1,1122 @@ +#!/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(后端托管的**旧构建**)," + "不是 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()) diff --git a/scripts/verify-all.py b/scripts/verify-all.py index 8b6b5f5..9710999 100644 --- a/scripts/verify-all.py +++ b/scripts/verify-all.py @@ -24,11 +24,12 @@ ```bash python scripts/verify-all.py # 全部(跳过需要外部前置的,并说明) -python scripts/verify-all.py --with-e2e # 连 37 个脚本的接口断言一起跑(需要后端在跑) +python scripts/verify-all.py --with-e2e # 连 38 个脚本的接口断言一起跑(需要后端在跑) python scripts/verify-all.py --quick # 只跑不需要任何前置的五项 ``` -> 脚本数与断言总数会随需求变化(2026-10 需求收敛后是 **37 个脚本**,断言总数以本命令 +> 脚本数与断言总数会随需求变化(2026-10 需求收敛后是 **37 个脚本**,加上 +> `e2e-m3-flow.py` 后是 **38 个**;断言总数以本命令 > 实际输出为准 —— 别再往文档里抄一个会过期的固定数字)。 退出码:0 = 全过;1 = 有失败(**跳过不算失败**,但会明确列出来)。 @@ -130,7 +131,7 @@ def main(): results.append(("接口断言(E2E)", None, msg)) print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg)) elif not args.with_e2e: - msg = "**跳过**:要跑就加 `--with-e2e`(37 个脚本、约 10 分钟)" + msg = "**跳过**:要跑就加 `--with-e2e`(38 个脚本、约 10 分钟)" results.append(("接口断言(E2E)", None, msg)) print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg)) else: