package com.mydreamjob.job; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; 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; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import static org.assertj.core.api.Assertions.assertThat; /** * Q01:{@link JobSearchSpecification} 的**真实数据库**覆盖(H2 + 真实 Flyway + 真实 JobRepository)。 * *

与 {@code JobSearchServiceTest} 的区别:这里不 Mock Repository / Specification / * EntityManager / 事务,直接执行 {@code jobRepository.findAll(spec, pageable)}, * 断言返回的**ID 及顺序**,从而真正验证 Criteria 谓词、过滤与排序。

* *

夹具隔离:每个用例使用唯一城市标记,只在自己的数据上断言,不依赖种子岗位。

*/ @SpringBootTest @ActiveProfiles("integration-test") class JobSearchSpecificationIntegrationTest { private static final AtomicLong SEQ = new AtomicLong(71000); /** 结果集足够大,避免分页测试被默认 size 截断。 */ private static final int MAX_RESULTS = 50; @MockBean private RedisTemplate redisTemplate; @Autowired private JobRepository jobRepository; @Autowired private CompanyRepository companyRepository; @Autowired private UserRepository userRepository; @Autowired private JdbcTemplate jdbcTemplate; private final List jobIds = new ArrayList<>(); private final List companyIds = new ArrayList<>(); private final List userIds = new ArrayList<>(); private String tag; private Long companyA; private Long companyB; @BeforeEach void setUp() { tag = "Q01" + SEQ.incrementAndGet(); companyA = createCompany("甲"); companyB = createCompany("乙"); } @AfterEach void tearDown() { // FK 安全顺序:jobs -> companies -> users if (!jobIds.isEmpty()) { jobRepository.deleteAllById(jobIds); } if (!companyIds.isEmpty()) { companyRepository.deleteAllById(companyIds); } if (!userIds.isEmpty()) { userRepository.deleteAllById(userIds); } jobIds.clear(); companyIds.clear(); userIds.clear(); } // ── 夹具 ──────────────────────────────────────────────────── private Long createCompany(String label) { Long ownerId = userRepository.save(User.builder() .phone("18" + String.format("%09d", SEQ.incrementAndGet())) .nickname(tag + "-" + label) .role(User.Role.COMPANY) .roles(Set.of(User.Role.COMPANY)) .active(true).verified(true).build()).getId(); userIds.add(ownerId); Long id = companyRepository.save(Company.builder() .ownerUserId(ownerId).name(tag + "公司" + label).verified(true).build()).getId(); companyIds.add(id); return id; } private Long job(Long companyId, String title, String description, String requirements, String city, String employmentType, Integer salaryMin, Integer salaryMax, String status) { Long id = jobRepository.save(Job.builder() .companyId(companyId) .title(title) .description(description) .requirements(requirements) .city(city) .employmentType(employmentType) .salaryMin(salaryMin) .salaryMax(salaryMax) .status(status) .build()).getId(); jobIds.add(id); return id; } private Long publishedJob(Long companyId, String title, String description, String requirements, String city, String employmentType, Integer salaryMin, Integer salaryMax) { return job(companyId, title, description, requirements, city, employmentType, salaryMin, salaryMax, JobStatus.PUBLISHED.name()); } /** * 回写 createdAt:实体的 {@code @CreatedDate} 审计会在持久化时覆盖显式值, * 排序用例需要精确时间,因此用原生 SQL 回写(不经过审计)。 */ private void setCreatedAt(Long jobId, LocalDateTime createdAt) { jdbcTemplate.update("UPDATE jobs SET created_at = ? WHERE id = ?", createdAt, jobId); } /** 与服务一致的 latest 排序:createdAt DESC, id DESC。 */ private Pageable latest( int size) { return PageRequest.of(0, size, Sort.by(new Sort.Order(Sort.Direction.DESC, "createdAt"), new Sort.Order(Sort.Direction.DESC, "id"))); } private List searchIds(String keyword, String city, String employmentType, Integer salaryMin, Integer salaryMax, Long companyId) { return jobRepository.findAll( JobSearchSpecification.searchJobs(keyword, city, employmentType, salaryMin, salaryMax, companyId), latest(MAX_RESULTS)) .getContent().stream().map(Job::getId).toList(); } // ── keyword:三个字段 ─────────────────────────────────────── @Test void keywordMatchesTitle() { String city = tag + "CITY_TITLE"; Long inTitle = publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); // 其余两个字段均不含 "Java",确保命中的是 title publishedJob(companyA, "无关岗位", "无关描述", "无关要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds("Java", city, null, null, null, null)).containsExactly(inTitle); } @Test void keywordMatchesDescription() { String city = tag + "CITY_DESC"; Long inDesc = publishedJob(companyA, "岗位", tag + "负责Java后端开发", "要求", city, "FULL_TIME", 10000, 20000); publishedJob(companyA, "岗位", "无关描述", "要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds("Java", city, null, null, null, null)).containsExactly(inDesc); } @Test void keywordMatchesRequirements() { String city = tag + "CITY_REQ"; Long inReq = publishedJob(companyA, "岗位", "描述", tag + "熟悉Java", city, "FULL_TIME", 10000, 20000); publishedJob(companyA, "岗位", "描述", "无关要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds("Java", city, null, null, null, null)).containsExactly(inReq); } @Test void keywordIsCaseInsensitive() { String city = tag + "CITY_CASE"; Long lower = publishedJob(companyA, tag + "java开发", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long upper = publishedJob(companyA, tag + "JAVA开发", "描述", "要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds("java", city, null, null, null, null)) .containsExactlyInAnyOrder(lower, upper); assertThat(searchIds("JaVa", city, null, null, null, null)) .containsExactlyInAnyOrder(lower, upper); } @Test void nullBlankAndWhitespaceKeywordDoNotFilter() { String city = tag + "CITY_NOKW"; Long a = publishedJob(companyA, tag + "甲岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long b = publishedJob(companyA, tag + "乙岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); for (String blank : new String[]{null, "", " ", " ", "\t"}) { assertThat(searchIds(blank, city, null, null, null, null)) .as("blank keyword 不应参与过滤:" + blank) .containsExactlyInAnyOrder(a, b); } } @Test void keywordIsTrimmed() { String city = tag + "CITY_TRIM"; Long target = publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds(" Java ", city, null, null, null, null)).containsExactly(target); } // ── city / employmentType / companyId ────────────────────── @Test void cityUsesTrimmedExactMatch() { String city = tag + "CITY_EXACT"; Long target = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); publishedJob(companyA, "岗位", "描述", "要求", city + "别处", "FULL_TIME", 10000, 20000); // 精确匹配:前缀相同但不同的城市不得命中 assertThat(searchIds(null, city, null, null, null, null)).containsExactly(target); // trim 后精确匹配 assertThat(searchIds(null, " " + city + " ", null, null, null, null)).containsExactly(target); } @Test void cityCaseVariantHasSameResultAcrossDatabases() { String city = tag + "-Beijing"; Long target = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds(null, city.toLowerCase(), null, null, null, null)) .containsExactly(target); assertThat(searchIds(null, " " + city.toLowerCase() + " ", null, null, null, null)) .containsExactly(target); } @Test void employmentTypeUsesTrimmedExactMatch() { String city = tag + "CITY_ET"; Long full = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); publishedJob(companyA, "岗位", "描述", "要求", city, "PART_TIME", 10000, 20000); assertThat(searchIds(null, city, "FULL_TIME", null, null, null)).containsExactly(full); assertThat(searchIds(null, city, " FULL_TIME ", null, null, null)).containsExactly(full); // 精确匹配:不得做前缀匹配 assertThat(searchIds(null, city, "FULL", null, null, null)).isEmpty(); } @Test void employmentTypeCaseVariantHasSameResultAcrossDatabases() { String city = tag + "CITY_EMP_CASE"; Long target = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds(null, city, "full_time", null, null, null)).containsExactly(target); assertThat(searchIds(null, city, " full_time ", null, null, null)).containsExactly(target); } @Test void companyIdFiltersExactly() { String city = tag + "CITY_COMPANY"; Long mine = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); publishedJob(companyB, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds(null, city, null, null, null, companyA)).containsExactly(mine); } // ── 薪资:区间重叠语义 ────────────────────────────────────── @Test void salaryMinMatchesWhenJobMaximumEqualsBoundary() { String city = tag + "CITY_SMIN"; // 岗位 salaryMax 恰好等于用户 salaryMin Long boundary = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 19999); assertThat(searchIds(null, city, null, 20000, null, null)).containsExactly(boundary); } @Test void salaryMaxMatchesWhenJobMinimumEqualsBoundary() { String city = tag + "CITY_SMAX"; // 岗位 salaryMin 恰好等于用户 salaryMax Long boundary = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10001, 20000); assertThat(searchIds(null, city, null, null, 10000, null)).containsExactly(boundary); } @Test void salaryRangesUseOverlapSemantics() { String city = tag + "CITY_OVERLAP"; // 岗位区间 [10k,20k] 与用户区间 [15k,25k] 部分重叠 —— 必须命中 Long overlapping = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); // 岗位区间完全包含用户区间 —— 必须命中 Long containing = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 12000, 30000); assertThat(searchIds(null, city, null, 15000, 25000, null)) .containsExactlyInAnyOrder(overlapping, containing); } @Test void nonOverlappingSalaryRangeIsExcluded() { String city = tag + "CITY_NOOVERLAP"; Long tooLow = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 5000, 9000); Long inRange = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 15000, 25000); Long tooHigh = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 40000, 60000); assertThat(searchIds(null, city, null, 10000, 30000, null)) .as("只有区间重叠的岗位命中") .containsExactly(inRange); } @Test void nullSalaryFieldsFollowExistingContract() { String city = tag + "CITY_NULLSAL"; Long bothNull = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", null, null); Long onlyMin = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, null); Long both = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); // 不加薪资条件时,NULL 薪资岗位必须照常返回 assertThat(searchIds(null, city, null, null, null, null)) .containsExactlyInAnyOrder(bothNull, onlyMin, both); // 加了 salaryMin 条件后:`job.salaryMax >= 5000` 对 NULL 求值为 UNKNOWN, // 因此 salaryMax 为 NULL 的两个岗位都被排除,只余 salaryMax 非空且达标的岗位。 // 这是 SQL 三值逻辑的既有契约,Q01 锁定该行为、不做修改。 assertThat(searchIds(null, city, null, 5000, null, null)).containsExactly(both); } // ── 多条件 AND / 状态 ─────────────────────────────────────── @Test void multipleConditionsUseAndSemantics() { String city = tag + "CITY_AND"; Long match = publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); // 关键词命中但城市不符 publishedJob(companyA, tag + "Java工程师", "描述", "要求", city + "X", "FULL_TIME", 10000, 20000); // 城市命中但用工类型不符 publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "PART_TIME", 10000, 20000); // 公司不符 publishedJob(companyB, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); // 薪资不重叠 publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 90000, 99000); assertThat(searchIds("Java", city, "FULL_TIME", 15000, 25000, companyA)).containsExactly(match); } @Test void nonPublishedJobsAreAlwaysExcluded() { String city = tag + "CITY_STATUS"; Long published = publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); job(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000, JobStatus.DRAFT.name()); job(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000, JobStatus.CLOSED.name()); assertThat(searchIds(null, city, null, null, null, null)).containsExactly(published); // 即使显式按公司过滤,非公开岗位也不得出现 assertThat(searchIds(null, city, null, null, null, companyA)).containsExactly(published); } // ── 特殊字符 / 分页 / 空结果 ──────────────────────────────── @Test void specialLikeCharactersFollowExistingContract() { String city = tag + "CITY_LIKE"; Long plain = publishedJob(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long percent = publishedJob(companyA, tag + "100%远程", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long underscore = publishedJob(companyA, tag + "A_B岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); // 锁定当前契约:keyword 直接拼进 LIKE,% 与 _ 仍作通配符(未转义)。 // 这是既有行为,Q01 不改语义,仅在真实库上固定它。 assertThat(searchIds("%", city, null, null, null, null)) .as("% 作为通配符:命中该城市下全部带标题的岗位") .containsExactlyInAnyOrder(plain, percent, underscore); assertThat(searchIds("_", city, null, null, null, null)) .as("_ 作为单字符通配符:命中标题含任意单字符的岗位") .containsExactlyInAnyOrder(plain, percent, underscore); assertThat(searchIds("A_B", city, null, null, null, null)) .as("A_B 以通配符解释,可命中 A_B 本身") .containsExactly(underscore); assertThat(searchIds("Java", city, null, null, null, null)).containsExactly(plain); } @Test void paginationReturnsStableOrderedIds() { String city = tag + "CITY_PAGE"; LocalDateTime base = LocalDateTime.of(2026, 1, 1, 0, 0); List ids = new ArrayList<>(); for (int i = 0; i < 5; i++) { Long id = publishedJob(companyA, "岗位" + i, "描述", "要求", city, "FULL_TIME", 10000, 20000); setCreatedAt(id, base.plusDays(i)); // 越大越新 ids.add(id); } // 期望顺序:createdAt DESC(越新越前) List expected = new ArrayList<>(ids); java.util.Collections.reverse(expected); Pageable first = PageRequest.of(0, 2, Sort.by(new Sort.Order(Sort.Direction.DESC, "createdAt"), new Sort.Order(Sort.Direction.DESC, "id"))); Pageable second = PageRequest.of(1, 2, Sort.by(new Sort.Order(Sort.Direction.DESC, "createdAt"), new Sort.Order(Sort.Direction.DESC, "id"))); var spec = JobSearchSpecification.searchJobs(null, city, null, null, null, null); var page0 = jobRepository.findAll(spec, first); var page1 = jobRepository.findAll(spec, second); assertThat(page0.getContent().stream().map(Job::getId).toList()) .as("第 0 页按 createdAt DESC 稳定排序").containsExactlyElementsOf(expected.subList(0, 2)); assertThat(page1.getContent().stream().map(Job::getId).toList()) .as("第 1 页继续按同一顺序,不重复不丢行").containsExactlyElementsOf(expected.subList(2, 4)); assertThat(page0.getTotalElements()).as("total 为全部匹配行数").isEqualTo(5); assertThat(page0.getTotalPages()).isEqualTo(3); } @Test void noResultReturnsEmptyPage() { String city = tag + "CITY_EMPTY"; publishedJob(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); var spec = JobSearchSpecification.searchJobs(tag + "不存在的关键词XYZ", city, null, null, null, null); var page = jobRepository.findAll(spec, latest(MAX_RESULTS)); assertThat(page.getContent()).isEmpty(); assertThat(page.getTotalElements()).isZero(); } }