forked from baskbuddy/zhangshun
4 changed files with 491 additions and 16 deletions
@ -0,0 +1,128 @@ |
|||
# Fairness 认证夹具隔离修复(A6 后独立纯测试提交) |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
A6 提交:`c6db74a1a8300e1aab8a452fb67909f86861fa43` |
|||
本轮性质:**纯测试修复** —— 生产代码 diff 为 0,Flyway 为 0,OpenAPI 为 0 |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
`FairnessMonitoringControllerTest` 已不再依赖种子数据、测试执行顺序或共享表清空, |
|||
**单独运行稳定通过**;并新增反证测试锁定该隔离性。 |
|||
|
|||
--- |
|||
|
|||
## 1. 缺陷(A6 独立复验定位) |
|||
|
|||
| 环节 | 事实 | |
|||
|---|---| |
|||
| 冲突 | 该测试用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`,而 `V1_1__seed_data.sql` 的种子账号占用 `users.id = 1/2/3` | |
|||
| 被禁用 | `V43__disable_legacy_seed_accounts.sql` 把这三行置为 `is_active = 0` | |
|||
| 夹具缺陷 | `TestAuthTokens.writeSession()` 仅在 `SELECT COUNT(*) FROM users WHERE id = ?` 为 0 时 INSERT,**不会激活已存在的禁用行** | |
|||
| 后果 | `AuthSessionService.isSessionActive()` 的用户检查短路 → 全部 Token 请求 401 | |
|||
| 被掩盖 | 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach` 执行 `userRepository.deleteAll()`,偶然清掉种子行,夹具随后才补建 active 用户 | |
|||
|
|||
单跑实测(修复前):`Tests run: 7, Failures: 6`,全部 `Status expected:<200|403|400> but was:<401>`。 |
|||
|
|||
详见 `docs/r01-a6-independent-verification-20260917.md` §9。 |
|||
|
|||
--- |
|||
|
|||
## 2. 修复方案 |
|||
|
|||
**选择:让 `FairnessMonitoringControllerTest` 使用独占用户,而不是修改公共 `TestAuthTokens`。** |
|||
理由:公共 helper 若"自动激活已存在的 inactive 用户",会静默复活本应被禁用的账号 |
|||
(包括生产语义上被 V43 禁用的种子账号),风险远大于收益。 |
|||
|
|||
### 2.1 改动内容 |
|||
|
|||
| 项 | 修复前 | 修复后 | |
|||
|---|---|---| |
|||
| 夹具身份 | `userId = 1` / `2`(与种子账号冲突) | **独占** `ADMIN_USER_ID = 9_310_001L`、`STUDENT_USER_ID = 9_310_002L` | |
|||
| 手机号 | 由 `TestAuthTokens` 隐式生成 `"t"+id` | 独占 `a6fair-admin` / `a6fair-student` | |
|||
| 用户来源 | 依赖 `TestAuthTokens` 的"不存在才插入" | `@BeforeEach` **显式插入 `is_active = 1`** 的独占用户 | |
|||
| 清理 | 无 | `@AfterEach` 按**精确 ID**、按外键顺序删除:`auth_tokens` → `user_roles` → `users` | |
|||
|
|||
`@BeforeEach` 先执行与 `@AfterEach` 相同的精确清理再插入,因此对"上轮异常中断残留"具备幂等性, |
|||
且**从不把任何已存在的 inactive 用户直接激活**(是删除后重建自有行)。 |
|||
|
|||
### 2.2 未使用的手段(明确排除) |
|||
|
|||
未改 `V43`;未改生产认证逻辑;未使用 `deleteAll()` 清空共享表;未使用 `@DirtiesContext` 掩盖污染; |
|||
未捕获并忽略约束异常;未依赖测试执行顺序;未放宽任何既有 Fairness 断言;未修改公共 `TestAuthTokens`。 |
|||
|
|||
--- |
|||
|
|||
## 3. 反证测试 `FairnessMonitoringFixtureIsolationTest`(7 例) |
|||
|
|||
| # | 用例 | 覆盖点 | |
|||
|---|---|---| |
|||
| 1 | `fixtureIdsAreOutsideSeedAccountRange` | 夹具 ID 不在种子号段 1/2/3;夹具用户确为 active;未占用种子手机号 | |
|||
| 2 | `seedRowsInactiveOrAbsent_stillAuthenticates` | 种子行 **inactive(V43 状态)** 与 **完全缺失** 两种前置下端点均返回 200 | |
|||
| 3 | `repeatedProvisioningAndRequests_doNotConflict` | 连续 3 轮"装配 + 请求"均成功,且不产生重复用户行 | |
|||
| 4 | `fixtureCycleLeavesSeedRowsUntouched` | 完整周期前后种子行逐字段不变(顺序无关的根本保证) | |
|||
| 5 | `cleanupRemovesOnlyOwnFixtureRows` | 清理只删本类行;探针用户及其会话不受波及 | |
|||
| 6 | `unrelatedUsersAndSessionsUnchanged` | 非夹具用户与会话逐字段不变 | |
|||
| 7 | `publicTokenFixtureDoesNotSilentlyReviveInactiveUser` | `TestAuthTokens` 对已存在 inactive 用户**不激活**,该 Token 请求为 401 且 Service 零调用 | |
|||
|
|||
用例 2 会临时快照并按主键**原样恢复**种子行(`try` 语义由 `@AfterEach` 兜底),不新增、不批量重建。 |
|||
|
|||
--- |
|||
|
|||
## 4. 验证矩阵 |
|||
|
|||
命令均在 `mydreamjob-server/` 下执行;日志位于 `docs/acceptance/r01-d4-2-20260916/ff-*.log`。 |
|||
|
|||
| # | 命令 | 结果 | 日志 | |
|||
|---|---|---|---| |
|||
| 1 | `mvn -Dtest="FairnessMonitoringControllerTest" test` | **7 / 7**,F=0 E=0 S=0 ✔ | `ff-1-standalone.log` | |
|||
| 2 | `mvn -Dtest="FairnessMonitoringFixtureIsolationTest,FairnessMonitoringControllerTest" test` | **14 / 14**,F=0 E=0 S=0 ✔ | `ff-2-isolation.log` | |
|||
| 3 | Fairness **先**跑 ×3(`-Dsurefire.runOrder=reversealphabetical`) | **13 / 13** ×3 ✔ | `ff-3-fairnessfirst-{1,2,3}.log` | |
|||
| 4 | Auth **先**跑 ×3(`-Dsurefire.runOrder=alphabetical`) | **13 / 13** ×3 ✔ | `ff-4-authfirst-{1,2,3}.log` | |
|||
| 5 | `mvn clean verify` | 见 §4.1 | `ff-5-clean-verify.log` | |
|||
| 6 | `python ..\docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | — | |
|||
|
|||
> **顺序说明**:surefire 默认 `runOrder=filesystem` 会忽略 `-Dtest` 的书写顺序, |
|||
> 因此额外用 `-Dsurefire.runOrder=reversealphabetical`(Fairness 先)与 `=alphabetical`(Auth 先) |
|||
> 强制两个方向,并在日志中逐次核对实际执行顺序(`reversealphabetical` 下 |
|||
> `scoringmonitoring.FairnessMonitoringControllerTest` 确在 `controller.AuthIntegrationTest` 之前)。 |
|||
> `runOrder=testorder` 在本仓库使用的 surefire 3.5.6 中不可用(已实测报错)。 |
|||
|
|||
### 4.1 全量结果 |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 全量测试总数 | 2278 → **2285**(+7:新增反证测试类) | |
|||
| Failures / Errors / Skipped | **0 / 0 / 0** ✔ | |
|||
| 生产代码 diff | **0** ✔ | |
|||
| V1–V45 | **未改动** ✔ | |
|||
| `docs/openapi-rc.json` | **未修改** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 5. 修改文件 |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java (修改:夹具独占化) |
|||
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java(新增:反证测试) |
|||
docs/r01-a6-fairness-fixture-isolation-fix-20260917.md (新增:本报告) |
|||
docs/backend-development-roadmap.md (夹具问题关闭记录) |
|||
``` |
|||
|
|||
**无生产代码、无 Flyway、无 ErrorCode、无 DTO、无接口、无 OpenAPI、无 Filter/Accessor/SecurityConfig 改动。** |
|||
|
|||
--- |
|||
|
|||
## 6. 明确声明 |
|||
|
|||
- 未修改任何生产代码,未修改 `V43`,未修改公共 `TestAuthTokens` 夹具。 |
|||
- 未删除、未放宽任何既有断言;`FairnessMonitoringControllerTest` 的业务断言逐字保留。 |
|||
- 未使用 `deleteAll()`、`@DirtiesContext`、顺序依赖或异常吞没等掩盖手段。 |
|||
- 未与 R01-B 混合。 |
|||
|
|||
--- |
|||
|
|||
> **FairnessMonitoringControllerTest 单跑夹具缺陷已通过独立纯测试提交修复; |
|||
> 该测试不再依赖种子数据状态与测试执行顺序。** |
|||
@ -0,0 +1,301 @@ |
|||
package com.mydreamjob.scoringmonitoring; |
|||
|
|||
import com.mydreamjob.auth.support.TestAuthTokens; |
|||
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.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.sql.Timestamp; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* Fairness 认证夹具隔离反证测试。 |
|||
* |
|||
* <p>背景:{@code FairnessMonitoringControllerTest} 曾用 {@code userId = 1/2} 申请 Token, |
|||
* 而 {@code V1_1__seed_data.sql} 的种子账号正好占用 {@code users.id = 1/2/3}, |
|||
* 且 {@code V43__disable_legacy_seed_accounts.sql} 已把它们置为 {@code is_active = 0}; |
|||
* {@code TestAuthTokens} 对"已存在但 inactive"的行不插入也不激活,于是该测试类 |
|||
* **单独运行时**全部 Token 请求被 401 拒绝。全量顺序下 |
|||
* {@code com.mydreamjob.controller.AuthIntegrationTest} 的 {@code userRepository.deleteAll()} |
|||
* 偶然清掉了种子行,问题才被掩盖。</p> |
|||
* |
|||
* <p>本类是该缺陷的**反证**:证明修复后的夹具既不依赖种子行的存在性/状态, |
|||
* 也不依赖测试执行顺序,并且在清理时只删除自己创建的数据。</p> |
|||
* |
|||
* <p>本类自身不使用种子 ID,全部操作按精确 ID 限定,不做任何整表删除。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class FairnessMonitoringFixtureIsolationTest { |
|||
|
|||
/** 与 {@code FairnessMonitoringControllerTest} 相同的独占夹具 ID(同包可见)。 */ |
|||
private static final long ADMIN_USER_ID = FairnessMonitoringControllerTest.ADMIN_USER_ID; |
|||
|
|||
/** V1_1 种子账号占用的 ID;本类只读、只做快照与恢复,绝不把它们当作夹具。 */ |
|||
private static final List<Long> SEED_IDS = List.of(1L, 2L, 3L); |
|||
|
|||
/** 本类自有的探针 ID,不与其他测试共用。 */ |
|||
private static final long SENTINEL_USER_ID = 9_310_009L; |
|||
private static final long INACTIVE_USER_ID = 9_310_008L; |
|||
|
|||
private static final String DRIFT = "/admin/scoring/fairness/drift"; |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@MockBean private FairnessMonitoringService service; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
/** 种子行快照;非 null 时 {@link #restoreSeedRows()} 负责原样恢复。 */ |
|||
private List<Map<String, Object>> seedSnapshot; |
|||
|
|||
@BeforeEach |
|||
void provision() { |
|||
cleanupOwnRows(); |
|||
insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN"); |
|||
when(service.drift(ADMIN_USER_ID)).thenReturn(FairnessResponse.Unavailable.builder() |
|||
.availability("UNAVAILABLE") |
|||
.reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE") |
|||
.build()); |
|||
} |
|||
|
|||
@AfterEach |
|||
void cleanup() { |
|||
restoreSeedRows(); |
|||
cleanupOwnRows(); |
|||
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", SENTINEL_USER_ID); |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", SENTINEL_USER_ID); |
|||
jdbcTemplate.update("DELETE FROM users WHERE id = ?", SENTINEL_USER_ID); |
|||
} |
|||
|
|||
private void cleanupOwnRows() { |
|||
for (long id : new long[]{ADMIN_USER_ID, INACTIVE_USER_ID}) { |
|||
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); |
|||
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); |
|||
} |
|||
} |
|||
|
|||
private void insertActiveUser(long id, String phone, String role) { |
|||
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " |
|||
+ "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "a6-fairness-isolation-" + role, role); |
|||
} |
|||
|
|||
private int isActive(long id) { |
|||
Integer v = jdbcTemplate.queryForObject("SELECT is_active FROM users WHERE id = ?", Integer.class, id); |
|||
return v == null ? -1 : v; |
|||
} |
|||
|
|||
private String bearer() { |
|||
return TestAuthTokens.bearer(ADMIN_USER_ID, "ADMIN"); |
|||
} |
|||
|
|||
// ── 1. 夹具 ID 不与被禁用的种子账号冲突 ────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("反证:夹具 ID 位于种子账号号段之外,且夹具用户确为 active") |
|||
void fixtureIdsAreOutsideSeedAccountRange() { |
|||
assertThat(ADMIN_USER_ID).as("夹具 ID 不得落在 V1_1 种子账号的 1/2/3 上") |
|||
.isNotIn(SEED_IDS); |
|||
assertThat(FairnessMonitoringControllerTest.STUDENT_USER_ID).isNotIn(SEED_IDS); |
|||
|
|||
assertThat(isActive(ADMIN_USER_ID)).as("夹具用户必须由本类显式插入为 active").isEqualTo(1); |
|||
|
|||
// 夹具手机号不得占用种子账号的手机号(唯一约束 + 语义隔离)
|
|||
Integer squat = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM users WHERE phone IN ('13800000001','13800000002','13800000003') " |
|||
+ "AND id IN (?, ?)", Integer.class, ADMIN_USER_ID, |
|||
FairnessMonitoringControllerTest.STUDENT_USER_ID); |
|||
assertThat(squat).as("夹具不得占用种子账号手机号").isZero(); |
|||
} |
|||
|
|||
// ── 2. 种子行为 inactive(V43 状态)或缺失时,夹具仍可认证 ──
|
|||
|
|||
@Test |
|||
@DisplayName("反证:种子行 inactive(V43 状态)或完全缺失时,Fairness 端点仍返回 200") |
|||
void seedRowsInactiveOrAbsent_stillAuthenticates() throws Exception { |
|||
snapshotAndTakeSeedRows(); |
|||
|
|||
// 阶段 A:种子行存在但 inactive(即 V43 执行后的真实状态,也是单跑时的状态)
|
|||
jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id IN (1, 2, 3)"); |
|||
mockMvc.perform(get(DRIFT).header("Authorization", bearer())) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")); |
|||
|
|||
// 阶段 B:种子行完全缺失(全量顺序下 AuthIntegrationTest.deleteAll() 之后的真实状态)
|
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (1, 2, 3)"); |
|||
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id IN (1, 2, 3)"); |
|||
jdbcTemplate.update("DELETE FROM users WHERE id IN (1, 2, 3)"); |
|||
mockMvc.perform(get(DRIFT).header("Authorization", bearer())) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")); |
|||
} |
|||
|
|||
// ── 3. 连续多次夹具装配与请求不冲突 ────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("反证:连续三次装配 + 三次请求均成功,无唯一约束或残留冲突") |
|||
void repeatedProvisioningAndRequests_doNotConflict() throws Exception { |
|||
for (int round = 1; round <= 3; round++) { |
|||
cleanupOwnRows(); |
|||
insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN"); |
|||
when(service.drift(ADMIN_USER_ID)).thenReturn(FairnessResponse.Unavailable.builder() |
|||
.availability("UNAVAILABLE") |
|||
.reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE") |
|||
.build()); |
|||
|
|||
mockMvc.perform(get(DRIFT).header("Authorization", bearer())) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
|
|||
Integer users = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, ADMIN_USER_ID); |
|||
assertThat(users).as("重复装配不得产生重复用户行").isEqualTo(1); |
|||
} |
|||
|
|||
// ── 4. 夹具不触碰种子数据(顺序无关的根本保证)────────────
|
|||
|
|||
@Test |
|||
@DisplayName("反证:一次完整装配→请求→清理周期后,种子行逐字段不变") |
|||
void fixtureCycleLeavesSeedRowsUntouched() throws Exception { |
|||
List<Map<String, Object>> before = seedRows(); |
|||
|
|||
mockMvc.perform(get(DRIFT).header("Authorization", bearer())).andExpect(status().isOk()); |
|||
cleanupOwnRows(); |
|||
insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN"); |
|||
|
|||
assertThat(seedRows()).as("夹具周期不得改写任何种子行").isEqualTo(before); |
|||
} |
|||
|
|||
// ── 5. 清理只删除本类自有数据 ──────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("反证:清理只删除本类夹具行,探针用户与会话保持不变") |
|||
void cleanupRemovesOnlyOwnFixtureRows() { |
|||
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " |
|||
+ "VALUES (?, ?, ?, ?, 1, 1)", SENTINEL_USER_ID, "a6fair-sentinel", |
|||
"a6-fairness-isolation-sentinel", "STUDENT"); |
|||
jdbcTemplate.update("INSERT INTO auth_tokens (user_id, token_hash, token_type, expired_at, revoked) " |
|||
+ "VALUES (?, ?, 'REFRESH', ?, 0)", SENTINEL_USER_ID, "a6fair-sentinel-hash", |
|||
Timestamp.valueOf(java.time.LocalDateTime.now().plusDays(1))); |
|||
|
|||
cleanupOwnRows(); |
|||
|
|||
assertThat(jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, ADMIN_USER_ID)) |
|||
.as("本类夹具行应已被清理").isZero(); |
|||
assertThat(jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, SENTINEL_USER_ID)) |
|||
.as("探针用户不得被清理波及").isEqualTo(1); |
|||
assertThat(jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM auth_tokens WHERE user_id = ?", Integer.class, SENTINEL_USER_ID)) |
|||
.as("探针会话不得被清理波及").isEqualTo(1); |
|||
} |
|||
|
|||
// ── 6. 其他用户与会话不受影响 ──────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("反证:装配→请求→清理周期前后,非夹具用户与会话逐字段不变") |
|||
void unrelatedUsersAndSessionsUnchanged() throws Exception { |
|||
List<Map<String, Object>> usersBefore = nonFixtureUsers(); |
|||
List<Map<String, Object>> tokensBefore = nonFixtureTokens(); |
|||
|
|||
mockMvc.perform(get(DRIFT).header("Authorization", bearer())).andExpect(status().isOk()); |
|||
cleanupOwnRows(); |
|||
|
|||
assertThat(nonFixtureUsers()).as("非夹具用户必须逐字段不变").isEqualTo(usersBefore); |
|||
assertThat(nonFixtureTokens()).as("非夹具会话必须逐字段不变").isEqualTo(tokensBefore); |
|||
} |
|||
|
|||
// ── 7. 公共夹具不会静默复活 inactive 用户 ─────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("反证:TestAuthTokens 不得把已存在的 inactive 用户静默激活") |
|||
void publicTokenFixtureDoesNotSilentlyReviveInactiveUser() throws Exception { |
|||
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " |
|||
+ "VALUES (?, ?, ?, ?, 0, 1)", INACTIVE_USER_ID, "a6fair-inactive", |
|||
"a6-fairness-isolation-inactive", "ADMIN"); |
|||
|
|||
String token = TestAuthTokens.bearer(INACTIVE_USER_ID, "ADMIN"); |
|||
|
|||
assertThat(isActive(INACTIVE_USER_ID)) |
|||
.as("公共夹具必须保留该用户被禁用的状态,不得静默复活") |
|||
.isEqualTo(0); |
|||
mockMvc.perform(get(DRIFT).header("Authorization", token)) |
|||
.andExpect(status().isUnauthorized()); |
|||
verify(service, never()).drift(any()); |
|||
} |
|||
|
|||
// ── 工具 ───────────────────────────────────────────────────
|
|||
|
|||
private List<Map<String, Object>> seedRows() { |
|||
return jdbcTemplate.queryForList( |
|||
"SELECT id, phone, email, nickname, password_hash, role, is_active, is_verified " |
|||
+ "FROM users WHERE id IN (1, 2, 3) ORDER BY id"); |
|||
} |
|||
|
|||
private List<Map<String, Object>> nonFixtureUsers() { |
|||
return jdbcTemplate.queryForList( |
|||
"SELECT id, phone, nickname, role, is_active FROM users WHERE id NOT IN (?, ?) ORDER BY id", |
|||
ADMIN_USER_ID, INACTIVE_USER_ID); |
|||
} |
|||
|
|||
private List<Map<String, Object>> nonFixtureTokens() { |
|||
return jdbcTemplate.queryForList( |
|||
"SELECT id, user_id, token_type, revoked FROM auth_tokens " |
|||
+ "WHERE user_id NOT IN (?, ?) ORDER BY id", |
|||
ADMIN_USER_ID, INACTIVE_USER_ID); |
|||
} |
|||
|
|||
/** 快照种子行(含主键与状态),供缺失场景后原样恢复。 */ |
|||
private void snapshotAndTakeSeedRows() { |
|||
seedSnapshot = jdbcTemplate.queryForList( |
|||
"SELECT id, phone, email, nickname, password_hash, role, is_active, is_verified, " |
|||
+ "created_at, updated_at FROM users WHERE id IN (1, 2, 3) ORDER BY id"); |
|||
} |
|||
|
|||
/** |
|||
* 按精确主键恢复种子行。 |
|||
* |
|||
* <p>只在测试**主动删除**过种子行时才执行({@code seedSnapshot} 非 null), |
|||
* 且严格还原全部字段,不新增、不批量重建、不影响其他行。</p> |
|||
*/ |
|||
private void restoreSeedRows() { |
|||
if (seedSnapshot == null) { |
|||
return; |
|||
} |
|||
List<Map<String, Object>> snapshot = new ArrayList<>(seedSnapshot); |
|||
seedSnapshot = null; |
|||
for (Map<String, Object> row : snapshot) { |
|||
Long id = ((Number) row.get("id")).longValue(); |
|||
if (jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, id) > 0) { |
|||
continue; |
|||
} |
|||
jdbcTemplate.update("INSERT INTO users (id, phone, email, nickname, password_hash, role, " |
|||
+ "is_active, is_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |
|||
id, row.get("phone"), row.get("email"), row.get("nickname"), row.get("password_hash"), |
|||
row.get("role"), row.get("is_active"), row.get("is_verified"), |
|||
row.get("created_at"), row.get("updated_at")); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue