Files
danyang-match/tests/e2e/service_appointment_test.py
WorkBuddy 3c2b967274
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
release: freeze 2026-10-15 validated release baseline
2026-10-15 09:47:31 +08:00

356 lines
14 KiB
Python
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.
"""
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, encoding="utf-8",
)
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 行):详情与取消只能操作本人预约。
断言必须是 **4xx**,不能只写 `!= 200`:后者会把 500 当成「越权被拒」放行。
此类服务端错误曾长期被这两处弱断言掩盖(`cancelAppointment` 抛裸
RuntimeException → 兜底 500),只有并发用例的 `< 500` 断言才咬了它一口。
"""
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 400 <= detail.status_code < 500, (
f"他人读取我的预约详情应被 4xx 拒绝(实际 HTTP {detail.status_code})"
)
cancel = api.call(
"POST", f"/service-appointments/{created['id']}/cancel",
token=stranger["token"], json={"reason": "越权取消"},
)
assert 400 <= cancel.status_code < 500, (
f"他人取消我的预约应被 4xx 拒绝(实际 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),
与用户侧不校验状态形成对照 —— 仅断言**已实现**的那一侧。
断言 4xx 而非 `!= 200`:状态冲突应当是 409,不是 500。
"""
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 400 <= resp.status_code < 500, (
f"已完成预约不得被运营取消,且必须是 4xx(实际 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}"
)