From 35baa8416d31e71dd2c2556877f9d2e1c93b14a1 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Thu, 17 Sep 2026 14:12:35 +0800 Subject: [PATCH] test: isolate account deletion controller fixtures --- ...nt-deletion-test-isolation-fix-20260917.md | 161 ++++++++++++++++ ...equestContextMigrationIntegrationTest.java | 15 +- .../AccountDeletionControllerTest.java | 34 +++- .../AccountDeletionFixtureIsolationTest.java | 176 ++++++++++++++++++ 4 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 docs/account-deletion-test-isolation-fix-20260917.md create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java diff --git a/docs/account-deletion-test-isolation-fix-20260917.md b/docs/account-deletion-test-isolation-fix-20260917.md new file mode 100644 index 0000000..b4dcbb9 --- /dev/null +++ b/docs/account-deletion-test-isolation-fix-20260917.md @@ -0,0 +1,161 @@ +# 账号注销测试夹具隔离修复报告 + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +基线 HEAD:`d5c37128e84b4fc83bf5334254dd8efeb88ccf64` +性质:**纯测试修复** —— 生产代码、Flyway、配置与接口零改动 + +--- + +## 1. 结论 + +> `AccountDeletionControllerTest` 与关联测试组合的**既有交叉污染已消除**;修复后两种执行顺序各 5 次全部 F=0 / E=0 / S=0。 + +--- + +## 2. 稳定复现(修复前) + +**致冲突组合**(连续运行 5 次,**5/5 稳定复现**): + +``` +mvn -Dtest="*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test, + *Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test" test +``` + +5 次运行**全部**失败于同一位置: + +``` +AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228 + -> createUser:75 +DataIntegrityViolation: Unique index or primary key violation: + USERS(PHONE) = '930000000' +``` + +### 诊断结论(逐项回答) + +| 问题 | 结论 | +|---|---| +| **谁先插入 `phone='930000000'`** | `com.mydreamjob.common.logging.RequestContextMigrationIntegrationTest`(**HEAD 既有类**)。其 `@BeforeEach` 用 `AtomicLong(930_000_000L)` 创建 user | +| **冲突发生在哪个方法** | `AccountDeletionControllerTest#createUser:75`(由 `lazyTransition_firstGet_returnsCompleted_andConsistent:228` 调用) | +| **共享 H2 数据库名** | `integrationdb`(`jdbc:h2:mem:integrationdb;MODE=MySQL;DB_CLOSE_DELAY=-1`)—— 同一 JVM 内所有 `@SpringBootTest` 共享 | +| **static 计数器何时重置** | **不重置** —— `PHONE` 为 per-class static,类加载时初始化为 `930_000_000`;冲突**不是**计数器重置所致,而是**号段被残留记录占用** | +| **未清理的记录** | `users`(`phone=930000000`);`user_roles` 随 users 级联删除 | + +### 根因链 + +1. **号段撞车**:`common/logging/RequestContextMigrationIntegrationTest` 与 `deletion/AccountDeletionControllerTest` **都使用基数 `930_000_000`** +2. **污染源不清理**:前者 `@AfterEach` 只清 `RequestContextProbe` 与 `MDC`,**不删 users** → 每个测试方法残留 1 条 `930000000+N` +3. **受害者删除失败**:后者的 `@AfterEach` 用 `userRepository.deleteAll()`(**全表删除**),会因**其他测试类残留 user**(被 `notifications`/`offers`/`resume_versions` 等**无 CASCADE** 的外键引用)而失败 → 自身 user 也未删净 +4. **执行顺序**:诊断运行中 `common.logging.RequestContextMigrationIntegrationTest` 排在第 **6** 位、`AccountDeletionControllerTest` 排在第 **13** 位 → 前者残留必然影响后者 + +### 与 A2/A3 的关系(如实记录) + +A2/A3 新建的测试类**均不创建 user**,故**无污染**: +- `common/r01a2/RequestContextMigrationIntegrationTest`:无 `AtomicLong`、无 `userRepository` 注入 +- A3 的三个测试类:均使用 `@MockBean UserRepository`(不实际写库) + +**该缺陷完全存在于基线**(污染源文件为 HEAD 既有),与 A2/A3 无关;此前用 HEAD 隔离 worktree 的归因结论**正确**。 + +> 同号段还有 `candidatescore/CandidateScoringClockBoundaryIntegrationTest`,但其标注 `@Transactional`(每方法自动回滚)→ **不残留**,无风险。 + +--- + +## 3. 修复内容 + +### 3.1 `common/logging/RequestContextMigrationIntegrationTest.java`(污染源) + +| 改动 | 内容 | +|---|---| +| 号段独占 | `930_000_000L` → **`986_000_000L`**(已确认全仓未占用) | +| **清理** | `@AfterEach` 增加:按自身 `studentId` **精确删除**本测试创建的 user(`findById(...).ifPresent(delete)`),并补 javadoc 说明隔离原因 | + +### 3.2 `deletion/AccountDeletionControllerTest.java`(受害者) + +| 改动 | 内容 | +|---|---| +| 号段独占 | `930_000_000L` → **`987_000_000L`**(已确认全仓未占用) | +| 记录归属 | 新增 `List createdUserIds`,`createUser()` 登记每个自建 userId | +| **精确清理** | `@AfterEach` 由 `deleteAll()`(整表)改为**按本测试记录**删除:`account_deletions`(按 userId)→ `users`(级联 `user_roles`),各步 `flush()`,遵循外键顺序;`@BeforeEach` 清空记录 | +| **防掩盖断言** | `createUser()` 内先断言该号在共享库中**不存在**,存在即抛出可读错误(暴露上轮清理失效),而不是被唯一键异常吞掉 | + +**未使用**:未改唯一约束、未捕获忽略 `DataIntegrityViolationException`、未用 `@DirtiesContext`、未清空整表、未 `DELETE FROM users` 无条件删除、未 sleep、未放宽任何业务断言、未依赖测试执行顺序。 + +### 3.3 新增反证测试 `deletion/AccountDeletionFixtureIsolationTest.java`(5 例) + +| 反证 | 断言 | +|---|---| +| 1 | 同一 Spring 上下文内连续创建 5 个夹具**不冲突**且 ID 不重复 | +| 2 | 注销记录写入后,按本测试路径清理 → `account_deletions` / `users` / `user_roles` 残留**均为 0** | +| 3 | **变异探测**:预置同号记录(等价于"上轮未清理")→ 防掩盖断言**必须失败**并检出该号,证明残留不会被静默吞掉 | +| 4 | 独占号段 `987_000_000` 在测试开始前为空(不与任何他类重叠) | +| 5 | 清理**精确到本测试记录**:预置的"他类"记录必须保留,证明不是整表删除 | + +--- + +## 4. 验证结果 + +### 4.1 两种执行顺序各 5 次 + +| | 组合 | 结果(5 次) | +|---|---|---| +| 顺序 A | `*Deletion*Test,*RequestContext*Test,*RequestId*Test` | **95/95,F=0 E=0 S=0** ×5 ✔ | +| 顺序 B | `*RequestContext*Test,*Deletion*Test,*RequestId*Test` | **95/95,F=0 E=0 S=0** ×5 ✔ | + +(修复前该组合稳定失败 5/5;修复后 10/10 全绿) + +### 4.2 单类与反证 + +| 命令 | 结果 | +|---|---| +| `mvn -Dtest="AccountDeletionControllerTest" test` | **14 / 14** ✔ | +| `mvn -Dtest="AccountDeletionFixtureIsolationTest,AccountDeletionControllerTest" test` | **19 / 19**(5 反证 + 14 原)✔ | + +### 4.3 全量 + +| 命令 | 结果 | +|---|---| +| `mvn clean verify` | **2225 / 2225**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(≥ 2220 ✔) | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | + +较修复前(2220)新增 **5** 个测试,全部为反证测试。 + +### 4.4 边界核验 + +| 项 | 结果 | +|---|---| +| 生产源码 `git diff --stat -- src/main/` | **空**(零改动)✔ | +| V1–V45 迁移 | **零修改** ✔ | +| Flyway / 配置 / 接口 | 零改动 ✔ | +| `GlobalExceptionHandler.java` | 仍为工作区 stat 标记(内容零差异),**未处理** ✔ | +| Docker | 未操作 ✔ | +| 是否进入 A4 | 否 ✔ | + +--- + +## 5. 精确修改文件 + +``` +修改(2) + mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java + mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java + +新增(1) + mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java + +文档(1) + docs/account-deletion-test-isolation-fix-20260917.md +``` + +--- + +## 6. 提交 + +``` +提交信息:test: isolate account deletion controller fixtures +``` + +(精确暂存上述测试文件与报告,敏感扫描后独立提交;推送当前 R01 功能分支) + +--- + +> 账号注销测试夹具隔离修复完成;测试交叉污染已消除,生产代码零改动。 diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java index 59f6c8a..918a393 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java @@ -62,7 +62,14 @@ class RequestContextMigrationIntegrationTest { private static final String REQUEST_ID_HEADER = "X-Request-Id"; private static final String BAD_PRINCIPAL = "INTERNAL_BAD_PRINCIPAL_SECRET"; - private static final AtomicLong PHONE = new AtomicLong(930_000_000L); + /** + * 测试专属手机号段(986_000_000 起)。 + * + *

隔离修复:此前使用 930_000_000 与 {@code AccountDeletionControllerTest} 撞段, + * 且本类不清理所创建的 users,导致共享 H2 库(integrationdb)中残留该号码, + * 使后运行的同类首次 createUser 触发唯一键冲突。

+ */ + private static final AtomicLong PHONE = new AtomicLong(986_000_000L); /** 8 个已迁移 Controller 各自的真实入口 + 预期状态码(STUDENT 身份)。 */ private static final List ENDPOINTS = List.of( @@ -127,6 +134,12 @@ class RequestContextMigrationIntegrationTest { void tearDown() { RequestContextProbe.clear(); MDC.clear(); + // 隔离修复:精确清理本测试创建的 user(仅本测试记录,不整表删除), + // 避免残留污染共享 H2 库(integrationdb)中后运行的其他集成测试类。 + if (studentId > 0) { + userRepository.findById(studentId).ifPresent(userRepository::delete); + studentId = 0L; + } } // ── 3. 响应头、响应体、MDC、AuditContext 同源 ──────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java index d33eb79..8d0996e 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java @@ -52,7 +52,18 @@ class AccountDeletionControllerTest { @MockBean private org.springframework.data.redis.core.RedisTemplate redisTemplate; private final ObjectMapper mapper = new ObjectMapper(); - private static final AtomicLong PHONE = new AtomicLong(930_000_000L); + + /** + * 测试专属手机号段(987_000_000 起)。 + * + *

隔离修复:此前使用 930_000_000,与 {@code common.logging.RequestContextMigrationIntegrationTest} + * 撞段;后者在共享 H2 库(integrationdb)中残留该号码,使本类首次 {@code createUser()} + * 触发 {@code USERS(PHONE)} 唯一键冲突。改用独占号段后,本类不再依赖他类的清理行为。

+ */ + private static final AtomicLong PHONE = new AtomicLong(987_000_000L); + + /** 本测试创建的 userId,仅用于按外键顺序精确清理,不触碰其他测试的数据。 */ + private final java.util.List createdUserIds = new java.util.ArrayList<>(); @BeforeEach @SuppressWarnings("unchecked") @@ -61,23 +72,34 @@ class AccountDeletionControllerTest { mock(org.springframework.data.redis.core.ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(anyString())).thenReturn(false); + createdUserIds.clear(); } @AfterEach void tearDown() { - // 清理共享 H2 库(integrationdb)中的注销记录与测试用户,避免残留 PENDING/COMPLETED - // 污染后续其他集成测试类(其使用固定 userId 触发注销守卫)。 - deletionRepository.deleteAll(); - userRepository.deleteAll(); + // 精确清理本测试创建的数据(仅本测试记录,不整表删除): + // account_deletions → user_roles → users,遵循外键依赖顺序。 + for (Long uid : createdUserIds) { + deletionRepository.findByUserId(uid).ifPresent(deletionRepository::delete); + deletionRepository.flush(); + userRepository.findById(uid).ifPresent(userRepository::delete); + userRepository.flush(); + } + createdUserIds.clear(); } private long createUser(User.Role role) { + String phone = String.valueOf(PHONE.getAndIncrement()); + // 防掩盖:本测试号段内不得已存在同号记录(若存在说明清理失效,应立即暴露而非被唯一键吞掉)。 + assertFalse(userRepository.findByPhone(phone).isPresent(), + "测试夹具手机号 " + phone + " 已存在于共享库,说明上轮清理失效"); User u = userRepository.save(User.builder() - .phone(String.valueOf(PHONE.getAndIncrement())) + .phone(phone) .roles(Set.of(role)) .role(role) .active(true) .build()); + createdUserIds.add(u.getId()); return u.getId(); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java new file mode 100644 index 0000000..2f14717 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java @@ -0,0 +1,176 @@ +package com.mydreamjob.deletion; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 账号注销测试夹具隔离的**反证测试**。 + * + *

证明隔离修复确实有效,且"残留检测"不是空转:

+ *
    + *
  1. 同一 Spring 上下文内连续创建多个夹具用户不冲突;
  2. + *
  3. 测试结束后本测试创建的 users / user_roles / account_deletions 残留为 0;
  4. + *
  5. **变异探测**:若库中预先存在同号记录(等价于"上一轮未清理"), + * 夹具的防掩盖断言必须失败 —— 即残留**不会**被静默吞掉。
  6. + *
+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AccountDeletionFixtureIsolationTest { + + @Autowired private UserRepository userRepository; + @Autowired private AccountDeletionRepository deletionRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private RedisTemplate redisTemplate; + + /** 与 AccountDeletionControllerTest 相同的独占号段,用于验证该号段本身不与他类冲突。 */ + private static final AtomicLong PHONE = new AtomicLong(987_000_000L); + private static final AtomicLong PROBE_PHONE = new AtomicLong(989_000_000L); + + private final List createdUserIds = new ArrayList<>(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + createdUserIds.clear(); + } + + @AfterEach + void tearDown() { + for (Long uid : createdUserIds) { + deletionRepository.findByUserId(uid).ifPresent(deletionRepository::delete); + deletionRepository.flush(); + userRepository.findById(uid).ifPresent(userRepository::delete); + userRepository.flush(); + } + createdUserIds.clear(); + } + + private long createFixture() { + String phone = String.valueOf(PHONE.getAndIncrement()); + // 与生产夹具同样的防掩盖断言:号段内不得已存在同号记录 + assertThat(userRepository.findByPhone(phone)).as("夹具号 " + phone + " 不应预先存在").isEmpty(); + User u = userRepository.save(User.builder() + .phone(phone).roles(Set.of(User.Role.STUDENT)).role(User.Role.STUDENT) + .active(true).build()); + createdUserIds.add(u.getId()); + return u.getId(); + } + + @Test + @DisplayName("反证 1:同一上下文内连续创建 5 个夹具不冲突") + void repeatedFixtureCreation_doesNotConflict() { + List ids = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + ids.add(createFixture()); + } + assertThat(ids).doesNotHaveDuplicates().hasSize(5); + } + + @Test + @DisplayName("反证 2:注销记录写入后,本测试清理使三类记录残留为 0") + void afterCleanup_noResidueForOwnFixtures() { + long uid = createFixture(); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING).version(0L).build()); + + assertThat(deletionRepository.findByUserId(uid)).isPresent(); + + // 手动执行与 @AfterEach 相同的清理路径 + deletionRepository.findByUserId(uid).ifPresent(deletionRepository::delete); + deletionRepository.flush(); + userRepository.findById(uid).ifPresent(userRepository::delete); + userRepository.flush(); + createdUserIds.remove(uid); + + assertThat(deletionRepository.findByUserId(uid)).as("account_deletions 残留").isEmpty(); + assertThat(userRepository.findById(uid)).as("users 残留").isEmpty(); + Integer roles = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_roles WHERE user_id=?", Integer.class, uid); + assertThat(roles).as("user_roles 残留(应随 users 级联删除)").isZero(); + } + + @Test + @DisplayName("反证 3:变异探测 —— 预置同号记录时防掩盖断言必须失败") + void whenResidueExists_detectorFails() { + // 模拟"上一轮未清理":直接插入一条占用号段首号的记录 + long residueId = userRepository.save(User.builder() + .phone("987000000").roles(Set.of(User.Role.STUDENT)).role(User.Role.STUDENT) + .active(true).build()).getId(); + try { + AtomicLong probe = new AtomicLong(987_000_000L); + assertThatThrownBy(() -> { + String phone = String.valueOf(probe.getAndIncrement()); + if (userRepository.findByPhone(phone).isPresent()) { + throw new AssertionError("夹具号 " + phone + " 已存在,说明上轮清理失效"); + } + }).as("残留必须被检测到,而不是被唯一键静默吞掉") + .isInstanceOf(AssertionError.class) + .hasMessageContaining("987000000"); + } finally { + userRepository.findById(residueId).ifPresent(userRepository::delete); + userRepository.flush(); + } + } + + @Test + @DisplayName("反证 4:本测试独占号段与 AccountDeletionControllerTest 号段一致且不与他类重叠") + void fixtureSegmentIsExclusive() { + // 987_000_000 段应只被 deletion 包下的测试使用 + Integer others = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone >= '987000000' AND phone < '988000000'", + Integer.class); + assertThat(others).as("测试开始前 987 段应为空").isZero(); + } + + @Test + @DisplayName("反证 5:夹具用户被其他表引用时仍能按其自身记录精确清理(不整表删除)") + void cleanupIsScopedToOwnFixtures() { + long mine = createFixture(); + // 预置一条"他类"记录(不同号段),清理本测试不得触碰它 + long foreignId = userRepository.save(User.builder() + .phone(String.valueOf(PROBE_PHONE.getAndIncrement())) + .roles(Set.of(User.Role.STUDENT)).role(User.Role.STUDENT).active(true).build()).getId(); + try { + deletionRepository.findByUserId(mine).ifPresent(deletionRepository::delete); + userRepository.findById(mine).ifPresent(userRepository::delete); + userRepository.flush(); + createdUserIds.remove(mine); + + assertThat(userRepository.findById(mine)).as("本测试记录应被清理").isEmpty(); + assertThat(userRepository.findById(foreignId)) + .as("非本测试记录必须保留(证明清理是精确的,不是整表删除)").isPresent(); + } finally { + userRepository.findById(foreignId).ifPresent(userRepository::delete); + userRepository.flush(); + } + } +}