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