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();
}
}