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
356 lines
14 KiB
Python
356 lines
14 KiB
Python
"""
|
||
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}"
|
||
)
|