Files
danyang-match/tests/e2e/service_appointment_test.py
T
WorkBuddy e666ee6f8a 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 出口)。
2026-09-19 12:28:16 +08:00

349 lines
14 KiB
Python
Raw 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.
"""
V1.1 服务预约 E2E —— 场景真实实现(Phase 23 Task 2.1,2026-09-19)
原文件 7 个测试函数**全部**为 `pass` 空壳(基线见 git b46b48e),依赖不存在的
`service_location_factory` / `appointment_factory` / `operator_client` 等 fixture,
从未执行过,却在 `docs/STATUS.md` 中被计为 P23-T08「服务预约 E2E ✅」。
本文件按文件头保留的场景清单逐条实现为**真实 HTTP 用例**。
实现纪律(与 conftest 一致,不得回退)
------------------------------------
1. 不 mock 业务行为:走真实 HTTP + 真实数据库;
2. 每个用例使用**独立新用户**,避免用例互相污染;
3. 栈不可达时由 conftest `pytest.exit(returncode=4)`,**不退化为 skip**。
被验证的调用链(实测自 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)
关于场景 7(原 `test_cancel_after_confirmation_denied`)
-------------------------------------------------------
原空壳里的用例名是「已确认状态不可自行取消」,但**全仓没有任何规格支撑该规则**:
需求基线里用户侧取消的权威口径只有归属隔离 ——
「修复预约 IDOR:普通用户创建、详情、列表、取消只能操作本人」(V1.1 计划第 488 行)。
实测实现也确实**不按状态拦截用户取消**(`ServiceAppointmentService.cancelAppointment`
只校验 `userId` 归属)。Global Constraints 明令「不修改业务规则」,
因此本文件不把无规格支撑的期待写成断言,而是实现**有规格支撑**的那一条:
归属隔离 + 运营侧的状态闸门(`cancelAppointmentByOperator` 明确拒绝
completed / cancelled 的预约)。原用例名的意图差异已单独上报,待产品裁定。
已知不对称(**本文件不固化、留待裁定**)
--------------------------------------
用户侧 `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}"
)