test(e2e): Phase 23B — rebuild E2E infrastructure; fix 6 real prod defects it exposed
Phase 23 closed as PARTIAL: fake coverage was gone, but the E2E layer itself
was still empty — no conftest.py anywhere, ~15 fixtures that never existed,
6 files crashing at import on os.environ["E2E_PASSWORD"], and CI never ran E2E.
STATUS's "10 E2E files / ~80+ cases ✅" described files that had never executed.
Infrastructure delivered:
- tests/e2e/conftest.py: ApiClient (trust_env=False — a host http_proxy turns
localhost calls into 502), operator_token(username) / new_user / admin_token
fixtures, and a session probe that calls pytest.exit(4) when the stack is
unreachable. NOT skip: skip would launder "did not run" into "passed".
- pytest.ini, tests/e2e/requirements.txt, and tests/e2e/sql/
seed_e2e_operators.sql — migrations contain NO operator seed at all, so a
fresh database has zero logins; the seed creates 6 e2e accounts with role
bindings, idempotent, password injected via `psql -v pwd`.
- CI job `e2e`: compose up -> poll /health/live (timeout = fail) -> seed ->
pytest -> always teardown. Gate D-23-D is now closed.
- 8 E2E files migrated off import-time env reads; tautological assertions
(`x == t or not x`), a counselor token standing in for content_editor, and a
bare `status_code == 200` claiming to prove INV-020 were replaced with real
permission/behavior assertions.
Six real production defects found only by running against real PostgreSQL
(H2 has zero coverage for all of them):
- D-23B-A (Critical): stale V042__add_appointment_operator_register_permission.sql
left in target/classes; src had replaced it with V042__add_service_location_
announcements.sql. Packing without clean put two V042 in the jar -> Flyway
"Found more than one migration with version 042" -> app would not start.
Guard added: testBuildOutputHasNoDuplicateMigrationVersions scans build output
(src is clean, so only this catches it).
- D-23B-B (High): non-UUID path variable reached the DB -> 500 + leaked
`invalid input syntax for type uuid`. Now rejected at the controller.
- D-23B-C (High): 6 E2E files raised KeyError at import -> never collected.
- D-23B-E (Critical): permissions = 48, role_permissions for super_admin = 18,
matchmaker = 0. V015's "grant all to super_admin" was a point-in-time snapshot;
the 30 permissions added later were never granted. On a fresh stack an admin
cannot create a service station and matchmakers can do nothing. V044 restores
super_admin to its documented definition (not an expansion); the matchmaker
permission set needs a product decision and is left as a residual.
- D-23B-I (Medium): IllegalArgumentException (e.g. invalid activity type) was
unmapped -> 500. Now BAD_REQUEST.
- D-23B-J (High): `operator does not exist: uuid = character varying` — Java
ids are String, columns are uuid, PostgreSQL does not implicitly cast in
comparisons. Fixed at the source with JDBC `stringtype=unspecified` (docker
profile); production datasource comes from deploy env and needs the same.
One judgement retracted: an initial read suggested service_appointment.register
was missing. It is not — the endpoint uses service.assisted_registration, seeded
in V029, and the code deliberately avoids a duplicate permission. Nothing was
changed on that basis.
Verification: 33 E2E passed against a live stack; unreachable stack and a
missing operator password both exit 4 (never skip/pass); backend regression
656 / 0 failures / 0 errors / 3 skipped (baseline 655, +1 guard, negatively
validated by injecting a duplicate V042 -> exactly one test turned red).
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
善缘丹阳 E2E 测试基建(pytest conftest)。
|
||||
|
||||
设计纪律(Phase 23 复验后确立,不得回退):
|
||||
|
||||
1. **栈不可达 = 退出,不是跳过。** `skip` 会把"未执行"伪装成"通过",
|
||||
这正是 Phase 23 清掉的 44 个 `pass` 空壳的另一种变体。因此本文件在
|
||||
活栈不可达时调用 `pytest.exit(returncode=4)` —— 明确报"没跑成"。
|
||||
2. **不 mock 业务行为。** 所有 fixture 走真实 HTTP 与真实数据库;
|
||||
唯一被替换的是微信登录(后端 `MockWechatLoginProvider` 默认激活)。
|
||||
3. **用户隔离。** 每个用例拿独立新用户(mock 登录同 code 同 openid,
|
||||
换 code 即换人),避免用例互相污染与"上一个用例造的数据让本用例变绿"。
|
||||
|
||||
环境变量:
|
||||
- `E2E_BASE_URL` 活栈地址,默认 http://localhost:8085/api/v1
|
||||
- `E2E_OPERATOR_PASSWORD` 运营账号密码(必需;CI 由生成的 .env 提供)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
DEFAULT_BASE_URL = "http://localhost:8085/api/v1"
|
||||
HEALTH_PATH = "/health/live"
|
||||
|
||||
|
||||
class ApiClient:
|
||||
"""极薄 HTTP 客户端:只负责超时与错误上下文,不做业务判断。"""
|
||||
|
||||
def __init__(self, base_url: str, timeout: float = 15.0) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
# E2E 打的是 localhost 活栈。若宿主机设置了 http_proxy,
|
||||
# requests 默认会把 localhost 请求也发给代理并返回 502,
|
||||
# 表现为"连接失败"而非真实的业务结果——直接绕开环境变量。
|
||||
self.session.trust_env = False
|
||||
|
||||
def call(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
token: Optional[str] = None,
|
||||
json: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> requests.Response:
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
return self.session.request(
|
||||
method,
|
||||
f"{self.base_url}{path}",
|
||||
headers=headers,
|
||||
json=json,
|
||||
params=params,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
def ok(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
"""断言 HTTP 200 且业务 `success=true`,返回 `data`。"""
|
||||
resp = self.call(method, path, **kwargs)
|
||||
assert resp.status_code == 200, (
|
||||
f"{method} {path} -> HTTP {resp.status_code}: {resp.text[:400]}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert body.get("success") is True, f"{method} {path} -> 业务失败: {body}"
|
||||
return body.get("data")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fixtures
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url() -> str:
|
||||
return os.environ.get("E2E_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def api(base_url: str) -> ApiClient:
|
||||
return ApiClient(base_url)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def live_stack(api: ApiClient) -> None:
|
||||
"""会话级探活。栈不可达 → 退出(退出码 4),绝不 skip。"""
|
||||
last = "unknown"
|
||||
for _ in range(3):
|
||||
try:
|
||||
resp = api.call("GET", HEALTH_PATH)
|
||||
if resp.status_code == 200:
|
||||
return
|
||||
last = f"HTTP {resp.status_code}"
|
||||
except requests.RequestException as exc:
|
||||
last = repr(exc)
|
||||
time.sleep(2)
|
||||
pytest.exit(
|
||||
f"E2E 需要活栈:GET {api.base_url}{HEALTH_PATH} 不可达({last})。\n"
|
||||
f"请先起栈(infrastructure/docker-compose.yml)或设置 E2E_BASE_URL。",
|
||||
returncode=4,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def operator_password() -> str:
|
||||
pwd = os.environ.get("E2E_OPERATOR_PASSWORD")
|
||||
if not pwd:
|
||||
pytest.exit("E2E_OPERATOR_PASSWORD 未设置:E2E 无法登录运营账号,不能执行", returncode=4)
|
||||
return pwd
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def admin_token(api: ApiClient, operator_password: str) -> str:
|
||||
data = api.ok(
|
||||
"POST",
|
||||
"/auth/operator/login",
|
||||
json={"username": "admin", "password": operator_password},
|
||||
)
|
||||
token = data.get("accessToken")
|
||||
assert token, "admin 登录未返回 accessToken"
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operator_token(
|
||||
api: ApiClient, operator_password: str
|
||||
) -> Callable[[str], str]:
|
||||
"""按用户名取运营 token(会话内缓存)。用法:`operator_token("counselor01")`。"""
|
||||
cache: Dict[str, str] = {}
|
||||
|
||||
def _get(username: str) -> str:
|
||||
if username not in cache:
|
||||
data = api.ok(
|
||||
"POST",
|
||||
"/auth/operator/login",
|
||||
json={"username": username, "password": operator_password},
|
||||
)
|
||||
token = data.get("accessToken")
|
||||
assert token, f"{username} 登录未返回 accessToken"
|
||||
cache[username] = token
|
||||
return cache[username]
|
||||
|
||||
return _get
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def new_user(api: ApiClient) -> Callable[[], Dict[str, Any]]:
|
||||
"""注册并登录一个全新用户,返回 {'token','user_id','code'}。"""
|
||||
created: list = []
|
||||
|
||||
def _create() -> Dict[str, Any]:
|
||||
code = f"e2e-{uuid.uuid4()}"
|
||||
data = api.ok("POST", "/auth/wechat/login", json={"code": code})
|
||||
user = {
|
||||
"token": data.get("accessToken"),
|
||||
"user_id": data.get("userId"),
|
||||
"code": code,
|
||||
}
|
||||
assert user["token"] and user["user_id"], f"新用户登录返回异常: {data}"
|
||||
created.append(user)
|
||||
return user
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_token(new_user: Callable[[], Dict[str, Any]]) -> str:
|
||||
"""独立新用户的 token。"""
|
||||
return new_user()["token"]
|
||||
@@ -1,72 +1,42 @@
|
||||
"""
|
||||
E2E 测试:Phase 23 - 内容管理 E2E
|
||||
内容管理 E2E(内容编辑员权限边界 + 文章生命周期)。
|
||||
|
||||
迁移说明:原文件在 import 期就读 `os.environ["E2E_PASSWORD"]`,
|
||||
栈没配该变量时**整个文件收集失败**(从未执行过)。现改为 conftest fixture。
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = 'http://localhost:8085/api/v1'
|
||||
E2E_PASSWORD = os.environ["E2E_PASSWORD"]
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
def get_admin_token():
|
||||
r = requests.post(f'{BASE_URL}/auth/operator/login', json={'username': 'admin', 'password': E2E_PASSWORD})
|
||||
return r.json()['data']['accessToken']
|
||||
|
||||
def auth_header(token):
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
def test_get_content_categories():
|
||||
r = requests.get(f'{BASE_URL}/content/categories')
|
||||
assert r.status_code == 200
|
||||
data = r.json()['data']
|
||||
def test_content_categories_are_listable(api, user_token):
|
||||
data = api.ok("GET", "/content/categories", token=user_token)
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_create_content_category():
|
||||
token = get_admin_token()
|
||||
r = requests.post(
|
||||
f'{BASE_URL}/content/operator/categories',
|
||||
headers=auth_header(token),
|
||||
json={'name': '测试分类', 'code': 'test_cat_001', 'sortOrder': 0}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()['data']['code'] == 'test_cat_001'
|
||||
|
||||
def test_create_and_publish_article():
|
||||
token = get_admin_token()
|
||||
# 创建文章
|
||||
create_r = requests.post(
|
||||
f'{BASE_URL}/content/operator/articles',
|
||||
headers=auth_header(token),
|
||||
json={'title': '测试文章', 'summary': '测试摘要', 'content': '测试内容', 'sortOrder': 0}
|
||||
def test_article_lifecycle_draft_publish_offline(api, operator_token):
|
||||
"""文章生命周期:草稿 → 发布 → 下架,状态必须真实流转。"""
|
||||
token = operator_token("e2e_content")
|
||||
created = api.ok(
|
||||
"POST",
|
||||
"/content/operator/articles",
|
||||
token=token,
|
||||
json={"title": "E2E 文章", "summary": "E2E 摘要", "content": "E2E 正文", "sortOrder": 0},
|
||||
)
|
||||
assert create_r.status_code == 200
|
||||
article_id = create_r.json()['data']['id']
|
||||
assert create_r.json()['data']['status'] == 'draft'
|
||||
# 发布
|
||||
pub_r = requests.post(
|
||||
f'{BASE_URL}/content/operator/articles/{article_id}/publish',
|
||||
headers=auth_header(token)
|
||||
)
|
||||
assert pub_r.status_code == 200
|
||||
assert pub_r.json()['data']['status'] == 'published'
|
||||
# 用户端可访问
|
||||
r = requests.get(f'{BASE_URL}/content/articles/{article_id}')
|
||||
assert r.status_code == 200
|
||||
# 下架
|
||||
off_r = requests.post(
|
||||
f'{BASE_URL}/content/operator/articles/{article_id}/offline',
|
||||
headers=auth_header(token)
|
||||
)
|
||||
assert off_r.status_code == 200
|
||||
assert off_r.json()['data']['status'] == 'offline'
|
||||
article_id = created["id"]
|
||||
assert created["status"] == "draft", f"新建文章状态应为 draft:{created}"
|
||||
|
||||
def test_content_stats():
|
||||
r = requests.get(f'{BASE_URL}/operator/analytics/v11/content')
|
||||
assert r.status_code == 200
|
||||
data = r.json()['data']
|
||||
assert 'totalPublishedArticles' in data
|
||||
assert 'totalViews' in data
|
||||
assert 'topArticles' in data
|
||||
published = api.ok("POST", f"/content/operator/articles/{article_id}/publish", token=token)
|
||||
assert published["status"] == "published", f"发布后状态错误:{published}"
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
offline = api.ok("POST", f"/content/operator/articles/{article_id}/offline", token=token)
|
||||
assert offline["status"] == "offline", f"下架后状态错误:{offline}"
|
||||
|
||||
|
||||
def test_content_editor_cannot_read_user_matching_data(api, operator_token):
|
||||
"""INV-019:内容编辑员不得访问用户婚恋数据。"""
|
||||
token = operator_token("e2e_content")
|
||||
resp = api.call("GET", "/operator/users", token=token)
|
||||
assert resp.status_code == 403, (
|
||||
f"内容编辑员竟能访问用户库:HTTP {resp.status_code} {resp.text[:200]}"
|
||||
)
|
||||
|
||||
@@ -1,41 +1,75 @@
|
||||
"""
|
||||
V1.1 咨询 / 服务站 / 内容编辑员权限 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18)
|
||||
婚恋咨询权限 E2E(INV-012 只能访问分配的咨询 / INV-013 红娘不能读咨询正文)。
|
||||
|
||||
历史问题
|
||||
--------
|
||||
原文件 10 个测试函数**全部**为 `pass` 空壳(注释「TODO: P13A / P17 / P19 实现后完善」),
|
||||
依赖不存在的 `counselor_client` / `counseling_service_factory` / `content_editor_client`
|
||||
等 fixture(全局无 `conftest.py`),从未执行过,
|
||||
却在 `docs/STATUS.md` 中被计为 P23-T07「咨询权限 ✅」。
|
||||
|
||||
计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。
|
||||
|
||||
当前状态
|
||||
--------
|
||||
本文件现**不含任何测试函数**,pytest 收集 0 个用例。
|
||||
|
||||
仍待真实实现的场景(需求不因移除空壳而丢失)
|
||||
------------------------------------------
|
||||
1. Counselor 只能看到被分配给自己的咨询(INV-012)
|
||||
2. Counselor 不可查看其他咨询师的服务
|
||||
3. 红娘不可查看咨询正文(INV-013)
|
||||
4. 普通管理员默认不可查看咨询敏感备注
|
||||
5. 咨询管理员可分配咨询人员
|
||||
6. 咨询过程中可转交风控
|
||||
7. 服务站人员不能浏览全平台用户(INV-018)
|
||||
8. 服务站人员可处理本服务站预约
|
||||
9. 内容编辑员不能访问用户婚恋档案(INV-019)
|
||||
10. 内容编辑员可管理文章内容
|
||||
|
||||
现状补充(Phase 23 实测)
|
||||
------------------------
|
||||
- INV-012 已由 `CounselingPhase13ATest` 的 4 个真实用例覆盖;
|
||||
- INV-013 / INV-019 已由 Phase 23 新增的真实行为用例覆盖
|
||||
(`V11InvariantsTest#inv013MatchmakerCannotReadCounselingBody`、
|
||||
`V11InvariantsTest#inv019ContentEditorHasNoUserDataPermission`,
|
||||
走真实 `CounselingService.getDetail`,已通过负向注入验证);
|
||||
- INV-018 已由 `ServiceAppointmentDataScopeTest` 覆盖。
|
||||
即:E2E 层待补,但不变量本身已有真实覆盖。
|
||||
|
||||
真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py`
|
||||
关键设计:**先证明本人能读到正文**,再证明他人读不到。
|
||||
只断言"他人读不到"会被"全局脱敏/接口全量报错"轻易蒙混过关——
|
||||
那证明的不是 INV-013,而是接口坏了。
|
||||
"""
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
SENSITIVE = "SENSITIVE-BODY-MARKER"
|
||||
|
||||
|
||||
def _create_counseling(api, token: str) -> str:
|
||||
data = api.ok(
|
||||
"POST",
|
||||
"/counseling/services",
|
||||
token=token,
|
||||
json={
|
||||
"serviceType": "relationship_guidance",
|
||||
"requestSource": "user_self",
|
||||
"userRequestSummary": SENSITIVE,
|
||||
"preferredServiceMode": "online",
|
||||
"preferredTime": "morning",
|
||||
},
|
||||
)
|
||||
assert data["status"] == "pending", f"咨询初始状态异常:{data}"
|
||||
return data["id"]
|
||||
|
||||
|
||||
def test_owner_can_read_own_request_summary(api, user_token):
|
||||
"""反证:接口确实会返回正文,"读不到"才可能是权限生效而非接口失效。"""
|
||||
service_id = _create_counseling(api, user_token)
|
||||
data = api.ok("GET", f"/counseling/services/{service_id}", token=user_token)
|
||||
assert data["userRequestSummary"] == SENSITIVE, f"本人竟读不到自己的咨询正文:{data}"
|
||||
|
||||
|
||||
def test_matchmaker_has_no_counseling_view(api, user_token, operator_token):
|
||||
"""INV-013:红娘不具备 counseling.service.view,读取咨询详情必须被拒。"""
|
||||
service_id = _create_counseling(api, user_token)
|
||||
resp = api.call(
|
||||
"GET",
|
||||
f"/counseling/services/operator/{service_id}",
|
||||
token=operator_token("e2e_matchmaker"),
|
||||
)
|
||||
assert resp.status_code == 403, (
|
||||
f"红娘竟被允许访问咨询详情:HTTP {resp.status_code} {resp.text[:200]}"
|
||||
)
|
||||
assert SENSITIVE not in resp.text, "响应体泄漏了咨询正文"
|
||||
|
||||
|
||||
def test_counselor_cannot_read_unassigned_service(api, user_token, operator_token):
|
||||
"""INV-012:咨询师只能访问明确分配给自己的服务。"""
|
||||
service_id = _create_counseling(api, user_token)
|
||||
resp = api.call(
|
||||
"GET",
|
||||
f"/counseling/services/operator/{service_id}",
|
||||
token=operator_token("e2e_counselor"),
|
||||
)
|
||||
body = resp.json()
|
||||
assert body.get("success") is False, f"未分配的咨询竟被咨询师读到:{body}"
|
||||
assert SENSITIVE not in resp.text, "响应体泄漏了咨询正文"
|
||||
|
||||
|
||||
def test_non_uuid_service_id_is_rejected_not_500(api, user_token):
|
||||
"""
|
||||
非 UUID 的 path 参数应被判为 400,而不是打到数据库抛出
|
||||
`operator does not exist: uuid = character varying` 变成 500。
|
||||
"""
|
||||
resp = api.call("GET", "/counseling/services/not-a-uuid", token=user_token)
|
||||
assert resp.status_code != 500, (
|
||||
f"非法 ID 触发了 500(数据库类型异常外泄):HTTP {resp.status_code}"
|
||||
)
|
||||
assert 400 <= resp.status_code < 500, f"非法 ID 应返回 4xx,实际 {resp.status_code}"
|
||||
|
||||
@@ -1,124 +1,58 @@
|
||||
"""
|
||||
E2E 测试:Phase 13A - 公益婚恋咨询与婚前辅导
|
||||
验证 INV-012(Counselor assigned scope)等不变量
|
||||
婚恋咨询 / 婚前辅导 E2E(咨询主流程)。
|
||||
|
||||
迁移说明:原文件依赖 3 个环境变量(import 期即 KeyError → 从未被收集执行),
|
||||
并直连 psycopg2 校验通知落库,且用不存在的固定 ID 做断言。
|
||||
现统一改为 conftest fixture + 真实 API;数据库断言交后端集成测试,E2E 只走 HTTP。
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8085/api/v1"
|
||||
E2E_PASSWORD = os.environ["E2E_PASSWORD"]
|
||||
E2E_USER_TOKEN = os.environ["E2E_USER_TOKEN"]
|
||||
E2E_DB_PASSWORD = os.environ["E2E_DB_PASSWORD"]
|
||||
|
||||
def get_user_token():
|
||||
return E2E_USER_TOKEN
|
||||
|
||||
def get_counselor_token():
|
||||
r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "counselor01", "password": E2E_PASSWORD})
|
||||
return r.json()["data"]["accessToken"]
|
||||
|
||||
def auth_header(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
# ---- P13A-T05: 获取咨询介绍 ----
|
||||
def test_get_counseling_intro():
|
||||
r = requests.get(f"{BASE_URL}/counseling/services/intro")
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 3
|
||||
types = {item["type"] for item in data}
|
||||
assert "relationship_guidance" in types
|
||||
assert "premarital_guidance" in types
|
||||
def test_counseling_intro_lists_guidance_types(api, user_token):
|
||||
data = api.ok("GET", "/counseling/services/intro", token=user_token)
|
||||
assert isinstance(data, list) and len(data) >= 2, f"咨询介绍异常:{data}"
|
||||
types = {item.get("type") for item in data}
|
||||
for expected in ("relationship_guidance", "premarital_guidance"):
|
||||
assert expected in types, f"咨询介绍缺少 {expected}:{sorted(types)}"
|
||||
|
||||
|
||||
# ---- P13A-T06: 申请咨询 ----
|
||||
def test_create_counseling():
|
||||
token = get_user_token()
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/counseling/services",
|
||||
headers=auth_header(token),
|
||||
json={"serviceType": "relationship_guidance", "userRequestSummary": "恋爱沟通困难"}
|
||||
def test_create_counseling_is_user_self_and_pending(api, user_token):
|
||||
"""用户端创建咨询:source 必须恒为 user_self,不可由请求体伪造。"""
|
||||
data = api.ok(
|
||||
"POST",
|
||||
"/counseling/services",
|
||||
token=user_token,
|
||||
json={"serviceType": "relationship_guidance", "userRequestSummary": "恋爱沟通困难"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
assert data["status"] == "pending"
|
||||
assert data["serviceType"] == "relationship_guidance"
|
||||
assert data["requestSource"] == "user_self"
|
||||
counseling_id = data["id"]
|
||||
|
||||
# 验证通知已发送(通过查询数据库)
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(f"dbname=danyang_match user=postgres password={E2E_DB_PASSWORD} host=localhost port=5434")
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM notifications WHERE business_id = %s AND type = 'counseling'", (counseling_id,))
|
||||
count = cur.fetchone()[0]
|
||||
assert count >= 1
|
||||
cur.close()
|
||||
conn.close()
|
||||
assert data["requestSource"] == "user_self", f"来源被污染:{data}"
|
||||
|
||||
|
||||
# ---- P13A-T07: 获取我的咨询列表 ----
|
||||
def test_get_my_counselings():
|
||||
token = get_user_token()
|
||||
r = requests.get(f"{BASE_URL}/counseling/services", headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
assert isinstance(r.json()["data"], list)
|
||||
def test_my_counseling_list_is_accessible(api, user_token):
|
||||
api.ok("POST", "/counseling/services", token=user_token,
|
||||
json={"serviceType": "relationship_guidance", "userRequestSummary": "列表用例"})
|
||||
data = api.ok("GET", "/counseling/services", token=user_token)
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
# ---- P13A-T08: 咨询详情(无权访问他人记录) ----
|
||||
def test_counseling_detail_no_access():
|
||||
token = get_user_token()
|
||||
# 尝试访问不属于自己的咨询
|
||||
r = requests.get(f"{BASE_URL}/counseling/services/non-existent-id", headers=auth_header(token))
|
||||
assert r.status_code in (400, 404)
|
||||
|
||||
|
||||
# ---- P13A-T09: 取消咨询 ----
|
||||
def test_cancel_counseling():
|
||||
token = get_user_token()
|
||||
# 先创建再取消
|
||||
create_r = requests.post(
|
||||
f"{BASE_URL}/counseling/services",
|
||||
headers=auth_header(token),
|
||||
json={"serviceType": "emotional_support", "userRequestSummary": "测试取消"}
|
||||
def test_cancel_counseling(api, user_token):
|
||||
created = api.ok(
|
||||
"POST",
|
||||
"/counseling/services",
|
||||
token=user_token,
|
||||
json={"serviceType": "emotional_support", "userRequestSummary": "测试取消"},
|
||||
)
|
||||
counseling_id = create_r.json()["data"]["id"]
|
||||
cancel_r = requests.post(
|
||||
f"{BASE_URL}/counseling/services/{counseling_id}/cancel",
|
||||
headers=auth_header(token)
|
||||
cancelled = api.ok(
|
||||
"POST", f"/counseling/services/{created['id']}/cancel", token=user_token
|
||||
)
|
||||
assert cancel_r.status_code == 200
|
||||
assert cancel_r.json()["data"]["status"] == "cancelled"
|
||||
assert cancelled["status"] == "cancelled", f"取消后状态错误:{cancelled}"
|
||||
|
||||
|
||||
# ---- INV-012: Counselor只能访问分配给自己的服务 ----
|
||||
def test_counselor_assigned_scope():
|
||||
counselor_token = get_counselor_token()
|
||||
# Counselor 只能看到分配给自己的
|
||||
r = requests.get(f"{BASE_URL}/counseling/services/operator/assigned", headers=auth_header(counselor_token))
|
||||
assert r.status_code == 200
|
||||
# 不应包含未分配给自己的记录
|
||||
# (假设数据库中 counselor01 没有被分配的记录,返回空列表)
|
||||
assert isinstance(r.json()["data"], list)
|
||||
|
||||
|
||||
# ---- INV-013: Matchmaker不能读取Counseling正文 ----
|
||||
# Phase 23 KEEP-VERIFY(2026-09-18):原 test_matchmaker_cannot_read_counseling_body
|
||||
# 的函数体为 `pass` 空壳(注释自述「在当前测试框架下简化验证」),从未真实断言过任何权限,
|
||||
# 却在 docs/STATUS.md 中被计为 P23-T06「咨询 E2E ✅」的一部分。
|
||||
# 计划 Phase 23 Gate「不允许 skip/pass 空壳」→ 已移除该空壳。
|
||||
#
|
||||
# INV-013 现由后端真实行为用例覆盖:
|
||||
# backend/.../invariants/V11InvariantsTest#inv013MatchmakerCannotReadCounselingBody
|
||||
# 走真实 CounselingService.getDetail:断言 Matchmaker 读到的 user_request_summary 为 null,
|
||||
# 并以「assigned Counselor 能读到正文」作反证,避免全局脱敏造成的假阳性;
|
||||
# 已通过负向注入验证(把 isMatchmaker 分支改为返回全文 → 该用例精准变红)。
|
||||
#
|
||||
# E2E 层待补场景:用真实红娘 token 调用咨询详情接口并断言被拒。
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
def test_counselor_assigned_list_is_scoped(api, operator_token):
|
||||
"""INV-012:咨询师只能看到分配给自己的服务(列表必须可访问且为列表)。"""
|
||||
token = operator_token("e2e_counselor")
|
||||
data = api.ok("GET", "/counseling/services/operator/assigned", token=token)
|
||||
assert isinstance(data, list)
|
||||
|
||||
+46
-41
@@ -1,44 +1,49 @@
|
||||
"""
|
||||
V1.1 首页 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18)
|
||||
首页 E2E(INV-017 八入口固定 / INV-016 Banner 禁外链 / 首页结构固定)。
|
||||
|
||||
历史问题
|
||||
--------
|
||||
原文件 9 个测试函数中 **7 个是 `pass` 空壳**,其余 5 个虽含断言,
|
||||
但全部依赖 `api_client` / `authenticated_user` / `banner_factory` /
|
||||
`user_factory` / `verified_user` / `searching_user` / `paused_user` /
|
||||
`successful_user` / `admin_client` 等**在本仓库中根本不存在**的 pytest fixture
|
||||
(全局无 `conftest.py`)→ 整个文件**一次都没执行过**,
|
||||
却在 `docs/STATUS.md` 中被计为 P23-T01~T04「首页 E2E ✅」。
|
||||
|
||||
计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。
|
||||
|
||||
当前状态
|
||||
--------
|
||||
本文件现**不含任何测试函数**,pytest 收集 0 个用例。
|
||||
|
||||
仍待真实实现的场景(需求不因移除空壳而丢失)
|
||||
------------------------------------------
|
||||
1. `GET /api/v1/home` 返回 userState
|
||||
2. Banner 按时间过滤(未开始 / 已过期不显示)
|
||||
3. Banner 按 audience_scope 过滤
|
||||
4. 首页 8 核心入口顺序固定(INV-017)
|
||||
5. 未审核用户不返回真实推荐
|
||||
6. Paused 用户不返回新推荐
|
||||
7. Successful 用户不返回找对象推荐
|
||||
8. 服务站可展示
|
||||
9. Banner 不能创建外部 URL 跳转(INV-016)
|
||||
10. `counselingEnabled` flag 控制 `canApplyCounseling`
|
||||
11. Capability:`canReceiveRecommendations` 随账号状态变化
|
||||
|
||||
现状补充(Phase 23 实测)
|
||||
------------------------
|
||||
- 首页聚合与 8 入口已有真实 HTTP 覆盖:
|
||||
`tests/e2e/run_v11_tests.py::test_02_home` 与 `::test_03_home_8_entries`;
|
||||
- **INV-017 已由 Phase 23 新增的后端真实行为用例覆盖**
|
||||
(`V11InvariantsTest#inv017QuickEntriesFixedOrder`,走真实
|
||||
`HomeApplicationService.getHome(userId)`,断言 8 个编码与顺序完全固定);
|
||||
- INV-016 已有 `BannerOperatorPhase19IntegrationTest` 真实覆盖。
|
||||
即:E2E 层待补,但不变量本身已有真实覆盖。
|
||||
|
||||
真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py`
|
||||
这些断言打的是**真实活栈**:`GET /api/v1/home` 的真实响应,不是常量、不是自造字面量。
|
||||
"""
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
# AGENTS.md 硬规则 #14/#15:首页 8 核心入口固定,顺序不可更改、运营不可重排。
|
||||
EXPECTED_QUICK_ENTRIES = [
|
||||
"find_object", # 第一排
|
||||
"matchmaker",
|
||||
"activities",
|
||||
"counseling",
|
||||
"classroom", # 第二排
|
||||
"premarital",
|
||||
"anti_fraud",
|
||||
"service_station",
|
||||
]
|
||||
|
||||
# INV-016:Banner 仅内部跳转,target_type 白名单(与生产 HomeBannerOperatorController 一致)。
|
||||
ALLOWED_BANNER_TARGETS = {
|
||||
"activity", "matchmaker", "counseling", "premarital",
|
||||
"service_location", "article", "internal_page", "none",
|
||||
}
|
||||
|
||||
|
||||
def test_home_returns_fixed_sections(api, user_token):
|
||||
"""规则 #14:首页区块齐备(缺一个即结构被改)。"""
|
||||
data = api.ok("GET", "/home", token=user_token)
|
||||
for key in ("quickEntries", "banners", "userState", "capabilities", "safetyNotice"):
|
||||
assert key in data, f"首页缺少区块 {key},实际 keys={sorted(data.keys())}"
|
||||
|
||||
|
||||
def test_quick_entries_fixed_order(api, user_token):
|
||||
"""INV-017:8 个核心入口固定且顺序不可更改。"""
|
||||
data = api.ok("GET", "/home", token=user_token)
|
||||
codes = [e.get("code") for e in data["quickEntries"]]
|
||||
assert codes == EXPECTED_QUICK_ENTRIES, f"首页入口顺序被改动:{codes}"
|
||||
|
||||
|
||||
def test_banner_target_types_are_internal_only(api, user_token):
|
||||
"""INV-016:Banner 不允许外链(target_type 必须落在白名单内)。"""
|
||||
data = api.ok("GET", "/home", token=user_token)
|
||||
banners = data.get("banners") or []
|
||||
for b in banners:
|
||||
target = b.get("targetType")
|
||||
assert target in ALLOWED_BANNER_TARGETS, f"Banner 出现非白名单 target_type={target!r}"
|
||||
|
||||
@@ -1,39 +1,54 @@
|
||||
"""
|
||||
V1.1 本地资格 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18)
|
||||
本地资格 E2E(INV-011:本地资格佐证不得被未授权方读取)。
|
||||
|
||||
历史问题
|
||||
--------
|
||||
原文件 11 个测试函数**全部**为 `pass` 空壳(注释均为「TODO: P04/P05/P06 实现后完善」),
|
||||
且依赖 `api_client` / `verified_user` / `local_eligibility_factory` 等
|
||||
**在本仓库中根本不存在**的 pytest fixture(全局无 `conftest.py`)。
|
||||
这些用例从未执行过,却在 `docs/STATUS.md` 中被计为 P23-T05「本地资格 E2E ✅」。
|
||||
|
||||
计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。
|
||||
|
||||
当前状态
|
||||
--------
|
||||
本文件现**不含任何测试函数**,pytest 将收集到 0 个用例 ——
|
||||
以「没有假覆盖」取代「假装有覆盖」。
|
||||
|
||||
仍待真实实现的场景(需求不因移除空壳而丢失)
|
||||
------------------------------------------
|
||||
1. 用户可声明丹阳工作 / 户籍 / 居住
|
||||
2. 声明本地资格不默认要求上传佐证
|
||||
3. 佐证文件上传需要鉴权
|
||||
4. 审核员可访问本地资格佐证(INV-011)
|
||||
5. 红娘不可访问本地资格佐证(INV-011)
|
||||
6. `/me/status` 返回 localEligibilityStatus
|
||||
7. 本地资格未核验影响匹配资格
|
||||
8. 本地资格审核进入 `review_type=local_eligibility` 队列
|
||||
9. 审核通过更新 local_eligibility 状态
|
||||
10. 需补充佐证时触发用户上传
|
||||
11. 查看佐证文件须记录审计日志
|
||||
|
||||
现状补充(Phase 23 实测)
|
||||
------------------------
|
||||
`LocalEligibilityService` 全部方法均以 `userId` 自限定、**无任何 role 分支**,
|
||||
佐证无独立的服务端读取入口(仅经受权限控制的审核队列 `ReviewController` 访问),
|
||||
故 INV-011 在结构上成立;但缺少显式守卫测试,列为遗留待补。
|
||||
|
||||
真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py`
|
||||
打真实活栈:新用户经 mock 微信登录,走真实的声明/查询链路。
|
||||
"""
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
# 生产 local_eligibility 的 eligibility_type CHECK 白名单内的合规取值。
|
||||
ELIGIBILITY_TYPE = "household_registration"
|
||||
REGION_CODE = "321181" # 丹阳
|
||||
|
||||
|
||||
def test_new_user_is_not_declared(api, user_token):
|
||||
data = api.ok("GET", "/local-eligibility", token=user_token)
|
||||
assert data["verificationStatus"] == "not_declared", f"新用户初始状态异常:{data}"
|
||||
|
||||
|
||||
def test_declaration_is_persisted(api, user_token):
|
||||
"""声明后应能查回声明内容(真实落库,不是内存态)。"""
|
||||
api.ok(
|
||||
"POST",
|
||||
"/local-eligibility/declaration",
|
||||
token=user_token,
|
||||
json={"eligibilityType": ELIGIBILITY_TYPE, "regionCode": REGION_CODE},
|
||||
)
|
||||
data = api.ok("GET", "/local-eligibility", token=user_token)
|
||||
assert data["eligibilityType"] == ELIGIBILITY_TYPE, f"声明未落库:{data}"
|
||||
assert data["verificationStatus"] != "not_declared", f"声明后状态未推进:{data}"
|
||||
|
||||
|
||||
def test_other_user_cannot_read_my_declaration(api, new_user):
|
||||
"""
|
||||
INV-011(数据侧):资格查询是 userId 自限定的,
|
||||
另一个用户读到的必须是**他自己的**空状态,绝不能看到 A 的声明。
|
||||
"""
|
||||
user_a = new_user()
|
||||
api.ok(
|
||||
"POST",
|
||||
"/local-eligibility/declaration",
|
||||
token=user_a["token"],
|
||||
json={"eligibilityType": ELIGIBILITY_TYPE, "regionCode": REGION_CODE},
|
||||
)
|
||||
mine = api.ok("GET", "/local-eligibility", token=user_a["token"])
|
||||
assert mine["eligibilityType"] == ELIGIBILITY_TYPE
|
||||
|
||||
user_b = new_user()
|
||||
other = api.ok("GET", "/local-eligibility", token=user_b["token"])
|
||||
|
||||
assert other["userId"] == user_b["user_id"], "返回了他人 userId(越权)"
|
||||
assert other["userId"] != user_a["user_id"]
|
||||
assert other["eligibilityType"] is None, f"B 看到了 A 的资格声明(越权):{other}"
|
||||
assert other["verificationStatus"] == "not_declared"
|
||||
|
||||
@@ -1,47 +1,45 @@
|
||||
"""
|
||||
E2E 测试:Phase 23 - 多类型活动 E2E
|
||||
多类型活动 E2E。
|
||||
|
||||
迁移说明:原文件 import 期读环境变量导致收集失败;
|
||||
且 `assert item.get("activityType") == x or not item.get("activityType")` 是
|
||||
**恒真断言**(字段缺失或列表为空都通过),已改为真实类型校验。
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8085/api/v1"
|
||||
E2E_PASSWORD = os.environ["E2E_PASSWORD"]
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
def get_admin_token():
|
||||
r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "admin", "password": E2E_PASSWORD})
|
||||
return r.json()["data"]["accessToken"]
|
||||
|
||||
def auth_header(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def test_get_activities_by_type():
|
||||
r = requests.get(f"{BASE_URL}/activities/types/matchmaking")
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
def test_activities_list_is_accessible(api, user_token):
|
||||
data = api.ok("GET", "/activities", token=user_token)
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_activity_type_filter():
|
||||
r = requests.get(f"{BASE_URL}/activities/types/anti_fraud")
|
||||
assert r.status_code == 200
|
||||
for item in r.json()["data"]:
|
||||
assert item.get("activityType") == "anti_fraud" or not item.get("activityType")
|
||||
|
||||
def test_activity_organizations():
|
||||
# 创建活动后获取组织列表
|
||||
r = requests.get(f"{BASE_URL}/activities/act-001/organizations")
|
||||
assert r.status_code == 200
|
||||
# 必须是生产 chk_activity_type 白名单内的合法值(ActivityService.VALID_ACTIVITY_TYPES);
|
||||
# 传非法值应为 400 而非 500 —— 由 test_invalid_activity_type_returns_400 覆盖。
|
||||
@pytest.mark.parametrize(
|
||||
"activity_type",
|
||||
["matchmaking", "interest_social", "relationship_education",
|
||||
"premarital_guidance", "marriage_custom_culture", "anti_fraud"],
|
||||
)
|
||||
def test_activities_can_be_filtered_by_type(api, user_token, activity_type):
|
||||
"""按类型筛选:返回的每一项都必须是该类型(不能靠字段缺失蒙混)。"""
|
||||
data = api.ok("GET", f"/activities/types/{activity_type}", token=user_token)
|
||||
assert isinstance(data, list)
|
||||
for item in data:
|
||||
assert item.get("activityType") == activity_type, (
|
||||
f"类型筛选返回了其它类型的活动:{item}"
|
||||
)
|
||||
|
||||
def test_activity_capacity_groups():
|
||||
r = requests.get(f"{BASE_URL}/activities/act-001/capacity-groups")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_registration_source_stats():
|
||||
r = requests.get(f"{BASE_URL}/operator/analytics/v11/registration-sources")
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
assert "totalRegistrations" in data
|
||||
assert "bySource" in data
|
||||
def test_invalid_activity_type_returns_bad_request_not_500(api, user_token):
|
||||
"""
|
||||
非法类型属客户端参数错误,必须回 BAD_REQUEST。
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
注意本项目的约定:业务错误是 **HTTP 200 + body.code**(只有框架层
|
||||
鉴权失败才是真 HTTP 403)。因此这里断言的是 code,不是 HTTP 状态码。
|
||||
"""
|
||||
resp = api.call("GET", "/activities/types/not-a-real-type", token=user_token)
|
||||
assert resp.status_code != 500, f"非法活动类型触发了 500:{resp.text[:200]}"
|
||||
body = resp.json()
|
||||
assert body.get("code") == "BAD_REQUEST", f"非法类型应回 BAD_REQUEST,实际 {body}"
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
"""
|
||||
E2E 测试:Phase 23 - 组织关系隔离
|
||||
组织关系隔离 E2E(INV-018 / INV-020)。
|
||||
|
||||
迁移说明:原文件两个用例只断言 `status_code == 200`,却宣称验证了
|
||||
"组织关系不自动授予用户数据访问权限" —— 这与 INV-020 毫无逻辑关联,
|
||||
属典型假覆盖。现改为真实的越权访问断言。
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8085/api/v1"
|
||||
E2E_PASSWORD = os.environ["E2E_PASSWORD"]
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
def get_admin_token():
|
||||
r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "admin", "password": E2E_PASSWORD})
|
||||
return r.json()["data"]["accessToken"]
|
||||
|
||||
def auth_header(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
def test_station_operator_cannot_browse_all_users(api, operator_token):
|
||||
"""
|
||||
INV-018:服务站运营人员只能处理现场预约与指定任务,
|
||||
不得浏览全平台用户库(INV-020:组织关系不自动授予数据权限)。
|
||||
"""
|
||||
token = operator_token("e2e_station")
|
||||
resp = api.call("GET", "/operator/users", token=token)
|
||||
assert resp.status_code == 403, (
|
||||
f"服务站运营竟能浏览全用户库:HTTP {resp.status_code} {resp.text[:200]}"
|
||||
)
|
||||
|
||||
def test_activity_organizations_are_isolated():
|
||||
# 组织A的管理员不应看到组织B的活动详情
|
||||
token = get_admin_token()
|
||||
r = requests.get(f"{BASE_URL}/activities", headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
# 验证活动列表返回正常
|
||||
|
||||
def test_no_auto_permission_from_org_relationship():
|
||||
# 验证组织关系不自动授予用户数据访问权限(INV-020)
|
||||
# 此测试验证数据库中不存在基于组织关系的自动权限授予
|
||||
r = requests.get(f"{BASE_URL}/operator/analytics/v11/service-programs", headers=auth_header(get_admin_token()))
|
||||
assert r.status_code == 200
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
def test_counselor_cannot_browse_all_users(api, operator_token):
|
||||
"""INV-012/020:咨询师同理,组织/角色关系不等于全量数据权限。"""
|
||||
token = operator_token("e2e_counselor")
|
||||
resp = api.call("GET", "/operator/users", token=token)
|
||||
assert resp.status_code == 403, (
|
||||
f"咨询师竟能浏览全用户库:HTTP {resp.status_code} {resp.text[:200]}"
|
||||
)
|
||||
|
||||
+39
-134
@@ -1,145 +1,50 @@
|
||||
"""
|
||||
E2E 测试:Phase 13 - 红娘服务转介
|
||||
验证 INV-005(红娘不可代用户操作)及转介追踪链路
|
||||
红娘转介 E2E(INV-005:红娘不可代用户操作)。
|
||||
|
||||
迁移说明:原文件依赖 3 个环境变量(import 期 KeyError → 从未执行),
|
||||
用 `user-test-001` / `loc-001` 等**不存在的固定 ID** 构造请求,
|
||||
甚至出现 `auth_header("Bearer dummy")` 这种错误用法 —— 从未真正跑通过。
|
||||
|
||||
改造后只保留可真实执行的断言;被 D-24-E 权限缺口阻塞的场景在下方注释中
|
||||
如实记录,不写成"看起来在测、实际恒过"的假用例。
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8085/api/v1"
|
||||
E2E_PASSWORD = os.environ["E2E_PASSWORD"]
|
||||
E2E_USER_TOKEN = os.environ["E2E_USER_TOKEN"]
|
||||
E2E_DB_PASSWORD = os.environ["E2E_DB_PASSWORD"]
|
||||
|
||||
# ---- 工具函数 ----
|
||||
def get_user_token():
|
||||
return E2E_USER_TOKEN
|
||||
|
||||
def get_matchmaker_token():
|
||||
r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "mm001", "password": E2E_PASSWORD})
|
||||
return r.json()["data"]["accessToken"]
|
||||
|
||||
def auth_header(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
# ---- P13-T05: 转介确认后激活 ----
|
||||
def test_referral_requires_user_confirmation():
|
||||
"""红娘转介后,预约状态为 submitted,需用户 confirm 后才变为 pending_confirmation"""
|
||||
user_token = get_user_token()
|
||||
mm_token = get_matchmaker_token()
|
||||
def test_referral_history_is_accessible(api, user_token):
|
||||
data = api.ok("GET", "/referrals/my-history", token=user_token)
|
||||
assert isinstance(data, list)
|
||||
|
||||
# 红娘创建转介预约
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/referrals/service-station",
|
||||
headers=auth_header(mm_token),
|
||||
json={"userId": "user-test-001", "locationId": "loc-001", "serviceType": "counselor_consultation"}
|
||||
|
||||
def test_matchmaker_cannot_confirm_on_users_behalf(api, operator_token, new_user):
|
||||
"""
|
||||
INV-005:红娘不能替用户确认转介。
|
||||
|
||||
用一个**不存在的转介 ID** 只能证明"资源不存在",证明不了权限生效,
|
||||
因此这里断言的是端点级拒绝:红娘侧 confirm 不应返回 2xx。
|
||||
"""
|
||||
mm_token = operator_token("e2e_matchmaker")
|
||||
resp = api.call(
|
||||
"POST",
|
||||
"/referrals/00000000-0000-0000-0000-000000000000/confirm",
|
||||
token=mm_token,
|
||||
json={"targetType": "service_station"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
appt_id = r.json()["data"]["appointmentId"]
|
||||
assert r.json()["data"]["status"] == "submitted"
|
||||
assert r.json()["data"]["needsUserConfirmation"] is True
|
||||
|
||||
# 用户确认
|
||||
r2 = requests.post(
|
||||
f"{BASE_URL}/referrals/{appt_id}/confirm",
|
||||
headers=auth_header(user_token),
|
||||
json={"targetType": "service_station"}
|
||||
assert not (200 <= resp.status_code < 300), (
|
||||
f"红娘竟能代用户确认转介:HTTP {resp.status_code} {resp.text[:200]}"
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["data"]["status"] == "pending_confirmation"
|
||||
|
||||
|
||||
# ---- P13-T02: 转介活动 ----
|
||||
def test_matchmaker_refers_activity():
|
||||
"""红娘转介用户到公益活动,需用户确认"""
|
||||
user_token = get_user_token()
|
||||
mm_token = get_matchmaker_token()
|
||||
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/referrals/activity",
|
||||
headers=auth_header(mm_token),
|
||||
json={"userId": "user-test-001", "activityId": "act-001",
|
||||
"emergencyContactName": "张某某", "emergencyContactPhoneEnc": "enc123"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
reg_id = r.json()["data"]["registrationId"]
|
||||
assert r.json()["data"]["referralType"] == "activity"
|
||||
assert r.json()["data"]["needsUserConfirmation"] is True
|
||||
|
||||
# 用户确认
|
||||
r2 = requests.post(
|
||||
f"{BASE_URL}/referrals/{reg_id}/confirm",
|
||||
headers=auth_header(user_token),
|
||||
json={"targetType": "activity"}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
# ---- P13-T04: 转介历史查询 ----
|
||||
def test_referral_history():
|
||||
"""查询用户的转介历史记录"""
|
||||
user_token = get_user_token()
|
||||
r = requests.get(f"{BASE_URL}/referrals/my-history", headers=auth_header(user_token))
|
||||
assert r.status_code == 200
|
||||
history = r.json()["data"]
|
||||
assert isinstance(history, list)
|
||||
|
||||
|
||||
# ---- INV-005: 红娘不能确认用户的转介 ----
|
||||
def test_matchmaker_cannot_confirm_for_user():
|
||||
"""INV-005: 红娘不能替用户确认转介,必须用户自己确认"""
|
||||
mm_token = get_matchmaker_token()
|
||||
|
||||
# 红娘尝试确认(应该失败)
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/referrals/some-id/confirm",
|
||||
headers=auth_header(mm_token),
|
||||
json={"targetType": "service_station"}
|
||||
)
|
||||
# 非对应用户ID,应返回 403 或 404
|
||||
assert r.status_code in (400, 403, 404)
|
||||
|
||||
|
||||
# ---- INV-005: 红娘不能取消用户的转介 ----
|
||||
def test_matchmaker_cannot_cancel_for_user():
|
||||
"""红娘不能取消用户的预约,只能用户自己取消"""
|
||||
mm_token = get_matchmaker_token()
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/service-appointments/some-id/cancel",
|
||||
headers=auth_header(mm_token),
|
||||
json={"reason": "测试取消"}
|
||||
)
|
||||
assert r.status_code in (400, 403, 404)
|
||||
|
||||
|
||||
# ---- 转介来源追踪验证 ----
|
||||
def test_referral_source_tracking():
|
||||
"""验证转介时 source 字段正确设置"""
|
||||
mm_token = get_matchmaker_token()
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/referrals/service-station",
|
||||
headers=auth_header(mm_token),
|
||||
json={"userId": "user-test-001", "locationId": "loc-001", "serviceType": "station_visit"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
appt_id = r.json()["data"]["appointmentId"]
|
||||
|
||||
# 查询预约详情,验证 source
|
||||
r2 = requests.get(f"{BASE_URL}/service-appointments/{appt_id}",
|
||||
headers=auth_header("Bearer dummy"))
|
||||
# 通过数据库直接验证 source 字段
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(f"dbname=danyang_match user=postgres password={E2E_DB_PASSWORD} host=localhost port=5434")
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT source, request_source, created_by_operator_id FROM service_appointments WHERE id = %s", (appt_id,))
|
||||
row = cur.fetchone()
|
||||
assert row[0] == "matchmaker_referral"
|
||||
assert row[1] == "matchmaker_referral"
|
||||
assert row[2] is not None
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
# ---------------------------------------------------------------------------
|
||||
# 被阻塞场景(D-24-E:全新库下 matchmaker 角色 0 权限、super_admin 仅 18/48)
|
||||
#
|
||||
# 下列场景当前无法真实执行,故**不写成用例**(写上去只能靠"资源不存在"恒过,
|
||||
# 那正是本轮要清除的假覆盖):
|
||||
# - 红娘创建转介(POST /referrals/service-station)→ 当前 403
|
||||
# - 红娘转介活动(POST /referrals/activity)→ 当前 403
|
||||
# - 转介 source 追踪(需真实 appointmentId)
|
||||
#
|
||||
# 待权限 seed 补齐(见 Phase 24 报告 D-24-E)后,按上述真实链路补齐用例。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pytest>=7.4.0
|
||||
requests>=2.31.0
|
||||
@@ -1,40 +1,37 @@
|
||||
"""
|
||||
E2E 测试:Phase 23 - 服务站权限隔离
|
||||
V1.1 新增 Operator 角色权限边界 E2E(INV-012 / INV-018 / INV-019)。
|
||||
|
||||
迁移说明:原文件用 counselor 的 token 去"模拟" content_editor 的权限边界
|
||||
(名不副实——测的根本不是 content_editor),且 import 期读环境变量导致收集失败。
|
||||
现改为真实的角色账号。
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8085/api/v1"
|
||||
E2E_PASSWORD = os.environ["E2E_PASSWORD"]
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
def get_station_operator_token():
|
||||
r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "stationop", "password": E2E_PASSWORD})
|
||||
return r.json()["data"]["accessToken"]
|
||||
|
||||
def get_counselor_token():
|
||||
r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "counselor01", "password": E2E_PASSWORD})
|
||||
return r.json()["data"]["accessToken"]
|
||||
def test_station_operator_cannot_view_counseling_queue(api, operator_token):
|
||||
"""INV-012:服务站运营人员不得进入咨询队列。"""
|
||||
token = operator_token("e2e_station")
|
||||
resp = api.call("GET", "/counseling/services/operator/queue", token=token)
|
||||
assert resp.status_code == 403, (
|
||||
f"服务站运营竟能查看咨询队列:HTTP {resp.status_code} {resp.text[:200]}"
|
||||
)
|
||||
|
||||
def auth_header(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def test_station_operator_cannot_view_counseling():
|
||||
token = get_station_operator_token()
|
||||
r = requests.get(f"{BASE_URL}/counseling/services/operator/queue", headers=auth_header(token))
|
||||
# 服务站运营人员不应有咨询队列权限,返回403或空
|
||||
assert r.status_code == 403
|
||||
def test_counselor_cannot_view_all_service_appointments(api, operator_token):
|
||||
"""INV-012:咨询师不得浏览全量服务预约。"""
|
||||
token = operator_token("e2e_counselor")
|
||||
resp = api.call("GET", "/service-appointments/operator/list", token=token)
|
||||
assert resp.status_code == 403, (
|
||||
f"咨询师竟能浏览全量预约:HTTP {resp.status_code} {resp.text[:200]}"
|
||||
)
|
||||
|
||||
def test_counselor_cannot_view_service_appointments():
|
||||
token = get_counselor_token()
|
||||
r = requests.get(f"{BASE_URL}/service-appointments/operator", headers=auth_header(token))
|
||||
# Counselor只能查看自己相关的预约,不能浏览全部
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_content_editor_cannot_access_user_data():
|
||||
token = get_counselor_token() # 复用counselor token模拟content_editor权限边界
|
||||
r = requests.get(f"{BASE_URL}/operator/appeals/pending", headers=auth_header(token))
|
||||
assert r.status_code == 403
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
def test_content_editor_cannot_access_appeals(api, operator_token):
|
||||
"""INV-019:内容编辑员不得访问申诉等用户数据处理职能。"""
|
||||
token = operator_token("e2e_content")
|
||||
resp = api.call("GET", "/operator/appeals/pending", token=token)
|
||||
assert resp.status_code == 403, (
|
||||
f"内容编辑员竟能访问申诉队列:HTTP {resp.status_code} {resp.text[:200]}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- ============================================================
|
||||
-- E2E 测试运营账号 seed(幂等,CI 与本机通用)
|
||||
--
|
||||
-- 为什么需要:
|
||||
-- 迁移里只有 roles / permissions 的种子,**没有任何 operator 账号**。
|
||||
-- 全新库起栈后一个运营账号都没有,E2E 无法登录,operator 侧链路
|
||||
-- (权限矩阵、代登记、审核队列)全部无法验证。本脚本把"可自举"
|
||||
-- 做成基建的一部分,而不是依赖某台机器上手工创建的遗留账号。
|
||||
--
|
||||
-- 用法(密码由环境变量注入,不入库明文、不进版本库):
|
||||
-- psql -v pwd="$E2E_OPERATOR_PASSWORD" -f seed_e2e_operators.sql
|
||||
--
|
||||
-- 幂等:全部走 ON CONFLICT DO NOTHING,可重复执行。
|
||||
-- ============================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- 账号:密码相同($pwd),角色不同,用于验证权限隔离(INV-012/013/019 等)
|
||||
INSERT INTO operators (id, name, username, password_hash, status) VALUES
|
||||
('e2e00000-0000-0000-0000-000000000001', 'E2E 超级管理员', 'e2e_admin', crypt(:'pwd', gen_salt('bf', 8)), 'active'),
|
||||
('e2e00000-0000-0000-0000-000000000002', 'E2E 平台管理员', 'e2e_platform', crypt(:'pwd', gen_salt('bf', 8)), 'active'),
|
||||
('e2e00000-0000-0000-0000-000000000003', 'E2E 婚恋咨询师', 'e2e_counselor', crypt(:'pwd', gen_salt('bf', 8)), 'active'),
|
||||
('e2e00000-0000-0000-0000-000000000004', 'E2E 服务站运营', 'e2e_station', crypt(:'pwd', gen_salt('bf', 8)), 'active'),
|
||||
('e2e00000-0000-0000-0000-000000000005', 'E2E 公益红娘', 'e2e_matchmaker', crypt(:'pwd', gen_salt('bf', 8)), 'active'),
|
||||
('e2e00000-0000-0000-0000-000000000006', 'E2E 内容编辑', 'e2e_content', crypt(:'pwd', gen_salt('bf', 8)), 'active')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- 角色绑定(role id 取自 V015 seed)
|
||||
-- super_admin / platform_admin
|
||||
INSERT INTO operator_roles (operator_id, role_id) VALUES
|
||||
('e2e00000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001'),
|
||||
('e2e00000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002'),
|
||||
-- counselor
|
||||
('e2e00000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000011'),
|
||||
-- service_station_operator
|
||||
('e2e00000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000010'),
|
||||
-- matchmaker
|
||||
('e2e00000-0000-0000-0000-000000000005', '00000000-0000-0000-0000-000000000005'),
|
||||
-- content_editor
|
||||
('e2e00000-0000-0000-0000-000000000006', '00000000-0000-0000-0000-000000000013')
|
||||
ON CONFLICT (operator_id, role_id) DO NOTHING;
|
||||
Reference in New Issue
Block a user