Files
danyang-match/docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md
WorkBuddy ae1bd2b734 test(phase23): KEEP-VERIFY — remove 44 pass shells + fake INV tests, add shell guard
Phase 23 "可信系统测试重建 (REFACTOR)" re-audit. STATUS claimed "10 E2E
files / ~80+ cases" and "INV-011~020 ✅"; measurement showed the opposite.

D-23-A (Critical): V11InvariantsTest's 14 cases asserted only Constants
strings. INV-016 "Banner target types" actually asserted ARTICLE_STATUS_*;
INV-017 "home 8 entries" declared local arrays then asserted
firstRow.length == 4 (tautology, never called the home service);
INV-018/019/020 used appointment/article/account status constants as
stand-ins for permission and data-scope checks. Rewritten to 12 cases:
4 real service-behavior guards (INV-013 via real CounselingService.getDetail
— Matchmaker sees null userRequestSummary, with an assigned-Counselor
counter-proof so global sanitization cannot fake a pass; INV-016 reads the
production ALLOWED_TARGET_TYPES whitelist and asserts no external/http/url/
web entry; INV-017 calls real getHome and pins all 8 codes + order;
INV-019 asserts content_editor reads null), plus assertBehaviorTestExists
turning "行为验证见 XxxTest" pointers into executable Class.forName contracts.

D-23-B (High): 44 `pass` shells across 6 files in tests/e2e (4 files were
100% shells). Gate forbids skip/pass shells. Rewritten in place as honest
placeholders with the full scenario lists preserved.

D-23-C (High, residual): the pytest E2E suite is not executable at all —
no conftest.py anywhere, ~15 fixtures (api_client/verified_user/
banner_factory/...) do not exist. Only run_v11_tests.py really runs
(16 cases, needs a live stack + E2E_PASSWORD).

D-23-D (High, residual, NOT fixed): CI does not chain E2E as the plan
requires. Needs an infrastructure decision on how CI provisions a live
stack; not implemented rather than written unverified.

D-23-E (Medium): the CI migration job claimed to "验证 migration SQL 语法"
but only checked file non-emptiness, while the real syntax/baseline checks
live in MigrationValidationTest (already run by the backend job). Replaced
with a real sequence check (naming / non-empty / unique versions / no
gaps); verified by extracting the exact inline script and running it.

New gate: scripts/check-no-empty-tests.py (AST scan, blocks pass-only and
unconditionally skipped tests) + a CI test-shell-guard job. Verified both
ways: 0 findings on the real tree, 2 precise findings on injected shells.

Negative validation: 3 injected production defects (external_url in the
banner whitelist, swapped home entry order, isMatchmaker returning the full
record) turned exactly inv016/inv017/inv013 red, 9 others green, zero
unrelated failures; md5sum -c confirmed byte-identical restore.

Full regression: 655 tests / 652 passed / 0 failures / 0 errors / 3 skipped
(baseline 657; -2 because 14 vacuous cases were merged into 12). The 3
skips carry explicit reasons (2x @EnabledIfEnvironmentVariable(CI=true),
1x @Disabled "需要完整数据库连接") — none is a shell.

Phase 23 is PARTIALLY closed: fake coverage is gone, but the UAT gate
requires the end-to-end chain to actually execute, so "失败为零可进入 UAT"
is NOT claimed.
2026-09-18 11:49:18 +08:00

165 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 23 — 可信系统测试重建(REFACTOR)KEEP-VERIFY 复验报告
- **日期**:2026-09-18
- **执行方式**:KEEP-VERIFY(历史 ✅ 不可信,逐条核对运行时路径与真实约束)
- **结果**:**部分关闭(PARTIAL CLOSED)** —— 核心假覆盖已清除并补真实守卫;CI 串联 E2E 一项因需基础设施决策,列为遗留
- **回归**:**655 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(起点 657)
---
## 一、Gate 要求的测试报告
计划 Phase 23 Gate:「**不允许 skip/pass 空壳;报告列出总数、通过、失败、跳过及原因,失败为零才进入 UAT。**」
| 项 | 数值 |
|---|---|
| 总数(后端 `mvn -o test`) | **655** |
| 通过 | **652** |
| 失败 | **0** |
| 错误 | **0** |
| 跳过 | **3** |
### 跳过明细及原因(均有显式声明,非空壳)
| 测试类 | 跳过数 | 原因(代码内原文) |
|---|---|---|
| `concurrent.ConcurrentTests` | 2 | `@EnabledIfEnvironmentVariable(named = "CI", matches = "true", disabledReason = "本地并发测试跳过")` —— 仅在 CI 环境执行的并发竞态用例 |
| `core.CoreE2ETest` | 1 | `@Disabled("需要完整数据库连接,仅验证 Spring Context 可加载")` |
> 数量说明:起点 657 → 655,净 **−2**。
> 原因:`V11InvariantsTest` 原有 **14 个**空转用例被重写合并为 **12 个**(INV-014 状态机 / INV-014 意向 / INV-015 三合一)。
> 合并后 10 个不变量(INV-011~020)**全部仍有对应用例**,其中 **4 个为真实服务行为验证**。
---
## 二、真实缺陷清单
### D-23-A(Critical):`V11InvariantsTest` 全部 14 个用例只断言常量字符串,且不变量归属错误
计划 Gate 明文禁止:「INV-001~020 必须测试真实服务行为,不能只断言常量字符串存在」。
实测原文件问题:
| 用例 | 声称验证 | 实际断言 | 判定 |
|---|---|---|---|
| INV-016「Banner 目标类型验证」 | Banner `target_type` 白名单 | `ARTICLE_STATUS_PUBLISHED/DRAFT`(**文章状态**) | ❌ 完全跑错域 |
| INV-017「首页 8 核心入口编码固定」 | 首页入口顺序 | 声明局部数组后 `assertEquals(4, firstRow.length)` | ❌ 对自己刚写的字面量做断言(重言式),**从未调用首页服务** |
| INV-018「服务站数据范围」 | 服务站数据范围 | `APPOINTMENT_STATUS_*`(预约状态) | ❌ 无权限/范围判定 |
| INV-019「内容编辑员零用户数据」 | 零用户数据权限 | `ARTICLE_STATUS_*`(文章状态) | ❌ 无权限判定 |
| INV-020「组织关系不授权」 | 组织关系权限 | `ACCOUNT_STATUS_*`(账号状态) | ❌ 无权限判定 |
| 其余 9 个 | 各自不变量 | 仅 `assertEquals(常量, 字面量)` / `assertNotNull(常量)` | ❌ 不触碰任何服务行为 |
**修复**:重写为 12 个用例 ——
- **真实服务行为(4 个)**
- `inv013MatchmakerCannotReadCounselingBody`:真实 `CounselingService.getDetail`;断言 Matchmaker 读到的 `userRequestSummary` 为 `null`,**并以 assigned Counselor 能读到正文作反证**(防止「全局脱敏」造成的假阳性),并断言无关角色返回 `null`。
- `inv016BannerTargetTypeWhitelistExcludesExternal`:反射读取生产 `HomeBannerOperatorController.ALLOWED_TARGET_TYPES`,断言含 8 个站内类型且**不含任何 external/http/url/web 外链类型**。
- `inv017QuickEntriesFixedOrder`:真实 `HomeApplicationService.getHome(userId)`,断言 8 个入口编码与顺序完全等于 AGENTS.md 规则 15 的顺序。
- `inv019ContentEditorHasNoUserDataPermission`:真实 `getDetail` 断言 `content_editor` 读到 `null`。
- **诚实常量口径(8 个)**:沿用 `CoreInvariantsTest` 既有模式,标题标注「常量口径 … 行为验证见 XxxTest」,并新增 `assertBehaviorTestExists(fqcn)` —— **用 `Class.forName` 断言被引用的真实测试类确实存在**,把文档指针变成可执行契约,防指针腐烂。
### D-23-B(High):`tests/e2e/` 下 44 个 `pass` 空壳(Gate 明令禁止)
| 文件 | 空壳数 / 总测试数 | 说明 |
|---|---|---|
| `local_eligibility_test.py` | 11 / 11 | 100% 空壳 |
| `counseling_permission_test.py` | 10 / 10 | 100% 空壳 |
| `activity_mutual_consent_test.py` | 8 / 8 | 100% 空壳 |
| `service_appointment_test.py` | 7 / 7 | 100% 空壳 |
| `home_v11_test.py` | 7 / 9 | 混合(另 2 个亦不可执行,见 D-23-C) |
| `counseling_v11_test.py` | 1 / — | 注释自述「在当前测试框架下简化验证」,函数体 `pass` |
**修复**:6 个文件全部改写为**不含任何测试函数的诚实占位**(pytest 收集 0 用例),并在文件头完整保留原场景清单(需求不丢失)+ 说明「后端已有哪个真实测试覆盖该不变量」,避免把「删除空壳」误读为「删除需求」。
### D-23-C(High):pytest E2E 套件整体不可执行
- 全仓库**无 `conftest.py`**,`api_client` / `verified_user` / `banner_factory` / `counselor_client` / `user_a` 等约 15 个 fixture **根本不存在** → 依赖它们的用例连收集都会报错。
- 唯一真实可执行的 E2E 是 `tests/e2e/run_v11_tests.py`(16 个用例,真实 HTTP 打 `localhost:8085`,需活栈 + `E2E_PASSWORD`)。
- 因此 STATUS 宣称的「10 个 E2E 文件、~80+ 测试用例」**从未产生过一次真实执行结果**。
### D-23-D(High,遗留未修):CI 未串联 E2E
计划要求「CI 串联迁移、backend test、admin type-check/build、miniapp type-check 和 **E2E**,任何失败阻断发布」。
实测 `.github/workflows/ci.yml` 只有 backend / admin-web / miniapp / migration 四个 job,**完全没有 E2E**。
**未在本轮修复**,原因:E2E 需要可用的活栈(后端 + PostgreSQL + 种子数据 + `E2E_PASSWORD`),在 CI 中如何供给属基础设施决策,且本地无法验证其正确性 —— 按「不静默扩权 / 不写无法验证的测试」纪律,列为遗留,交由决策后再实施。
本轮已改为接入**可验证**的 `test-shell-guard` job(见下)。
### D-23-E(Medium):CI 的 migration job 是名不副实的假校验
原 job 注释写「验证 migration SQL 语法(通过检查文件是否存在且非空)」,实际代码只 `if [ ! -s "$f" ]` 判非空 —— **没有任何语法校验**。
而真正的语法 / 命名 / 基线表校验在后端测试 `MigrationValidationTest` 中,随 backend job 的 `mvn verify` 已运行。
**修复**:改为该测试未覆盖的**序列校验**(命名合规 / 非空 / 版本号唯一 / 无缺号),并如实改写注释说明职责边界。
---
## 三、本轮新增的可验证产物
| 产物 | 作用 | 验证方式 |
|---|---|---|
| `backend/.../invariants/V11InvariantsTest.java`(重写) | INV-011~020 真实行为 + 指针契约 | 12 用例全绿;负向注入 3 处 → 精准 3 红 |
| `scripts/check-no-empty-tests.py` | AST 扫描,机械阻断 `pass` 空壳与无条件 `skip` | 真实目录 0 处;注入后精准报 2 处并 EXIT=1 |
| `.github/workflows/ci.yml` | 新增 `test-shell-guard` job;`migration` job 改为真实序列校验 | YAML 解析通过(5 job);内联校验脚本抽取实跑通过 |
---
## 四、验证证据
### 1. 负向验证(证明新守卫非假绿)
同时注入 3 处生产缺陷:
| 注入点 | 注入内容 | 预期变红用例 | 实际 |
|---|---|---|---|
| `HomeBannerOperatorController.ALLOWED_TARGET_TYPES` | 增加 `"external_url"` | `inv016` | ✅ 变红:`发现: [external_url]` |
| `HomeApplicationService.buildQuickEntries` | 交换 `classroom` / `premarital` 顺序 | `inv017` | ✅ 变红:实得 `[..., premarital, classroom, ...]` |
| `CounselingService.getDetail` 的 `isMatchmaker` 分支 | 改为返回全文 `attachFollowups(full)` | `inv013` | ✅ 变红:`expected: <null> but was: <不愿被红娘看到的求助正文>` |
结果:**12 用例中恰好 3 个变红,其余 9 个通过,零无关失败**;`md5sum -c` 确认 3 份生产文件还原一致。
### 2. 空壳守卫双向验证
- 扫描真实 `tests/`(14 个 py 文件):`OK: 未发现 skip/pass 空壳` EXIT=0
- 注入 `docstring + pass` 与 `@pytest.mark.skip` 各一:精准报出 2 处并 EXIT=1
### 3. CI 校验
- `ci.yml` YAML 解析通过,job 为 `backend / admin-web / miniapp / migration / test-shell-guard`
- 从 `ci.yml` 中**原样抽取**内联迁移校验脚本并执行:`OK: 43 个迁移文件,版本 1~43 连续且唯一`
### 4. 全量回归
`655 tests, Failures: 0, Errors: 0, Skipped: 3` — **BUILD SUCCESS**
---
## 五、遗留(未静默扩权)
1. **E2E 未真正进入 CI**(D-23-D):需先决定 CI 中活栈的供给方式(docker-compose / service container / 种子数据与 `E2E_PASSWORD` 注入)。
2. **pytest fixture 体系缺失**(D-23-C):6 个 E2E 文件现为诚实占位;若要恢复执行,需先建 `conftest.py` 与 fixture 工厂。
3. **INV-011 缺少显式守卫**:`LocalEligibilityService` 全部方法以 `userId` 自限定、零 role 分支,佐证无独立服务端读取入口(仅经受权限控制的 `ReviewController` 审核队列),**结构性满足**;但无测试固化,列为待补。
4. **E2E 场景待真实实现**:场景清单已完整保留在各占位文件头部(本地资格 11 项、互选 8 项、权限 10 项、预约 7 项、首页 11 项)。
---
## 六、环境事故记录(本机特有,务必注意)
在 `tests/e2e/` 下**删除任意一个文件**,会触发本机常驻监听进程**级联清空整个 `tests/` 目录**(不止 `tests/e2e`,`tests/` 根及其子目录一并消失)。
AGENTS.md 已记载该进程会清理 `refs/heads/` 下嵌套目录;本次实测表明**其作用范围不止于此**。
- 已发生 2 次,均通过 `git reset -q HEAD tests/ && git checkout -- tests/` 完整恢复(21 个文件全部还原,`git status --porcelain tests/` 为 0)。
- 结论:**本仓库禁止用 `rm` / `git rm` 删除 `tests/` 下文件**;需要移除内容时改用原地重写(Write/Edit)。本轮最终即采用该方式。
---
## 七、结论
Phase 23 的核心价值不在于新增了多少用例,而在于**清除假覆盖**:
- STATUS 曾宣称「10 个 E2E 文件 ~80+ 用例」「INV-011~020 ✅」;
- 实测:44 个 `pass` 空壳 + 14 个常量字符串断言 + 整个 pytest 套件不可执行 + CI 无 E2E + 一个假迁移校验;
- 现:假覆盖归零,INV-013/016/017/019 有经负向验证的真实行为守卫,并新增机械化的空壳阻断闸门。
**建议**:本 Phase 判定为**部分关闭**;在遗留项 1(E2E 进入 CI)决策完成前,**不宜据此宣布「失败为零可进入 UAT」** —— 因为 UAT 前的 Gate 要求的是端到端链路被真实执行,而目前 E2E 一环仍缺。