From b893ed67f078eedb0c4b8a03341311bd79342354 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Fri, 18 Sep 2026 12:17:55 +0800 Subject: [PATCH] test: isolate scoring fairness fixtures --- docs/backend-development-roadmap.md | 2 +- ...fairness-fixture-isolation-fix-20260918.md | 183 ++++++++++++++++++ ...dateScoringPersistenceIntegrationTest.java | 49 ++++- ...CandidateScoringRefreshClaimFenceTest.java | 17 ++ ...CandidateScoringRefreshClaimTokenTest.java | 16 ++ ...eScoringRefreshFailureTransactionTest.java | 17 ++ ...idateScoringRefreshInfrastructureTest.java | 17 ++ .../R01CScoringFixtureIsolationGateTest.java | 98 ++++++++++ 8 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 docs/r01-c-fairness-fixture-isolation-fix-20260918.md create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 20a31f9..379ae69 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -15,7 +15,7 @@ | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | 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 | -| 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仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/r01-c-fairness-fixture-isolation-fix-20260918.md b/docs/r01-c-fairness-fixture-isolation-fix-20260918.md new file mode 100644 index 0000000..8b96faf --- /dev/null +++ b/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`、`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 组合测试污染已完成精确归因,并通过独立纯测试提交修复。** diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java index 0e26ac5..ae43fca 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java +++ b/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.candidatescore.dto.CandidateScoringOverviewResponse; import com.mydreamjob.candidatescore.dto.CandidateScoringTrendResponse; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -32,21 +34,66 @@ class CandidateScoringPersistenceIntegrationTest { @Autowired private CandidateScoringCalculator calculator; @Autowired private UserRepository userRepository; @Autowired private CandidateDimensionScoreRepository snapshotRepository; + @Autowired private JdbcTemplate jdbcTemplate; @MockBean private RedisTemplate redisTemplate; + /** + * 本测试**独占**的手机号段。用于: + *
    + *
  1. 前置断言"独占键不存在",避免上一轮残留被静默覆盖而掩盖污染;
  2. + *
  3. 收尾按精确条件清理本测试创建的数据。
  4. + *
+ */ + private static final List OWN_PHONES = + List.of("13900009901", "13900009902", "13900009903"); + + /** 本测试创建的用户 id(每个用例自建,收尾按精确 id 清理)。 */ + private final List ownUserIds = new java.util.ArrayList<>(); + @BeforeEach @SuppressWarnings("unchecked") void setUp() { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); 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** 删除本测试创建的数据,遵循外键顺序(快照 → 角色 → 用户)。 + * + *

背景:本类会以 {@code currentPeriod()} 物化 8 条维度快照。这些行若残留, + * 会进入同一 H2 库中其它测试对"当前期间"的总体统计 + * (例如 {@code FairnessMonitoringHttpIntegrationTest} 的 {@code populationSize}), + * 造成顺序相关的误失败。此处只删除自有 id,**不使用 {@code deleteAll}**, + * 不触碰任何其它测试的数据。

+ */ + @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) { User u = User.builder().phone(phone).nickname("scoring-test") .role(User.Role.STUDENT).active(true).verified(true).build(); 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() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java index ff2ad8b..d92342b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java +++ b/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.UserRepository; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -212,4 +213,20 @@ class CandidateScoringRefreshClaimFenceTest { return jdbcTemplate.queryForObject( "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); + } + } + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java index b1a7fea..391460a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java +++ b/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)); 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); + } + } + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java index b62267e..4a2020f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java +++ b/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.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; 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=?", 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); + } + } + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java index d635a1a..4c073ac 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java +++ b/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.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -385,4 +386,20 @@ class CandidateScoringRefreshInfrastructureTest { Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); 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); + } + } + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java new file mode 100644 index 0000000..1165248 --- /dev/null +++ b/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 阶段二:评分夹具隔离**门禁**(独立反证)。 + * + *

背景:多个 {@code CandidateScoring*} 测试会以**当前 period** 物化维度快照, + * 若不清理由自有 userId,这些行会进入同库中其它测试对"当前期间"的总体统计 + * (典型受害者:{@code FairnessMonitoringHttpIntegrationTest} 的 {@code populationSize}), + * 造成顺序相关的误失败。本门禁在与其相同的 H2 库中运行,断言**污染类的独占手机号段零残留**。

+ * + *

本类与污染类共用 {@code integrationdb};只要任一污染类遗留自有行, + * 本门禁即失败。若将来新增同类夹具而未清理,也会在此被拦下。

+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +class R01CScoringFixtureIsolationGateTest { + + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + /** 已确认的污染类 → 其**独占**手机号前缀。 */ + private static final Map> 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 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); + } +}