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
+343 -28
View File
@@ -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}"
)