feat(e2e): Phase 23 Task 2.1 —— 活动互选 8 场景 + 服务预约 8 场景真实实现(16 个真实用例)

背景:`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 出口)。
This commit is contained in:
WorkBuddy
2026-09-19 12:28:16 +08:00
parent 2ae72aabe7
commit e666ee6f8a
5 changed files with 902 additions and 79 deletions
+45
View File
@@ -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;