Files
group_xinghuo_jinrong/docs/superpowers/plans/2026-09-13-e2e-coverage-gap.md
zhanghongyu_0626 f856ab4aa6 fix(tests): Resolve pytest session binding issues and update test cases
- Updated test files to import `AgentSessionLocal` from `advisor_db` instead of directly, preventing session binding to the real database during tests.
- Fixed 6 test cases to use the new login token utility, ensuring consistency across authentication methods.
- Adjusted customer risk codes in `test_convert_confirm.py` to reflect changes in customer classification (C3 to C4).
- Verified that changes resulted in zero database pollution during test runs, maintaining integrity of the testing environment.
- Documented findings and updates in the relevant test logs and memory files, ensuring clarity on the current state of tests and defects.
2026-09-13 23:21:34 +08:00

465 lines
40 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.
# 端到端覆盖缺口补测轮 · 三条线并跑(2026-09-13)
> 方法:superpowers 的 verification-before-completion(先取证再下结论)· systematic-debugging(先定根因再提修法)· dispatching-parallel-agents(独立域并行)· writing-plans(本计划)。
> 执行时把本计划归档副本写入 `docs/superpowers/plans/2026-09-13-e2e-coverage-gap.md`。
> **📌 修复轮跟进(2026-09-13 晚):** 本计划原口径「缺陷只报告不修」,用户随后拍板「开始修吧」⇒ 对可修项**开修复轮**,已落地:**F-β 选 A 已执行**(`test_convert_confirm.py` C3→C4,四文件 77/0)· **F-ε 已修**(10 个 `test_sprint*.py` 模块属性访问 + skip 写真库 subprocess,零污染)· **6 个 sprint2 陈旧登录契约已迁移** · **P0 mock 登录门禁「本轮不动,只记录」**。详见 `docs/memory/tests/2026-09-13-crosscut-e2e/`。
## Context
**为什么做这一轮:** 上一轮(`TEST-2026-09-13-CT-001`)刚把**客户线交易**做成全量端到端——真浏览器 + 真 HTTP + 真库对账,Part A `135 PASS / 0 FAIL / 1 SKIP / 25 INFO`(退出码 0)、Part B `41 PASS / 0 FAIL / 0 SKIP / 7 INFO`(退出码 0)。
本轮起因是一个问题:**「还有哪块没有端到端跑过?」**
盘点已完成(结论见下节),答案是:**三条业务线尚未闭环**(问数线最弱——12 条路由零 HTTP E2E)、**前端大量浅覆盖**、以及**一批要收进仓库的脚本自身是坏的**(恒真断言 / 路由写错 / 选择器过期)。本轮按拍板口径**直接开跑补齐**,缺陷**只报告不修**,修复另开轮次。
**预期产出:** 四个可复跑的独立证据包 + 逐条缺陷清单 + 一份测试基建修复台账(与业务缺陷分开记账)。
---
## 一、盘点结论(本轮的事实基础)
> 以下全部为**实测**,非推断。带 ⚠️ 的是我复核后**修正**过 Explore agent 结论的地方。
### 1.1 后端:66 路径 / 71 个 path+method 处理器
挂载点 `app/main.py:103-125`(23 个 router)+ `:128`/`:172`(app 级)。
**零 E2E 覆盖(★ = 连单测都没有):**
| 路由 | 证据 |
|---|---|
| `POST /api/customers/{id}/threshold-check` ★ | 前端按钮 `web/src/pages/customer/CustomerHoldingsPage.tsx:21-27`;全仓 e2e grep 零命中;`tests/test_main.py:104` 仅路径字符串断言 |
| `GET /api/analyst/ops/metrics` ★ | 前端无任何调用方;脚本零命中 |
| `POST /api/analyst/interpret` | 前端有 `web/src/api/analyst.ts:84` 但**无页面调用**(`AnalystQueryPage.tsx:105` 只传 `interpret:false`) |
| `POST /api/analyst/escalate` | 仅 UI 按钮 `AnalystQueryPage.tsx:361`,无脚本点过 |
| `GET /api/analyst/query/{trace_id}/sample` | 仅 UI 按钮 `AnalystQueryPage.tsx:442` |
| `POST /api/analyst/analyze` | 仅 UI 弹窗按钮 `AnalystQueryPage.tsx:388` |
| `POST /api/analyst/assets` | 仅 UI 表单 `AnalystAssetsPage.tsx:15-23`;`04-analyst-market-advisor.mjs:40` 只开页未提交 |
| `POST /api/analyst/assets/{kind}/{asset_id}/publish` | 零命中 |
| `POST /api/analyst/dict/ambiguity-check` | 零命中 |
| `GET /api/analyst/dashboard` · `GET /api/analyst/template-prompts` | 仅"开页时请求发出",**无任何断言** |
**仅单测覆盖:** `GET /api/products/{product_id}`(前端无人调用)· `GET /api/staff/me`(前端零调用)· `POST /api/chat/sessions/close-all`(按钮 `ChatPanel.tsx:72` 无人点)· `POST /api/chat/sessions/{id}/close`(弹窗 `ChatPanel.tsx:176` 无人点)。
**已充分覆盖:** 风控线 4 条路由全覆盖;顾问线 `/api/advisor-agent/*` 写操作由 `advisor_e2e_smoke.py` 全覆盖;客户线交易全套由 CT-001 覆盖。
**顾问线但前端无调用方**(仅 smoke 一条命脉):`POST /advisor-agent/guard/check` · `POST /advisor-agent/copy/track` · `GET .../dashboard/ping` · `GET .../allocation/ping`。
**非 HTTP 入口未测:** `scripts/cron/escalation_scan.py` · `scripts/cron/agent_behavior_scan.py`(服务层有单测,CLI 无)· `scripts/demo/push_threshold_alerts.py` · `scripts/demo/subscribe_alerts.py`。
**不存在:** MCP、WebSocket、工单实体、`app/api/admin.py` 与 `app/api/knowledge.py`(各仅 1 行 docstring,零路由)。
**已失效脚本(会 404,不能当覆盖用):** `scripts/demo/run_demo_smoke.ps1`(仍在打已不存在的 `/api/v1/*`)· `scripts/eval/evaluate_agent.py:49,59`(同)。
### 1.2 前端:30 路由 / 27 组件
**从未访问:** `/register` · `/app/home` · `/app`。
**浅覆盖:** `AnalystAssetsPage`(表单从未提交)· `AnalystQueryPage`(**数字从未对账**)· `AdvisorClientsDashboard`(**AUM 从未对账**)· `AdvisorCustomersPage`(代客下单链接从未点)· 所有聊天会话侧栏 · 面包屑 · 登出 · 页面级响应式。
> ⚠️ **两个数字待 Phase 0 复核,本计划不当作事实**:①深/浅的**具体分档数**(Explore agent 报 12 深/13 浅/3 未访问,但 **12+13+3=28 ≠ 30**,分档口径本身对不上,须以 `web/src/routes/menus.tsx` 实际路由表重新分档);②"27 组件"与 30 路由的关系未核。**`/register` 零覆盖这一条是独立的、可信的**(有独立证据:`web/src/pages/login/RegisterPage.tsx` 新增且无任何脚本访问),不依赖上述分档。
### 1.3 ⚠️ 我复核出的新发现(Explore agent 报错了,我重新核过)
**agent 原报:**「`app/graph/agent_graph.py:158` 与 `app/service/agent_tools.py` 7 处函数签名不匹配,wiring 就 500」。
**复核结果:这条**描述错误**,但底下的问题**真实存在且更严重**。**
- 路径就错了:两个文件都在 `app/service/`,不存在 `app/graph/`。
- `build_advisor_tools` 的**签名与调用点完全一致**(`agent_graph.py:163-168` ↔ `agent_tools.py:178-183`),无签名不匹配。
- 那"7 处"行号(`70,113,118,123,128,150,170`)全是**函数体内的方法调用**,不是函数定义。
- **真正的问题在更下一层**——那些工具调用的 service 方法**有三个不存在**。我逐个核对真实 service 源码:
| # | 工具 | 工具内调用 | 真实 API | 判定 |
|---|---|---|---|---|
| 1 | `create_query_holdings_tool` | `core_repo.get_customer_l0` · `core_repo.list_holdings` | `core_ro.py:63` ✅ · `core_ro.py:282` ✅ | **OK** |
| 2 | `create_query_fund_nav_tool` | `core_repo.list_product_nav_history(product_id, limit=)` | `core_ro.py:889` ✅ 签名一致 | **OK** |
| 3 | `create_manage_kyc_tool` | `kyc_service.chat(session_id=, user_input=)` | 真名 **`chat_session`**(`kyc_session_service.py:140`) | ❌ `AttributeError` |
| 3' | 同上 | `kyc_service.get_session(session_id=)` | `:133` 需 **keyword-only `auth: AdvisorAuthContext`** | ❌ `TypeError` |
| 4 | `create_search_templates_tool` | `template_service.search(query=, limit=)` | `TemplateService` **无 `search`**(只有 `try_render`/`list_published_prompts`/`reload`);真搜索在 `script_template_service.py` | ❌ `AttributeError` |
| 5 | `create_compliance_check_tool` | `compliance_service.check(text=, scene=)` | 真名 **`check_text(payload: ComplianceCheckRequest, *, trace_id, advisor_id)`**(`compliance_check_service.py:38`)—— 连调用形态都不同 | ❌ `TypeError` |
**即 `build_advisor_tools` 5 个工具里 3 个(工具 3/4/5)接错。**
**为什么一直没被发现:** `build_advisor_graph` 是**休眠代码**——全仓唯一调用方是 `tests/test_step11_agent_graph.py:113`,而它在 `:114-117` 给四个 service **全注入 `Mock()`**。`Mock()` 对任意属性访问都返回可调用对象,所以 `kyc_service.chat(...)`、`template_service.search(...)`、`compliance_service.check(...)` **在测试里全部"通过"**。
> 这正是盘点里"19 个 `test_sprint*.py` 伪造全部 Core 数据源 → 结构上不可能发现接缝不匹配"的**具体实例**。本轮 AD8 组要把它从"推断"变成"取证"。
### 1.4 测试资产腐化(已定位行号)
| 文件(**均在仓库外** `C:/Users/Windows/e2e-jinrong/`,见 1.5) | 问题 |
|---|---|
| `03-risk.mjs:23` | `ok(..., true, ...)` **恒真断言** —— 永远 PASS,不构成证据 |
| `06-chat.mjs:12` | 构造了不存在的路由 `/app/analyst/chat`(正确为 `/app/analytics/chat`) |
| `08-edge.mjs:15,41` | 选择器过期,预计在 `:42` 抛异常 |
| `11-cross-role.mjs:104` | `C1.6` 前提已过期 |
| `run_demo_smoke.ps1` | 已失效(打 `/api/v1/*`) |
| `docs/演示文档/功能演示版验收标准.md` | 多条质量门槛陈旧:`/api/v1/ping` 路由已不存在;`alembic heads` 写的是旧版本号;`run_demo_smoke.ps1` 连续执行通过这条已无法成立 |
| `docs/答辩/DEMO-功能测试流程.md` | **`D2`/`D3` 一号两义**(第 64-65 行是用例编号、第 79/89 行是缺陷编号,详见 4.2 AD7)。**答辩稿里的编号歧义**,须记入横切包 XC |
### 1.5 ⚠️ 浏览器脚本的真实分布(修正)
**`scripts/e2e/` 目前只有两个文件**:`12-customer-trade.mjs`(37293 B)+ `harness.mjs`(9301 B)+ `shots/`。
**`01`–`11` 全部在仓库外** `C:/Users/Windows/e2e-jinrong/`,另有 `FINDINGS.md`、`results-*.json`、一批 `dbg*.mjs`。
**两个 `harness.mjs` 已经分叉**,不是同一份:
| | 仓库内 `scripts/e2e/harness.mjs` | 仓库外 `e2e-jinrong/harness.mjs` |
|---|---|---|
| 大小 | 9301 B | 6049 B |
| 来源 | 上一轮 CT-001 新建 | 09-10 那轮,`harness.mjs.bak-pre-20260913` 是改前备份 |
→ **09/10/11 收回仓库时必须改指向**:把 `require` 从仓库外 harness 改为 `scripts/e2e/harness.mjs`,并逐一核对它们用到的原语(`login`/`clearAuth`/`waitRows`/`issues`)在仓库内版本里是否存在、语义是否一致。**这是收回动作的真实成本,不要当成 `cp`。**
**可得基线证据:** `e2e-jinrong/results-09-advisor.json`(18:41)· `results-10-risk.json`(18:42)· `results-11-cross-role.json`(18:38)—— 是上一轮 Part C 跑出的产物,可作本轮对照。
### 1.6 工程基建缺口
无 CI · 无覆盖率工具 · 无 Makefile / 统一测试入口。`tests/` 下 19 个 `test_sprint*.py` 共 65 条失败。→ **只记录**。
---
## 二 · 补 · 复核修正(设计 agent 提出、我已逐条实测确认,**以本节为准**)
| # | 我原先写的 | 实测 | 修正 |
|---|---|---|---|
| **C-1** | `customer_trade_e2e_smoke.py`「含两个头构造器 + SSE 逐帧 + **`--snapshot/--restore`」 | **没有 `--snapshot/--restore`**。`scripts/dev/customer_trade_e2e_smoke.py:2175-2178` 只有 `--base-url / --report / --only / --timeout`。快照/还原在上一轮是**外部 shell 步骤**,从来不是脚本机制 | 新脚本**不继承不存在的开关**;快照/还原继续用外部 `mysqldump`/`mysql`(与上轮口径一致)。**本轮也不自造这组开关** |
| **C-2** | 三支 smoke「同构」,INFO 纪律通用 | `risk_e2e_smoke.py:60` 只有 `PASS, FAIL, SKIP = "PASS","FAIL","SKIP"`——**没有 INFO**;`advisor_e2e_smoke.py` 同 | 「INFO 不是 PASS」是 `customer_trade_e2e_smoke.py` **独有**的。新脚本照抄 **customer_trade** 的原语,**不要照抄 risk** |
| **C-3** | F-06 要「改造脚本把截图路径参数化」 | `scripts/e2e/harness.mjs:39` 已是 `export const SHOT_DIR = process.env.E2E_SHOT_DIR \|\| path.join(__dirname,'shots')` | **F-06 降级为「用 env 变量」**,零代码改动。每支脚本跑时给不同 `E2E_SHOT_DIR` 即可 |
| **C-4** | 「扩充 `advisor_e2e_smoke.py` / `risk_e2e_smoke.py` 新增分组」 | 这两支是 Part C 的**回归基线**,其价值是「退出码仍为 0」。而 `AD8` 是**预期 FAIL 的取证组** → 加进去会让退出码变 1,**与真回归不可区分** | **改为新建独立脚本**(见下)。两支既有脚本**一个字都不动** |
**脚本落点修正(取代原「扩充」方案):**
| 脚本 | 覆盖 |
|---|---|
| `scripts/dev/analyst_e2e_smoke.py`(新) | AN1–AN12,问数线 12 个 handler |
| `scripts/dev/gap_routes_e2e_smoke.py`(新) | GX1–GX5,不属任何单线的孤立路由(★`threshold-check`、`staff/me`、`products/{id}`、chat 会话侧栏)+ 原 XC1/XC4 |
| `scripts/dev/advisor_gap_e2e_smoke.py`(新) | AD7–AD10,含 **AD8 接缝取证(预期 FAIL,单独成脚本正是为了不污染基线)** |
| `scripts/dev/risk_gap_e2e_smoke.py`(新) | RK7–RK8 |
| ~~扩充 advisor_e2e_smoke.py / risk_e2e_smoke.py~~ | **取消**——原样保留作 Part C 基线 |
**关于 `AD8` 的一处不同意见(记录在案):** 设计 agent 实测 `build_advisor_graph(...Mock())` → `graph ok: True True`,据此判定"无签名不匹配、只是孤儿代码、不是缺陷"。**这与我 1.3 的结论不矛盾且不充分**——它用 `Mock()` 验证,而 `Mock()` 正是遮蔽该问题的原因(对任意属性访问都返回可调用对象)。我已**读真实 service 源码**确认 3 处绑定错误(`chat`/`search`/`check` 不存在;`get_session` 缺 keyword-only `auth`)。**结论**:`agent_graph.py` 整支确为**孤儿代码**(全仓仅 `tests/test_step11_agent_graph.py` 引用)→ 严重度**降级**为"潜在缺陷 + mock 遮蔽",但 AD8 取证**保留**,因为"孤儿代码里 3/5 工具接错 + 测试给出假绿"本身就是必须记录的事实。报告里按**潜在**缺陷列,不列为 P0/P1。
---
## 二、已拍板口径(七项,不可动摇)
| # | 项 | 选择 |
|---|---|---|
| 1 | 本轮产出边界 | **盘点 + 直接开跑补齐** |
| 2 | 发现缺陷的处置 | **只报告不修**,修复另开轮次 |
| 3 | 仓库外 `09/10/11` | **本轮收回仓库** |
| 4 | 数据副作用 | **`mysqldump` 快照 + 跑后还原** |
| 5 | 腐化测试资产的处置 | **修,但单独记账**(测试基建台账,与业务缺陷分开) |
| 6 | 覆盖范围 | **三条线全补** |
| 7 | 报告包粒度 | **一线一包** |
---
## 二 · 补2 · Phase 0 实测结果(2026-09-13 20:12~20:20,**已落盘,以此为基线**)
| 门槛 | 文档 / 上一轮声称 | **本轮实测** | 判定 |
|---|---|---|---|
| `/api/ready` | — | `ok=true degraded=false redis=true`,且新增 `chat_close_all/chat_sessions/analyst_chat/products_nav_history` 四项检查 | ✅ |
| openapi | 66 路径 | **66 路径 / 71 handler** | ✅ 与计划一致 |
| Vite 5173 | — | HTTP 200 | ✅ |
| `npm run build` | 退出码 0 | **退出码 0**(47.18s) | ✅ |
| `npm run test` | 27 passed | **27 passed (8 files)** | ✅ |
| `pytest -q --ignore-glob=test_sprint*.py` | **896 passed** | ❌ **1232 passed / 102 failed / 21 errors / 9 skipped** | ⚠️ **发现 F-α** |
| 交易四文件单测 | **76 passed** | ❌ **49 passed / 28 failed** | ⚠️ **发现 F-β** |
| `core` 5 表首值 | `95/59/73/19/0` | **`95/59/74/19/0`**(`core_share_lot`=74) | ⚠️ **发现 F-γ** |
| `core_trade` 非测试前缀行 | — | 95 行中 **0 行**带测试前缀 | ✅ 还原安全 |
**快照:** `/d/tmp/e2e-gap/snapshot.sql`(45658 B,2026-09-13 20:13)—— 覆盖 `core_trade/core_holding/core_share_lot/core_convert_request/core_convert_lot_detail`。
### Phase 0 三项新发现(均属"测试基建/文档",非业务缺陷)
> ⚠️ **`/tmp` 两义性再修正**:Git Bash 的 `/tmp` ≠ Python 的 `/tmp`。实测 **Python 的 `/tmp` 解析为 `<当前盘符>:\tmp`**(本轮 cwd 在 D 盘 → `D:\tmp`),**不是**上一轮记录的 `C:\tmp`。本轮统一用 **`/d/tmp/e2e-gap`**(bash)/ **`D:/tmp/e2e-gap`**(Python),彻底绕开。
**F-α — 验收文档的门槛数字与实测严重不符。**
`docs/答辩/DEMO-功能测试流程.md` A3 称 `896 passed`;实测 **1232 passed / 102 failed / 21 errors**。
根因已定位(非 DB 连通性问题):测试跑在 **SQLite 内存库 + `conftest.py` 自建假 DDL** 上,而假 DDL 与 fixture 自身的插入数据**不一致**——例:`tests/test_platform_api.py` 全部 21 条 error 是
`sqlalchemy.exc.OperationalError: table core_holding has no column named quantity`(fixture INSERT 了 `quantity`,假 DDL 未声明该列)。
→ **只报告**。本轮完成判据中的"896 不退步"**改为**"不退步于实测基线 **1232 passed / 102 failed / 21 errors**"。
**F-β — 上一轮的 P1 修复打断了 29 条单测,且上一轮报告"76 passed 不退步"已不成立。**(**本轮最高价值发现**)
证据链完整:
1. `git show 5c18164 -- app/service/convert/convert_service.py` 显示该 commit 把
`if suit.blocked:` → `if suit.blocked or simulate_self_service_blocked(suit):`(**就是上一轮的 P1 修复**)。
2. 该 commit 同步更新了 `tests/test_convert_accept.py`(43 行),**但漏改了 `tests/test_convert_confirm.py`**。
3. 后者 `tests/test_convert_confirm.py:42` 仍写着 `CUST = "CUST-CC" # C3 客户 → 转入 R4 = 放行`——**固化的是"R4 转入=放行(需揭示)"这条旧语义**。
4. 新语义下该路径**走入阻塞分支**;而 `convert_service.py:522-535` 的阻塞返回体**不含 `forced_full_transfer` 键** → `tests/test_convert_confirm.py:1013` 的 `accepted["forced_full_transfer"]` **`KeyError`** → 28 条失败。
5. 另有 `tests/test_convert_accept.py::test_accept_uk_idem_race_falls_back_to_idempotent` 1 条失败(疑与该 commit 的 409 幂等改动相关)。
→ **这正好实证了上一轮 `MEMORY.md` 里"⚠️ 复跑新发现(需拍板)"的第 ② 条**:「P1 修复可能收窄了业务能力」。**只报告不修**,但它是一条**明确的待拍板项**(业务上 R4 到底该"放行+揭示"还是"直接阻断")。
**F-γ — `core_share_lot` 首值为 74,不是上一轮记录的 73。**
上一轮 README 载明还原目标是 `95/59/73/19/0`;本轮跑前实测为 `95/59/74/19/0`(多 1 行)。
→ 不影响本轮(**以本轮快照为准**),但说明两轮之间基线发生过变化(疑为手工演示动作)。**还原目标 = 本轮快照的 `95/59/74/19/0`。**
---
## 三、交付物:四个包
用户定的是"一线一包"。三条业务线各一包;**横切件(跨角色 / 前端全局 / 腐化资产台账 / 基建缺口)单独成第四包**——理由:跨角色用例跨三条线、腐化资产是测试基建而非任何一条线的业务缺陷,塞进任一业务线包都会污染那个包的结论边界。
| 包 | 路径 | 日志编号 | 覆盖 |
|---|---|---|---|
| 问数线 | `docs/memory/tests/2026-09-13-analyst-e2e/` | `TEST-LOG-2026-09-13-AN-001.md` | 12 条路由全覆盖(本轮重点) |
| 理财师线 | `docs/memory/tests/2026-09-13-advisor-e2e/` | `TEST-LOG-2026-09-13-AD-001.md` | KYC 闭环 + 接缝取证 + 代客下单 |
| 风控线 | `docs/memory/tests/2026-09-13-risk-e2e/` | `TEST-LOG-2026-09-13-RK-001.md` | 浏览器深挖 + 恒真修复后复跑 |
| 横切 | `docs/memory/tests/2026-09-13-crosscut-e2e/` | `TEST-LOG-2026-09-13-XC-001.md` | 跨角色 / 前端全局 / 资产台账 / 基建缺口 |
每包结构照 `docs/memory/tests/_TEMPLATE.md`(8 节):`README.md`(结论一行 + 产物表 + 复现三连)+ `TEST-LOG-*.md` + `_raw/`。
**企业级范例:** `docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md`。
> **截图路径坑(必处理):** `_raw/shots/` 在脚本里是**固定常量路径**,重跑就地覆盖。上一轮因此丢了 v1.0 修复前截图,已记录为证据链缺口。本轮四个包各跑各的,**必须先把各脚本的截图输出路径参数化**(或在脚本内按包名拼路径),否则四个包会互相顶掉。
---
## 四、Part A 用例矩阵(真实 HTTP)
命名 `AN<n>-<mm>` / `AD<n>-<mm>` / `RK<n>-<mm>`。取证列:`HTTP`(状态码+响应字段)· `DB`(只读查库交叉印证)· `SSE`(逐帧)· `DOM`(浏览器)。
**所有写侧断言一律写成「前后差值」**(见第七节),矩阵不再重复标注。
### 4.1 问数线 AN —— 新建 `scripts/dev/analyst_e2e_smoke.py`(约 40 条)
骨架**照抄 `scripts/dev/customer_trade_e2e_smoke.py`**(最新最全),继承其 `preflight`/`Result`/`db_query`/退出码三级约定。
**AN1 前置与契约冻结(4)**
`/health` 可达 · `/api/ready` 通过(`checks.redis` 记录但不据此判前置失败)· **openapi 必需路由自检**:12 条 analyst 路由逐一存在 + **记录路径总数基线 66**(缺任一 → **退出码 2**)· 五角色登录签发 token(`customer`/`advisor`/`analyst`/`risk_officer`/`ops`,账号取自 `web/src/config/defenseAccounts.ts`)· `db_query` 加载基线写入 `ctx.state`(期望值**由库中事实推导,不写死**)
**AN2 鉴权与数据域(6)** —— 走 `app/api/analyst_auth_adapter.py:assert_analyst_query_access`
这是本项目**第三条鉴权路径**(前两条是 `get_auth_context` 与 `get_platform_auth_context`),五角色 → 五数据域映射:`customer→self` · `advisor→assigned` · `analyst→full` · `risk_officer→risk` · `ops→aggregate`。
`customer`(带 `customer_id`)→ `self` · `customer` 无 `customer_id` → **403 `AUTH_403_ROLE`** · `advisor`→`assigned` · `analyst`→`full` · `risk_officer`→`risk` · 无 token → **401**。
**跨域越权探针(本组关键)**:`customer` 身份问全量聚合问题(如"全部客户数")→ 应被 `sql_guard` 按 `self` 域拦下 → **记录实测,不预设通过**。
**AN3 chat 主链路(4)** —— `POST /api/analyst/chat`
单问返回 `status`/`SQL`/`rows`/`answer` 四段齐全 · **SQL 只读红线**:断言返回 SQL **不含** `INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE`(对应红线「数据分析:仅 SELECT」)· **不处置预警红线**:调 chat 前后 `risk_alert` 计数**零增量**(对应红线「不处置预警」)· 空问题/未知问题 → 记录实测
**AN4 `interpret`(3)** · **AN5 `analyze`(3)** · **AN6 `query/{trace_id}/sample`(3)** · **AN7 `escalate`(3)** —— 四条**零覆盖**路由,各含正常路径 + 鉴权负例 + 字段面枚举
**AN8 `template-prompts` + `dashboard`(4)** —— 补齐"仅开页无断言"的空白;`template-prompts` 断言按角色过滤(`list_published_prompts(roles)`)
**AN9 `assets` CRUD + `publish`(6)** —— 零覆盖;含 `POST /assets` → `GET /assets` → `POST /assets/{kind}/{asset_id}/publish` 全链 + 鉴权负例 + 未知 `kind` 负例
**AN10 `dict/ambiguity-check`(3)** —— 零覆盖,含命中/不命中两向
**AN11 `ops/metrics`(2)** —— 零覆盖,且**前端无任何调用方**(记录这一事实:接口无消费方)
**AN12 缓存与模板 Tag(3)** —— 对应 `docs/答辩/DEMO-功能测试流程.md` 的 D1/D2(模板 Tag / 缓存 Tag)
### 4.2 理财师线 AD —— **扩充** `scripts/dev/advisor_e2e_smoke.py` 新增分组(约 20 条)
> **边界:只新增分组,绝不动既有断言** —— 它是回归基线,改了会让 FAIL→PASS 的对照失效。
**AD7 KYC 全链路复现(6)** —— ①复现 `GET /api/advisor-agent/kyc/sessions/{id}` 的 500 ②`created_at` 缺字段 ③`complete` 状态机 ④未授权 `session_id` 越权探针 ⑤会话不存在 → 404 ⑥`ADV-001` 报告余下的 3 条 FAIL 逐条复跑,判定**真修复 / 仍开放 / 已失效**三态
> ⚠️ **编号陷阱(本轮新发现的一条文档缺陷)**:`docs/答辩/DEMO-功能测试流程.md` 里 **`D2`/`D3` 一号两义**——
> 第 64-65 行是**问数线用例编号**(`D2` = 缓存 Tag、`D3` = interpret),
> 第 79 行(E5 行)与第 89 行(F2 行)却是**缺陷编号**("D2 500 已知"、"已知 3 FAIL(D2/D3)")。
> **同一份答辩稿里同一个符号指两件事**。本轮报告**一律不使用裸 `D2`/`D3`**,改用「问数线 D2」或「ADV 缺陷-2」这类带前缀写法;并把该文档缺陷记入横切包 XC。
**AD8 接缝探针(5)—— 本轮最高价值的新增组,把 1.3 的发现从"读代码推断"变成"运行取证"**
用**真实 service 实例**(而非 `Mock()`)构造 `build_advisor_tools` 的 5 个工具,逐一 `invoke`:
| 用例 | 工具 | 期望(本轮取证目标) |
|---|---|---|
| AD8-01 | `query_customer_holdings` | 正常返回(对照组,证明探针方法本身有效) |
| AD8-02 | `query_fund_nav` | 正常返回(对照组) |
| AD8-03 | `manage_kyc_session(action="chat")` | 取证 `AttributeError: chat`(真名 `chat_session`) |
| AD8-04 | `manage_kyc_session(action="get")` | 取证 `TypeError`(缺 keyword-only `auth`) |
| AD8-05 | `search_templates` / `compliance_check` | 取证 `AttributeError: search` / `TypeError: check` |
**两个对照组是刻意的**:没有 AD8-01/02 就无法排除"探针写错了"这一解释。**预期本组多数 FAIL —— 这是取证,不是回归。**
**AD9 无前端调用方的端点(4)** —— `POST /advisor-agent/guard/check` · `POST /advisor-agent/copy/track` · `GET .../dashboard/ping` · `GET .../allocation/ping`:记录"接口能用但无消费方"这一事实
**AD10 代客下单链路(约 6)** —— 对应前端浅覆盖点 `AdvisorCustomersPage` 的"代客下单"链接从未被点;后端走 `risk_e2e_smoke.py` 的 R6 组口径作交叉印证
### 4.3 风控线 RK —— **扩充** `scripts/dev/risk_e2e_smoke.py` 新增分组(约 12 条)
> 风控线**后端 4 条路由已全覆盖**(`risk_e2e_smoke.py:245-457` 覆盖 alerts/suitability/aml + 处置状态机)。本线缺口**不在后端而在浏览器与资产**。
**RK7 后端补漏(4)** —— 只补盘点查出的空白:`/api/risk/suitability/check` 与 `POST /api/customers/{id}/threshold-check` 的语义交叉(后者是本项目★级零覆盖路由,且前端有按钮);`risk_alerts` 分页边界;处置状态机非法迁移负例
**RK8 浏览器复跑对照(8)** —— 修完 `03-risk.mjs:23` 恒真断言后,用 `10-risk-pages.mjs` 重跑并**逐条对照** `e2e-jinrong/results-10-risk.json`:**修复前 PASS 但修复后 FAIL 的条目,正是原来被恒真断言掩盖的真缺陷**。这是本线最有价值的一步。
### 4.4 横切 XC —— 新建 `scripts/dev/crosscut_e2e_smoke.py` + 浏览器(约 20 条)
**XC1 跨角色 F1–F3(6)** —— 收复 `11-cross-role.mjs` 并修 `:104` 的 `C1.6` 过期前提;对应 `DEMO-功能测试流程.md` F1/F2/F3
**XC2 前端从未访问路由(4)** —— `/register`(**注册链路零覆盖**,含表单校验与重复注册负例)· `/app/home` · `/app`
**XC3 前端浅覆盖补齐(5)** —— 登出 · 面包屑 · 聊天会话侧栏 · 切换会话 · 响应式(`07-responsive-a11y.mjs`)
**XC4 仅单测路由的 E2E 补齐(3)** —— `GET /api/products/{product_id}` · `GET /api/staff/me` · `POST /api/chat/sessions/close-all`(**注意二者前端无人调用,须用 HTTP 直连取证**)
**XC5 腐化资产修复台账(3)** —— 修复前后对照(见第六节);外加 `docs/答辩/DEMO-功能测试流程.md` 的 **`D2`/`D3` 编号歧义**与 `docs/演示文档/功能演示版验收标准.md` 的陈旧门槛(**文档类发现,同样只报告不修**)
---
## 五、Part B 浏览器 + Part C 回归
**Part B(真浏览器)** —— Playwright **刻意不装进 `web/package.json`**,从 `C:/Users/Windows/e2e-jinrong/node_modules` 经 `PW_HOME` + `createRequire` 解析,**不动 `web/package.json`**。
顺序:只读导航先行 → 再写侧 → 对话类(依赖真实 LLM,最脆)放最后。每包只跑本包相关脚本,**串行**——脚本内 `clearAuth` 切角色,**并发跑会互相顶掉 localStorage**。
**Part C(回归)** —— `09`→`10`→`11` 三支**收复并改指向仓库内 harness 后**复跑;再跑既有的四文件交易单测基线(**76 passed**,不得退步)与 `npm run build` 类型闸门。
**门槛复跑(对应 `DEMO-功能测试流程.md` A3/A4):** `pytest -q --ignore-glob=test_sprint*.py` · `npm run test`。
> ⚠️ **这两个数字不是实测值,是 `docs/答辩/DEMO-功能测试流程.md` 里写死的文档值**(A3 称 896 passed、A4 称 27 passed)。**Phase 0 必须重新实测并以此为准**——若实测与文档值不符,**以实测为准,并把文档值的偏差作为一条发现记录**(该验收文档本身已被查出门槛陈旧,见 1.4)。同理,交易四文件单测的 **76 passed** 是上一轮实跑值,Phase 0 同样需复测确认未退步。
---
## 六、腐化资产修复台账(测试基建,单独记账)
按口径 5 **修,但与业务缺陷分开记账**。修复只碰**测试脚本自身**,不碰 `app/**` 与 `web/src/**`。四个包的报告里,本台账单列一节,**不计入业务缺陷数**。
| # | 文件 | 修法 |
|---|---|---|
| F-01 | `03-risk.mjs:23` | 把 `ok(..., true, ...)` 恒真断言改为**对实测值的真实断言**(先记录当前实际返回值,再据此写期望) |
| F-02 | `06-chat.mjs:12` | `/app/analyst/chat` → `/app/analytics/chat`(以 `web/src/routes/menus.tsx` 为准) |
| F-03 | `08-edge.mjs:15,41` | 更新过期选择器;修完后确认不再在 `:42` 抛异常 |
| F-04 | `11-cross-role.mjs:104` | 更新 `C1.6` 的过期前提 |
| F-05 | `09/10/11` 的 harness 指向 | 从仓库外 harness 改为 `scripts/e2e/harness.mjs`,核对 `login`/`clearAuth`/`waitRows`/`issues` 原语(见 1.5 分叉事实) |
| F-06 | 截图路径参数化 | 各脚本 `shots` 固定常量 → 按包名拼路径(防四包互相覆盖) |
**只记录不修:** `run_demo_smoke.ps1`(已失效)· `scripts/eval/evaluate_agent.py:49,59` · `docs/演示文档/功能演示版验收标准.md` 的陈旧门槛 · 19 个 `test_sprint*.py` 的 65 条失败 · 无 CI / 无覆盖率 / 无统一入口。
→ 理由:这些要么是**文档/残留**、要么修复属**工程基建立项**,超出本轮"补测"范围。**在横切包 XC 的"剩余风险"节列明。**
---
## 七、数据副作用:三层防线
**问题本质** —— 同一批表被多条用例串行读写,**前一条污染后一条的前置**(顾问线 3 条假 FAIL 的成因)。
**第 1 层(必做,零成本):断言一律用「前后差值」,禁止绝对计数。**
`before` 在同一条用例内**紧邻**采集;批次类断言按**业务键**定位(`WHERE customer_id=? AND product_id=?`)而非行号/主键;幂等键一律 **`E2E-<线>-<runstamp>-<nn>` 每轮唯一**(固定键重跑会命中上一轮的单,语义退化成"非首次受理")。
→ 这层保证污染**不会造成假 PASS/假 FAIL**;快照只是回滚手段,**不是正确性前提**。
**第 2 层(本轮默认):跑前 `mysqldump` 快照,跑后还原。**
范围仅 **`core_trade` / `core_holding` / `core_share_lot` / `core_convert_request` / `core_convert_lot_detail`**(`jinrong_core`),`--single-transaction --add-drop-table`,跑后 `mysql jinrong_core < snap.sql` 恢复。
**刻意不还原:** `core_product` / `core_product_nav` / `core_customer_advisor` / 交易日历(只读,还原反破坏基线);`jinrong_agent` 的 `risk_alert` / `risk_suitability_log` / `audit_log` —— **追加型审计日志,按语义**永不**回滚,回滚反而破坏审计链**。
**风险:** 还原会一并回滚本轮之前的**手工演示态**。执行前先查 `core_trade` 是否含非本测试前缀的近期行;若有且重要,先单独导出。
**第 3 层(兜底,本轮不用):`scripts/core/reset.ps1`。** 会 DROP 重建 `jinrong_core`(丢全部手工演示态)、**不动 `jinrong_agent`** → agent 库残留指向已消失 Core 数据的引用,使"用 agent 库对账"的用例结论不可信。仅当快照不可用时启用,且须在报告中显式声明基线变更。
---
## 八、风险与假结论清单
| # | 风险 | 处置 |
|---|---|---|
| **R1** | **`_maintain_lots`(`app/gateway/trade_gateway.py:281-467`)整段 try 包住、失败不阻断交易 → 「交易成功但批次没动」的假 PASS。这是本轮最容易出假 PASS 的点** | 写侧用例**同时**断言四条链:`core_trade` 新行 **且** `core_share_lot` 批次变动 **且** `core_holding` 快照变动 **且** `GET /holdings` 返回值变动。**四点缺一即 FAIL**,不允许"`trade_id` 非空就算过" |
| **R2** | LLM 随机性导致问数线/对话类假 FAIL(AN3、AN4、AD7 等大面积依赖真实 LLM) | 硬断言只放**不依赖 LLM 语义**的部分:HTTP 状态码、字段结构、SQL 只读性、预警零增量、鉴权。凡"回复必须提到 X 关键词"一律降级为 **soft check**(记录实际,**不计入 FAIL 分母**) |
| **R3** | **`AD8` 是「预期 FAIL」的取证组**,容易被误读成回归退化 | 在 `README.md` 结论行与测试日志显式标注「AD8 为接缝取证,FAIL 是预期结果」;**不计入通过率**,单独列节 |
| **R4** | 顺序敏感用例:上一轮 `CT3-05` 踩过 **15 分钟墙钟窗口**坑(`audit_log.created_at >= DATE_SUB(NOW(), INTERVAL 15 MINUTE)` + `jinrong_agent` 永不还原 → 距上轮 <15 分钟复跑**假 FAIL**) | 同类窗口查询一律**距上轮 ≥15 分钟**;或改按**本轮 runstamp** 过滤(更优)。执行顺序里显式留窗口 |
| **R5** | 四个包共用一个 Vite 5173,`clearAuth` 切角色并发会互顶 localStorage | Part B **严格串行**;每个包跑完先 `clearAuth` 再交接 |
| **R6** | 截图互相覆盖(上一轮已丢 v1.0 截图,是记录在案的证据链缺口) | 见 F-06:**执行前先参数化截图路径**,否则四包截图互毁 |
| **R7** | Vite 绑 `[::1]` 而脚本用 `localhost` | 若 `ECONNREFUSED`,回退 `BASE=http://127.0.0.1:5173`(须确认 Vite 也听 IPv4)。**不做自动重试**(会掩盖真实配置问题),报告中记录实际 `BASE` |
| **R8** | 收复 `09/10/11` 时 harness 语义分叉(仓库内 9301 B vs 仓库外 6049 B) | 逐支核对原语后再改指向;**先跑通一支再收下一支**,不要批量 `cp` 后一起调 |
| **R9** | 问数线数据域(`self`/`assigned`/`full`/`risk`/`aggregate`)与 `sql_guard` 的边界口径未知 | AN2 的跨域探针**期望值以实测冻结核对**,不预设 |
| **R10** | 未知客户/未知资源 404 vs 200+blocked 的边界二义 | 一律用**两个探针**(格式合法未知 ID / 格式非法),期望值以实测冻结 |
---
## 九、边界(本轮不做)
- **不改任何业务代码**(`app/**`、`web/src/**`)—— **发现的缺陷只进报告**。`AD8` 取证的 3 处工具绑定错误**只报告,不在本轮修**。
- **不改** `tests/conftest.py` · `scripts/core/reset.ps1` · `scripts/dev/restart-dev.ps1`
- **不动既有 `advisor_e2e_smoke.py` / `risk_e2e_smoke.py` 的既有断言**(只新增分组)——它们是回归基线,改了会让 FAIL→PASS 对照失效
- 不新增浏览器框架依赖(**不动 `web/package.json`**)· 不新增 Python 依赖(`httpx`/`pymysql` 已有)
- 第 1.6 节基建缺口与第六节"只记录不修"项**只记录**
---
## 十、执行顺序
原则:**先证明「被测进程是当前代码」→ 再证明「事实基线」→ 再只读对账 → 再写侧 → 最后浏览器 → 最后回归。**
**Phase 0 准备(串行)**
1. 确认 8000 / 5173 / Redis 6380 均在跑;记录 uvicorn PID 与启动时间(`--reload` 场景下确认已加载当前代码)
2. `mysqldump` 5 表快照 → `/tmp/e2e-gap/snapshot.sql`
3. **先做 F-06(截图路径参数化)与 F-01~F-05(资产修复)** —— 必须早于任何 Part B,否则四包截图互毁、且三支脚本跑不出可信结论
4. **实测并按结果锁定四项门槛基线**(写在报告里当作"不得退步"的基准):`npm run build`(类型闸门,秒级)· 四文件交易单测(上一轮实测 76 passed)· `pytest -q --ignore-glob=test_sprint*.py`(文档称 896)· `npm run test`(文档称 27)。
**后两项的数字来自文档而非实测,此处必须重测**;若与文档值不符,以实测为准,并把偏差记为一条发现(该验收文档已被查出门槛陈旧,见 1.4)
5. **复核 1.2 的前端路由分档**(`12+13+3=28≠30`,须以 `web/src/routes/menus.tsx` 实际路由表重新分档)
6. 记录 `core_trade` 既有非测试前缀行(供还原前取舍判断)
**Phase 1 只读组(三条线并行,无写所以可并行)**
各线 `preflight` + 鉴权矩阵 + 契约冻结 + 基线加载;问数线 AN1/AN2、理财师 AD8(**纯读工具调用**)、横切 XC2/XC3 只读部分。
**Phase 2 写侧组(串行)**
理财师 AD7/AD10 → 横切 XC1/XC4 → 风控 RK7。问数线**无写侧**(红线:仅 SELECT、不处置预警)——这本身就是 AN3 的断言内容。
**Phase 3 写侧后对账**
各线读侧对账;采 `core_trade` 等五表**首尾计数差**供还原核对。
**Phase 4 Part B 浏览器(四包严格串行)**
问数线(含 `04-analyst-market-advisor.mjs` 收复 + 表单提交深挖)→ 理财师线(`09` 收复)→ 风控线(`10` 收复 + RK8 恒真修复对照)→ 横切(`11` 收复、`07-responsive-a11y.mjs`、`/register`)。
LLM 依赖最重的对话类用例放各自包的**最后**,失败不阻塞整轮结论。
**Phase 5 Part C 回归**
`09` → `10` → `11`(改指向仓库内 harness 后)→ 重跑四文件交易单测 + `npm run build`,确认不退步。
**Phase 6 还原与报告**
`mysql jinrong_core < snapshot.sql` → 用 Phase 3 的计数对照校验 5 表回到首值 → 写四个包 → 更新 `docs/memory/MEMORY.md` 与 `docs/memory/TODO.md`。
---
## 十一、验证方式
```bash
# 0) 前置:uvicorn 8000 + Vite 5173 + Redis 6380 均在跑
curl -s http://127.0.0.1:8000/api/ready # 期望 ok=true, redis=true
curl -s http://127.0.0.1:8000/openapi.json | python -c "import sys,json;print(len(json.load(sys.stdin)['paths']))" # 基线 66
# 1) 快照
mysqldump -h127.0.0.1 -uroot -p jinrong_core \
core_trade core_holding core_share_lot core_convert_request core_convert_lot_detail \
--single-transaction --add-drop-table > /tmp/e2e-gap/snapshot.sql
# 2) 门槛基线(先在 Phase 0 实测锁定,之后不得退步)
# 文档值 896 / 27 仅供参考,实测不符时以实测为准并记录偏差
cd web && npm run build && npm run test && cd ..
python -m pytest -q --ignore-glob=test_sprint*.py # 文档称 896 passed,须实测
python -m pytest tests/test_trade_gateway.py tests/test_trade_flow_service.py \
tests/test_trade_action_service.py tests/test_convert_confirm.py -q # 上一轮实测 76 passed,须复测
# 3) Part A 真实 HTTP E2E(退出码 0 全过 / 1 有 FAIL / 2 前置失败)
python scripts/dev/analyst_e2e_smoke.py --report /tmp/e2e-gap/an.md
python scripts/dev/advisor_e2e_smoke.py --only AD7 --only AD8 --only AD9 --only AD10 --report /tmp/e2e-gap/ad.md
python scripts/dev/risk_e2e_smoke.py --only RK7 --report /tmp/e2e-gap/rk.md
python scripts/dev/crosscut_e2e_smoke.py --report /tmp/e2e-gap/xc.md
# 4) Part B 真浏览器(须 Vite 5173;严格串行,勿并发)
cd scripts/e2e && node 04-analyst-market-advisor.mjs
node 09-advisor-pages.mjs && node 10-risk-pages.mjs && node 11-cross-role.mjs && node 07-responsive-a11y.mjs
# 5) 还原并核对(用 Phase 3 的计数对照)
mysql -h127.0.0.1 -uroot -p jinrong_core < /tmp/e2e-gap/snapshot.sql
```
**完成判据:**
1. 三条线各自 `preflight` 通过(openapi 必需路由齐全,路径总数 66)
2. `pytest` 896 passed **不退步** · 交易四文件 76 passed **不退步** · `npm run build` 与 `npm run test` 退出码 0
3. 每条 FAIL 都附**可复现命令 + 实测输出**(verification-before-completion 铁律:**没跑过的验证不能写成通过**)
4. 每条业务缺陷都给了**根因**(systematic-debugging:不得只报症状就提修法)
5. **`AD8` 的预期 FAIL 与业务缺陷分列**,不混入通过率分母;`INFO`/`SOFT` 同样不进入分母
6. 5 张写表计数**还原到首值**
7. 测试基建修复台账(F-01~F-06)与业务缺陷清单**分开记账**
---
## 十二、产出物清单
**新建(脚本)**
- `scripts/dev/analyst_e2e_smoke.py`(问数线,AN1–AN12)
- `scripts/dev/crosscut_e2e_smoke.py`(横切,XC1–XC5)
- `scripts/e2e/09-advisor-pages.mjs` · `10-risk-pages.mjs` · `11-cross-role.mjs` · `04-analyst-market-advisor.mjs` · `07-responsive-a11y.mjs` · `03-risk.mjs` · `06-chat.mjs` · `08-edge.mjs`(**从仓库外收复**,含 F-01~F-06 修复)
**扩充(脚本,只新增分组)**
- `scripts/dev/advisor_e2e_smoke.py`(+AD7–AD10)
- `scripts/dev/risk_e2e_smoke.py`(+RK7–RK8)
**报告包(四个)**
- `docs/memory/tests/2026-09-13-analyst-e2e/` · `-advisor-e2e/` · `-risk-e2e/` · `-crosscut-e2e/`
各含 `README.md` + `TEST-LOG-*.md` + `_raw/`(含 `shots/`、脚本原生输出、`results-*.json`)
**文档更新**
- `docs/memory/MEMORY.md`(§0 加第五轮行 + 新发现)· `docs/memory/TODO.md`
- `docs/superpowers/plans/2026-09-13-e2e-coverage-gap.md`(本计划的归档副本)