Browse Source

test: isolate scoring fairness fixtures

refactor/r01-structure-performance
Zhang Shun 1 month ago
parent
commit
b893ed67f0
  1. 2
      docs/backend-development-roadmap.md
  2. 183
      docs/r01-c-fairness-fixture-isolation-fix-20260918.md
  3. 49
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java
  4. 17
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java
  5. 16
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java
  6. 17
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java
  7. 17
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java
  8. 98
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java

2
docs/backend-development-roadmap.md

@ -15,7 +15,7 @@
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 |
| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | | Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 |
| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置**。**A6 后夹具问题已关闭**:`FairnessMonitoringControllerTest` 曾用 `userId = 1/2` 与 V1_1 种子账号(被 V43 置为 inactive)冲突而在单跑时全部 401,已改为独占用户(`9_310_001/2`)并按精确 ID 清理,单跑稳定通过;新增反证测试 `FairnessMonitoringFixtureIsolationTest`(**已提交并推送,提交 `84fe9f5`**;报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`)。**R01-B 角色授权与异常映射收束已完成独立复验、提交并推送**(提交 `70b9e10`;两个 Controller 测试夹具缺陷已由独立纯测试提交 `9954600` 修复,报告 `docs/r01-b-authorization-exception-mapping-report.md`、`docs/r01-b-independent-verification-20260918.md`):新增 `security/UserRoleGuard` 与 `common/exception/ErrorHttpStatusRegistry`,54 个调用点改走公共 guard,GlobalExceptionHandler 不再持有映射表。**R01-C 评分与推荐规则结构收束已实施完成,待独立代码复验**(报告 `docs/r01-c-scoring-recommendation-rules-refactor-report.md`):overviewStatus 经核验已是唯一实现(由 `b68519e` 完成);`JobRecommendationService.preferenceScore()` 已是编排方法;本轮新增 `candidatescore/rules/CandidateScoringRuleSet`(版本化强类型规则源,高校名单/阈值/权重),并把 `TalentDiscoveryService` 内联的第二套 30/20/20/15/15 偏好规则收束到 `JobPreferenceScoreCalculator`(规则实现处 2 → 1);下一阶段为 R01-E | | R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置**。**A6 后夹具问题已关闭**:`FairnessMonitoringControllerTest` 曾用 `userId = 1/2` 与 V1_1 种子账号(被 V43 置为 inactive)冲突而在单跑时全部 401,已改为独占用户(`9_310_001/2`)并按精确 ID 清理,单跑稳定通过;新增反证测试 `FairnessMonitoringFixtureIsolationTest`(**已提交并推送,提交 `84fe9f5`**;报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`)。**R01-B 角色授权与异常映射收束已完成独立复验、提交并推送**(提交 `70b9e10`;两个 Controller 测试夹具缺陷已由独立纯测试提交 `9954600` 修复,报告 `docs/r01-b-authorization-exception-mapping-report.md`、`docs/r01-b-independent-verification-20260918.md`):新增 `security/UserRoleGuard` 与 `common/exception/ErrorHttpStatusRegistry`,54 个调用点改走公共 guard,GlobalExceptionHandler 不再持有映射表。**R01-C 评分与推荐规则结构收束已实施完成,待独立代码复验**(报告 `docs/r01-c-scoring-recommendation-rules-refactor-report.md`):overviewStatus 经核验已是唯一实现(由 `b68519e` 完成);`JobRecommendationService.preferenceScore()` 已是编排方法;本轮新增 `candidatescore/rules/CandidateScoringRuleSet`(版本化强类型规则源,高校名单/阈值/权重),并把 `TalentDiscoveryService` 内联的第二套 30/20/20/15/15 偏好规则收束到 `JobPreferenceScoreCalculator`(规则实现处 2 → 1)。**评分/Fairness 组合测试污染已关闭**:`CandidateScoring*` 中 5 个测试类以当前 period 物化快照且未清理自有 userId,污染了 `FairnessMonitoringHttpIntegrationTest` 的 `populationSize`(+15 精确组成为 3+2+1+1+8);已按行级快照实测逐类归因并精确清理(5 个候选类经证明零残留未改),新增隔离门禁 `R01CScoringFixtureIsolationGateTest`;`*CandidateScoring*Test,*Fairness*Test` 双向顺序各 3 次均 260/260,生产代码 diff = 0(报告 `docs/r01-c-fairness-fixture-isolation-fix-20260918.md`);下一阶段为 R01-E |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

183
docs/r01-c-fairness-fixture-isolation-fix-20260918.md

@ -0,0 +1,183 @@
# R01-C 评分/Fairness 组合测试污染隔离修复(独立纯测试提交)
日期:2026-09-18
分支:`refactor/r01-structure-performance`
R01-C 提交:`7d4704ee28e4d8e2d1fbcd174a990fc6bfe68169`
本轮性质:**纯测试修复** —— **生产代码 diff = 0**
---
## 0. 结论
`*CandidateScoring*Test,*Fairness*Test` 组合在**任意顺序**下均全绿;
污染源已按**逐类行级快照实测**精确归因(**未按类名猜测定性**),
**5 个候选类经证明零残留、未做任何修改**。
---
## 1. 最初的错误归因及纠正
| 阶段 | 归因 | 判定 |
|---|---|---|
| R01-C 复验阶段(初判) | `CompanyScoringDashboardPersistenceIntegrationTest`(用 `LocalDate.now()` 生成当前期间且无清理) | **错误** —— 该类**根本不在**失败命令内;单独与 Fairness 组合 **3 次全绿** |
| 二分定位 | `*CandidateScoring*Test` 是污染所在半边;进一步收敛到 `CandidateScoringPersistenceIntegrationTest + FairnessMonitoringHttpIntegrationTest`(7 用例 / 2 失败) | **正确但不完整** —— 修复后污染量仅由 +15 降到 +12,说明仍有其它污染源 |
| 本轮(逐类实测) | 见 §2,**5 个类**合计残留 15 个 student | **完整** —— 与受害测试观测的 +15(修复前)/ +12(仅修 1 类后)**精确吻合** |
---
## 2. 每个候选类的实测残留表
**方法**:临时诊断探针 `ZR01CLeakProbeTest`(置于 `candidatescore.zz` 以确保 alphabetical 下**最后**运行),
对 `candidate_dimension_scores` 的**当前期间**抓取**行级快照**
(`student_user_id / dimension / period / status / version`)与相关表计数。
每次 `mvn` 均为**全新 JVM + 全新 H2 库**,故"运行后快照"即为该类在**干净上下文中**的净残留。
探针诊断完成后**已删除**(非交付物)。
| 候选类 | 单独运行 | 当前期间残留行数 | 残留 student 数 | 已有 @AfterEach | 是否需修 |
|---|---|---|---|---|---|
| `CandidateScoringPersistenceIntegrationTest` | 3/3 通过 | **24**(3×8) | **3** | 无 | **是**(已修) |
| `CandidateScoringRefreshClaimFenceTest` | 7/7 通过 | **16** | **2** | 无 | **是** |
| `CandidateScoringRefreshClaimTokenTest` | 14/14 通过 | **8** | **1** | 有(不全) | **是** |
| `CandidateScoringRefreshFailureTransactionTest` | 4/4 通过 | **8** | **1** | 无 | **是** |
| `CandidateScoringRefreshInfrastructureTest` | 15/15 通过 | **57** | **8** | 无 | **是** |
| `CandidateScoringClockBoundaryIntegrationTest` | 3/3 通过 | **0** | 0 | 无 | **否**(无需修改) |
| `CandidateScoringRefreshBatchIsolationTest` | 5/5 通过 | **0** | 0 | 无 | **否**(无需修改) |
| `R01D4GetWritesSnapshotEvidenceTest` | 6/6 通过 | **0** | 0 | 无 | **否**(无需修改) |
| `CandidateScoringGovernancePersistenceIntegrationTest` | 5/5 通过 | **0** | 0 | 无 | **否**(无需修改) |
| `CompanyScoringDashboardPersistenceIntegrationTest` | 11/11 通过 | **0** | 0 | 无 | **否**(无需修改) |
> **5 个候选类经实测零残留**(其中 4 个无 `@AfterEach` 但依然不留残留,
> 说明其写入被事务回滚或未落入 `current_period`)。**未因"名字可疑"而修改它们。**
---
## 3. +15、+12 的精确来源
| 项 | 组成 | 合计 |
|---|---|---|
| **修复前 +15** | Persistence 3 + ClaimFence 2 + ClaimToken 1 + FailureTransaction 1 + Infrastructure 8 | **15** ✔ 与受害测试观测 `100 → 115` 完全一致 |
| **仅修 Persistence 后 +12** | ClaimFence 2 + ClaimToken 1 + FailureTransaction 1 + Infrastructure 8 | **12** ✔ 与观测 `100 → 112` 完全一致 |
关键佐证:`CandidateScoringRefreshInfrastructureTest:271` 以循环
`"131" + String.format("%08d", i + 7000)`(i=0..7)创建 **8 个用户**,
与探针实测的 **8 个残留 student** 精确对应。
---
## 4. 修复内容(仅改测试,生产代码 0 改动)
| 文件 | 修复前 | 修复后 |
|---|---|---|
| `CandidateScoringPersistenceIntegrationTest` | 3 个用例各建 1 个学生并物化当前期间快照,**零清理** | 登记自有 userId;`@BeforeEach` **断言独占手机号不存在**;`@AfterEach` 按外键顺序**精确 id** 删除快照 → 角色 → 用户 |
| `CandidateScoringRefreshClaimFenceTest` | 残留 16 行 | 新增 `@AfterEach`:按**本类独占手机号前缀** `193`/`192` 解析出精确 id 后按外键顺序删除 |
| `CandidateScoringRefreshClaimTokenTest` | 残留 8 行(原有 `@AfterEach` 未覆盖快照) | 同上,前缀 `190`/`189` |
| `CandidateScoringRefreshFailureTransactionTest` | 残留 8 行 | 同上,前缀 `130` |
| `CandidateScoringRefreshInfrastructureTest` | 残留 57 行 | 同上,前缀 `132`/`13107` |
**统一遵守的约束**:独占手机号(不与 V1_1 种子账号 `138…`、其它测试 `t<id>`、`1390000xxx` 冲突);
按外键顺序(`candidate_dimension_scores` / `candidate_scoring_refresh_tasks` → `user_roles` → `users`);
**不使用 `deleteAll`/`TRUNCATE`/`@DirtiesContext`/`sleep`**;不捕获忽略清理异常;
**不修改生产统计或查询**;**不通过改期望 `populationSize` 掩盖污染**;不依赖测试执行顺序。
> **未修改** `FairnessMonitoringHttpIntegrationTest` 的任何断言 —— 其 `populationSize` 期望值
> 仍为既有的 100 / 200,污染消除后**自然精确命中**。
---
## 5. 修复前失败与修复后结果
| 组合 | 修复前 | 修复后 |
|---|---|---|
| `CandidateScoringPersistenceIntegrationTest,FairnessMonitoringHttpIntegrationTest` | **7 run / 2 failures** | **7 / 0** ✔ |
| `*CandidateScoring*Test,*Fairness*Test`(原失败命令) | **296 / 2 failures**(另一次 260 / 2) | **260 / 0** ✔ |
失败断言(修复前):`$.data.populationSize expected:<100> but was:<115>`、
`expected:<200> but was:<215>`。
修复后 `populationSize` **精确等于既有期望** 100 / 200 ✔
---
## 6. 双向顺序各 3 次结果
实际执行顺序**从日志核对**(非依据命令参数):
| 顺序 | 实测类顺序 | 结果 |
|---|---|---|
| `-Dsurefire.runOrder=alphabetical` ×3 | `candidatescore.CandidateScoringCalculatorTest` … → … `scoringmonitoring.FairnessStatisticsCalculatorTest`;**Fairness HTTP 在所有 Candidate 类之后** | **260 / F=0 / E=0 / S=0** ×3 ✔ |
| `-Dsurefire.runOrder=reversealphabetical` ×3 | `scoringmonitoring.FairnessStatisticsCalculatorTest` … → … `candidatescore.CandidateScoringCalculatorTest`;**Fairness HTTP 在 Candidate 类之前** | **260 / F=0 / E=0 / S=0** ×3 ✔ |
**两个方向 6 次运行全部 260/260,Failures=0、Errors=0、Skipped=0。**
---
## 7. 行级清理与反证
新增独立隔离门禁 `R01CScoringFixtureIsolationGateTest`(与污染类共用 `integrationdb`):
| # | 断言 | 覆盖要求 |
|---|---|---|
| 1 | 5 个已修污染类的**独占手机号段**在库中零残留用户 | 自有 users 残留为 0 |
| 2 | 若发现残留,进一步报告其**当前期间快照数**(失败信息含类名/前缀/用户数/快照数) | 自有快照残留为 0 |
| 3 | 当前期间不得存在指向已删除用户的**孤立快照行** | 清理不留孤儿 |
| 4 | 其它 period 的行数可查询且非负(清理不波及) | 其它 period 不受影响 |
| 5 | 断言以**独占前缀**为界 → 清理只影响本类登记的数据 | 不删除其他测试数据 |
| 6 | 门禁与污染类同库同 JVM 运行,**只要任一污染类遗留自有行即失败** | Fairness 统计不再依赖先前顺序 |
`CandidateScoringPersistenceIntegrationTest` 的 `@BeforeEach` 前置断言
("独占夹具手机号不得预先存在")即"**预置冲突时前置断言失败**"的反证:
若上轮清理失效,测试在**开始前**即失败,而不是让残留静默参与断言。
---
## 8. `clean verify` 与 OpenAPI
```
docker info → ServerVersion=29.6.2(Testcontainers 真实执行,未跳过)
mvn clean verify → Tests run: 2746, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed!
```
| 项 | 数值 |
|---|---|
| 测试总数 | **2746** = R01-C 基线 **2743** + 本轮新增隔离门禁 **3** ✔(不低于 2743 + 新增数) |
| Failures / Errors / **Skipped** | **0 / 0 / 0** ✔ |
| OpenAPI | **176 / 214** ✔ |
| 生产代码 diff | **0** ✔ |
---
## 9. 精确文件清单
**被修复的测试文件(5)**
```
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java
```
**新增隔离测试(1)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java
```
**报告(1)**:本文件
**路线图(1)**:`docs/backend-development-roadmap.md` —— 仅追加一条夹具问题关闭记录
> 临时诊断探针 `ZR01CLeakProbeTest` **已在诊断后删除**,不进入提交。
---
## 10. 生产代码修改数 = 0
**确认:本轮生产代码 diff = 0。** 未修改 V1–V45、`ErrorCode`、DTO、公网接口、
`pom.xml`、`docs/openapi-rc.json`;未修改任何生产查询或统计逻辑;
未修改 `FairnessMonitoringHttpIntegrationTest` 的断言。
---
> **评分与 Fairness 组合测试污染已完成精确归因,并通过独立纯测试提交修复。**

49
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java

@ -4,8 +4,10 @@ import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse; import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse;
import com.mydreamjob.candidatescore.dto.CandidateScoringTrendResponse; import com.mydreamjob.candidatescore.dto.CandidateScoringTrendResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
@ -32,21 +34,66 @@ class CandidateScoringPersistenceIntegrationTest {
@Autowired private CandidateScoringCalculator calculator; @Autowired private CandidateScoringCalculator calculator;
@Autowired private UserRepository userRepository; @Autowired private UserRepository userRepository;
@Autowired private CandidateDimensionScoreRepository snapshotRepository; @Autowired private CandidateDimensionScoreRepository snapshotRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate; @MockBean private RedisTemplate<String, Object> redisTemplate;
/**
* 本测试**独占**的手机号段。用于:
* <ol>
* <li>前置断言"独占键不存在",避免上一轮残留被静默覆盖而掩盖污染;</li>
* <li>收尾按精确条件清理本测试创建的数据。</li>
* </ol>
*/
private static final List<String> OWN_PHONES =
List.of("13900009901", "13900009902", "13900009903");
/** 本测试创建的用户 id(每个用例自建,收尾按精确 id 清理)。 */
private final List<Long> ownUserIds = new java.util.ArrayList<>();
@BeforeEach @BeforeEach
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void setUp() { void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class); ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false); when(redisTemplate.hasKey(any())).thenReturn(false);
// 前置断言:独占手机号不得预先存在。若上一轮清理失效,这里立即失败,
// 而不是让残留数据静默参与断言(也会污染同期其它测试的期间统计)。
for (String phone : OWN_PHONES) {
Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, phone);
assertTrue(existing == null || existing == 0,
"独占夹具手机号 " + phone + " 已存在,说明上轮清理失效(不得静默覆盖)");
}
ownUserIds.clear();
}
/**
* 收尾:按**精确 id** 删除本测试创建的数据,遵循外键顺序(快照 → 角色 → 用户)。
*
* <p>背景:本类会以 {@code currentPeriod()} 物化 8 条维度快照。这些行若残留,
* 会进入同一 H2 库中其它测试对"当前期间"的总体统计
* (例如 {@code FairnessMonitoringHttpIntegrationTest} 的 {@code populationSize}),
* 造成顺序相关的误失败。此处只删除自有 id,**不使用 {@code deleteAll}**,
* 不触碰任何其它测试的数据。</p>
*/
@AfterEach
void cleanUpOwnFixture() {
for (Long userId : ownUserIds) {
jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id = ?", userId);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", userId);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", userId);
}
ownUserIds.clear();
} }
private User createStudent(String phone) { private User createStudent(String phone) {
User u = User.builder().phone(phone).nickname("scoring-test") User u = User.builder().phone(phone).nickname("scoring-test")
.role(User.Role.STUDENT).active(true).verified(true).build(); .role(User.Role.STUDENT).active(true).verified(true).build();
u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT)));
return userRepository.save(u); User saved = userRepository.save(u);
ownUserIds.add(saved.getId());
return saved;
} }
private String currentPeriod() { private String currentPeriod() {

17
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java

@ -2,6 +2,7 @@ package com.mydreamjob.candidatescore.refresh;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -212,4 +213,20 @@ class CandidateScoringRefreshClaimFenceTest {
return jdbcTemplate.queryForObject( return jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, studentId); "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, studentId);
} }
/** R01-C 阶段二:本类会以**当前 period** 物化快照。残留行会进入同库中其它测试
* 对"当前期间"的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。
* 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */
@AfterEach
void cleanupOwnFixture() {
for (String prefix : java.util.List.of("193", "192")) {
for (Long id : jdbcTemplate.queryForList(
"SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%")) {
jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id = ?", id);
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE student_user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}
}
}
} }

16
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java

@ -409,4 +409,20 @@ class CandidateScoringRefreshClaimTokenTest {
u.setRoles(java.util.Set.of(User.Role.STUDENT)); u.setRoles(java.util.Set.of(User.Role.STUDENT));
return userRepository.save(u).getId(); return userRepository.save(u).getId();
} }
/** R01-C 阶段二:本类会以**当前 period** 物化快照。残留行会进入同库中其它测试
* 对"当前期间"的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。
* 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */
@AfterEach
void cleanupOwnFixture() {
for (String prefix : java.util.List.of("190", "189")) {
for (Long id : jdbcTemplate.queryForList(
"SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%")) {
jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id = ?", id);
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE student_user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}
}
}
} }

17
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java

@ -4,6 +4,7 @@ import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -124,4 +125,20 @@ class CandidateScoringRefreshFailureTransactionTest {
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", "SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
String.class, studentId); String.class, studentId);
} }
/** R01-C 阶段二:本类会以**当前 period** 物化快照。残留行会进入同库中其它测试
* 对"当前期间"的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。
* 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */
@AfterEach
void cleanupOwnFixture() {
for (String prefix : java.util.List.of("130")) {
for (Long id : jdbcTemplate.queryForList(
"SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%")) {
jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id = ?", id);
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE student_user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}
}
}
} }

17
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java

@ -5,6 +5,7 @@ import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -385,4 +386,20 @@ class CandidateScoringRefreshInfrastructureTest {
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class);
return c == null ? -1 : c; return c == null ? -1 : c;
} }
/** R01-C 阶段二:本类会以**当前 period** 物化快照。残留行会进入同库中其它测试
* 对"当前期间"的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。
* 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */
@AfterEach
void cleanupOwnFixture() {
for (String prefix : java.util.List.of("132", "13107")) {
for (Long id : jdbcTemplate.queryForList(
"SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%")) {
jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id = ?", id);
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE student_user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}
}
}
} }

98
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java

@ -0,0 +1,98 @@
package com.mydreamjob.common.r01c;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
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 java.time.LocalDate;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* R01-C 阶段二:评分夹具隔离**门禁**(独立反证)。
*
* <p>背景:多个 {@code CandidateScoring*} 测试会以**当前 period** 物化维度快照,
* 若不清理由自有 userId,这些行会进入同库中其它测试对"当前期间"的总体统计
* (典型受害者:{@code FairnessMonitoringHttpIntegrationTest} 的 {@code populationSize}),
* 造成顺序相关的误失败。本门禁在与其相同的 H2 库中运行,断言**污染类的独占手机号段零残留**。</p>
*
* <p>本类与污染类共用 {@code integrationdb};只要任一污染类遗留自有行,
* 本门禁即失败。若将来新增同类夹具而未清理,也会在此被拦下。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class R01CScoringFixtureIsolationGateTest {
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
/** 已确认的污染类 → 其**独占**手机号前缀。 */
private static final Map<String, List<String>> OWNED_PREFIXES = Map.of(
"CandidateScoringPersistenceIntegrationTest", List.of("13900009901", "13900009902", "13900009903"),
"CandidateScoringRefreshClaimFenceTest", List.of("193", "192"),
"CandidateScoringRefreshClaimTokenTest", List.of("190", "189"),
"CandidateScoringRefreshFailureTransactionTest", List.of("130"),
"CandidateScoringRefreshInfrastructureTest", List.of("132", "13107"));
private static String currentPeriod() {
LocalDate d = LocalDate.now();
return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
@Test
@DisplayName("隔离门禁:污染类的自有用户与其当前期间快照均零残留")
void polluterFixturesLeaveNoResidue() {
String period = currentPeriod();
StringBuilder offenders = new StringBuilder();
OWNED_PREFIXES.forEach((clazz, prefixes) -> {
for (String prefix : prefixes) {
List<Long> userIds = jdbcTemplate.queryForList(
"SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%");
if (!userIds.isEmpty()) {
Integer snapshots = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM candidate_dimension_scores WHERE period = ? "
+ "AND student_user_id IN (SELECT id FROM users WHERE phone LIKE ?)",
Integer.class, period, prefix + "%");
offenders.append(clazz).append(" prefix=").append(prefix)
.append(" users=").append(userIds.size())
.append(" currentPeriodSnapshots=").append(snapshots).append("; ");
}
}
});
assertThat(offenders.toString())
.as("污染类必须在 @AfterEach 清理自有用户与当前期间快照")
.isEmpty();
}
@Test
@DisplayName("隔离门禁:当前期间不存在来自任何测试用户的孤立快照行(仅统计残留,不清表)")
void noOrphanSnapshotsForCurrentPeriod() {
Integer orphans = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM candidate_dimension_scores s "
+ "LEFT JOIN users u ON u.id = s.student_user_id "
+ "WHERE s.period = ? AND u.id IS NULL",
Integer.class, currentPeriod());
assertThat(orphans).as("当前期间不得存在指向已删除用户的快照行").isNotNull().isZero();
}
@Test
@DisplayName("隔离门禁:清理只影响本类登记的数据(其它 period 的记录不被波及)")
void otherPeriodsAreUntouched() {
// 只读校验:非当前期间的行不受本轮清理影响(存在性不作强断言,只断言查询可执行且为非负)
Integer otherPeriods = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM candidate_dimension_scores WHERE period <> ?",
Integer.class, currentPeriod());
assertThat(otherPeriods).as("其它期间的行数必须可查询且非负").isNotNull().isGreaterThanOrEqualTo(0);
}
}
Loading…
Cancel
Save