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
+64
View File
@@ -142,3 +142,67 @@ jobs:
# 机械阻断空壳回归 —— 详见 scripts/check-no-empty-tests.py。 # 机械阻断空壳回归 —— 详见 scripts/check-no-empty-tests.py。
- name: 禁止 skip/pass 空壳测试 - name: 禁止 skip/pass 空壳测试
run: python3 scripts/check-no-empty-tests.py tests run: python3 scripts/check-no-empty-tests.py tests
e2e:
name: E2E (live stack)
runs-on: ubuntu-latest
# Phase 23 遗留 D-23-D:计划要求 CI 串联 E2E。此前 E2E 从未真正执行过——
# 无 conftest、fixture 不存在、文件在 import 期就崩。本 job 起真实栈后执行。
needs: [backend, migration]
env:
E2E_BASE_URL: http://localhost:8085/api/v1
# CI 专用测试口令,仅存在于 runner 运行期,不入库、不用于任何真实环境
E2E_OPERATOR_PASSWORD: ci-e2e-only-password
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install E2E dependencies
run: pip install -r tests/e2e/requirements.txt
- name: 生成 CI 测试用 .env(仅运行期存在,不入库)
run: |
cat > infrastructure/.env <<'ENV'
DB_PASSWORD=ci-e2e-db-password
REDIS_PASSWORD=ci-e2e-redis-password
MINIO_ACCESS_KEY=ci-e2e-minio
MINIO_SECRET_KEY=ci-e2e-minio-secret
JWT_ACCESS_SECRET=ci-e2e-access-secret-only-for-ci
JWT_REFRESH_SECRET=ci-e2e-refresh-secret-only-for-ci
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
WECHAT_APP_SECRET=
ENV
- name: 起真实栈(postgres + redis + backend)
working-directory: infrastructure
run: docker compose up -d --build postgres redis backend
- name: 等待 backend 就绪(超时即失败,不静默跳过)
run: |
for i in $(seq 1 60); do
if curl -sf "$E2E_BASE_URL/health/live" > /dev/null; then
echo "backend READY after ${i} checks"; exit 0
fi
sleep 5
done
echo "::error::backend 未在 5 分钟内就绪"
docker compose -f infrastructure/docker-compose.yml logs backend
exit 1
- name: Seed E2E 运营账号(迁移不含 operator 种子,全新库无账号可登录)
working-directory: infrastructure
run: |
docker compose exec -T postgres psql -U postgres -d danyang_match \
-v pwd="$E2E_OPERATOR_PASSWORD" -f - < ../tests/e2e/sql/seed_e2e_operators.sql
- name: Run E2E
run: pytest -v
- name: Teardown(无论成败都清理并留日志)
if: always()
run: |
docker compose -f infrastructure/docker-compose.yml logs backend || true
docker compose -f infrastructure/docker-compose.yml down -v || true
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.*;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
/** /**
* 婚恋咨询 / 婚前辅导 端点(Phase 13A) * 婚恋咨询 / 婚前辅导 端点(Phase 13A)
@@ -49,6 +50,10 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class CounselingController { public class CounselingController {
/** 主键为 uuid 列,非法字符串必须先挡在 Controller 层,避免打到数据库变成 500。 */
private static final Pattern UUID_PATTERN = Pattern.compile(
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
private final CounselingService counselingService; private final CounselingService counselingService;
// ==================== 用户端 ==================== // ==================== 用户端 ====================
@@ -91,6 +96,12 @@ public class CounselingController {
public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id, public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) { @AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized(); if (principal == null) return ApiResponse.unauthorized();
// 非法 UUID 直接 400。否则参数会打到数据库,PostgreSQL 抛
// `invalid input syntax for type uuid` → 500 且泄漏数据库异常。
if (!UUID_PATTERN.matcher(id).matches()) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "非法的 ID 格式");
}
CounselingServiceRecord svc = counselingService.getDetail(principal.id(), principal.role(), id); CounselingServiceRecord svc = counselingService.getDetail(principal.id(), principal.role(), id);
if (svc == null) return ApiResponse.forbidden(); if (svc == null) return ApiResponse.forbidden();
return ApiResponse.ok(svc); return ApiResponse.ok(svc);
@@ -111,6 +111,18 @@ public class GlobalExceptionHandler {
return ApiResponse.error(e.getCode(), e.getMessage()); return ApiResponse.error(e.getCode(), e.getMessage());
} }
/**
* 参数非法(例:活动类型不在 {@code chk_activity_type} 白名单内)必须回 400。
*
* <p>此前未映射 {@link IllegalArgumentException},这类"客户端传错值"的问题
* 会被兜底成 500:调用方无法区分是自己的参数错还是服务端故障,
* 真实 PostgreSQL 上还会伴随数据库异常语义外泄(H2 测试库则不报错 → 零覆盖)。
*/
@ExceptionHandler(IllegalArgumentException.class)
public ApiResponse<Void> handleIllegalArgument(IllegalArgumentException e) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, e.getMessage());
}
@ExceptionHandler(AccessDeniedException.class) @ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN) @ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> handleAccessDenied(AccessDeniedException e) { public ApiResponse<Void> handleAccessDenied(AccessDeniedException e) {
@@ -1,6 +1,9 @@
spring: spring:
datasource: datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:danyang_match} # stringtype=unspecified:Java 侧实体主键/外键统一用 String,而 PostgreSQL 列是 uuid。
# 不加该参数时 pgjdbc 以 varchar 发送,PostgreSQL 在比较操作上不做隐式转换,
# 会抛 `operator does not exist: uuid = character varying`(H2 测试库不报错 → 零覆盖)。
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:danyang_match}?stringtype=unspecified
username: ${DB_USER:postgres} username: ${DB_USER:postgres}
password: ${DB_PASSWORD} password: ${DB_PASSWORD}
@@ -0,0 +1,20 @@
-- ============================================================
-- V044: 补齐 super_admin 权限(实现与定义对齐,非扩权)
--
-- 发现方式:Phase 24 搭建 E2E 基建、用真实 PostgreSQL 活栈执行时发现
-- - permissions 共 48 条,role_permissions 里 super_admin 只有 18 条
-- - matchmaker 角色为 0 条(见报告 D-24-E 遗留项)
--
-- 根因:V015 的「为 super_admin 分配全部权限」是**执行时点快照**,
-- V016 之后新增的 30 条权限从未回补给 super_admin。
-- 后果:全新环境起栈后,超级管理员创建服务站、查看咨询等操作全部 403。
--
-- super_admin 在 V015 中的定义即「系统超级管理员,拥有全部权限」,
-- 本迁移只是让实现回到定义;不新增权限、不授予其它角色。
-- 幂等:ON CONFLICT DO NOTHING。
-- ============================================================
INSERT INTO role_permissions (role_id, permission_id)
SELECT '00000000-0000-0000-0000-000000000001', id
FROM permissions
ON CONFLICT (role_id, permission_id) DO NOTHING;
@@ -18,7 +18,8 @@ class MigrationValidationTest {
// 基线守卫:新增 Flyway 迁移时必须同步递增此值 // 基线守卫:新增 Flyway 迁移时必须同步递增此值
// Phase 19:V042 善缘服务站「临时公告」表(service_location_announcements) // Phase 19:V042 善缘服务站「临时公告」表(service_location_announcements)
// Phase 20:V043 打通 analytics.view 授权(data_viewer 专设却零权限 + platform_admin 被老权限段漏掉) // Phase 20:V043 打通 analytics.view 授权(data_viewer 专设却零权限 + platform_admin 被老权限段漏掉)
private static final int LATEST_MIGRATION = 43; // Phase 24:V044 补齐 super_admin 权限(V015 之后新增的 30 条权限从未回补给 super_admin)
private static final int LATEST_MIGRATION = 44;
private static final String MIGRATION_DIR = private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/"; "backend/src/main/resources/db/migration/";
@@ -151,6 +152,37 @@ class MigrationValidationTest {
assertTrue(v014.contains("user_privacy_settings"), "V014 应创建 privacy settings 表"); assertTrue(v014.contains("user_privacy_settings"), "V014 应创建 privacy settings 表");
} }
/**
* 源码目录版本号唯一 ≠ 打包出来的 jar 版本号唯一。
*
* <p>未 clean 时 {@code target/classes} 会残留历史文件(例如被改名/删除的旧
* {@code V042__*.sql}),打包进 jar 后 Flyway 直接抛
* {@code Found more than one migration with version 042} —— **应用起不来**。
* 该问题在源码层扫不出来(src 是干净的),只有扫构建产物才能发现。
*/
@Test
void testBuildOutputHasNoDuplicateMigrationVersions() throws IOException {
Path dir = Paths.get("backend/target/classes/db/migration");
assertTrue(Files.exists(dir), "构建产物目录不存在,无法校验:" + dir.toAbsolutePath());
Map<Integer, List<String>> byVersion = new HashMap<>();
Pattern versionPattern = Pattern.compile("^V(\\d+)__");
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "V*.sql")) {
for (Path p : stream) {
Matcher m = versionPattern.matcher(p.getFileName().toString());
if (m.find()) {
byVersion.computeIfAbsent(Integer.valueOf(m.group(1)), k -> new ArrayList<>())
.add(p.getFileName().toString());
}
}
}
assertFalse(byVersion.isEmpty(), "构建产物中没有任何迁移文件,校验无意义");
byVersion.forEach((version, names) -> assertEquals(1, names.size(),
"构建产物中版本号 " + version + " 重复 " + names
+ " —— 多为未 clean 的陈旧文件残留,会导致 Flyway 启动失败;请执行 mvn clean"));
}
@Test @Test
void testNoBackwardIncompatibleChanges() throws IOException { void testNoBackwardIncompatibleChanges() throws IOException {
// 验证没有破坏性变更(如 DROP COLUMN) // 验证没有破坏性变更(如 DROP COLUMN)
+2 -1
View File
@@ -180,7 +180,8 @@
| Phase 20 Analytics 数据统计与管理驾驶舱复验 | 已完成(2026-09-17) | 见 `Phase-20-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **20 处真实缺陷**(**D1 实名人数用 `profile_status != 'rejected'` 冒充 → 把"资料还在填"算成已实名**;**D3 Chat Started 直接数会话行数 → 系统通知计入**;**D4 Match 不分来源 → 10 对互选被当 10 对成功引用**;**D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码**;**D6 Banner 曝光/点击硬编码 0**;**D12 系统性软删缺陷:`BaseEntity.deletedAt` 无 `@TableLogic` + 全局无逻辑删除配置 → `selectCount` 把软删数据全部计入且无任何报错**,修复 45 处统计调用(含 2 轮复验补扫出的 5 处次级路径);**D15 三套独立实现** → 抽 `AnalyticsMetricsCalculator` 统一口径;**D13/权限 授予脱节:`data_viewer` 空壳角色 + `platform_admin` 被 `id >= '20000000-…'` 条件漏掉 V1.0 老权限段 → 整个模块除 super_admin 外全员 403**;**D14 `AnalyticsServiceTest` = 5×`assertTrue(true)` 假绿 → 删除**;D20 `getDailySummary` 无边界校验;前端 `AnalyticsView.vue` 未接 `/v11-dashboard`);建立指标字典 `MetricCatalog`(36 指标 × 12 字段)+ `docs/ANALYTICS_METRICS.md`;新增 V043 授权(**刻意排除 `organization_admin`** —— 相关表无组织维度,无范围全量数据即越权);新增守卫 `MetricCatalogGuardTest`(11) / `AnalyticsMetricsPhase20IntegrationTest`(21) / `AnalyticsSoftDeleteGuardTest`(4,结构性) / `AnalyticsPermissionGrantTest`(6);**跨阶段修复 X1~X3**(**注销写入 `user_identities` 状态域的 `'failed'` → 违反 V017 `chk_elig_verify_status` → 真实 PG 上账号注销 100% 不可用**;`checkCanWithdraw` 资格拦截死代码;测试库补两条 CHECK 后暴露 `ActivityPhase14Test` 夹具违规)并新增 `LocalEligibilityStatusVocabularyGuardTest`(9);负向验证两轮 7 注入 → 7 用例精准变红并还原(`md5sum` 逐字节一致);后端 **648 tests 全绿**;admin-web `vue-tsc` EXIT=0;**Phase 21 已复验关闭(2026-09-18,650 tests 全绿)** | | Phase 20 Analytics 数据统计与管理驾驶舱复验 | 已完成(2026-09-17) | 见 `Phase-20-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **20 处真实缺陷**(**D1 实名人数用 `profile_status != 'rejected'` 冒充 → 把"资料还在填"算成已实名**;**D3 Chat Started 直接数会话行数 → 系统通知计入**;**D4 Match 不分来源 → 10 对互选被当 10 对成功引用**;**D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码**;**D6 Banner 曝光/点击硬编码 0**;**D12 系统性软删缺陷:`BaseEntity.deletedAt` 无 `@TableLogic` + 全局无逻辑删除配置 → `selectCount` 把软删数据全部计入且无任何报错**,修复 45 处统计调用(含 2 轮复验补扫出的 5 处次级路径);**D15 三套独立实现** → 抽 `AnalyticsMetricsCalculator` 统一口径;**D13/权限 授予脱节:`data_viewer` 空壳角色 + `platform_admin` 被 `id >= '20000000-…'` 条件漏掉 V1.0 老权限段 → 整个模块除 super_admin 外全员 403**;**D14 `AnalyticsServiceTest` = 5×`assertTrue(true)` 假绿 → 删除**;D20 `getDailySummary` 无边界校验;前端 `AnalyticsView.vue` 未接 `/v11-dashboard`);建立指标字典 `MetricCatalog`(36 指标 × 12 字段)+ `docs/ANALYTICS_METRICS.md`;新增 V043 授权(**刻意排除 `organization_admin`** —— 相关表无组织维度,无范围全量数据即越权);新增守卫 `MetricCatalogGuardTest`(11) / `AnalyticsMetricsPhase20IntegrationTest`(21) / `AnalyticsSoftDeleteGuardTest`(4,结构性) / `AnalyticsPermissionGrantTest`(6);**跨阶段修复 X1~X3**(**注销写入 `user_identities` 状态域的 `'failed'` → 违反 V017 `chk_elig_verify_status` → 真实 PG 上账号注销 100% 不可用**;`checkCanWithdraw` 资格拦截死代码;测试库补两条 CHECK 后暴露 `ActivityPhase14Test` 夹具违规)并新增 `LocalEligibilityStatusVocabularyGuardTest`(9);负向验证两轮 7 注入 → 7 用例精准变红并还原(`md5sum` 逐字节一致);后端 **648 tests 全绿**;admin-web `vue-tsc` EXIT=0;**Phase 21 已复验关闭(2026-09-18,650 tests 全绿)** |
| Phase 21 账户生命周期增量复验 | 已完成(2026-09-18) | 见 `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **6 处真实缺陷 + 假绿测试**:① `AuthService.checkAccountStatus` 不拒 `CLOSED` → 注销后仍可登录(Gate ❌→✅);② `notifyActiveMatches` 为 TODO 桩 → 注销未关闭活跃 Match/会话(Gate ❌→✅,已实现关闭 Match + `chatService.closeConversationForMatch`);③ `cancelPendingActivityRegistrations` 为 TODO 桩 → 未来活动报名未关闭(Gate ❌→✅);④ `anonymizeServiceHistory` 已实现却**从未被 `closeAccount` 调用(死代码)** → 个人/咨询正文暴露(Gate ❌→✅,已接线 + 脱敏身份证占位符 + `counseling_followups` 正文);⑤ `closeAccount` 在 blockers 处硬抛异常使 `cancelXxx` 永远不可达(死代码矛盾 → 信息性日志 + 继续清理);⑥ 删除 7×`assertTrue(true)` 假绿 → 替换为 11 用例真实 DB 集成测试 + 3 用例登录守卫;跨模块防线(推荐引擎/匹配资格/Capability 已拒非 NORMAL)确认有效;后端 **650 tests 全绿**(起点 648,净 +2);**Phase 22 已复验关闭(2026-09-18,657 tests 全绿)** | | Phase 21 账户生命周期增量复验 | 已完成(2026-09-18) | 见 `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **6 处真实缺陷 + 假绿测试**:① `AuthService.checkAccountStatus` 不拒 `CLOSED` → 注销后仍可登录(Gate ❌→✅);② `notifyActiveMatches` 为 TODO 桩 → 注销未关闭活跃 Match/会话(Gate ❌→✅,已实现关闭 Match + `chatService.closeConversationForMatch`);③ `cancelPendingActivityRegistrations` 为 TODO 桩 → 未来活动报名未关闭(Gate ❌→✅);④ `anonymizeServiceHistory` 已实现却**从未被 `closeAccount` 调用(死代码)** → 个人/咨询正文暴露(Gate ❌→✅,已接线 + 脱敏身份证占位符 + `counseling_followups` 正文);⑤ `closeAccount` 在 blockers 处硬抛异常使 `cancelXxx` 永远不可达(死代码矛盾 → 信息性日志 + 继续清理);⑥ 删除 7×`assertTrue(true)` 假绿 → 替换为 11 用例真实 DB 集成测试 + 3 用例登录守卫;跨模块防线(推荐引擎/匹配资格/Capability 已拒非 NORMAL)确认有效;后端 **650 tests 全绿**(起点 648,净 +2);**Phase 22 已复验关闭(2026-09-18,657 tests 全绿)** |
| Phase 22 部署运维配置扩展(Feature Flag)复验 | 已完成(2026-09-18) | 见 `docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **2 处真实代码缺陷 + 2 处文档夸大**:① D-22-A(High)`FeatureFlagController.updateFlag` 仅支持布尔 flag(反射 `getMethod("set"+cap, boolean.class)`)→ 7 个非布尔 flag 经 API 更新必失败,已改为按字段类型分派 boolean/int/String;② D-22-B(Medium)`body.getOrDefault("value", false)` 缺 value 静默置 false + 非布尔转型崩,已改为缺 value/类型错→400 且不改原值;T01–T05 模块开关(`isCounselingEnabled` 等)全仓无业务调用点 → 死配置(暴露未 gate,遗留交各模块 Phase);T06 上传策略实测已在 `LocalEligibilityService`+`PhotoService` 真强制 ✅;T07 私有桶零引用 → 死配置(遗留);yml 无 `features:` 块、默认值实为 Java 字段默认(文档纠正);新增 `FeatureFlagControllerTest`(7 用例)+ 负向验证(仅布尔缺陷版恰 2 用例变红);后端 **657 tests 全绿**(起点 650,净 +7);**Phase 23 已复验(2026-09-18,部分关闭)** | | Phase 22 部署运维配置扩展(Feature Flag)复验 | 已完成(2026-09-18) | 见 `docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **2 处真实代码缺陷 + 2 处文档夸大**:① D-22-A(High)`FeatureFlagController.updateFlag` 仅支持布尔 flag(反射 `getMethod("set"+cap, boolean.class)`)→ 7 个非布尔 flag 经 API 更新必失败,已改为按字段类型分派 boolean/int/String;② D-22-B(Medium)`body.getOrDefault("value", false)` 缺 value 静默置 false + 非布尔转型崩,已改为缺 value/类型错→400 且不改原值;T01–T05 模块开关(`isCounselingEnabled` 等)全仓无业务调用点 → 死配置(暴露未 gate,遗留交各模块 Phase);T06 上传策略实测已在 `LocalEligibilityService`+`PhotoService` 真强制 ✅;T07 私有桶零引用 → 死配置(遗留);yml 无 `features:` 块、默认值实为 Java 字段默认(文档纠正);新增 `FeatureFlagControllerTest`(7 用例)+ 负向验证(仅布尔缺陷版恰 2 用例变红);后端 **657 tests 全绿**(起点 650,净 +7);**Phase 23 已复验(2026-09-18,部分关闭)** |
| Phase 23 可信系统测试重建(REFACTOR)复验 | ⚠️ 部分关闭(2026-09-18) | 见 `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **5 处真实缺陷**:① **D-23-A(Critical)** `V11InvariantsTest` 原 14 个用例全为常量字符串断言 —— INV-016「Banner 目标类型」实际断言**文章状态**常量(跑错域)、INV-017「首页 8 入口」是**对自己刚声明的字面量数组做 `length==4` 的重言式**(从未调用首页服务)、INV-018/019/020 用预约/文章/账号状态常量冒充权限与数据范围验证;已重写为 12 个用例,含 **4 个真实服务行为守卫**(INV-013 走真实 `CounselingService.getDetail` 断言 Matchmaker 读到 `userRequestSummary==null` 并以 assigned Counselor 能读正文作反证;INV-016 反射读生产 `ALLOWED_TARGET_TYPES` 断言不含外链;INV-017 真实 `getHome` 断言 8 入口编码与顺序固定;INV-019 断言 `content_editor` 读到 null)+ `assertBehaviorTestExists` 用 `Class.forName` 把「行为验证见 XxxTest」变成可执行契约;② **D-23-B(High)** `tests/e2e/` **44 个 `pass` 空壳**(4 个文件 100% 为空壳,另 2 个混合),违反 Gate「不允许 skip/pass 空壳」→ 已移除并改为诚实占位(保留完整场景清单);③ **D-23-C(High)** pytest E2E 套件**整体不可执行**:全仓无 `conftest.py`,约 15 个 fixture(`api_client`/`verified_user`/`banner_factory`…)不存在,唯一可执行的 `run_v11_tests.py`(16 用例)需活栈 + `E2E_PASSWORD`;④ **D-23-D(High,遗留未修)** CI 未串联 E2E(计划明确要求)→ 需先决定 CI 活栈供给方式,未实施;⑤ **D-23-E(Medium)** CI `migration` job 注释称「验证 SQL 语法」实为只查文件非空(真实语法校验在 `MigrationValidationTest`,随 backend job 跑)→ 改为真实序列校验(命名/非空/版本号唯一/无缺号,43 个文件 1~43 连续唯一)。**新增闸门**:`scripts/check-no-empty-tests.py`(AST 扫描阻断 `pass` 空壳与无条件 `skip`,已双向验证)+ CI `test-shell-guard` job。**证据**:负向注入 3 处生产缺陷 → 恰好 `inv013`/`inv016`/`inv017` 3 个用例精准变红、其余 9 绿、零无关失败,`md5sum -c` 还原一致;`655 tests / 通过 652 / 失败 0 / 错误 0 / 跳过 3`(起点 657,净 −2 因 14 空转用例合并为 12;3 个跳过均有显式原因,非空壳)。**遗留**:E2E 入 CI 待基础设施决策、pytest fixture 体系缺失、INV-011 缺显式守卫。**结论:不宣布「失败为零可进入 UAT」**;**Phase 24 尚未获准** | | Phase 23 可信系统测试重建(REFACTOR)复验 | ⚠️ 部分关闭(2026-09-18) | 见 `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **5 处真实缺陷**:① **D-23-A(Critical)** `V11InvariantsTest` 原 14 个用例全为常量字符串断言 —— INV-016「Banner 目标类型」实际断言**文章状态**常量(跑错域)、INV-017「首页 8 入口」是**对自己刚声明的字面量数组做 `length==4` 的重言式**(从未调用首页服务)、INV-018/019/020 用预约/文章/账号状态常量冒充权限与数据范围验证;已重写为 12 个用例,含 **4 个真实服务行为守卫**(INV-013 走真实 `CounselingService.getDetail` 断言 Matchmaker 读到 `userRequestSummary==null` 并以 assigned Counselor 能读正文作反证;INV-016 反射读生产 `ALLOWED_TARGET_TYPES` 断言不含外链;INV-017 真实 `getHome` 断言 8 入口编码与顺序固定;INV-019 断言 `content_editor` 读到 null)+ `assertBehaviorTestExists` 用 `Class.forName` 把「行为验证见 XxxTest」变成可执行契约;② **D-23-B(High)** `tests/e2e/` **44 个 `pass` 空壳**(4 个文件 100% 为空壳,另 2 个混合),违反 Gate「不允许 skip/pass 空壳」→ 已移除并改为诚实占位(保留完整场景清单);③ **D-23-C(High)** pytest E2E 套件**整体不可执行**:全仓无 `conftest.py`,约 15 个 fixture(`api_client`/`verified_user`/`banner_factory`…)不存在,唯一可执行的 `run_v11_tests.py`(16 用例)需活栈 + `E2E_PASSWORD`;④ **D-23-D(High,遗留未修)** CI 未串联 E2E(计划明确要求)→ 需先决定 CI 活栈供给方式,未实施;⑤ **D-23-E(Medium)** CI `migration` job 注释称「验证 SQL 语法」实为只查文件非空(真实语法校验在 `MigrationValidationTest`,随 backend job 跑)→ 改为真实序列校验(命名/非空/版本号唯一/无缺号,43 个文件 1~43 连续唯一)。**新增闸门**:`scripts/check-no-empty-tests.py`(AST 扫描阻断 `pass` 空壳与无条件 `skip`,已双向验证)+ CI `test-shell-guard` job。**证据**:负向注入 3 处生产缺陷 → 恰好 `inv013`/`inv016`/`inv017` 3 个用例精准变红、其余 9 绿、零无关失败,`md5sum -c` 还原一致;`655 tests / 通过 652 / 失败 0 / 错误 0 / 跳过 3`(起点 657,净 −2 因 14 空转用例合并为 12;3 个跳过均有显式原因,非空壳)。**遗留**:pytest fixture 体系缺失、INV-011 缺显式守卫(E2E 入 CI 与 fixture 体系已由 Phase 23B 补齐)。**结论:不宣布「失败为零可进入 UAT」**;**Phase 23B(E2E 测试基建)已于 2026-09-18 完成并关闭** |
| Phase 23B E2E 测试基建重建 | 已完成(2026-09-18) | 补齐 Phase 23 遗留 D-23-C(E2E 整体不可执行)与 D-23-D(CI 未串联 E2E)。**交付**:`tests/e2e/conftest.py`(ApiClient / `operator_token` / `new_user` / 栈探活)、`pytest.ini`、`tests/e2e/requirements.txt`、`tests/e2e/sql/seed_e2e_operators.sql`(迁移里**没有任何 operator 种子**,全新库无账号可登录 → 6 个 e2e 账号 + 角色绑定,幂等,口令由 `psql -v pwd` 注入)、CI job `e2e`(起真实栈→等就绪→seed→pytest→失败阻断→清理)、8 个 E2E 文件改造(消除 import 期 `KeyError`,替换恒真/名不副实断言)。**核心设计**:栈不可达时 `pytest.exit(4)` **而非 skip**(skip 会把未执行伪装成通过)。**验证**:本机真实活栈 **33 passed**;栈不可达/缺口令均 exit 4;后端 **656 tests / 0 failures / 0 errors / 3 skipped**(基线 655,+1 为新增构建产物守卫,已负向验证:注入重复 `V042` → 恰 1 用例变红)。**本轮暴露并修复 6 处真实生产缺陷**(H2 零覆盖):D-23B-A 构建产物残留重复迁移版本号 042 → 应用起不来;D-23B-B 非 UUID path → 500 + 数据库异常外泄;D-23B-C 6 个 E2E 文件 import 期崩溃 → 从未执行;D-23B-E `super_admin` 仅 18/48 权限、`matchmaker` 0 权限(matchmaker 部分列遗留,需产品定义);D-23B-I 非法枚举参数 → 500(应 BAD_REQUEST);D-23B-J `uuid = character varying` → JDBC 加 `stringtype=unspecified`。**另有一处判断被证伪后撤回**:初查以为 `service_appointment.register` 权限丢失,核实后端点实际用 `service.assisted_registration`(V029 已 seed),未据此改动任何代码。详见 `docs/Phase-23B-V11-E2E-Infrastructure-Completion-Report-2026-09-18.md` |
| UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 |
| UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 |
| UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 |
@@ -0,0 +1,126 @@
# Phase 23B — E2E 测试基建重建:复验完成报告
- 日期:2026-09-18
- 范围:**补上 E2E 基建**(用户批准:Phase 23 暴露 E2E 层几乎为空后的决定)
- 结论:**E2E 层首次真实可执行**;过程中暴露并修复 6 处真实生产缺陷
- 基线:`v1.0.0-RC.2` / V1.1 KEEP-VERIFY 纪律(历史 ✅ 不可信,必须由运行时证明)
---
## 一、为什么有这一轮
Phase 23 的结论是「部分关闭」:假覆盖归零了,但 **E2E 这一环仍然是空的**——
- 全仓无 `conftest.py`,约 15 个 fixture(`api_client` / `verified_user` / `banner_factory` …)根本不存在;
- 多个文件在 **import 期**就 `os.environ["E2E_PASSWORD"]` → `KeyError` → 收集失败;
- CI 从不跑 E2E(D-23-D)。
即:STATUS 里「10 个 E2E 文件 ~80+ 用例 ✅」描述的,是一批**从未被执行过**的文件。
本轮的目标不是"补几个用例",而是把「能真实执行"这件事本身"建起来:
栈怎么起、账号从哪来、缺栈时怎样失败、CI 怎样阻断。
---
## 二、交付物
| 交付 | 文件 | 作用 |
|---|---|---|
| fixture 体系 | `tests/e2e/conftest.py` | `ApiClient`、`admin_token`、`operator_token(username)`、`new_user`、`live_stack` 探活 |
| pytest 配置 | `pytest.ini` | `testpaths=tests/e2e`、标记 `e2e`、说明为何不收集 `run_v11_tests.py` |
| 依赖清单 | `tests/e2e/requirements.txt` | pytest / requests |
| 运营账号自举 | `tests/e2e/sql/seed_e2e_operators.sql` | 6 个 e2e 账号 + 角色绑定,幂等,密码由 `psql -v pwd` 注入 |
| CI 串联 | `.github/workflows/ci.yml` → job `e2e` | 起真实栈 → 等就绪 → seed → pytest → 失败阻断 → 清理 |
| 8 个测试文件改造 | `tests/e2e/*_test.py` | 消除 import 期崩溃;把恒真/名不副实的断言换成真实断言 |
### conftest 的三条硬设计(写进代码注释,防止回退)
1. **栈不可达 = `pytest.exit(4)`,不是 `skip`。**
`skip` 会把"未执行"伪装成"通过"——那正是 Phase 23 清掉的 44 个 `pass` 空壳的另一种变体。
2. **`session.trust_env = False`。**
宿主机若设了 `http_proxy`,requests 会把 localhost 请求也发给代理并返回 502,
表现为"连接失败"而不是真实业务结果(本机实测踩到)。
3. **用户隔离。** mock 微信登录同 code 同 openid,换 code 即换人;每个用例用独立新用户。
---
## 三、本轮发现并修复的真实生产缺陷
> 这些不是"测试问题",是**起真栈才暴露**的生产缺陷。H2 测试库全部零覆盖。
| 编号 | 级别 | 真相 | 处置 |
|---|---|---|---|
| **D-23B-A** | Critical | `backend/target/classes/db/migration/` 残留旧 `V042__add_appointment_operator_register_permission.sql`(src 中已被 `V042__add_service_location_announcements.sql` 取代)。未 clean 打包 → jar 内两个 042 → Flyway 抛 `Found more than one migration with version 042` → **应用起不来** | 清理残留;新增守卫 `testBuildOutputHasNoDuplicateMigrationVersions`(扫构建产物,src 干净也扫得出) |
| **D-23B-B** | High | `GET /counseling/services/{id}` 传入非 UUID → PostgreSQL 抛 `invalid input syntax for type uuid` → **500 且泄漏数据库异常** | Controller 层 UUID 格式校验 → `BAD_REQUEST` |
| **D-23B-C** | High | 6 个 E2E 文件 import 期 `os.environ["E2E_PASSWORD"]` → `KeyError` → 收集失败,**从未执行** | 全部迁移到 conftest fixture |
| **D-23B-E** | Critical | 权限 seed 缺口:permissions 共 **48** 条,`super_admin` 只有 **18** 条,`matchmaker` **0** 条。V015 的"为 super_admin 分配全部权限"是**执行时点快照**,之后新增的 30 条从未回补 → 全新环境下管理员创建服务站 403、红娘全部功能 403 | 新增 `V044__grant_all_permissions_to_super_admin.sql`(对齐 super_admin 定义,非扩权)。**matchmaker 权限集需产品定义,列为遗留** |
| **D-23B-I** | Medium | `ActivityService` 对非法活动类型抛 `IllegalArgumentException`,全局异常处理器未映射 → **500**(客户端参数错被伪装成服务端故障) | `GlobalExceptionHandler` 增加 `IllegalArgumentException → BAD_REQUEST` |
| **D-23B-J** | High | `operator does not exist: uuid = character varying`:Java 实体主键/外键统一用 `String`,PostgreSQL 列是 `uuid`,比较操作不做隐式转换 → 多处查询 500(`/service-appointments/my` 等)。**H2 不报错 → 零覆盖** | docker profile JDBC URL 增加 `?stringtype=unspecified`(pgjdbc 官方解法,一处解决全局) |
### 一处"判断被证伪",如实撤回
初查时发现 `V042__add_appointment_operator_register_permission.sql` 的内容
(`service_appointment.register` 权限)不在 src 里,一度判定为"功能缺失"。
进一步核实:端点实际用的是 `service.assisted_registration`(V029 已 seed 并授予
服务站运营/平台管理员),Controller 注释明确写着"刻意收敛,避免权限膨胀"。
→ **该判断不成立,已撤回**,未据此改动任何代码。
---
## 四、验证证据
### 1. E2E 真实执行(本机活栈:postgres + redis + 当前代码 jar @8085)
```
33 passed in 3.21s
```
覆盖:首页 8 入口固定顺序(INV-017)、Banner 禁外链(INV-016)、本地资格声明与
用户隔离(INV-011)、咨询正文权限(INV-012/013)、内容编辑员边界(INV-019)、
服务站/咨询师越权(INV-018/020)、活动类型筛选与非法值、文章生命周期、咨询取消。
### 2. 基建自身的负向属性("未执行"不会被算成"通过")
| 场景 | 结果 |
|---|---|
| `E2E_BASE_URL` 指向未监听端口 | `pytest.exit`,**退出码 4**,输出明确提示 |
| 未设置 `E2E_OPERATOR_PASSWORD` | `pytest.exit`,退出码 4 |
### 3. 后端全量回归
```
Tests run: 656, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS
```
基线 655(Phase 23 后),净 +1 为新增的构建产物守卫 `testBuildOutputHasNoDuplicateMigrationVersions`。
3 个 skip 均有显式原因(2× `@EnabledIfEnvironmentVariable(CI=true)`、1× `@Disabled("需要完整数据库连接")`),无一为空壳。
**守卫负向验证**:向 `target/classes/db/migration` 注入一个重复的 `V042__*.sql` →
恰好 `testBuildOutputHasNoDuplicateMigrationVersions` 变红(报"版本号 42 重复"),
其余 12 个用例仍绿;移除注入文件后恢复全绿。
---
## 五、遗留(未静默扩权)
1. **matchmaker 角色 0 权限**(D-23B-E 一半):红娘该拥有哪些权限属产品定义,
本轮不擅自授予;转介类 E2E 场景因此仍无法正向执行(已在文件中注明,未写假用例)。
2. **NPE → 500 的跨模块根因**:`@AuthenticationPrincipal UserPrincipal` 在
operator token 下为 `null`,部分端点未做 null 检查 → 500(应 401/403)。
本轮只修了被 E2E 命中的路径,其余建议统一处理。
3. **`run_v11_tests.py` 未被 pytest 收集**:它在 import 期登录、依赖
`docker exec` 取库内 ID,是"人工跑的活栈脚本"。其 16 个场景应逐步迁移进
fixture 体系。
4. **生产环境 JDBC**:`stringtype=unspecified` 本次只改了 docker profile;
生产的 datasource 由部署环境变量提供,需同步加上(运维项)。
5. **CI 首次运行成本**:`e2e` job 需构建 backend 镜像(Dockerfile 内联网构建),耗时较长。
---
## 六、结论
Phase 23B **关闭**。
E2E 从"一批从未执行过的文件 + 一个不存在的 fixture 体系",变为
「**有栈就真跑、没栈就退出、CI 会阻断**」的可执行基建;
顺带用真实 PostgreSQL 挖出 6 个 H2 永远发现不了的生产缺陷。
+30
View File
@@ -660,6 +660,36 @@ Invariant测试:10个(INV-011~020)→ **实测:原 14 个用例无任何
**Phase 22(部署运维配置扩展)— 已完成** ✅ **Phase 22(部署运维配置扩展)— 已完成** ✅
### Phase 23B — E2E 测试基建重建(2026-09-18)✅ 已关闭
> 本轮补齐的是 Phase 23 的两条遗留(D-23-C「E2E 整体不可执行」、D-23-D「CI 未串联 E2E」),
> 与下方 **「Phase 24 — UAT增量」是不同工作**,不要混为一谈。
| 交付 | 说明 |
|---|---|
| `tests/e2e/conftest.py` | fixture 体系:ApiClient、`operator_token(username)`、`new_user`、栈探活 |
| `pytest.ini` / `tests/e2e/requirements.txt` | 收集范围与依赖 |
| `tests/e2e/sql/seed_e2e_operators.sql` | 运营账号自举(**迁移里没有任何 operator 种子**,全新库无账号可登录) |
| CI job `e2e` | 起真实栈 → 等就绪 → seed → pytest → 失败阻断 → 清理 |
| 8 个 E2E 文件改造 | 消除 import 期 `KeyError`;替换恒真 / 名不副实的断言 |
**核心设计**:栈不可达时 `pytest.exit(4)`,**不是 skip** —— skip 会把"未执行"伪装成"通过"。
**验证**:本机真实活栈 **33 passed**;后端全量回归见本节末。
**本轮暴露并修复的真实生产缺陷**(H2 测试库全部零覆盖,只有真 PostgreSQL 才现形):
| 编号 | 真相 |
|---|---|
| D-23B-A | 构建产物残留重复迁移版本号 042 → Flyway 报错 → **应用起不来** |
| D-23B-B | 非 UUID path 参数 → 500 且泄漏数据库异常 |
| D-23B-C | 6 个 E2E 文件 import 期崩溃 → **从未被执行过** |
| D-23B-E | 权限 seed 缺口:`super_admin` 仅 18/48 条、`matchmaker` **0** 条(matchmaker 部分列为遗留) |
| D-23B-I | 非法枚举参数 `IllegalArgumentException` 未映射 → 500(应 BAD_REQUEST) |
| D-23B-J | `operator does not exist: uuid = character varying` → JDBC 增加 `stringtype=unspecified` |
详见 `docs/Phase-23B-V11-E2E-Infrastructure-Completion-Report-2026-09-18.md`。
### Phase 24 — UAT增量 ✅ 已就绪(测试数据支持) ### Phase 24 — UAT增量 ✅ 已就绪(测试数据支持)
| 任务 | 状态 | | 任务 | 状态 |
|------|------| |------|------|
+14
View File
@@ -0,0 +1,14 @@
[pytest]
# E2E 只收集 tests/e2e。
#
# 注意:tests/e2e/run_v11_tests.py 故意**不**被收集(文件名不匹配 *_test.py):
# 它在 import 期就执行 operator 登录、依赖 docker exec 取库内 ID,
# 是一条"人工跑的活栈脚本",不是可收集的 pytest 用例。
# 该文件的 16 个场景应逐步迁移到 conftest fixture 体系下(见遗留项)。
testpaths = tests/e2e
python_files = *_test.py
python_classes = Test*
python_functions = test_*
addopts = -ra --strict-markers
markers =
e2e: 端到端测试,需要活栈(E2E_BASE_URL + E2E_OPERATOR_PASSWORD)
+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 pytest
import requests
BASE_URL = 'http://localhost:8085/api/v1' pytestmark = pytest.mark.e2e
E2E_PASSWORD = os.environ["E2E_PASSWORD"]
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): def test_content_categories_are_listable(api, user_token):
return {'Authorization': f'Bearer {token}'} data = api.ok("GET", "/content/categories", token=user_token)
def test_get_content_categories():
r = requests.get(f'{BASE_URL}/content/categories')
assert r.status_code == 200
data = r.json()['data']
assert isinstance(data, list) 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(): def test_article_lifecycle_draft_publish_offline(api, operator_token):
token = get_admin_token() """文章生命周期:草稿 → 发布 → 下架,状态必须真实流转。"""
# 创建文章 token = operator_token("e2e_content")
create_r = requests.post( created = api.ok(
f'{BASE_URL}/content/operator/articles', "POST",
headers=auth_header(token), "/content/operator/articles",
json={'title': '测试文章', 'summary': '测试摘要', 'content': '测试内容', 'sortOrder': 0} token=token,
json={"title": "E2E 文章", "summary": "E2E 摘要", "content": "E2E 正文", "sortOrder": 0},
) )
assert create_r.status_code == 200 article_id = created["id"]
article_id = create_r.json()['data']['id'] assert created["status"] == "draft", f"新建文章状态应为 draft:{created}"
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'
def test_content_stats(): published = api.ok("POST", f"/content/operator/articles/{article_id}/publish", token=token)
r = requests.get(f'{BASE_URL}/operator/analytics/v11/content') assert published["status"] == "published", f"发布后状态错误:{published}"
assert r.status_code == 200
data = r.json()['data']
assert 'totalPublishedArticles' in data
assert 'totalViews' in data
assert 'topArticles' in data
if __name__ == '__main__': offline = api.ok("POST", f"/content/operator/articles/{article_id}/offline", token=token)
pytest.main([__file__, '-v']) 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 实现后完善」), 那证明的不是 INV-013,而是接口坏了。
依赖不存在的 `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`
""" """
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 - 公益婚恋咨询与婚前辅导 婚恋咨询 / 婚前辅导 E2E(咨询主流程)。
验证 INV-012(Counselor assigned scope)等不变量
迁移说明:原文件依赖 3 个环境变量(import 期即 KeyError → 从未被收集执行),
并直连 psycopg2 校验通知落库,且用不存在的固定 ID 做断言。
现统一改为 conftest fixture + 真实 API;数据库断言交后端集成测试,E2E 只走 HTTP。
""" """
import os
import pytest import pytest
import requests
BASE_URL = "http://localhost:8085/api/v1" pytestmark = pytest.mark.e2e
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}"}
# ---- P13A-T05: 获取咨询介绍 ---- def test_counseling_intro_lists_guidance_types(api, user_token):
def test_get_counseling_intro(): data = api.ok("GET", "/counseling/services/intro", token=user_token)
r = requests.get(f"{BASE_URL}/counseling/services/intro") assert isinstance(data, list) and len(data) >= 2, f"咨询介绍异常:{data}"
assert r.status_code == 200 types = {item.get("type") for item in data}
data = r.json()["data"] for expected in ("relationship_guidance", "premarital_guidance"):
assert isinstance(data, list) assert expected in types, f"咨询介绍缺少 {expected}:{sorted(types)}"
assert len(data) >= 3
types = {item["type"] for item in data}
assert "relationship_guidance" in types
assert "premarital_guidance" in types
# ---- P13A-T06: 申请咨询 ---- def test_create_counseling_is_user_self_and_pending(api, user_token):
def test_create_counseling(): """用户端创建咨询:source 必须恒为 user_self,不可由请求体伪造。"""
token = get_user_token() data = api.ok(
r = requests.post( "POST",
f"{BASE_URL}/counseling/services", "/counseling/services",
headers=auth_header(token), token=user_token,
json={"serviceType": "relationship_guidance", "userRequestSummary": "恋爱沟通困难"} json={"serviceType": "relationship_guidance", "userRequestSummary": "恋爱沟通困难"},
) )
assert r.status_code == 200
data = r.json()["data"]
assert data["status"] == "pending" assert data["status"] == "pending"
assert data["serviceType"] == "relationship_guidance" assert data["serviceType"] == "relationship_guidance"
assert data["requestSource"] == "user_self" assert data["requestSource"] == "user_self", f"来源被污染:{data}"
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()
# ---- P13A-T07: 获取我的咨询列表 ---- def test_my_counseling_list_is_accessible(api, user_token):
def test_get_my_counselings(): api.ok("POST", "/counseling/services", token=user_token,
token = get_user_token() json={"serviceType": "relationship_guidance", "userRequestSummary": "列表用例"})
r = requests.get(f"{BASE_URL}/counseling/services", headers=auth_header(token)) data = api.ok("GET", "/counseling/services", token=user_token)
assert r.status_code == 200 assert isinstance(data, list)
assert isinstance(r.json()["data"], list)
# ---- P13A-T08: 咨询详情(无权访问他人记录) ---- def test_cancel_counseling(api, user_token):
def test_counseling_detail_no_access(): created = api.ok(
token = get_user_token() "POST",
# 尝试访问不属于自己的咨询 "/counseling/services",
r = requests.get(f"{BASE_URL}/counseling/services/non-existent-id", headers=auth_header(token)) token=user_token,
assert r.status_code in (400, 404) json={"serviceType": "emotional_support", "userRequestSummary": "测试取消"},
# ---- 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": "测试取消"}
) )
counseling_id = create_r.json()["data"]["id"] cancelled = api.ok(
cancel_r = requests.post( "POST", f"/counseling/services/{created['id']}/cancel", token=user_token
f"{BASE_URL}/counseling/services/{counseling_id}/cancel",
headers=auth_header(token)
) )
assert cancel_r.status_code == 200 assert cancelled["status"] == "cancelled", f"取消后状态错误:{cancelled}"
assert cancel_r.json()["data"]["status"] == "cancelled"
# ---- INV-012: Counselor只能访问分配给自己的服务 ---- def test_counselor_assigned_list_is_scoped(api, operator_token):
def test_counselor_assigned_scope(): """INV-012:咨询师只能看到分配给自己的服务(列表必须可访问且为列表)。"""
counselor_token = get_counselor_token() token = operator_token("e2e_counselor")
# Counselor 只能看到分配给自己的 data = api.ok("GET", "/counseling/services/operator/assigned", token=token)
r = requests.get(f"{BASE_URL}/counseling/services/operator/assigned", headers=auth_header(counselor_token)) assert isinstance(data, list)
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"])
+46 -41
View File
@@ -1,44 +1,49 @@
""" """
V1.1 首页 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18) 首页 E2E(INV-017 八入口固定 / INV-016 Banner 禁外链 / 首页结构固定)。
历史问题 这些断言打的是**真实活栈**:`GET /api/v1/home` 的真实响应,不是常量、不是自造字面量。
--------
原文件 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`
""" """
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:本地资格佐证不得被未授权方读取)。
历史问题 打真实活栈:新用户经 mock 微信登录,走真实的声明/查询链路。
--------
原文件 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`
""" """
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 pytest
import requests
BASE_URL = "http://localhost:8085/api/v1" pytestmark = pytest.mark.e2e
E2E_PASSWORD = os.environ["E2E_PASSWORD"]
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): def test_activities_list_is_accessible(api, user_token):
return {"Authorization": f"Bearer {token}"} data = api.ok("GET", "/activities", token=user_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"]
assert isinstance(data, list) 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(): # 必须是生产 chk_activity_type 白名单内的合法值(ActivityService.VALID_ACTIVITY_TYPES);
# 创建活动后获取组织列表 # 传非法值应为 400 而非 500 —— 由 test_invalid_activity_type_returns_400 覆盖。
r = requests.get(f"{BASE_URL}/activities/act-001/organizations") @pytest.mark.parametrize(
assert r.status_code == 200 "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(): def test_invalid_activity_type_returns_bad_request_not_500(api, user_token):
r = requests.get(f"{BASE_URL}/operator/analytics/v11/registration-sources") """
assert r.status_code == 200 非法类型属客户端参数错误,必须回 BAD_REQUEST。
data = r.json()["data"]
assert "totalRegistrations" in data
assert "bySource" in data
if __name__ == "__main__": 注意本项目的约定:业务错误是 **HTTP 200 + body.code**(只有框架层
pytest.main([__file__, "-v"]) 鉴权失败才是真 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 pytest
import requests
BASE_URL = "http://localhost:8085/api/v1" pytestmark = pytest.mark.e2e
E2E_PASSWORD = os.environ["E2E_PASSWORD"]
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): def test_station_operator_cannot_browse_all_users(api, operator_token):
return {"Authorization": f"Bearer {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(): def test_counselor_cannot_browse_all_users(api, operator_token):
# 验证组织关系不自动授予用户数据访问权限(INV-020) """INV-012/020:咨询师同理,组织/角色关系不等于全量数据权限。"""
# 此测试验证数据库中不存在基于组织关系的自动权限授予 token = operator_token("e2e_counselor")
r = requests.get(f"{BASE_URL}/operator/analytics/v11/service-programs", headers=auth_header(get_admin_token())) resp = api.call("GET", "/operator/users", token=token)
assert r.status_code == 200 assert resp.status_code == 403, (
f"咨询师竟能浏览全用户库:HTTP {resp.status_code} {resp.text[:200]}"
if __name__ == "__main__": )
pytest.main([__file__, "-v"])
+39 -134
View File
@@ -1,145 +1,50 @@
""" """
E2E 测试:Phase 13 - 红娘服务转介 红娘转介 E2E(INV-005:红娘不可代用户操作)。
验证 INV-005(红娘不可代用户操作)及转介追踪链路
迁移说明:原文件依赖 3 个环境变量(import 期 KeyError → 从未执行),
用 `user-test-001` / `loc-001` 等**不存在的固定 ID** 构造请求,
甚至出现 `auth_header("Bearer dummy")` 这种错误用法 —— 从未真正跑通过。
改造后只保留可真实执行的断言;被 D-24-E 权限缺口阻塞的场景在下方注释中
如实记录,不写成"看起来在测、实际恒过"的假用例。
""" """
import os
import pytest import pytest
import requests
BASE_URL = "http://localhost:8085/api/v1" pytestmark = pytest.mark.e2e
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}"}
# ---- P13-T05: 转介确认后激活 ---- def test_referral_history_is_accessible(api, user_token):
def test_referral_requires_user_confirmation(): data = api.ok("GET", "/referrals/my-history", token=user_token)
"""红娘转介后,预约状态为 submitted,需用户 confirm 后才变为 pending_confirmation""" assert isinstance(data, list)
user_token = get_user_token()
mm_token = get_matchmaker_token()
# 红娘创建转介预约
r = requests.post( def test_matchmaker_cannot_confirm_on_users_behalf(api, operator_token, new_user):
f"{BASE_URL}/referrals/service-station", """
headers=auth_header(mm_token), INV-005:红娘不能替用户确认转介。
json={"userId": "user-test-001", "locationId": "loc-001", "serviceType": "counselor_consultation"}
用一个**不存在的转介 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 assert not (200 <= resp.status_code < 300), (
appt_id = r.json()["data"]["appointmentId"] f"红娘竟能代用户确认转介:HTTP {resp.status_code} {resp.text[:200]}"
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 r2.status_code == 200
assert r2.json()["data"]["status"] == "pending_confirmation"
# ---- P13-T02: 转介活动 ---- # ---------------------------------------------------------------------------
def test_matchmaker_refers_activity(): # 被阻塞场景(D-24-E:全新库下 matchmaker 角色 0 权限、super_admin 仅 18/48)
"""红娘转介用户到公益活动,需用户确认""" #
user_token = get_user_token() # 下列场景当前无法真实执行,故**不写成用例**(写上去只能靠"资源不存在"恒过,
mm_token = get_matchmaker_token() # 那正是本轮要清除的假覆盖):
# - 红娘创建转介(POST /referrals/service-station)→ 当前 403
r = requests.post( # - 红娘转介活动(POST /referrals/activity)→ 当前 403
f"{BASE_URL}/referrals/activity", # - 转介 source 追踪(需真实 appointmentId)
headers=auth_header(mm_token), #
json={"userId": "user-test-001", "activityId": "act-001", # 待权限 seed 补齐(见 Phase 24 报告 D-24-E)后,按上述真实链路补齐用例。
"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"])
+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 pytest
import requests
BASE_URL = "http://localhost:8085/api/v1" pytestmark = pytest.mark.e2e
E2E_PASSWORD = os.environ["E2E_PASSWORD"]
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(): def test_station_operator_cannot_view_counseling_queue(api, operator_token):
r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "counselor01", "password": E2E_PASSWORD}) """INV-012:服务站运营人员不得进入咨询队列。"""
return r.json()["data"]["accessToken"] 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(): def test_counselor_cannot_view_all_service_appointments(api, operator_token):
token = get_station_operator_token() """INV-012:咨询师不得浏览全量服务预约。"""
r = requests.get(f"{BASE_URL}/counseling/services/operator/queue", headers=auth_header(token)) token = operator_token("e2e_counselor")
# 服务站运营人员不应有咨询队列权限,返回403或空 resp = api.call("GET", "/service-appointments/operator/list", token=token)
assert r.status_code == 403 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(): def test_content_editor_cannot_access_appeals(api, operator_token):
token = get_counselor_token() # 复用counselor token模拟content_editor权限边界 """INV-019:内容编辑员不得访问申诉等用户数据处理职能。"""
r = requests.get(f"{BASE_URL}/operator/appeals/pending", headers=auth_header(token)) token = operator_token("e2e_content")
assert r.status_code == 403 resp = api.call("GET", "/operator/appeals/pending", token=token)
assert resp.status_code == 403, (
if __name__ == "__main__": f"内容编辑员竟能访问申诉队列:HTTP {resp.status_code} {resp.text[:200]}"
pytest.main([__file__, "-v"]) )
+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;