Browse Source

test: isolate account deletion controller fixtures

refactor/r01-structure-performance
Zhang Shun 1 month ago
parent
commit
35baa8416d
  1. 161
      docs/account-deletion-test-isolation-fix-20260917.md
  2. 15
      mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java
  3. 34
      mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java
  4. 176
      mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java

161
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<Long> 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 功能分支)
---
> 账号注销测试夹具隔离修复完成;测试交叉污染已消除,生产代码零改动。

15
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 起)。
*
* <p>隔离修复:此前使用 930_000_000 与 {@code AccountDeletionControllerTest} 撞段,
* 且本类不清理所创建的 users,导致共享 H2 库(integrationdb)中残留该号码,
* 使后运行的同类首次 createUser 触发唯一键冲突。</p>
*/
private static final AtomicLong PHONE = new AtomicLong(986_000_000L);
/** 8 个已迁移 Controller 各自的真实入口 + 预期状态码(STUDENT 身份)。 */
private static final List<MigratedEndpoint> 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 同源 ────────────────

34
mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java

@ -52,7 +52,18 @@ class AccountDeletionControllerTest {
@MockBean private org.springframework.data.redis.core.RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper mapper = new ObjectMapper();
private static final AtomicLong PHONE = new AtomicLong(930_000_000L);
/**
* 测试专属手机号段(987_000_000 起)。
*
* <p>隔离修复:此前使用 930_000_000,与 {@code common.logging.RequestContextMigrationIntegrationTest}
* 撞段;后者在共享 H2 库(integrationdb)中残留该号码,使本类首次 {@code createUser()}
* 触发 {@code USERS(PHONE)} 唯一键冲突。改用独占号段后,本类不再依赖他类的清理行为。</p>
*/
private static final AtomicLong PHONE = new AtomicLong(987_000_000L);
/** 本测试创建的 userId,仅用于按外键顺序精确清理,不触碰其他测试的数据。 */
private final java.util.List<Long> 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();
}

176
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;
/**
* 账号注销测试夹具隔离的**反证测试**。
*
* <p>证明隔离修复确实有效,且"残留检测"不是空转:</p>
* <ol>
* <li>同一 Spring 上下文内连续创建多个夹具用户不冲突;</li>
* <li>测试结束后本测试创建的 users / user_roles / account_deletions 残留为 0;</li>
* <li>**变异探测**:若库中预先存在同号记录(等价于"上一轮未清理"),
* 夹具的防掩盖断言必须失败 —— 即残留**不会**被静默吞掉。</li>
* </ol>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class AccountDeletionFixtureIsolationTest {
@Autowired private UserRepository userRepository;
@Autowired private AccountDeletionRepository deletionRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> 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<Long> createdUserIds = new ArrayList<>();
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> 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<Long> 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();
}
}
}
Loading…
Cancel
Save