3 changed files with 546 additions and 1 deletions
@ -0,0 +1,224 @@ |
|||
# DEF-01 定向整改:`GET /jobs` 公开列表关键词规范化 |
|||
|
|||
日期:2026-09-20 |
|||
分支:`refactor/r01-structure-performance` |
|||
基线 HEAD:`2319f97203a2cafcf8012869fda8c30599ffb305`(整改前的 upstream 同点) |
|||
方案:**方案 A**(服务入口局部规范化),**不采用方案 B** |
|||
本轮性质:定向整改 + 独立复验;未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 1. 修复前最小复现 |
|||
|
|||
真实 HTTP(整改前,`mvn` 于 `2319f97`): |
|||
|
|||
``` |
|||
GET /api/v1/jobs?keyword=%20AlphaEngineer%20 → items=0 (期望 1:trim 后应命中) |
|||
GET /api/v1/jobs?keyword=%20%20%20 → items=0 (期望 20:空白应等同未传) |
|||
GET /api/v1/jobs/ch?keyword=AlphaEngineer → items=1 |
|||
GET /api/v1/jobs → items=20 |
|||
GET /api/v1/jobs/search?keyword=%20AlphaEngineer%20 → 1 (正确) |
|||
GET /api/v1/jobs/search?keyword=%20%20%20 → 20 (正确) |
|||
``` |
|||
|
|||
## 2. 两个端点的调用链 |
|||
|
|||
| 端点 | 调用链 | 关键词处理 | |
|||
|---|---|---| |
|||
| `GET /jobs` | `JobController.listJobs:97` → `JobService.listPublishedJobs:123` → `JobRepository.findPublishedJobs`(JPQL) | 只判 `:keyword IS NULL`,**无 trim、无 blank 归一** | |
|||
| `GET /jobs/search` | `JobController.searchJobs:134` → `JobService.searchJobs` → `JobSearchSpecification:52-53` | `!keyword.isBlank()` 判空 + `keyword.trim()` | |
|||
|
|||
## 3. 根因 |
|||
|
|||
`JobRepository.findPublishedJobs` 的 JPQL 为: |
|||
|
|||
```sql |
|||
AND (:keyword IS NULL OR j.title LIKE %:keyword% OR j.description LIKE %:keyword%) |
|||
``` |
|||
|
|||
- `" AlphaEngineer "` **不是 null** → 生成 `LIKE '% AlphaEngineer %'` → 前导/尾随空格进入匹配串 → **0 条**; |
|||
- `" "` **不是 null** → 生成 `LIKE '% %'` → 无意义的过滤条件 → **0 条**; |
|||
- `/jobs/search` 已做 `trim()` 与 `isBlank()` 归一 → 两条**公开**查询路径的输入语义不一致。 |
|||
|
|||
## 4. 为什么选方案 A |
|||
|
|||
- 缺陷是**输入规范化缺失**,不是查询实现缺陷; |
|||
- 方案 A 把规范化放在**唯一入口**(`listPublishedJobs`),一处生效、语义显式、可被单测直接覆盖; |
|||
- 改动面最小(1 个文件、+7/−1),**不触碰** Repository / Specification / Controller / DTO / OpenAPI / |
|||
排序 / 分页 / 薪资 / 城市 / 状态,回归风险最低。 |
|||
|
|||
## 5. 为什么不选方案 B |
|||
|
|||
- 方案 B 让 `/jobs` 改走 `JobSearchSpecification`,会改变 `/jobs` 的**分页 DTO 形态、排序实现与 |
|||
查询计划**(`JobSearchItem` 与现有 `toPublicView` 字段并不相同),属于超出缺陷范围的语义扩张; |
|||
- 两套实现并存是**既有事实**(`/jobs` 与 `/jobs/search` 本就服务不同调用方), |
|||
合并应作为独立重构批次评估,不应与本次缺陷修复捆绑。 |
|||
|
|||
## 6. 精确生产修改 |
|||
|
|||
**唯一文件**:`mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java` |
|||
(`git diff --stat`:**1 file changed, 7 insertions(+), 1 deletion(-)**) |
|||
|
|||
```java |
|||
public Page<Map<String, Object>> listPublishedJobs(String keyword, String city, ...) { |
|||
if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); // ← 新增 |
|||
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); |
|||
Page<Job> result = jobRepository.findPublishedJobs(normalizedKeyword, city, employmentType, // ← 原为 keyword |
|||
salaryMin, salaryMax, pageable); |
|||
return result.map(this::toPublicView); |
|||
} |
|||
``` |
|||
|
|||
(另含一段 Javadoc 说明 DEF-01 的语义契约;不改变任何行为。) |
|||
|
|||
**未新增通用工具类**:项目内不存在语义完全相同的既有规范化工具,故保持这段局部逻辑。 |
|||
|
|||
## 7. 输入规范化决策表 |
|||
|
|||
| 输入 | 规范化结果 | |
|||
|---|---| |
|||
| `null` | `null` | |
|||
| `""` | `null` | |
|||
| `" "` | `null` | |
|||
| `"AlphaEngineer"` | `"AlphaEngineer"` | |
|||
| `" AlphaEngineer "` | `"AlphaEngineer"` | |
|||
| `"Alpha Engineer"` | `"Alpha Engineer"`(中间空格**不折叠**) | |
|||
|
|||
未做:大小写转换、中间空格折叠、SQL 拼接、Repository 改动、Specification 改动、Controller 改动、 |
|||
排序/分页/薪资/城市/状态语义改动、公开岗位范围改动。 |
|||
|
|||
## 8. 新增测试方法名 |
|||
|
|||
`mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java` |
|||
(9 项,走**真实链路** HTTP → SecurityFilterChain → JobController → JobService → JobRepository): |
|||
|
|||
`plainKeywordFindsPublishedJob`、`trimmedKeywordFindsSamePublishedJob`、`blankKeywordBehavesAsNoKeyword`、 |
|||
`emptyKeywordBehavesAsNoKeyword`、`nullOrMissingKeywordListsPublishedJobs`、 |
|||
`draftJobNeverAppearsAfterNormalization`、`normalizationDoesNotChangePagination`、 |
|||
`normalizationDoesNotChangeExistingSortContract`、`listJobsIsReadOnly` |
|||
|
|||
夹具:1 条标题含 `AlphaEngineer` 的 PUBLISHED + 2 条其它 PUBLISHED + 1 条 DRAFT + 1 条 CLOSED; |
|||
唯一前缀 `DEF01KEYWORD_`;清理**只按显式登记的主键**,不使用 `deleteAll()`;不依赖执行顺序。 |
|||
|
|||
## 9. 修复前失败结果 |
|||
|
|||
以**变异验证**给出权威的修复前证据(把 `normalizedKeyword` 改回 `keyword`,其余不动): |
|||
|
|||
``` |
|||
Tests run: 9, Failures: 4, Errors: 0, Skipped: 0 |
|||
JobListKeywordNormalizationIntegrationTest.trimmedKeywordFindsSamePublishedJob FAIL |
|||
JobListKeywordNormalizationIntegrationTest.blankKeywordBehavesAsNoKeyword FAIL |
|||
JobListKeywordNormalizationIntegrationTest.normalizationDoesNotChangePagination FAIL |
|||
JobListKeywordNormalizationIntegrationTest.normalizationDoesNotChangeExistingSortContract FAIL |
|||
``` |
|||
|
|||
即 **trim 与 blank 两类断言在缺失修复时必然失败**(≥2 项,满足要求)。 |
|||
|
|||
## 10. 修复后结果 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="JobListKeywordNormalizationIntegrationTest" test` | **9 / 0 / 0 / 0** | |
|||
| `mvn -Dtest="*Job*Test,*JobSearch*Test" test` | **219 / 0 / 0 / 0** | |
|||
|
|||
## 11. 真实 MySQL HTTP 结果 |
|||
|
|||
最新 JAR(`mvn clean verify` 产物)+ 真实 MySQL/Redis/RabbitMQ,前缀 `DEF01_20260920_`: |
|||
**11 / 11 通过**。 |
|||
|
|||
| 断言 | 实测 | |
|||
|---|---| |
|||
| 普通关键词 HTTP 200 且只命中目标 | `200, [target]` ✔ | |
|||
| 两侧空白与普通关键词:ID/顺序/数量一致 | `pad=[T] plain=[T]` ✔ | |
|||
| 纯空白与无关键词:ID/顺序/总数一致 | `blank == none` ✔ | |
|||
| 空字符串与无关键词:一致 | `empty == none` ✔ | |
|||
| DRAFT 不进入公开结果 | `statuses=['PUBLISHED']` ✔ | |
|||
| 分页 metadata 不因规范化变化 | `total=1,page=0,size=1,totalPages=1` ✔ | |
|||
| 默认排序不变(夹具 createdAt 降序) | `none == blank`,desc ✔ | |
|||
| 不返回 4xx/5xx | `[200]×7` ✔ | |
|||
| 响应不泄露 SQL/异常/路径/堆栈 | `leaks=[]` ✔ | |
|||
| 对照 `/jobs/search` 的 trim/blank 语义一致 | `pad==plain`、`blank==全部` ✔ | |
|||
| 请求前后数据库逐字段零变化 | `changed=none` ✔ | |
|||
|
|||
## 12. 变异测试结果 |
|||
|
|||
- 保存 `JobService.java` **原始字节**(sha256 前 16 位 `b4fab0034b34d569`,14 699 字节); |
|||
- 变异:`findPublishedJobs(normalizedKeyword, …)` → `findPublishedJobs(keyword, …)`; |
|||
- 结果:**4 项失败**(见 §9); |
|||
- **按原始字节精确恢复**(**未使用** `git restore` / `git checkout`),恢复后 sha 一致; |
|||
- 复跑新增测试 **9/9 绿**;校验无变异残留(`findPublishedJobs(keyword,` 计数 = 0, |
|||
`String normalizedKeyword =` 计数 = 1)。 |
|||
|
|||
## 13. 岗位关联回归 |
|||
|
|||
`mvn -Dtest="*Job*Test,*JobSearch*Test" test` → **219 / 0 / 0 / 0**(未修改或删除任何既有断言)。 |
|||
|
|||
## 14. 全量测试数 |
|||
|
|||
| 命令 | Tests | Failures | Errors | Skipped | BUILD | |
|||
|---|---|---|---|---|---| |
|||
| `mvn test` | **2797** | 0 | 0 | 0 | SUCCESS | |
|||
| `mvn clean verify` | **2797** | 0 | 0 | 0 | SUCCESS | |
|||
|
|||
`2797 = 2788(基线)+ 9(本轮新增)`,满足「不低于 2788 + 新增数」。 |
|||
Testcontainers MySQL 用例实际执行(`Skipped=0`)。 |
|||
|
|||
## 15. OpenAPI |
|||
|
|||
``` |
|||
python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed! |
|||
``` |
|||
|
|||
`docs/openapi-rc.json` **未修改**(`git diff` 为空)。 |
|||
|
|||
## 16. 数据副作用 |
|||
|
|||
- 黑盒前后 7 张表(jobs / companies / users / user_roles / audit_logs / notifications / applications) |
|||
**逐字段零变化**; |
|||
- 单测 `listJobsIsReadOnly` 对 5 张表逐字段零变化; |
|||
- 夹具残留 **0**;历史未跟踪文件与历史数据(含 `users.id` 7938/7939 的 `user_roles`)完好; |
|||
- `audit_logs` **未删除任何行**。 |
|||
|
|||
## 17. 精确修改文件 |
|||
|
|||
``` |
|||
M mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java (+7 −1) |
|||
?? mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java (新增,9 项) |
|||
?? docs/def-01-job-keyword-normalization-remediation-20260920.md (本报告) |
|||
?? docs/final-independent-acceptance-20260918.md (追加 DEF-01 章节) |
|||
``` |
|||
|
|||
## 18. Git 状态(整改与复验阶段) |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 生产代码改动 | **仅 `JobService.java` 1 个文件** | |
|||
| `JobRepository` / `JobSearchSpecification` / `JobController` | **零修改** | |
|||
| 其他生产模块 / Flyway / `pom.xml` / `openapi-rc.json` | **零修改** | |
|||
| 验收脚本与原始产物 | **零修改** | |
|||
|
|||
## 19. 未涉及范围 |
|||
|
|||
未进入:简历隐私矩阵、管理员初始化矩阵、Q01 H2/MySQL 对账与 EXPLAIN 分表、写侧刷新链路 |
|||
(四项 SKIP 仍待验收)。 |
|||
|
|||
## 20. 本轮测试自身缺陷(非产品缺陷,已修正并记录) |
|||
|
|||
| # | 现象 | 判定 | 修正 | |
|||
|---|---|---|---| |
|||
| T-1 | 「带空白关键词」用例恒返回空 | **测试缺陷**:MockMvc 对 URL 模板二次编码(`%20` → `%2520`),测的是字面量 `%20` 而非空白参数 | 改用 `.param()` 传参 | |
|||
| T-2 | 分页/排序断言随更大范围运行而失败 | **测试缺陷**:断言了既有契约**不保证**的顺序 —— 默认排序 `createdAt DESC` 无 tiebreaker,并列时入选子集与顺序均不稳定 | 夹具改用**显式互异 `created_at`**;分页用例只断言确定性元数据 | |
|||
| T-3 | 黑盒夹具未创建 | **脚本缺陷**:`seed_sms_codes/login` 使用 `lib.phone()`(`199000` 号段),而建公司语句另写了 `199002` 号段,两者是**不同号码**,公司被挂到不存在的用户上(号码为本地验收自建夹具,报告中脱敏) | 统一为 `lib.phone(CO)` | |
|||
| T-4 | 黑盒「默认排序」断言失败 | **脚本缺陷**:把「关键词过滤后的结果」与「无关键词的完整结果」做相等比较,语义不同 | 只比较同为未过滤的 `none` 与 `blank` | |
|||
|
|||
## 21. 明确声明 |
|||
|
|||
**未修改** `/jobs/search`、`JobSearchSpecification`、`JobRepository` JPQL、Controller 路由、 |
|||
排序/分页/薪资/城市/状态过滤、DTO、OpenAPI、`ErrorCode`、`SecurityConfig`、V1–V45、`pom.xml`; |
|||
**未新增 V46**;**未修改任何既有测试断言**;**未**执行 `git reset/restore/checkout/clean`; |
|||
**未**使用 `git add -A` / `git add .` / 目录级暂存;**未**删除或移动历史未跟踪文件。 |
|||
|
|||
本报告与 `docs/final-independent-acceptance-20260918.md` 的 DEF-01 追加章节于提交前保持**待提交**状态。 |
|||
@ -0,0 +1,315 @@ |
|||
package com.mydreamjob.job; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
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.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
|
|||
/** |
|||
* DEF-01 定向整改:<b>GET /jobs</b> 的关键词规范化。 |
|||
* |
|||
* <p>本类刻意走**真实链路**:HTTP → SecurityFilterChain → JobController → JobService → JobRepository |
|||
* (真实 Spring 上下文 + 真实数据库),<b>不</b>直接调用 Controller、<b>不</b> Mock |
|||
* JobService/JobRepository、<b>不</b>用 Mockito 校验字符串参数。</p> |
|||
* |
|||
* <p>固化的语义契约:`GET /jobs` 的关键词应与 `GET /jobs/search` 一致 —— |
|||
* 两端空白被 trim、空串/纯空白等同于「未传关键词」。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class JobListKeywordNormalizationIntegrationTest { |
|||
|
|||
private static final String TITLE_PREFIX = "DEF01KEYWORD_"; |
|||
private static final String TARGET_KEYWORD = "AlphaEngineer"; |
|||
private static final String PHONE_FMT = "199001%05d"; |
|||
/** 夹具统一基准时间,保证每条岗位的 created_at 互异且可预期。 */ |
|||
private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 9, 20, 9, 0, 0); |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@Autowired private ObjectMapper objectMapper; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
/** 显式登记的本轮夹具主键,清理只按这些主键进行。 */ |
|||
private final List<Long> userIds = new ArrayList<>(); |
|||
private final List<Long> companyIds = new ArrayList<>(); |
|||
private final List<Long> jobIds = new ArrayList<>(); |
|||
|
|||
private Long targetJobId; |
|||
|
|||
@BeforeEach |
|||
void seed() { |
|||
cleanup(); |
|||
long me = user(String.format(PHONE_FMT, 100 + userIds.size())); |
|||
long companyId = company(me, TITLE_PREFIX + "co"); |
|||
companyIds.add(companyId); |
|||
|
|||
// 1 条标题命中关键词的公开岗位 + 2 条其它公开岗位
|
|||
targetJobId = job(companyId, TITLE_PREFIX + TARGET_KEYWORD, "PUBLISHED"); |
|||
job(companyId, TITLE_PREFIX + "OtherRole", "PUBLISHED"); |
|||
job(companyId, TITLE_PREFIX + "ThirdRole", "PUBLISHED"); |
|||
// 1 条标题同样命中关键词、但状态为 DRAFT 的岗位
|
|||
job(companyId, TITLE_PREFIX + TARGET_KEYWORD + "_DRAFT", "DRAFT"); |
|||
// 1 条标题命中关键词、状态为 CLOSED(对照非公开范围)
|
|||
job(companyId, TITLE_PREFIX + TARGET_KEYWORD + "_CLOSED", "CLOSED"); |
|||
} |
|||
|
|||
@AfterEach |
|||
void cleanup() { |
|||
// 只按本轮显式登记的主键清理,不使用宽泛 deleteAll()
|
|||
if (!jobIds.isEmpty()) { |
|||
jdbcTemplate.update("DELETE FROM jobs WHERE id IN (" |
|||
+ String.join(",", jobIds.stream().map(String::valueOf).toList()) + ")"); |
|||
} |
|||
if (!companyIds.isEmpty()) { |
|||
jdbcTemplate.update("DELETE FROM companies WHERE id IN (" |
|||
+ String.join(",", companyIds.stream().map(String::valueOf).toList()) + ")"); |
|||
} |
|||
if (!userIds.isEmpty()) { |
|||
String ids = String.join(",", userIds.stream().map(String::valueOf).toList()); |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (" + ids + ")"); |
|||
jdbcTemplate.update("DELETE FROM users WHERE id IN (" + ids + ")"); |
|||
} |
|||
jobIds.clear(); |
|||
companyIds.clear(); |
|||
userIds.clear(); |
|||
targetJobId = null; |
|||
} |
|||
|
|||
private long user(String phone) { |
|||
jdbcTemplate.update("INSERT INTO users (phone, nickname, role, is_active, is_verified, created_at, " |
|||
+ "updated_at) VALUES (?, ?, 'COMPANY', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", |
|||
phone, TITLE_PREFIX + phone); |
|||
Long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); |
|||
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'COMPANY')", id); |
|||
userIds.add(id); |
|||
return id; |
|||
} |
|||
|
|||
private long company(long ownerUserId, String name) { |
|||
jdbcTemplate.update("INSERT INTO companies (owner_user_id, name, verified, created_at, updated_at) " |
|||
+ "VALUES (?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", ownerUserId, name); |
|||
return jdbcTemplate.queryForObject("SELECT id FROM companies WHERE name = ?", Long.class, name); |
|||
} |
|||
|
|||
/** |
|||
* 插入夹具岗位,created_at 显式取互异值(每条递减 1 分钟)。 |
|||
* |
|||
* <p>默认排序 {@code createdAt DESC} 没有 tiebreaker,若夹具时间相同,其相对顺序由引擎决定, |
|||
* 断言就会不稳定。显式互异后,夹具内部的顺序才是确定可断言的。</p> |
|||
*/ |
|||
private long job(long companyId, String title, String status) { |
|||
LocalDateTime createdAt = BASE_TIME.minusMinutes(jobIds.size()); |
|||
jdbcTemplate.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, " |
|||
+ "created_at, updated_at) VALUES (?, ?, 'desc', '北京', 'FULL_TIME', ?, ?, ?)", |
|||
companyId, title, status, createdAt, createdAt); |
|||
Long id = jdbcTemplate.queryForObject("SELECT id FROM jobs WHERE title = ?", Long.class, title); |
|||
jobIds.add(id); |
|||
return id; |
|||
} |
|||
|
|||
// ── HTTP 调用与解析 ──────────────────────────────────────
|
|||
|
|||
/** |
|||
* 走真实链路请求 /jobs。 |
|||
* |
|||
* <p>用 {@code .param(...)} 传参而非在 URL 串里手写 {@code %20}:MockMvc 会对 URL 模板再做一次 |
|||
* 编码({@code %20} → {@code %2520}),那样测的就不是「带空白的参数」而是「字面量 %20」。</p> |
|||
* |
|||
* @param keyword {@code null} = 不带该参数;{@code ""} = {@code keyword=};其余按原样发送 |
|||
*/ |
|||
private JsonNode listJobs(String keyword, String size, String page) throws Exception { |
|||
var builder = get("/jobs").param("size", size); |
|||
if (page != null) { |
|||
builder = builder.param("page", page); |
|||
} |
|||
if (keyword != null) { |
|||
builder = builder.param("keyword", keyword); |
|||
} |
|||
var response = mockMvc.perform(builder).andReturn().getResponse(); |
|||
assertThat(response.getStatus()).as("GET /jobs 不得返回 4xx/5xx").isEqualTo(200); |
|||
String body = response.getContentAsString(); |
|||
assertThat(body).as("响应不得泄露 SQL / 异常类 / 堆栈 / 本机路径") |
|||
.doesNotContain("java.sql").doesNotContain("Exception") |
|||
.doesNotContain("at com.mydreamjob").doesNotContain("C:\\"); |
|||
return objectMapper.readTree(body).path("data"); |
|||
} |
|||
|
|||
private JsonNode listJobs(String keyword, String size) throws Exception { |
|||
return listJobs(keyword, size, null); |
|||
} |
|||
|
|||
private List<Long> idsOf(JsonNode data) { |
|||
List<Long> ids = new ArrayList<>(); |
|||
for (JsonNode item : data.path("items")) { |
|||
ids.add(item.path("id").asLong()); |
|||
} |
|||
return ids; |
|||
} |
|||
|
|||
/** 只保留本轮夹具的 ID,避免同库其它测试数据干扰。 */ |
|||
private List<Long> fixtureIdsOf(JsonNode data) { |
|||
List<Long> ids = new ArrayList<>(); |
|||
for (JsonNode item : data.path("items")) { |
|||
if (item.path("title").asText("").startsWith(TITLE_PREFIX)) { |
|||
ids.add(item.path("id").asLong()); |
|||
} |
|||
} |
|||
return ids; |
|||
} |
|||
|
|||
// ── 用例 ────────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("普通关键词只命中目标公开岗位") |
|||
void plainKeywordFindsPublishedJob() throws Exception { |
|||
JsonNode data = listJobs(TARGET_KEYWORD, "50"); |
|||
assertThat(idsOf(data)).as("普通关键词应只命中 1 条").containsExactly(targetJobId); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("两侧空白关键词与普通关键词结果完全一致(trim)") |
|||
void trimmedKeywordFindsSamePublishedJob() throws Exception { |
|||
JsonNode plain = listJobs(TARGET_KEYWORD, "50"); |
|||
JsonNode padded = listJobs(" " + TARGET_KEYWORD + " ", "50"); |
|||
assertThat(idsOf(padded)) |
|||
.as("带两侧空白的关键词必须与普通关键词返回相同 ID 与顺序") |
|||
.isEqualTo(idsOf(plain)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("纯空白关键词等同于未传关键词") |
|||
void blankKeywordBehavesAsNoKeyword() throws Exception { |
|||
JsonNode none = listJobs(null, "50"); |
|||
JsonNode blank = listJobs(" ", "50"); |
|||
assertThat(fixtureIdsOf(blank)).as("纯空白关键词必须与无关键词返回相同夹具 ID 与顺序") |
|||
.isEqualTo(fixtureIdsOf(none)); |
|||
assertThat(blank.path("total").asLong()).as("总数也应一致").isEqualTo(none.path("total").asLong()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("空字符串关键词等同于未传关键词") |
|||
void emptyKeywordBehavesAsNoKeyword() throws Exception { |
|||
JsonNode none = listJobs(null, "50"); |
|||
JsonNode empty = listJobs("", "50"); |
|||
assertThat(fixtureIdsOf(empty)).as("空字符串关键词必须与无关键词返回相同夹具 ID 与顺序") |
|||
.isEqualTo(fixtureIdsOf(none)); |
|||
assertThat(empty.path("total").asLong()).isEqualTo(none.path("total").asLong()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("缺省关键词返回全部公开岗位") |
|||
void nullOrMissingKeywordListsPublishedJobs() throws Exception { |
|||
JsonNode data = listJobs(null, "50"); |
|||
assertThat(fixtureIdsOf(data)) |
|||
.as("本轮 3 条 PUBLISHED 夹具都应返回") |
|||
.hasSize(3); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("关键词规范化后 DRAFT / CLOSED 仍不进入公开结果") |
|||
void draftJobNeverAppearsAfterNormalization() throws Exception { |
|||
for (String q : new String[]{TARGET_KEYWORD, " " + TARGET_KEYWORD + " ", " ", "", null}) { |
|||
JsonNode data = listJobs(q, "50"); |
|||
for (JsonNode item : data.path("items")) { |
|||
assertThat(item.path("status").asText()) |
|||
.as("公开列表只允许 PUBLISHED") |
|||
.isEqualTo("PUBLISHED"); |
|||
assertThat(item.path("title").asText()) |
|||
.as("DRAFT/CLOSED 夹具不得出现") |
|||
.doesNotContain("_DRAFT") |
|||
.doesNotContain("_CLOSED"); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("关键词规范化不改变分页元数据") |
|||
void normalizationDoesNotChangePagination() throws Exception { |
|||
JsonNode plain = listJobs(TARGET_KEYWORD, "1", "0"); |
|||
JsonNode padded = listJobs(" " + TARGET_KEYWORD + " ", "1", "0"); |
|||
for (String field : new String[]{"total", "page", "size", "totalPages"}) { |
|||
assertThat(padded.path(field).asLong()) |
|||
.as("分页字段 %s 不应因规范化而变化", field) |
|||
.isEqualTo(plain.path(field).asLong()); |
|||
} |
|||
JsonNode none = listJobs(null, "2", "0"); |
|||
JsonNode blank = listJobs(" ", "2", "0"); |
|||
// 非夹具行之间存在 created_at 并列,而默认排序无 tiebreaker —— 小页尺寸下连「入选子集」
|
|||
// 都不由契约保证。故此用例只断言**确定性**的分页元数据与夹具集合。
|
|||
for (String field : new String[]{"total", "page", "size", "totalPages"}) { |
|||
assertThat(blank.path(field).asLong()) |
|||
.as("分页字段 %s 不应因空白关键词而变化", field) |
|||
.isEqualTo(none.path(field).asLong()); |
|||
} |
|||
assertThat(fixtureIdsOf(blank)).as("分页下夹具集合应与无关键词一致").isEqualTo(fixtureIdsOf(none)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("关键词规范化不改变既有默认排序契约(createdAt 降序)") |
|||
void normalizationDoesNotChangeExistingSortContract() throws Exception { |
|||
JsonNode none = listJobs(null, "50"); |
|||
JsonNode blank = listJobs(" ", "50"); |
|||
JsonNode padded = listJobs(" " + TARGET_KEYWORD + " ", "50"); |
|||
|
|||
List<Long> fixtureNone = fixtureIdsOf(none); |
|||
assertThat(fixtureNone).as("同一批夹具在两种查询下顺序应一致(夹具 created_at 互异)") |
|||
.isEqualTo(fixtureIdsOf(blank)); |
|||
assertThat(padded.path("items").isArray()).isTrue(); |
|||
|
|||
List<String> createdDesc = new ArrayList<>(); |
|||
for (JsonNode item : blank.path("items")) { |
|||
createdDesc.add(item.path("createdAt").asText()); |
|||
} |
|||
assertThat(createdDesc).as("默认排序仍应为 createdAt 降序").isSortedAccordingTo( |
|||
(a, b) -> b.compareTo(a)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("副作用:GET 前后相关业务表逐字段零变化") |
|||
void listJobsIsReadOnly() throws Exception { |
|||
Map<String, List<Map<String, Object>>> before = snapshot(); |
|||
listJobs(TARGET_KEYWORD, "50"); |
|||
listJobs(" ", "50"); |
|||
listJobs(null, "50"); |
|||
Map<String, List<Map<String, Object>>> after = snapshot(); |
|||
for (String table : before.keySet()) { |
|||
assertThat(after.get(table)).as("%s 必须逐字段零变化", table).isEqualTo(before.get(table)); |
|||
} |
|||
} |
|||
|
|||
private Map<String, List<Map<String, Object>>> snapshot() { |
|||
Map<String, List<Map<String, Object>>> s = new LinkedHashMap<>(); |
|||
s.put("jobs", jdbcTemplate.queryForList( |
|||
"SELECT id, company_id, title, status, updated_at FROM jobs ORDER BY id")); |
|||
s.put("companies", jdbcTemplate.queryForList( |
|||
"SELECT id, owner_user_id, name, verified FROM companies ORDER BY id")); |
|||
s.put("users", jdbcTemplate.queryForList( |
|||
"SELECT id, phone, nickname, role, is_active FROM users ORDER BY id")); |
|||
s.put("audit_logs", jdbcTemplate.queryForList( |
|||
"SELECT id, user_id, action, entity_type FROM audit_logs ORDER BY id")); |
|||
s.put("notifications", jdbcTemplate.queryForList( |
|||
"SELECT id, user_id, type, is_read FROM notifications ORDER BY id")); |
|||
return s; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue