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:
WorkBuddy
2026-09-18 13:50:19 +08:00
parent ae1bd2b734
commit 730dddde30
22 changed files with 891 additions and 506 deletions
+172
View File
@@ -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"]
+30 -60
View File
@@ -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]}"
)
+72 -38
View File
@@ -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}"
+39 -105
View File
@@ -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
View File
@@ -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}"
+51 -36
View File
@@ -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"
+33 -35
View File
@@ -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}"
+23 -24
View File
@@ -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
View File
@@ -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)后,按上述真实链路补齐用例。
# ---------------------------------------------------------------------------
+2
View File
@@ -0,0 +1,2 @@
pytest>=7.4.0
requests>=2.31.0
+27 -30
View File
@@ -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]}"
)
+41
View File
@@ -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;