forked from baskbuddy/zhangshun
4 changed files with 379 additions and 7 deletions
@ -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 功能分支) |
|||
|
|||
--- |
|||
|
|||
> 账号注销测试夹具隔离修复完成;测试交叉污染已消除,生产代码零改动。 |
|||
@ -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…
Reference in new issue