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 org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.annotation.DirtiesContext; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; 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} 在**真实 MySQL 8** 上的行为对账。 * *

使用 Testcontainers 启动独立 MySQL(不复用开发库),执行正式 Flyway 迁移并以 * {@code ddl-auto=validate} 启动,重点对账 H2 难以覆盖的差异:大小写比较规则、 * LIKE 特殊字符、NULL 排序、边界比较与分页稳定性。

* *

不打印任何数据库凭据。Docker 不可用时环境性跳过({@code disabledWithoutDocker}), * 但独立验收必须显式运行本类并得到 Tests run > 0、Skipped = 0。

*/ @Testcontainers(disabledWithoutDocker = true) @SpringBootTest @ActiveProfiles("mysql-it") @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class JobSearchSpecificationMySqlIntegrationTest { private static final AtomicLong SEQ = new AtomicLong(73000); private static final int MAX_RESULTS = 50; @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") .withDatabaseName("mydreamjob_q01") .withUsername("q01") .withPassword("q01-test-only"); /** * 连接参数全部来自容器,运行期注入,不落盘、不打印密码。 * 应用级测试开关与测试密钥由 {@code mysql-it} profile 提供(见 * {@code src/test/resources/application-mysql-it.yml}),方言沿用主配置的 MySQLDialect。 */ @DynamicPropertySource static void datasource(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", MYSQL::getJdbcUrl); registry.add("spring.datasource.username", MYSQL::getUsername); registry.add("spring.datasource.password", MYSQL::getPassword); registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); } @MockBean private RedisTemplate redisTemplate; @Autowired private JobRepository jobRepository; @Autowired private JobService jobService; @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 = "Q01M" + SEQ.incrementAndGet(); companyA = createCompany("甲"); companyB = createCompany("乙"); } @AfterEach void tearDown() { 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("17" + 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 published(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()); } private void setCreatedAt(Long jobId, LocalDateTime createdAt) { jdbcTemplate.update("UPDATE jobs SET created_at = ? WHERE id = ?", createdAt, jobId); } private Pageable latestSorted() { return PageRequest.of(0, MAX_RESULTS, 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), latestSorted()) .getContent().stream().map(Job::getId).toList(); } // ── 与 H2 逐项对账的场景 ──────────────────────────────────── @Test void keywordCaseInsensitiveMatchesThreeFields() { String city = tag + "MYSQL_KW"; Long inTitle = published(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long inDesc = published(companyA, "岗位", tag + "负责Java开发", "要求", city, "FULL_TIME", 10000, 20000); Long inReq = published(companyA, "岗位", "描述", tag + "熟悉JAVA", city, "FULL_TIME", 10000, 20000); published(companyA, "无关岗位", "无关描述", "无关要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds("java", city, null, null, null, null)) .as("MySQL 下大小写不敏感且覆盖三字段") .containsExactlyInAnyOrder(inTitle, inDesc, inReq); assertThat(searchIds("JaVa", city, null, null, null, null)) .containsExactlyInAnyOrder(inTitle, inDesc, inReq); } @Test void nullBlankAndWhitespaceKeywordDoNotFilter() { String city = tag + "MYSQL_NOKW"; Long a = published(companyA, tag + "甲岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long b = published(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 + "MYSQL_TRIM"; Long target = published(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds(" Java ", city, null, null, null, null)).containsExactly(target); } @Test void specialLikeCharactersFollowExistingContract() { String city = tag + "MYSQL_LIKE"; Long plain = published(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long percent = published(companyA, tag + "100%远程", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long underscore = published(companyA, tag + "A_B岗", "描述", "要求", city, "FULL_TIME", 10000, 20000); // 锁定既有契约:% 与 _ 未转义,仍作 LIKE 通配符 —— MySQL 与 H2 行为一致。 assertThat(searchIds("%", city, null, null, null, null)) .containsExactlyInAnyOrder(plain, percent, underscore); assertThat(searchIds("_", city, null, null, null, null)) .containsExactlyInAnyOrder(plain, percent, underscore); assertThat(searchIds("A_B", city, null, null, null, null)).containsExactly(underscore); } @Test void cityAndEmploymentTypeUseTrimmedExactMatch() { String city = tag + "MYSQL_CITY"; Long target = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long otherCity = published(companyA, "岗位", "描述", "要求", city + "别处", "FULL_TIME", 10000, 20000); Long otherType = published(companyA, "岗位", "描述", "要求", city, "PART_TIME", 10000, 20000); // 只给 city 条件时,同城的其他用工类型同样必须返回(city 与 employmentType 是两个独立条件); // 前缀相同但不同的城市不得命中。 assertThat(searchIds(null, city, null, null, null, null)) .as("city 精确匹配:同城全部命中,前缀相同的 city+别处 不命中") .containsExactly(otherType, target); assertThat(searchIds(null, city, null, null, null, null)).doesNotContain(otherCity); // 两个条件都给出时,trim 后各自精确匹配 assertThat(searchIds(null, " " + city + " ", " FULL_TIME ", null, null, null)) .containsExactly(target); } @Test void cityCaseVariantHasSameResultAcrossDatabases() { String city = tag + "-Beijing"; Long target = published(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 employmentTypeCaseVariantHasSameResultAcrossDatabases() { String city = tag + "MYSQL_EMP_CASE"; Long target = published(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 salaryBoundariesUseOverlapSemantics() { String city = tag + "MYSQL_SALARY"; Long boundary = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long noOverlapLow = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 5000, 9000); Long noOverlapHigh = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 40000, 60000); // 契约是**单边**比较:salaryMin 只约束「岗位 salaryMax >= 用户 salaryMin」, // 因此边界相等必须命中,salaryMax 更高的岗位同样命中,低于边界的才被排除。 assertThat(searchIds(null, city, null, 20000, null, null)) .as("岗位 salaryMax >= 20000:边界 20000 与 60000 命中,9000 排除") .containsExactly(noOverlapHigh, boundary); assertThat(searchIds(null, city, null, null, 10000, null)) .as("岗位 salaryMin <= 10000:边界 10000 与 5000 命中,40000 排除") .containsExactly(noOverlapLow, boundary); // 两端都给出时按区间重叠:部分重叠命中 assertThat(searchIds(null, city, null, 15000, 25000, null)).containsExactly(boundary); // 区间不重叠(高于用户区间 / 低于用户区间)必须排除 assertThat(searchIds(null, city, null, 30000, 35000, null)).isEmpty(); assertThat(searchIds(null, city, null, 1000, 4000, null)).isEmpty(); } @Test void nullSalaryFieldsFollowExistingContract() { String city = tag + "MYSQL_NULLSAL"; Long bothNull = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", null, null); Long onlyMin = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, null); Long both = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); // 不加薪资条件时,NULL 薪资岗位照常返回 assertThat(searchIds(null, city, null, null, null, null)) .containsExactlyInAnyOrder(bothNull, onlyMin, both); // 一旦给出 salaryMin,`job.salaryMax >= ?` 对 NULL 求值为 UNKNOWN, // salaryMax 为 NULL 的两个岗位被排除 —— SQL 三值逻辑的既有契约,Q01 锁定不改。 assertThat(searchIds(null, city, null, 5000, null, null)).containsExactly(both); } @Test void multipleConditionsUseAndSemantics() { String city = tag + "MYSQL_AND"; Long match = published(companyA, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); // 关键词命中但城市不符 published(companyA, tag + "Java工程师", "描述", "要求", city + "X", "FULL_TIME", 10000, 20000); // 城市命中但用工类型不符 published(companyA, tag + "Java工程师", "描述", "要求", city, "PART_TIME", 10000, 20000); // 公司不符 published(companyB, tag + "Java工程师", "描述", "要求", city, "FULL_TIME", 10000, 20000); // 薪资不重叠 published(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 + "MYSQL_STATUS"; Long published = published(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 latestOrderAndPaginationAreStable() { String city = tag + "MYSQL_PAGE"; LocalDateTime base = LocalDateTime.of(2026, 3, 1, 0, 0); List ids = new ArrayList<>(); for (int i = 0; i < 5; i++) { Long id = published(companyA, "岗位" + i, "描述", "要求", city, "FULL_TIME", 10000, 20000); setCreatedAt(id, base.plusDays(i)); ids.add(id); } List expected = new ArrayList<>(ids); java.util.Collections.reverse(expected); var spec = JobSearchSpecification.searchJobs(null, city, null, null, null, null); Pageable first = PageRequest.of(0, 2, latestSorted().getSort()); Pageable second = PageRequest.of(1, 2, latestSorted().getSort()); var p0 = jobRepository.findAll(spec, first); var p1 = jobRepository.findAll(spec, second); assertThat(p0.getContent().stream().map(Job::getId).toList()) .containsExactlyElementsOf(expected.subList(0, 2)); assertThat(p1.getContent().stream().map(Job::getId).toList()) .containsExactlyElementsOf(expected.subList(2, 4)); assertThat(p0.getTotalElements()).isEqualTo(5); assertThat(p0.getTotalPages()).isEqualTo(3); } @Test void salarySortNullLastIsConsistentWithH2() { String city = tag + "MYSQL_NULLLAST"; Long high = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 30000); Long mid = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); Long nullMax = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, null); // 通过 Service 的 CASE WHEN 排序路径验证 NULL LAST assertThat(idsViaSalarySort(city)).as("salaryMax DESC,NULL 最后") .containsExactly(high, mid, nullMax); } /** 直接调用 Service 的 salary 排序路径(真实 EntityManager + CASE WHEN)。 */ private List idsViaSalarySort(String city) { return jobService.searchJobs(null, city, null, null, null, null, "salary", 0, MAX_RESULTS) .getContent().stream().map(com.mydreamjob.job.dto.JobSearchItem::getId).toList(); } @Test void companyIdFiltersExactly() { String city = tag + "MYSQL_COMPANY"; Long mine = published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); published(companyB, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); assertThat(searchIds(null, city, null, null, null, companyA)).containsExactly(mine); } @Test void noResultReturnsEmptyPage() { String city = tag + "MYSQL_EMPTY"; published(companyA, "岗位", "描述", "要求", city, "FULL_TIME", 10000, 20000); var page = jobRepository.findAll( JobSearchSpecification.searchJobs(tag + "不存在XYZ", city, null, null, null, null), latestSorted()); assertThat(page.getContent()).isEmpty(); assertThat(page.getTotalElements()).isZero(); } }