You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

384 lines
18 KiB

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** 上的行为对账。
*
* <p>使用 Testcontainers 启动独立 MySQL(不复用开发库),执行正式 Flyway 迁移并以
* {@code ddl-auto=validate} 启动,重点对账 H2 难以覆盖的差异:大小写比较规则、
* LIKE 特殊字符、NULL 排序、边界比较与分页稳定性。</p>
*
* <p>不打印任何数据库凭据。Docker 不可用时环境性跳过({@code disabledWithoutDocker}),
* 但独立验收必须显式运行本类并得到 Tests run &gt; 0、Skipped = 0。</p>
*/
@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<String, Object> redisTemplate;
@Autowired private JobRepository jobRepository;
@Autowired private JobService jobService;
@Autowired private CompanyRepository companyRepository;
@Autowired private UserRepository userRepository;
@Autowired private JdbcTemplate jdbcTemplate;
private final List<Long> jobIds = new ArrayList<>();
private final List<Long> companyIds = new ArrayList<>();
private final List<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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();
}
}