From e666ee6f8a281e3deab05ff4f11c0619fd4681a3 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Sat, 19 Sep 2026 12:28:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(e2e):=20Phase=2023=20Task=202.1=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=B4=BB=E5=8A=A8=E4=BA=92=E9=80=89=208?= =?UTF-8?q?=20=E5=9C=BA=E6=99=AF=20+=20=E6=9C=8D=E5=8A=A1=E9=A2=84?= =?UTF-8?q?=E7=BA=A6=208=20=E5=9C=BA=E6=99=AF=E7=9C=9F=E5=AE=9E=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=EF=BC=8816=20=E4=B8=AA=E7=9C=9F=E5=AE=9E=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:`tests/e2e/activity_mutual_consent_test.py` 与 `service_appointment_test.py` 此前是 **诚实占位**(仅 docstring、0 用例,指针指向 run_v11_tests.py),计划 Task 2.1 要求把 文件头保留的场景清单逐条实现为真实 HTTP 用例。本提交完成该任务。 ═══ 新增真实用例 16 个(本机活栈 pytest tests/e2e 49 → 65 passed)═══ activity_mutual_consent_test.py(8,覆盖 INV-014/015/006 + AGENTS.md #19/#20) 1 建立互选记录不建 Match(INV-014) 2 单方确认不建 Match(INV-015) 3 双方确认建 Match Episode 且双方可见 4 已有 active Match 时再次双确认必须复用(#19) 5 曾 Unmatch 后重新双确认开启新 Episode:episode +1(#20) 6 一方拒绝不建 Match,对方随后确认也不建 7 超时:已过期记录不得被推动成双确认(含「未过期必须生效」对照,防假绿) 8 单向意向不泄露(INV-006,含「B 确认后必须看到 accepted」反证) service_appointment_test.py(8) 1 提交预约;2 幂等键重放不得产生第二条;3 运营确认 submitted→confirmed(用户侧同态可见) 4 并发「用户取消 × 运营确认」不得 5xx、最终状态必须收敛;5 我的列表只含本人 6 待确认可取消;7 归属隔离(他人不可读详情/不可取消) 8 运营侧状态闸门:已完成预约不得再被取消 ═══ 两个幂等 seed(E2E 前置数据,本机与 CI 通用)═══ - seed_e2e_activity.sql:organization + 两个 activity。 必须建的原因是硬约束而非选择:`activity_mutual_connection_consents.activity_id` 有 **FK 到 activities(id)**(实测约束名 …_activity_id_fkey),而 activities.organizer_org_id 又 FK 到 organizations(id) 且 NOT NULL —— 全新库里三张表全空,互选无活动可挂。 - seed_e2e_service_station.sql:service_location + **站点-运营关联**。 关联必需:运营确认受数据范围约束(`assertOperatorScope` → `canAccessByOperator`), super_admin **不豁免**,实测 e2e_admin 确认他站预约返回 403。 E2E 因此改用 seed 中该站点关联的 e2e_station。 两个 seed 由测试首用时幂等执行(docker exec psql),无需改 CI 步骤。 ═══ 负向注入(Task 2.1 Step 3)═══ ③ 撤销 service_station_operator 的 service_appointment.confirm → 恰 2 红(test_operator_can_confirm / test_operator_cannot_cancel_completed,403),其余 6 绿; ④ 触发器强制新建互选记录的对方侧 accepted → 3 红(both_confirm_creates_match_episode / timeout_handled / single_interest_not_leaked),其余 5 绿。 两处均已还原并复跑全绿(65 passed),确认无残留(无 e2e* 触发器、权限计数回到 1)。 **两次失败的注入(记录以免后人重复踩)**: - 删除站点→运营关联:**无效** —— 测试的 seed fixture 在用例前幂等重放 seed 会自愈该前置条件; - 改 user_b_status 列默认值:**无效** —— `createMutualConsent` 在 Java 侧显式写 pending, DB 默认值根本不参与(侧面证明写入路径的权威性)。 ═══ 计划文档校正(审阅产物)═══ 按实测推翻 4 处失实断言,登记 7 项新发现(详见计划文档「附 A / 附 B」): - 推翻:matchmaker_services「无表/需扩权」(实为 V009 已建表且 mapper 齐备); 门禁 14/14(实为 18/18);「tests/e2e 现为占位」(实为仅 2 个文件); E2E 基线 49(实为 65)。 - 新发现:① `POST /operator/organizations` 必然 500(createOrganization 不赋 id + IdType.INPUT + 无 DB 默认值;且未校验 chk_org_type)→ 连带「无法新建活动」; ② `createMutualConsent` 从不写 expires_at → 互选永不过期(即批 3 P16-T13,配置与 V041 部分索引空转);③ 用户侧取消预约不校验状态(已确认/已完成也能自取消), 与运营侧明确拒绝 completed 口径不一致 → 需产品裁定; ④ E2E 环境 organizations/activities/service_locations 均为空表(无迁移种子); ⑤ 运营数据范围 super_admin 不豁免(预期行为,勿误判); ⑥ 本机复跑会遇到 Redis 登录锁定键(auth:operator_login_lock:*); ⑦ e2e_* 账号因 ON CONFLICT DO NOTHING 重跑 seed 不覆盖旧口令。 ═══ 验证 ═══ - `pytest tests/e2e` → **65 passed**(含新增 16);`node scripts/run-ui-contracts.mjs` → **18/18 PASS**,退出 0; - 本机环境处置(不属代码改动):把 6 个 e2e_* 账号口令对齐到 CI 口令、清除 Redis 登录锁定键。 - **未验证**:微信开发者工具、CI 上的首次 E2E 实测绿(Task 2.3 Step 3,本机无 Docker Hub 出口)。 --- ...9-18-phase23-closeout-and-uat-readiness.md | 157 ++++++-- tests/e2e/activity_mutual_consent_test.py | 371 ++++++++++++++++-- tests/e2e/service_appointment_test.py | 369 +++++++++++++++-- tests/e2e/sql/seed_e2e_activity.sql | 45 +++ tests/e2e/sql/seed_e2e_service_station.sql | 39 ++ 5 files changed, 902 insertions(+), 79 deletions(-) create mode 100644 tests/e2e/sql/seed_e2e_activity.sql create mode 100644 tests/e2e/sql/seed_e2e_service_station.sql diff --git a/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md b/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md index cf50317..b01bd54 100644 --- a/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md +++ b/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md @@ -18,11 +18,11 @@ | 项 | 状态 | |---|---| | Phase 01~22 | ✅ 复验关闭 | -| Phase 23 | ⚠️ **部分关闭** —— 44 个 `pass` 空壳已清、4 个真实 INV 行为守卫已补;**E2E 场景实现与 CI 串联未完成** | -| Phase 23B | ✅ E2E 测试基建重建(conftest + seed + CI e2e job,本机活栈 33→49 passed) | +| Phase 23 | ⚠️ **部分关闭** —— 44 个 `pass` 空壳已清、4 个真实 INV 行为守卫已补;**E2E 场景实现已完成(2026-09-19,见 Task 2.1)**,仅余 E2E 在 CI 上的首次实测绿 | +| Phase 23B | ✅ E2E 测试基建重建(conftest + seed + CI e2e job,本机活栈 49 → **65 passed**) | | 横向加固 | ✅ HTTP 语义(105 处真实状态码)+ 身份收敛 + 幂等键(30 端点,V045/V046)+ 端口/入口守卫 | | 后端回归 | **669 / 0 failures / 0 errors / 3 skipped** | -| UI 契约门禁 | **14/14 绿** | +| UI 契约门禁 | **18/18 绿**(原文 14/14 已过期,见 Global Constraints) | | 停点 | **Phase 24(UAT)启动门前 —— 需用户确认** | > ✅ 批 2 的 E2E 项**已在本机真实执行**(2026-09-18:`pytest tests/e2e` → 49 passed,真栈 + 真库 + 当日代码), @@ -35,8 +35,11 @@ - **禁止用 `rm` / `git rm` 删除文件** —— 本机有 safe-delete 钩子会把「删文件」放大为「删父目录」(2026-09-18 实测:`git rm` 单个文件导致整个 `docs/` 被移除)。需要移除内容时**一律原地重写**(Write/Edit)。 - 不修改业务规则、不扩大 V1 范围;新增能力必须走 Phase 顺序。 -- 每批完成后跑 `node scripts/run-ui-contracts.mjs`(14 个守卫,必须 **14/14 绿**;已接入 CI job `ui-contracts`)+ `ui-document-baseline-contracts.mjs`(含端口与 `/admin/` 子路径断言)。 +- 每批完成后跑 `node scripts/run-ui-contracts.mjs`(**守卫数量以执行器 `MIN_GUARDS` 为准;2026-09-19 实为 18 个,须 18/18 绿** —— 原文「14 个 / 14/14」已过期:2026-09-19 起相继新增 `wxml-structure-contracts`、`miniapp-self-method-contracts`、`wxss-component-selector-contracts`、`miniapp-api-client-behavior`)+ `ui-document-baseline-contracts.mjs`(含端口与 `/admin/` 子路径断言)。 - 后端改动跑离线容器 `mvn -o test`,必须 0 failures / 0 errors。 +- **E2E 改动跑 `pytest tests/e2e`**(需活栈;本机 `E2E_BASE_URL=http://localhost:8085/api/v1`、`E2E_OPERATOR_PASSWORD` 见 CI 变量),必须 0 failed。 + 本机复跑前若出现 `429 账号已锁定`,是 Redis 里的登录失败锁定键(`auth:operator_login_lock:`,TTL 15 分钟), + 由前一轮失败登录累积触发 —— 删掉该键即可,不是业务缺陷。 - 提交纪律:每批独立 commit + `git tag`(**单段名**,带 `/` 会被本机常驻进程清理)+ 回读验证;`git bundle create` 覆盖最新提交。 - 涉及密码/密钥:不写入文档,只用环境变量或安全 Seed。 @@ -74,29 +77,53 @@ ## 批 2 — Phase 23 剩余(核心阻塞) -### Task 2.1: E2E 场景真实实现 +### Task 2.1: E2E 场景真实实现 ✅(2026-09-19 完成) **Files:** -- Modify: `tests/e2e/**`(现为占位文件,场景清单已完整保留在各文件头部) +- Modify: `tests/e2e/activity_mutual_consent_test.py`、`tests/e2e/service_appointment_test.py` +- Add: `tests/e2e/sql/seed_e2e_activity.sql`、`tests/e2e/sql/seed_e2e_service_station.sql` - Test: 本地活栈 `pytest tests/e2e` +> **前置状态修正(2026-09-19 实测)**:原文「现为占位文件」只对上述**两个**文件成立 —— +> 它们在 2026-09-18 的 KEEP-VERIFY 轮已从「44 个 `pass` 空壳」改为**诚实占位**(仅 docstring、 +> 0 个用例)。其余文件(`home_v11_test.py` / `local_eligibility_test.py` / `counseling_*` / +> `http_semantics_test.py` / `idempotency_test.py` 等)在 Phase 23B 已是**真实用例**,不是占位。 + **场景清单(占位文件头部已列,不得缩减):** -| 文件 | 场景数 | 覆盖 | -|---|---|---| -| 首页聚合 | 11 | 首页 8 入口顺序、Banner 仅内部跳转、今日推荐、公益服务、安全提醒 | -| 本地资格 | 11 | 声明 → 佐证上传 → 审核 → 状态流转;INV-011 未授权不可见 | -| 互选 | 8 | 二阶段同意:互选结果 → 双方分别确认 → 创建 Match;已 Match 复用 / 已 Unmatch 新建 | -| 权限 | 10 | 各角色数据范围(Counselor Assigned / ContentEditor 零用户数据 / 站点点范围) | -| 预约 | 7 | 用户创建/详情/列表/取消仅本人;代登记走独立 operator 接口 | -| 内容 | — | 婚恋课堂 / 反诈列表与详情 | -| 组织隔离 | — | 跨组织默认拒绝 | +| 文件 | 场景数 | 覆盖 | 实现状态 | +|---|---|---|---| +| 首页聚合 | 11 | 首页 8 入口顺序、Banner 仅内部跳转、今日推荐、公益服务、安全提醒 | 分散在 `home_v11_test.py` 等(场景编号与用例数非一一对应,未逐条对账) | +| 本地资格 | 11 | 声明 → 佐证上传 → 审核 → 状态流转;INV-011 未授权不可见 | 分散在 `local_eligibility_test.py` 等(同上) | +| 互选 | 8 | 二阶段同意:互选结果 → 双方分别确认 → 创建 Match;已 Match 复用 / 已 Unmatch 新建 | ✅ **`activity_mutual_consent_test.py` 8/8 通过** | +| 权限 | 10 | 各角色数据范围(Counselor Assigned / ContentEditor 零用户数据 / 站点点范围) | 分散在 `counseling_permission_test.py` / `service_station_permission_test.py` / `organization_isolation_test.py` | +| 预约 | 7 | 用户创建/详情/列表/取消仅本人;代登记走独立 operator 接口 | ✅ **`service_appointment_test.py` 8/8 通过**(原「已确认不可自行取消」一条见下方说明,改为有规格支撑的归属隔离 + 运营侧状态闸门两条) | +| 内容 | — | 婚恋课堂 / 反诈列表与详情 | `content_e2e_test.py` | +| 组织隔离 | — | 跨组织默认拒绝 | `organization_isolation_test.py` | -- [ ] **Step 1:** 逐文件实现场景(**先删除空 `pass`**,禁止保留空壳)。 -- [ ] **Step 2:** 本地活栈跑通(需后端 + PostgreSQL + 种子数据 + `E2E_PASSWORD`)。 -- [ ] **Step 3:** 负向验证:注入 1~2 处缺陷,确认对应 E2E 变红。 +- [x] **Step 1:** 逐文件实现场景(**先删除空 `pass`**,禁止保留空壳)。两文件共 16 个真实用例,`pass` 空壳为 0。 +- [x] **Step 2:** 本地活栈跑通(需后端 + PostgreSQL + 种子数据 + `E2E_PASSWORD`)。 + 本机实测 `pytest tests/e2e` → **65 passed**(49 基线 + 16 新增)。新增两个幂等 seed 文件承载 + 「活动互选」与「服务站/站点-运营关联」的前置数据,本机与 CI 通用。 +- [x] **Step 3:** 负向验证:注入 2 处缺陷,确认对应 E2E 变红(详见下方「负向注入记录」)。 -**Acceptance:** 场景全部真实执行且通过;无空 `pass` 残留。 +**Acceptance:** 场景全部真实执行且通过;无空 `pass` 残留。✅ + +**负向注入记录(Step 3,2026-09-19 实测)** + +| # | 注入 | 期望 | 实测 | +|---|---|---|---| +| ③ | 撤销 `service_station_operator` 的 `service_appointment.confirm` | 运营确认类用例变红 | **恰 2 个红**(`test_operator_can_confirm` / `test_operator_cannot_cancel_completed`,HTTP 403),其余 6 绿 | +| ④ | 触发器强制新建互选记录的对方侧为 `accepted` | 依赖「对方未确认」的用例变红 | **3 个红**(`test_both_confirm_creates_match_episode` / `test_timeout_handled` / `test_single_interest_not_leaked`),其余 5 绿 | + +两处注入均已还原并复跑全绿(65 passed),且确认无残留(无 `e2e*` 触发器、权限计数回到 1)。 + +**两次失败注入(记录在案,避免后人重复踩)** + +- 注入①「删除站点→运营关联」**无效**:测试的 seed fixture 会在用例前幂等重放 seed, + 把关联补回来 —— 即 fixture 会自愈它自己的前置条件。要注入必须选 fixture **不会修复**的东西。 +- 注入②「把 `user_b_status` 列默认值改成 `accepted`」**无效**:`createMutualConsent` + 在 Java 侧**显式写入** `pending`,DB 默认值根本不参与。这条侧面证明了写入路径的权威性。 ### Task 2.2: INV-011 显式守卫 @@ -106,9 +133,29 @@ **背景**:`LocalEligibilityService` 全部方法以 `userId` 自限定、零 role 分支,佐证无独立服务端读取入口(仅经受权限控制的审核队列)—— **结构性满足,但无测试固化**。 +**2026-09-19 实测落点(照此实现即可,不必重新勘察)** + +- 当前唯一服务端读取入口:`POST /api/v1/operator/reviews/{taskId}/evidences/{evidenceId}/access` + (`ReviewController:165`),**两级 gate**:类级 `hasAuthority('review.view')` + + 方法级 `hasAuthority('local_eligibility.evidence.view')`。 +- 业务侧 `ReviewService.accessLocalEligibilityEvidence(taskId, evidenceId, operatorId, purpose)` 在 + 权限注解之外**再校验四件事**:`purpose` 非空 → 任务是 `local_eligibility` 类型 → + `requireClaimOwner`(领取人本人)→ 佐证归属该任务用户的资格记录。 + **成功与拒绝都会写** `sensitive_access_logs`(`logEvidenceAccess`,`result = success|denied`)—— + 这正是「可核查」的落库路径,也是负向断言应该咬住的地方。 +- 用户侧入口 `/local-eligibility/{declaration,evidences,submit}` 全部以 `principal.id()` 自限定,无 userId 入参。 +- 佐证上传前置:`featureFlags.isLocalEvidenceUploadEnabled()`、`evidenceType` 白名单、 + `fileId` 必须属于该用户自己的 `user_photos`(`userPhotoMapper.findByUserIdOrderBySort`)。 +- 测试基建:`@SpringBootTest @ActiveProfiles("test")`(H2,`schema-h2.sql`),参照 `V11InvariantsTest`。 + - [ ] **Step 1:** 写负向测试:非授权角色读取他人本地资格佐证 → 必须 403/不可达。 -- [ ] **Step 2:** 确认真实落库路径(不是只断言常量,见 `CoreInvariantsTest` 的教训)。 + (建议同时覆盖:非领取人、跨用户佐证、非 local_eligibility 任务、`purpose` 为空) +- [ ] **Step 2:** 确认真实落库路径(不是只断言常量,见 `CoreInvariantsTest` 的教训)—— + 即断言 `sensitive_access_logs` 真的新增了对应 `result` 的记录。 - [ ] **Step 3:** 负向注入验证守卫变红。 + ⚠️ 本机后端跑的是**当日构建的 jar**,改 Java 代码需重建镜像;注入应优先选 + **不需要重建**的手段(如撤销 `local_eligibility.evidence.view` 的角色授予、 + 或直接改库内状态),否则该步骤成本极高。 **Acceptance:** INV-011 有真实行为守卫。 @@ -166,7 +213,19 @@ ### Task 3.1: Phase 21 遗留 - [ ] **隐私设置接口**:`getPrivacySettings` / `updatePrivacySettings` 仍是 **TODO 桩**(返回假数据 / 不更新)→ 接真实 `user_privacy_settings`。 -- [ ] **注销红娘服务拦截硬编码 `0`**:根因是数据模型**没有 `matchmaker_services` 表**(全仓搜索为空),拦截项在设计上无法落地。**建表属扩权** → 需产品先确认红娘服务的存储模型。 +- [ ] **注销红娘服务拦截硬编码 `0`**: + 原文断言「数据模型**没有 `matchmaker_services` 表**(全仓搜索为空),建表属扩权,需产品先确认」—— + **三条均与实况不符,2026-09-19 实测推翻**: + ① 表**存在**,由 `V009__add_matchmaker_tables.sql` 创建(`chk_matchmaker_status` 白名单: + `submitted / assigned / in_service / waiting_user / completed / cancelled`); + ② `MatchmakerServiceMapper`、`MatchmakerServiceRecord`、`MatchmakerServiceService` **均已存在**; + ③ 因此**不需要扩权、不需要产品裁定**,属可直接实现的接线缺陷。 + 另:`AccountLifecycleService.notifyMatchmaker`(注销时通知红娘服务)同样是 TODO 空转。 + 位置:`AccountLifecycleService.checkCanWithdraw` 中 + `long activeMatchmakerServices = 0; // 需要 MatchmakerServiceMapper`。 + (原文「全仓搜索为空」很可能是只在 `db/migration/` 内搜了表名的另一种写法 —— 该表在 + `demo-seed-*.sql` 与 `V009` 中都有;凡「全仓无 X」这类断言必须给出**搜索命令与范围**, + 否则无法复核,本项目已多次因此产生失实结论。) - [ ] **数据保留流程**:按 `docs/legal/privacy-policy.md` 与 `docs/security/PIA.md` 落实保留期限(**不得擅自永久保存或删除**)。 ### Task 3.2: Phase 22 遗留(死配置) @@ -215,12 +274,64 @@ ## 验收与提交纪律 -1. 批 1~3 每批完成后:`ui-tests/*.mjs` **14/14 绿** + `ui-document-baseline-contracts.mjs` 通过 + 后端 `mvn -o test` 0 failures。 -2. 批 2 的**端到端前置已满足**(2026-09-18 本机 E2E 49 passed);仍缺的是 **E2E 进 CI 的首次实测绿**(Task 2.3 Step 3)—— 不阻塞 UAT 启动,属回归防线缺口。 +1. 批 1~3 每批完成后:`ui-tests/*.mjs` **18/18 绿**(数量以执行器 `MIN_GUARDS` 为准)+ `ui-document-baseline-contracts.mjs` 通过 + 后端 `mvn -o test` 0 failures + `pytest tests/e2e` 0 failed。 +2. 批 2 的**端到端前置已满足**(2026-09-19 本机 `pytest tests/e2e` → **65 passed**);仍缺的是 **E2E 进 CI 的首次实测绿**(Task 2.3 Step 3)—— 不阻塞 UAT 启动,属回归防线缺口。 3. 每批独立 commit;`git tag` 用**单段名**;`git bundle create` 覆盖最新提交并回读验证。 4. `docs/STATUS.md` / `docs/JIRA_BACKLOG.md` / `docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` 随批同步。 5. **不做**:不修改 Recovery 报告与 `releases/` 冻结快照;不触碰 bundle 自保产物。 --- +## 附:2026-09-19 审阅校正与新发现 + +### A. 校正(本文件中已被实测推翻的断言) + +| 位置 | 原文 | 实测 | +|---|---|---| +| Task 3.1 第 2 项 | `matchmaker_services` 表不存在、建表属扩权、需产品确认 | 表由 **V009** 创建;mapper/entity/service 齐备;**无需扩权**,可直接接线 | +| Global Constraints / 验收纪律 / 当前停点 | UI 契约门禁 14 个、14/14 绿 | **18 个**(2026-09-19 相继新增 4 项守卫),基线 18/18 | +| Task 2.1 | `tests/e2e/**` 现为占位文件 | **仅 2 个文件**是占位;其余在 Phase 23B 已是真实用例 | +| 当前停点 | 本机活栈 E2E `49 passed` | 基线 49,Task 2.1 完成后 **65 passed** | + +### B. 新发现(不在本计划内,需另行处置) + +1. **`POST /operator/organizations` 必然 500**(High) + `OrganizationService.createOrganization` 从不给 `id` 赋值,而 `BaseEntity` 用 + `@TableId(type = IdType.INPUT)`、`organizations.id` 无 DB 默认值 → + `invalid input syntax for type uuid: ""`。且该接口**未校验 `type`**, + 会撞生产 `chk_org_type`(白名单:`women_federation / trade_union / charity_center / + volunteer_team / civil_affairs_partner / other`),大概率在 PG 上再炸一次。 + 影响:运营端无法新建机构 → 无机构则**无法新建活动**(`activities.organizer_org_id` NOT NULL)。 +2. **互选记录永不过期**(Medium,即批 3 Task 3.3 的 P16-T13) + `createMutualConsent` 从不写 `expires_at`(列无默认值),而 + `confirmSideIfPending` 的过期闸门 `expires_at IS NULL OR expires_at > NOW()` + 对 NULL 恒为真 → 配置项 `default-mutual-deadline-hours: 48` 与 V041 的部分索引 + `idx_mutual_consents_expiry` 均为**空转**。E2E 的超时用例因此只能靠回拨库内值触发。 +3. **用户侧取消预约不校验状态**(Medium,需产品裁定) + `ServiceAppointmentService.cancelAppointment` 只校验归属,**已确认/已完成**的预约 + 都能被本人置为 `cancelled`;而运营侧 `cancelAppointmentByOperator` 明确拒绝 + `completed` / `cancelled`。两侧口径不一致。原空壳用例名 + `test_cancel_after_confirmation_denied` 无任何规格支撑(需求基线只写了归属隔离), + 故未被写成断言 —— 待产品确认后再定。 +4. **E2E 环境缺前置数据**(Low,已由本轮 seed 解决) + 全新库 `organizations` / `activities` / `service_locations` **均为空表**—— + 无任何迁移种子。已补 `tests/e2e/sql/seed_e2e_activity.sql`、 + `tests/e2e/sql/seed_e2e_service_station.sql`(幂等,本机与 CI 通用)。 +5. **运营确认受数据范围约束,super_admin 不豁免**(设计事实,易被误判为缺陷) + `assertOperatorScope` → `canAccessByOperator`: + `sa.assigned_operator_id = :op OR EXISTS(service_location_operators 关联该站点 × :op)`。 + 实测 `e2e_admin`(super_admin)确认他站预约返回 **403** —— 这是**预期行为**, + 不是权限配置错误。E2E 因此必须用站点关联的运营账号。 +6. **本机 E2E 复跑会被登录锁定拦住**(Low,环境事项) + Redis 键 `auth:operator_login_lock:`(TTL 15 分钟)由失败登录累积触发, + 表现为 `429 账号已锁定,请 N 分钟后重试`。清键即可。CI 每轮全新 Redis 不受影响。 +7. **`e2e_*` 账号口令与 CI 不一致**(Low,环境事项) + `seed_e2e_operators.sql` 用 `ON CONFLICT (username) DO NOTHING`,重跑 seed **不覆盖**旧口令; + 本机活栈若复用旧卷,口令会停在上一轮的值 → 大量 401。 + 本机已用 `UPDATE operators SET password_hash = crypt('', gen_salt('bf',8)) WHERE username LIKE 'e2e\_%'` + 对齐到 CI 口令。(这与 Task 2.3 顺带修的 CI 缺陷 #5 是同一根因的两面。) + +--- + *创建: 2026-09-18 — 承接 `docs/文档治理报告-2026-09-18.md` 与 STATUS.md 的两处悬空引用(D1)* +*审阅校正: 2026-09-19 — 按实测推翻 4 处失实断言(见附 A),登记 7 项新发现(见附 B)* diff --git a/tests/e2e/activity_mutual_consent_test.py b/tests/e2e/activity_mutual_consent_test.py index bfa9d3e..05d955b 100644 --- a/tests/e2e/activity_mutual_consent_test.py +++ b/tests/e2e/activity_mutual_consent_test.py @@ -1,35 +1,350 @@ """ -V1.1 活动互选二阶段同意 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18) +V1.1 活动互选二阶段同意 E2E —— 场景真实实现(Phase 23 Task 2.1,2026-09-19) -历史问题 --------- -原文件 8 个测试函数**全部**为 `pass` 空壳(注释「TODO: P16 实现后完善」), -依赖不存在的 `activity_factory` / `user_a` / `user_b` 等 fixture(全局无 `conftest.py`), -从未执行过,却在 `docs/STATUS.md` 中被计为 P23-T11「活动互选 E2E ✅」。 +原文件 8 个测试函数**全部**为 `pass` 空壳(基线见 git b46b48e),依赖不存在的 +`activity_factory` / `user_a` / `user_b` 等 fixture,从未执行过, +却在 `docs/STATUS.md` 中被计为 P23-T11「活动互选 E2E ✅」。 +本文件按文件头保留的场景清单逐条实现为**真实 HTTP 用例**。 -计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。 +实现纪律(与 conftest 一致,不得回退) +------------------------------------ +1. 不 mock 业务行为:走真实 HTTP + 真实数据库; +2. 每个用例使用**独立新用户**,避免用例互相污染; +3. 栈不可达时由 conftest `pytest.exit(returncode=4)`,**不退化为 skip**。 -当前状态 --------- -本文件现**不含任何测试函数**,pytest 收集 0 个用例。 +被验证的调用链(实测自 ActivityMutualConsentController / ActivityMutualConsentService) +-------------------------------------------------------------------------------------- + POST /activities/{aid}/mutual/consent {partnerId} → 建一对 pending 记录 + POST /activities/{aid}/mutual/confirm {partnerId} → 本方 accepted;双 accepted 才建 Match + POST /activities/{aid}/mutual/decline {partnerId} → 本方 declined + GET /activities/{aid}/mutual/status/{partnerId} → 本方/对方状态(INV-006 保密) + GET /matches → 本人 active Match(data.items[]) + POST /matches/{id}/unmatch → 单方解除 -仍待真实实现的场景(需求不因移除空壳而丢失) ------------------------------------------- -1. Activity Mutual Interest 不直接创建 Match(INV-014) -2. 一方确认不创建 Match(INV-015) -3. 双方确认后创建新 Match Episode -4. 已有 active Match 时不重复创建(AGENTS.md #19) -5. 曾 Unmatch 后重新确认创建新 Episode(AGENTS.md #20) -6. 一方拒绝则不创建 Match -7. 超时(expires_at)处理 -8. 单向意向不泄露给对方(INV-006) +前置数据(为什么必须 seed) +-------------------------- +`activity_mutual_connection_consents.activity_id` 有 **FK 到 activities(id)**, +`activities.organizer_org_id` 又有 FK 到 organizations(id) 且 NOT NULL, +而全新库里 organizations / activities 均为**空表**。 +不走运营 API 建的原因:实测 `POST /operator/organizations` 当前**必然 500** +(`OrganizationService.createOrganization` 从不赋 id,而 BaseEntity 用 +`@TableId(type = IdType.INPUT)` 且 organizations.id 无 DB 默认值)—— +该缺陷另行记录。此处按同目录 `seed_e2e_operators.sql` 的既有约定直接落库, +seed 由 `_ensure_activity()` 在首次使用时幂等执行(本机与 CI 通用)。 -现状补充(Phase 23 实测) ------------------------- -INV-014 / INV-015 在后端已有**真实行为测试**覆盖: -`backend/.../activity_mutual/ActivityMutualConsentServiceTest` -(双确认 Gate、decline 不建 Match、复用已有 active Match、幂等、超时、软删过滤)。 -即:E2E 层待补,但不变量本身并非无覆盖。 - -真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py` +关于超时场景(第 7 条) +---------------------- +`confirmSideIfPending` 的 SQL 带 `AND (expires_at IS NULL OR expires_at > NOW())`, +即「确认时」的过期闸门**已实现**。但 `createMutualConsent` **从不写入 expires_at** +(列无默认值,服务也不设置),所以记录实际上永不过期 —— +这属计划批 3 的 P16-T13「互选超时接线(expires_at 已由 V041 提供)」,尚未接线。 +本文件的超时用例因此**直接回拨库内 expires_at**,验证的是已实现的确认闸门本身; +不变量本身(过期不得推动成双确认)与「谁负责写入 expires_at」是两件事。 """ +from __future__ import annotations + +import os +import re +import subprocess +import uuid +from typing import Any, Dict, List + +import pytest + +pytestmark = pytest.mark.e2e + +# 容器名取 compose 的 container_name,本地与 CI 一致(infrastructure/docker-compose.yml) +PG_CONTAINER = os.environ.get("E2E_PG_CONTAINER", "danyang-match-postgres") +PG_DB = os.environ.get("E2E_PG_DB", "danyang_match") + +# 与 tests/e2e/sql/seed_e2e_activity.sql 中的固定 UUID 一一对应 +ACTIVITY_A = "e2e00000-0000-0000-0000-0000000000a2" +ACTIVITY_B = "e2e00000-0000-0000-0000-0000000000a3" + +_UUID_RE = re.compile(r"^[0-9a-fA-F-]{36}$") +_activity_ready = False + + +# ------------------------------------------------------------------ 库内辅助 + + +def _psql(sql: str) -> str: + """在容器内执行一段 SQL 并返回输出。 + + 失败时明确报错而不是跳过 —— 「没跑成」必须与「通过」区分开。 + 与仓库既有做法一致(tests/e2e/run_v11_tests.py 亦用 docker exec 取库内数据)。 + """ + proc = subprocess.run( + ["docker", "exec", PG_CONTAINER, "psql", "-U", "postgres", "-d", PG_DB, + "-v", "ON_ERROR_STOP=1", "-t", "-c", sql], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, ( + f"库操作失败(需要容器 {PG_CONTAINER} 可访问):\n" + f"stdout={proc.stdout}\nstderr={proc.stderr}" + ) + return proc.stdout + + +def _ensure_activity(which: str = "A") -> str: + """幂等确保互选所需的活动存在,返回活动 id。""" + global _activity_ready + if not _activity_ready: + seed_path = os.path.join(os.path.dirname(__file__), "sql", "seed_e2e_activity.sql") + with open(seed_path, encoding="utf-8") as fh: + seed = fh.read() + proc = subprocess.run( + ["docker", "exec", "-i", PG_CONTAINER, "psql", "-U", "postgres", "-d", PG_DB, + "-v", "ON_ERROR_STOP=1", "-q"], + input=seed, capture_output=True, text=True, + ) + assert proc.returncode == 0, ( + f"互选前置数据 seed 失败(需要容器 {PG_CONTAINER} 可访问):\n" + f"stdout={proc.stdout}\nstderr={proc.stderr}" + ) + _activity_ready = True + return ACTIVITY_A if which == "A" else ACTIVITY_B + + +def _expire_consent(consent_id: str) -> None: + """把互选记录的 expires_at 回拨到过去,触发确认闸门。 + + 这是**唯一**能触发超时路径的手段:服务端从不写 expires_at(见模块 docstring)。 + """ + assert _UUID_RE.match(consent_id or ""), f"非法的互选记录 id: {consent_id!r}" + out = _psql( + "UPDATE activity_mutual_connection_consents " + f"SET expires_at = NOW() - INTERVAL '1 hour' WHERE id = '{consent_id}';" + ) + assert "UPDATE 1" in out, f"预期恰好更新 1 行,实际输出:{out!r}" + + +# ------------------------------------------------------------------ HTTP 辅助 + + +def _consent(api: Any, token: str, activity_id: str, partner_id: str) -> Dict[str, Any]: + return api.ok( + "POST", + f"/activities/{activity_id}/mutual/consent", + token=token, + json={"partnerId": partner_id}, + ) + + +def _confirm(api: Any, token: str, activity_id: str, partner_id: str) -> Dict[str, Any]: + return api.ok( + "POST", + f"/activities/{activity_id}/mutual/confirm", + token=token, + json={"partnerId": partner_id}, + ) + + +def _decline(api: Any, token: str, activity_id: str, partner_id: str) -> Dict[str, Any]: + return api.ok( + "POST", + f"/activities/{activity_id}/mutual/decline", + token=token, + json={"partnerId": partner_id}, + ) + + +def _status(api: Any, token: str, activity_id: str, partner_id: str) -> Dict[str, Any]: + return api.ok("GET", f"/activities/{activity_id}/mutual/status/{partner_id}", token=token) + + +def _active_matches(api: Any, token: str) -> List[Dict[str, Any]]: + data = api.ok("GET", "/matches", token=token) or {} + return data.get("items") or [] + + +def _match_ids(api: Any, token: str) -> set: + return {m.get("id") for m in _active_matches(api, token)} + + +def _pair(new_user: Any) -> tuple: + """两个互相独立的真实用户。""" + return new_user(), new_user() + + +def _double_confirm(api: Any, a: Dict[str, Any], b: Dict[str, Any], activity_id: str) -> Dict[str, Any]: + """走完「建记录 → A 确认 → B 确认」,返回 B 侧确认响应。""" + _consent(api, a["token"], activity_id, b["user_id"]) + _confirm(api, a["token"], activity_id, b["user_id"]) + return _confirm(api, b["token"], activity_id, a["user_id"]) + + +# ------------------------------------------------------------------ 场景 1(INV-014) + + +def test_mutual_interest_does_not_create_match(api, new_user): + """INV-014:建立互选记录(表达意向)**不得**直接创建 Match。""" + a, b = _pair(new_user) + aid = _ensure_activity() + + _consent(api, a["token"], aid, b["user_id"]) + + assert _match_ids(api, a["token"]) == set(), "INV-014: 仅建立互选记录不得创建 Match(A 侧)" + assert _match_ids(api, b["token"]) == set(), "INV-014: 仅建立互选记录不得创建 Match(B 侧)" + + +# ------------------------------------------------------------------ 场景 2(INV-015) + + +def test_one_side_confirm_no_match(api, new_user): + """INV-015:只有一方确认时**不得**创建 Match。""" + a, b = _pair(new_user) + aid = _ensure_activity() + _consent(api, a["token"], aid, b["user_id"]) + + result = _confirm(api, a["token"], aid, b["user_id"]) + + assert result["bothConfirmed"] is False, f"单方确认不得判定为双确认: {result}" + assert result["matchCreated"] is False, f"INV-015: 单方确认不得创建 Match: {result}" + assert _match_ids(api, a["token"]) == set(), "INV-015: 单方确认后 A 侧不得出现 Match" + assert _match_ids(api, b["token"]) == set(), "INV-015: 单方确认后 B 侧不得出现 Match" + + +# ------------------------------------------------------------------ 场景 3 + + +def test_both_confirm_creates_match_episode(api, new_user): + """双方分别确认后创建 Match Episode,且对双方立即可见。""" + a, b = _pair(new_user) + aid = _ensure_activity() + _consent(api, a["token"], aid, b["user_id"]) + + first = _confirm(api, a["token"], aid, b["user_id"]) + assert first["bothConfirmed"] is False, f"第一方确认后不应双确认: {first}" + + second = _confirm(api, b["token"], aid, a["user_id"]) + assert second["bothConfirmed"] is True, f"双方确认后必须 bothConfirmed=true: {second}" + assert second["matchCreated"] is True, f"双确认必须创建 Match: {second}" + + ids_a = _match_ids(api, a["token"]) + ids_b = _match_ids(api, b["token"]) + assert len(ids_a) == 1, f"双确认后应恰好 1 个 active Match(A 侧),实得 {ids_a}" + assert ids_a == ids_b, f"Match 对双方必须一致可见(A={ids_a}, B={ids_b})" + + +# ------------------------------------------------------------------ 场景 4 + + +def test_existing_active_match_not_duplicated(api, new_user): + """AGENTS.md #19:已有 active Match 时再次双确认必须**复用**,不得重复创建。""" + a, b = _pair(new_user) + + _double_confirm(api, a, b, _ensure_activity("A")) + before = _match_ids(api, a["token"]) + assert len(before) == 1, f"前置条件失败:第一次双确认应产生 1 个 Match,实得 {before}" + + _double_confirm(api, a, b, _ensure_activity("B")) + after = _match_ids(api, a["token"]) + assert after == before, ( + f"AGENTS.md #19: 已有 active Match 不得重复创建(before={before}, after={after})" + ) + + +# ------------------------------------------------------------------ 场景 5 + + +def test_prior_unmatch_creates_new_episode(api, new_user): + """AGENTS.md #20:曾 Unmatch 后重新双确认必须开启新 Episode(episode +1)。""" + a, b = _pair(new_user) + + _double_confirm(api, a, b, _ensure_activity("A")) + first = _active_matches(api, a["token"]) + assert len(first) == 1, f"前置条件失败:应恰好 1 个 active Match,实得 {first}" + match_id = first[0]["id"] + episode_before = first[0].get("episode") + + api.ok("POST", f"/matches/{match_id}/unmatch", token=a["token"]) + assert _active_matches(api, a["token"]) == [], "Unmatch 后不得再有 active Match" + + _double_confirm(api, a, b, _ensure_activity("B")) + second = _active_matches(api, a["token"]) + assert len(second) == 1, f"重新确认后应恰好 1 个 active Match,实得 {second}" + episode_after = second[0].get("episode") + assert episode_after == (episode_before or 1) + 1, ( + f"AGENTS.md #20: 历史 Unmatch 后 episode 必须 +1" + f"(before={episode_before}, after={episode_after})" + ) + + +# ------------------------------------------------------------------ 场景 6 + + +def test_one_side_decline_no_match(api, new_user): + """一方拒绝则不创建 Match;对方随后确认也不得创建。""" + a, b = _pair(new_user) + aid = _ensure_activity() + _consent(api, a["token"], aid, b["user_id"]) + + declined = _decline(api, a["token"], aid, b["user_id"]) + assert declined["myStatus"] == "declined", f"拒绝后本方状态应为 declined: {declined}" + assert _match_ids(api, a["token"]) == set(), "拒绝后不得创建 Match" + + after = _confirm(api, b["token"], aid, a["user_id"]) + assert after["bothConfirmed"] is False, f"对方已拒绝,本方确认不得判定双确认: {after}" + assert _match_ids(api, b["token"]) == set(), "对方拒绝后,本方确认也不得创建 Match" + + +# ------------------------------------------------------------------ 场景 7(超时) + + +def test_timeout_handled(api, new_user): + """超时(expires_at)处理:已过期的互选记录**不得**再被推动成双确认。 + + 注意本用例先做一次正常确认作为**对照**,证明「确认闸门本身可用」, + 否则「过期后确认不生效」可能只是因为确认功能整体坏掉(假绿)。 + """ + a, b = _pair(new_user) + + # 对照:未过期的记录,确认必须生效 + aid_ok = _ensure_activity() + _consent(api, a["token"], aid_ok, b["user_id"]) + control = _confirm(api, a["token"], aid_ok, b["user_id"]) + assert control["myStatus"] == "accepted", f"对照用例失败:未过期记录确认应生效: {control}" + + # 目标:已过期(expires_at 回拨)的记录,确认不得生效 + aid_expired = _ensure_activity("B") + consent = _consent(api, a["token"], aid_expired, b["user_id"]) + _expire_consent(consent["id"]) + + expired_a = _confirm(api, a["token"], aid_expired, b["user_id"]) + assert expired_a["myStatus"] == "pending", f"过期后确认不得写入 accepted: {expired_a}" + + expired_b = _confirm(api, b["token"], aid_expired, a["user_id"]) + assert expired_b["bothConfirmed"] is False, f"过期记录不得判定双确认: {expired_b}" + assert expired_b["matchCreated"] is False, f"过期记录不得创建 Match: {expired_b}" + assert _match_ids(api, a["token"]) == set(), "过期互选不得创建 Match" + + +# ------------------------------------------------------------------ 场景 8(INV-006) + + +def test_single_interest_not_leaked(api, new_user): + """INV-006:单向意向不泄露 —— 本方未确认时不得回显对方是否已确认。""" + a, b = _pair(new_user) + aid = _ensure_activity() + _consent(api, a["token"], aid, b["user_id"]) + _confirm(api, a["token"], aid, b["user_id"]) # A 已确认,B 尚未 + + status_b = _status(api, b["token"], aid, a["user_id"]) + assert status_b["exists"] is True, f"记录应存在: {status_b}" + assert status_b["partnerStatus"] == "pending", ( + f"INV-006: 本方(B)未确认时不得回显对方(A)意向(实得 {status_b})" + ) + assert status_b["bothConfirmed"] is False, f"未双确认不得报 bothConfirmed: {status_b}" + + status_a = _status(api, a["token"], aid, b["user_id"]) + assert status_a["myStatus"] == "accepted", f"A 侧本方状态应为 accepted: {status_a}" + assert status_a["partnerStatus"] == "pending", f"B 未确认,A 侧不应看到 accepted: {status_a}" + + # 反证:B 确认后双方都应看到 accepted —— 证明上面的 pending 不是「永远 pending」的假绿 + _confirm(api, b["token"], aid, a["user_id"]) + status_b_after = _status(api, b["token"], aid, a["user_id"]) + assert status_b_after["partnerStatus"] == "accepted", ( + f"B 确认后应看到对方 accepted(反证前面的 pending 是条件性保密而非恒定): {status_b_after}" + ) diff --git a/tests/e2e/service_appointment_test.py b/tests/e2e/service_appointment_test.py index baa3b98..a052b48 100644 --- a/tests/e2e/service_appointment_test.py +++ b/tests/e2e/service_appointment_test.py @@ -1,35 +1,348 @@ """ -V1.1 服务预约 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18) +V1.1 服务预约 E2E —— 场景真实实现(Phase 23 Task 2.1,2026-09-19) -历史问题 --------- -原文件 7 个测试函数**全部**为 `pass` 空壳(注释「TODO: P19 实现后完善」), -依赖不存在的 `service_location_factory` / `appointment_factory` / `operator_client` -等 fixture(全局无 `conftest.py`),从未执行过, -却在 `docs/STATUS.md` 中被计为 P23-T08「服务预约 E2E ✅」。 +原文件 7 个测试函数**全部**为 `pass` 空壳(基线见 git b46b48e),依赖不存在的 +`service_location_factory` / `appointment_factory` / `operator_client` 等 fixture, +从未执行过,却在 `docs/STATUS.md` 中被计为 P23-T08「服务预约 E2E ✅」。 +本文件按文件头保留的场景清单逐条实现为**真实 HTTP 用例**。 -计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。 +实现纪律(与 conftest 一致,不得回退) +------------------------------------ +1. 不 mock 业务行为:走真实 HTTP + 真实数据库; +2. 每个用例使用**独立新用户**,避免用例互相污染; +3. 栈不可达时由 conftest `pytest.exit(returncode=4)`,**不退化为 skip**。 -当前状态 --------- -本文件现**不含任何测试函数**,pytest 收集 0 个用例。 +被验证的调用链(实测自 ServiceAppointmentController / ServiceAppointmentService) +-------------------------------------------------------------------------------- + POST /service-appointments {serviceLocationId, serviceType, preferredDate, userNote} + GET /service-appointments 我的预约列表 + GET /service-appointments/{id} 详情(仅本人可见) + POST /service-appointments/{id}/cancel {reason} 本人取消 + POST /service-appointments/operator/{id}/confirm 运营确认(service_appointment.confirm) + POST /service-appointments/operator/{id}/complete 运营完成(service_appointment.complete) + POST /service-appointments/operator/{id}/cancel 运营取消(service_appointment.confirm) -仍待真实实现的场景(需求不因移除空壳而丢失) ------------------------------------------- -1. 用户可提交服务预约 -2. 预约提交支持幂等(Idempotency-Key) -3. 后台可确认预约 -4. 同一预约同时取消与确认的并发安全性 -5. 我的预约列表正确 -6. 待确认状态可取消 -7. 已确认状态不可自行取消 +关于场景 7(原 `test_cancel_after_confirmation_denied`) +------------------------------------------------------- +原空壳里的用例名是「已确认状态不可自行取消」,但**全仓没有任何规格支撑该规则**: +需求基线里用户侧取消的权威口径只有归属隔离 —— +「修复预约 IDOR:普通用户创建、详情、列表、取消只能操作本人」(V1.1 计划第 488 行)。 +实测实现也确实**不按状态拦截用户取消**(`ServiceAppointmentService.cancelAppointment` +只校验 `userId` 归属)。Global Constraints 明令「不修改业务规则」, +因此本文件不把无规格支撑的期待写成断言,而是实现**有规格支撑**的那一条: +归属隔离 + 运营侧的状态闸门(`cancelAppointmentByOperator` 明确拒绝 +completed / cancelled 的预约)。原用例名的意图差异已单独上报,待产品裁定。 -现状补充(Phase 23 实测) ------------------------- -预约 owner 归属与数据范围已有真实后端测试: -`ServiceAppointmentOwnerGuardTest`、`ServiceAppointmentDataScopeTest`。 -预约创建链路在 `tests/e2e/run_v11_tests.py::test_06_service_appointment` -中有真实 HTTP 覆盖。E2E 层其余场景待补。 - -真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py` +已知不对称(**本文件不固化、留待裁定**) +-------------------------------------- +用户侧 `cancelAppointment` 不校验状态,因此**已完成**的预约也能被本人置为 cancelled; +而运营侧 `cancelAppointmentByOperator` 明确拒绝 completed。两侧口径不一致。 +本文件不对此断言(会固化一个可疑行为),以「观察到的实现」形式在报告中说明。 """ +from __future__ import annotations + +import os +import subprocess +from concurrent.futures import ThreadPoolExecutor +from datetime import date, timedelta +from typing import Any, Dict, Optional + +import pytest + +pytestmark = pytest.mark.e2e + +SERVICE_TYPE = "counselor_consultation" +IDEMPOTENCY_HEADER = "Idempotency-Key" + +# 预约日期取「今天 + 30 天」,避免用例随日历漂移 +PREFERRED_DATE = (date.today() + timedelta(days=30)).isoformat() + +# 与 tests/e2e/sql/seed_e2e_service_station.sql 中的固定 UUID 一一对应 +E2E_STATION_ID = "e2e00000-0000-0000-0000-0000000000b1" +# 该站点在 seed 中关联的运营(service_location_operators), +# 也就是唯一能确认/完成本用例预约的运营账号(数据范围约束,super_admin 不豁免) +STATION_OPERATOR = "e2e_station" + +PG_CONTAINER = os.environ.get("E2E_PG_CONTAINER", "danyang-match-postgres") +PG_DB = os.environ.get("E2E_PG_DB", "danyang_match") + +_station_ready = False + + +def _ensure_station_seed() -> None: + """幂等确保「E2E 服务站 + 站点运营关联」存在。 + + 两样都必需:新库无站点;且运营确认受数据范围约束 + (`assertOperatorScope`,super_admin 也不豁免),没有关联必然 403。 + """ + global _station_ready + if _station_ready: + return + seed_path = os.path.join(os.path.dirname(__file__), "sql", "seed_e2e_service_station.sql") + with open(seed_path, encoding="utf-8") as fh: + seed = fh.read() + proc = subprocess.run( + ["docker", "exec", "-i", PG_CONTAINER, "psql", "-U", "postgres", "-d", PG_DB, + "-v", "ON_ERROR_STOP=1", "-q"], + input=seed, capture_output=True, text=True, + ) + assert proc.returncode == 0, ( + f"服务站前置数据 seed 失败(需要容器 {PG_CONTAINER} 可访问):\n" + f"stdout={proc.stdout}\nstderr={proc.stderr}" + ) + _station_ready = True + + + +# ------------------------------------------------------------------ 辅助 + + +def _headers(key: Optional[str] = None) -> Dict[str, str]: + return {IDEMPOTENCY_HEADER: key} if key else {} + + +def _station_id(api: Any, token: str) -> str: + """确认 seed 出来的 E2E 服务站真实存在且可见,返回其 id。 + + 不用「取第一个 active 站点」:本机可能还有历史站点, + 而运营确认受数据范围约束 —— 必须是 seed 里关联过运营的那一个。 + """ + _ensure_station_seed() + locations = api.ok("GET", "/operator/service-locations", token=token) or [] + ids = {loc.get("id") for loc in locations} + assert E2E_STATION_ID in ids, ( + f"seed 的 E2E 服务站未出现在运营站点列表中(实得 {sorted(ids)})" + ) + return E2E_STATION_ID + + +def _create(api: Any, token: str, station_id: str, *, key: Optional[str] = None) -> Dict[str, Any]: + return api.ok( + "POST", + "/service-appointments", + token=token, + headers=_headers(key), + json={ + "serviceLocationId": station_id, + "serviceType": SERVICE_TYPE, + "preferredDate": PREFERRED_DATE, + "userNote": "E2E 预约用例", + }, + ) + + +def _my_appointments(api: Any, token: str) -> list: + return api.ok("GET", "/service-appointments", token=token) or [] + + +def _by_id(api: Any, token: str, appointment_id: str) -> Optional[Dict[str, Any]]: + for item in _my_appointments(api, token): + if item.get("id") == appointment_id: + return item + return None + + +def _operator_confirm(api: Any, token: str, appointment_id: str) -> Dict[str, Any]: + return api.ok("POST", f"/service-appointments/operator/{appointment_id}/confirm", token=token) + + +def _operator_complete(api: Any, token: str, appointment_id: str) -> Dict[str, Any]: + return api.ok("POST", f"/service-appointments/operator/{appointment_id}/complete", token=token) + + +def _user_cancel(api: Any, token: str, appointment_id: str) -> Dict[str, Any]: + return api.ok( + "POST", + f"/service-appointments/{appointment_id}/cancel", + token=token, + json={"reason": "E2E 取消"}, + ) + + +@pytest.fixture +def station_id(api, operator_token) -> str: + """seed 出来的 E2E 服务站 id(该站点已关联 STATION_OPERATOR)。""" + return _station_id(api, operator_token(STATION_OPERATOR)) + + +# ------------------------------------------------------------------ 场景 1 + + +def test_submit_appointment(api, new_user, station_id): + """用户可提交服务预约,初始状态为 submitted。""" + user = new_user() + + created = _create(api, user["token"], station_id) + + assert created["id"], f"创建预约必须返回 id: {created}" + assert created["status"] == "submitted", f"新建预约状态应为 submitted: {created}" + assert created["serviceLocationId"] == station_id, f"服务站应回显: {created}" + + +# ------------------------------------------------------------------ 场景 2 + + +def test_appointment_idempotent(api, new_user, station_id): + """同一把 Idempotency-Key 的重复投递必须重放首次结果,不得产生第二条预约。""" + user = new_user() + key = f"e2e-appt-{user['code']}" + + first = _create(api, user["token"], station_id, key=key) + second = _create(api, user["token"], station_id, key=key) + + assert second["id"] == first["id"], ( + f"同一幂等键必须重放同一预约(first={first['id']}, second={second['id']})" + ) + matching = [a for a in _my_appointments(api, user["token"]) if a.get("id") == first["id"]] + assert len(matching) == 1, f"幂等键重复投递不得产生第二条预约(实得 {len(matching)} 条)" + + +# ------------------------------------------------------------------ 场景 3 + + +def test_operator_can_confirm(api, new_user, operator_token, station_id): + """后台可确认预约:submitted → confirmed。 + + 必须用**该站点关联的运营**(数据范围约束):super_admin 也拿不到别站预约。 + """ + user = new_user() + created = _create(api, user["token"], station_id) + + confirmed = _operator_confirm(api, operator_token(STATION_OPERATOR), created["id"]) + + assert confirmed["status"] == "confirmed", f"运营确认后状态应为 confirmed: {confirmed}" + mine = _by_id(api, user["token"], created["id"]) + assert mine is not None and mine["status"] == "confirmed", ( + f"用户侧应看到同一状态(不是运营侧自嗨): {mine}" + ) + + +# ------------------------------------------------------------------ 场景 4 + + +def test_appointment_concurrent_safety(api, new_user, operator_token, station_id): + """同一预约「用户取消」与「运营确认」并发:不得出现 5xx,最终状态必须是二者之一。 + + 验收口径是**一致性**:无论谁赢,库里都只能停在 confirmed 或 cancelled 之一, + 不能两个都发生、也不能报 500。 + """ + user = new_user() + created = _create(api, user["token"], station_id) + appointment_id = created["id"] + op_token = operator_token(STATION_OPERATOR) + + def do_cancel() -> int: + return api.call( + "POST", f"/service-appointments/{appointment_id}/cancel", + token=user["token"], json={"reason": "并发取消"}, + ).status_code + + def do_confirm() -> int: + return api.call( + "POST", f"/service-appointments/operator/{appointment_id}/confirm", + token=op_token, + ).status_code + + with ThreadPoolExecutor(max_workers=2) as pool: + cancel_code = pool.submit(do_cancel) + confirm_code = pool.submit(do_confirm) + codes = [cancel_code.result(), confirm_code.result()] + + assert all(code < 500 for code in codes), ( + f"并发取消/确认不得出现服务端错误(实际状态码 {codes})" + ) + + final = _by_id(api, user["token"], appointment_id) + assert final is not None, "并发后预约必须仍可读" + assert final["status"] in {"confirmed", "cancelled"}, ( + f"并发后最终状态必须收敛到 confirmed 或 cancelled,实得 {final['status']}" + ) + + +# ------------------------------------------------------------------ 场景 5 + + +def test_my_appointments_list(api, new_user, station_id): + """我的预约列表只包含本人预约。""" + owner = new_user() + stranger = new_user() + created = _create(api, owner["token"], station_id) + + mine = _my_appointments(api, owner["token"]) + assert any(a.get("id") == created["id"] for a in mine), ( + f"本人列表必须包含刚创建的预约: {mine}" + ) + + strangers = _my_appointments(api, stranger["token"]) + assert all(a.get("id") != created["id"] for a in strangers), ( + "预约不得出现在他人列表中(数据范围隔离)" + ) + + +# ------------------------------------------------------------------ 场景 6 + + +def test_cancel_before_confirmation(api, new_user, station_id): + """待确认(submitted)状态可取消。""" + user = new_user() + created = _create(api, user["token"], station_id) + + cancelled = _user_cancel(api, user["token"], created["id"]) + + assert cancelled["status"] == "cancelled", f"取消后状态应为 cancelled: {cancelled}" + + +# ------------------------------------------------------------------ 场景 7(归属隔离 + 运营侧状态闸门) + + +def test_owner_scope_on_detail_and_cancel(api, new_user, station_id): + """归属隔离(V1.1 计划第 488 行):详情与取消只能操作本人预约。""" + owner = new_user() + stranger = new_user() + created = _create(api, owner["token"], station_id) + + detail = api.call("GET", f"/service-appointments/{created['id']}", token=stranger["token"]) + assert detail.status_code != 200, ( + f"他人不得读取我的预约详情(实际 HTTP {detail.status_code})" + ) + + cancel = api.call( + "POST", f"/service-appointments/{created['id']}/cancel", + token=stranger["token"], json={"reason": "越权取消"}, + ) + assert cancel.status_code != 200, ( + f"他人不得取消我的预约(实际 HTTP {cancel.status_code})" + ) + + still = _by_id(api, owner["token"], created["id"]) + assert still is not None and still["status"] == "submitted", ( + f"越权取消失败后,本人预约状态不得被改动: {still}" + ) + + +def test_operator_cannot_cancel_completed(api, new_user, operator_token, station_id): + """运营侧状态闸门:已完成的预约不得再被取消。 + + 这是 `cancelAppointmentByOperator` 明确实现的规则(拒绝 completed / cancelled), + 与用户侧不校验状态形成对照 —— 仅断言**已实现**的那一侧。 + """ + user = new_user() + op_token = operator_token(STATION_OPERATOR) + created = _create(api, user["token"], station_id) + + _operator_confirm(api, op_token, created["id"]) + completed = _operator_complete(api, op_token, created["id"]) + assert completed["status"] == "completed", f"前置条件失败:应可完成预约: {completed}" + + resp = api.call( + "POST", f"/service-appointments/operator/{created['id']}/cancel", + token=op_token, json={"reason": "试图取消已完成预约"}, + ) + assert resp.status_code != 200, ( + f"已完成预约不得被运营取消(实际 HTTP {resp.status_code}: {resp.text[:200]})" + ) + + final = _by_id(api, user["token"], created["id"]) + assert final is not None and final["status"] == "completed", ( + f"被拒绝的取消不得改动状态: {final}" + ) diff --git a/tests/e2e/sql/seed_e2e_activity.sql b/tests/e2e/sql/seed_e2e_activity.sql new file mode 100644 index 0000000..45aacc2 --- /dev/null +++ b/tests/e2e/sql/seed_e2e_activity.sql @@ -0,0 +1,45 @@ +-- ============================================================ +-- E2E 活动互选前置数据 seed(幂等,本机与 CI 通用) +-- +-- 为什么需要: +-- `activity_mutual_connection_consents.activity_id` 有 **FK 到 activities(id)** +-- (实测:约束名 activity_mutual_connection_consents_activity_id_fkey), +-- 而 `activities.organizer_org_id` 又有 FK 到 organizations(id) 且 NOT NULL。 +-- 全新库里 organizations / activities 都是**空表** —— 互选 E2E 无活动可挂。 +-- +-- 为什么不走运营 API 建: +-- 实测 `POST /operator/organizations` 当前**必然 500** +-- (`OrganizationService.createOrganization` 从不给 id 赋值, +-- 而 BaseEntity 用 `@TableId(type = IdType.INPUT)`、organizations.id 无 DB 默认值 +-- → `invalid input syntax for type uuid: ""`)。 +-- 该缺陷已单独记录,不属本 seed 的职责范围;此处按 E2E 基线数据的既有约定 +-- (同目录 seed_e2e_operators.sql)直接落库。 +-- +-- 幂等:全部 ON CONFLICT DO NOTHING,可重复执行。 +-- 固定 UUID:测试按常量引用,不依赖「查库拿 id」。 +-- ============================================================ + +-- type 必须落在生产 chk_org_type 白名单内: +-- women_federation / trade_union / charity_center / volunteer_team / civil_affairs_partner / other +INSERT INTO organizations (id, name, type, status) +VALUES ('e2e00000-0000-0000-0000-0000000000a1', 'E2E 测试机构', 'charity_center', 'active') +ON CONFLICT (id) DO NOTHING; + +-- 两个活动:互选用例需要「同一对用户在两个不同活动里各走一次」 +-- (唯一约束是 (activity_id, user_a_id, user_b_id),换活动即换记录)。 +INSERT INTO activities ( + id, title, organizer_org_id, start_at, end_at, + location_name, location_address, total_limit, + need_review, status, created_by, activity_type +) VALUES + ('e2e00000-0000-0000-0000-0000000000a2', 'E2E 互选测试活动 A', + 'e2e00000-0000-0000-0000-0000000000a1', + NOW() - INTERVAL '1 day', NOW() + INTERVAL '30 day', + 'E2E 测试地点', 'E2E 测试地址', 30, + false, 'ongoing', 'e2e00000-0000-0000-0000-000000000001', 'matchmaking'), + ('e2e00000-0000-0000-0000-0000000000a3', 'E2E 互选测试活动 B', + 'e2e00000-0000-0000-0000-0000000000a1', + NOW() - INTERVAL '1 day', NOW() + INTERVAL '30 day', + 'E2E 测试地点', 'E2E 测试地址', 30, + false, 'ongoing', 'e2e00000-0000-0000-0000-000000000001', 'matchmaking') +ON CONFLICT (id) DO NOTHING; diff --git a/tests/e2e/sql/seed_e2e_service_station.sql b/tests/e2e/sql/seed_e2e_service_station.sql new file mode 100644 index 0000000..614124f --- /dev/null +++ b/tests/e2e/sql/seed_e2e_service_station.sql @@ -0,0 +1,39 @@ +-- ============================================================ +-- E2E 服务预约前置数据 seed(幂等,本机与 CI 通用) +-- +-- 为什么需要(两个缺一不可的原因): +-- 1. 全新库里 `service_locations` 是**空表** —— 没有任何迁移种站点, +-- 本机那两个站(含旧脚本留下的「E2E 预约测试站」)都是手工产物。 +-- 没有站点就没有 serviceLocationId,预约根本创建不了。 +-- 2. 运营确认/完成/取消受**数据范围**约束,不是「有权限就行」: +-- `ServiceAppointmentService.assertOperatorScope` 走 +-- `canAccessByOperator`,条件为 +-- sa.assigned_operator_id = :operatorId +-- OR EXISTS(service_location_operators 中该站点关联了 :operatorId) +-- —— super_admin 也**不豁免**(实测 e2e_admin 确认预约返回 403)。 +-- 因此必须显式建立「站点 → 运营」关联。 +-- +-- 关联的运营是 seed_e2e_operators.sql 建的 `e2e_station`(service_station_operator), +-- 它同时持有 service_location.view 与 service_appointment.confirm / .complete。 +-- +-- 幂等:全部 ON CONFLICT DO NOTHING,可重复执行。 +-- 固定 UUID:测试按常量引用(见 service_appointment_test.py 的 E2E_STATION_ID)。 +-- ============================================================ + +INSERT INTO service_locations (id, name, short_name, address, status) +VALUES ( + 'e2e00000-0000-0000-0000-0000000000b1', + 'E2E 预约测试服务站', + 'E2E 服务站', + 'E2E 测试地址(仅预约用例使用)', + 'active' +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO service_location_operators (service_location_id, operator_id, created_by) +VALUES ( + 'e2e00000-0000-0000-0000-0000000000b1', + 'e2e00000-0000-0000-0000-000000000004', -- e2e_station + 'e2e00000-0000-0000-0000-000000000001' -- e2e_admin(created_by 仅审计) +) +ON CONFLICT (service_location_id, operator_id) DO NOTHING;