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.
 
 
 

611 lines
27 KiB

package com.mydreamjob.job;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.dto.JobSearchItem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* {@link JobService#searchJobs} 的**编排与参数校验**测试(纯 Mockito)。
*
* <p>本类把 {@code JobRepository.findAll(Specification, Pageable)} 与
* {@code CompanyRepository} 整体 Mock 掉,因此它只能证明参数校验、分页对象、
* 排序参数传递和 DTO 组装的编排逻辑;**不能证明 Criteria 谓词、SQL 过滤、
* 排序与分页在真实数据库上的行为**。</p>
*
* <p>真实查询覆盖见 Q01 补充的
* {@link JobSearchSpecificationIntegrationTest}(H2 真实 Repository)、
* {@link JobSearchServiceIntegrationTest}(H2 真实 Service/EntityManager)、
* {@link JobSearchSpecificationMySqlIntegrationTest}(真实 MySQL 8)。</p>
*/
@ExtendWith(MockitoExtension.class)
class JobSearchServiceTest {
@Mock
private JobRepository jobRepository;
@Mock
private CompanyRepository companyRepository;
@Spy
@InjectMocks
private JobService jobService;
private Job pubJob1;
private Job pubJob2;
private Job pubJobNullSalary;
private Job draftJob;
private Job closedJob;
private Company company1;
private Company company2;
@BeforeEach
void setUp() {
company1 = Company.builder()
.id(1L).name("阿里巴巴").logoUrl("https://logo1.png")
.industry("IT").verified(true).build();
company2 = Company.builder()
.id(2L).name("腾讯科技").logoUrl("https://logo2.png")
.industry("IT").verified(false).build();
pubJob1 = Job.builder()
.id(1L).companyId(1L).title("Java开发工程师")
.description("负责后端开发").requirements("3年Java经验")
.city("北京").employmentType("FULL_TIME")
.salaryMin(15000).salaryMax(25000)
.status("PUBLISHED")
.publishedAt(LocalDateTime.of(2026, 8, 1, 10, 0))
.createdAt(LocalDateTime.of(2026, 8, 1, 9, 0))
.build();
pubJob2 = Job.builder()
.id(2L).companyId(2L).title("前端开发工程师")
.description("负责前端开发").requirements("3年React经验")
.city("上海").employmentType("FULL_TIME")
.salaryMin(20000).salaryMax(35000)
.status("PUBLISHED")
.publishedAt(LocalDateTime.of(2026, 8, 2, 10, 0))
.createdAt(LocalDateTime.of(2026, 8, 2, 9, 0))
.build();
pubJobNullSalary = Job.builder()
.id(5L).companyId(1L).title("无薪资岗位")
.description("无薪资").requirements("无")
.city("深圳").employmentType("INTERN")
.status("PUBLISHED")
.createdAt(LocalDateTime.of(2026, 8, 3, 9, 0))
.build();
draftJob = Job.builder()
.id(3L).companyId(1L).title("Draft岗位")
.description("草稿").city("北京").employmentType("FULL_TIME")
.status("DRAFT").build();
closedJob = Job.builder()
.id(4L).companyId(1L).title("已关闭岗位")
.description("已关闭").city("北京").employmentType("FULL_TIME")
.salaryMin(10000).salaryMax(20000)
.status("CLOSED")
.createdAt(LocalDateTime.of(2026, 7, 1, 9, 0))
.build();
}
// ── 1. 关键词搜索 ───────────────────────────────────────────
@Test
void search_keyword_in_title_shouldReturn() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
"Java", null, null, null, null, null, null, 0, 20);
assertEquals(1, result.getTotalElements());
assertEquals("Java开发工程师", result.getContent().get(0).getTitle());
}
@Test
void search_keyword_in_description_shouldReturn() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
"后端", null, null, null, null, null, null, 0, 20);
assertEquals(1, result.getTotalElements());
}
@Test
void search_keyword_in_requirements_shouldReturn() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob2)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company2));
Page<JobSearchItem> result = jobService.searchJobs(
"React", null, null, null, null, null, null, 0, 20);
assertEquals(1, result.getTotalElements());
}
// ── 2. 城市筛选 ─────────────────────────────────────────────
@Test
void search_city_filter_shouldReturn() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob2)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company2));
Page<JobSearchItem> result = jobService.searchJobs(
null, "上海", null, null, null, null, null, 0, 20);
assertEquals(1, result.getTotalElements());
assertEquals("上海", result.getContent().get(0).getCity());
}
// ── 3. 用工类型筛选 ─────────────────────────────────────────
@Test
void search_employmentType_filter_shouldReturn() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1, pubJob2)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, "FULL_TIME", null, null, null, null, 0, 20);
assertEquals(2, result.getTotalElements());
}
// ── 4. 薪资范围筛选 ─────────────────────────────────────────
@Test
void search_salary_range_shouldReturn() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob2)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company2));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, 20000, 30000, null, null, 0, 20);
assertEquals(1, result.getTotalElements());
assertEquals("前端开发工程师", result.getContent().get(0).getTitle());
}
@Test
void search_salary_null_shouldNotMatch() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(Collections.emptyList()));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, 10000, 20000, null, null, 0, 20);
assertEquals(0, result.getTotalElements());
}
// ── 5. 多条件组合 ───────────────────────────────────────────
@Test
void search_combined_filters_shouldWork() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
"Java", "北京", "FULL_TIME", 10000, 30000, null, null, 0, 20);
assertEquals(1, result.getTotalElements());
}
// ── 6. 分页 ─────────────────────────────────────────────────
@Test
void search_pagination_shouldReturnCorrectStructure() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1), PageRequest.of(0, 5), 10));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, null, 0, 5);
assertEquals(10, result.getTotalElements());
assertEquals(2, result.getTotalPages());
assertEquals(0, result.getNumber());
assertEquals(5, result.getSize());
}
// ── 7. 排序 latest ──────────────────────────────────────────
@Test
void search_sort_latest_shouldUseCreatedAtDesc() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob2, pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, "latest", 0, 20);
assertEquals(2, result.getTotalElements());
assertEquals("前端开发工程师", result.getContent().get(0).getTitle());
}
// ── 8. 排序 salary(通过 spy 模拟内部方法) ─────────────────
@Test
void search_sort_salary_shouldUseSalaryMaxDesc() {
doReturn(new PageImpl<>(List.of(pubJob2, pubJob1)))
.when(jobService).findWithSalarySort(any(), anyInt(), anyInt());
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, "salary", 0, 20);
assertEquals(2, result.getTotalElements());
assertEquals("前端开发工程师", result.getContent().get(0).getTitle());
}
// ── 9. 排序 salary NULL 最后 ────────────────────────────────
@Test
void search_sort_salary_nullLast_shouldPutNullLast() {
// salaryMax=null 的岗位应在最后
doReturn(new PageImpl<>(List.of(pubJob2, pubJob1, pubJobNullSalary)))
.when(jobService).findWithSalarySort(any(), anyInt(), anyInt());
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, "salary", 0, 20);
assertEquals(3, result.getTotalElements());
// 前两个有薪资,第三个无薪资
assertNotNull(result.getContent().get(0).getSalaryMax());
assertNotNull(result.getContent().get(1).getSalaryMax());
assertNull(result.getContent().get(2).getSalaryMax());
}
@Test
void search_sort_salary_nullSalaryMax_shouldBeLast() {
// 只有一个 null 薪资岗位的场景
doReturn(new PageImpl<>(List.of(pubJobNullSalary)))
.when(jobService).findWithSalarySort(any(), anyInt(), anyInt());
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, "salary", 0, 20);
assertEquals(1, result.getTotalElements());
assertNull(result.getContent().get(0).getSalaryMax());
}
// ── 10. 排序 relevance 兜底 ──────────────────────────────────
@Test
void search_sort_relevance_shouldFallbackToLatest() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob2, pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, "relevance", 0, 20);
assertEquals(2, result.getTotalElements());
}
// ── 11. 空结果 ──────────────────────────────────────────────
@Test
void search_empty_result_shouldReturnEmptyPage() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(Collections.emptyList()));
Page<JobSearchItem> result = jobService.searchJobs(
"不存在的关键词", null, null, null, null, null, null, 0, 20);
assertEquals(0, result.getTotalElements());
assertTrue(result.getContent().isEmpty());
}
// ── 12. 仅 PUBLISHED 可见 ───────────────────────────────────
@Test
void search_DRAFT_shouldNotAppear() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, null, 0, 20);
assertEquals(1, result.getTotalElements());
assertEquals("PUBLISHED", result.getContent().get(0).getStatus());
}
// ── 13. CLOSED 搜索隐藏 ─────────────────────────────────────
@Test
void search_CLOSED_shouldNotAppear() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, null, 0, 20);
assertFalse(result.getContent().stream().anyMatch(j -> "CLOSED".equals(j.getStatus())));
}
// ── 14. 边界 size ───────────────────────────────────────────
@Test
void search_size_1_shouldSucceed() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1), PageRequest.of(0, 1), 10));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, null, 0, 1);
assertEquals(1, result.getSize());
}
@Test
void search_size_50_shouldSucceed() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1), PageRequest.of(0, 50), 1));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, null, 0, 50);
assertEquals(50, result.getSize());
}
// ── 15. 边界异常 ────────────────────────────────────────────
@Test
void search_size_0_shouldThrow() {
BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.searchJobs(null, null, null, null, null, null, null, 0, 0));
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt());
}
@Test
void search_size_51_shouldThrow() {
BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.searchJobs(null, null, null, null, null, null, null, 0, 51));
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt());
}
@Test
void search_page_negative_shouldThrow() {
BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.searchJobs(null, null, null, null, null, null, null, -1, 20));
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt());
}
@Test
void search_salaryMin_greater_than_salaryMax_shouldThrow() {
BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.searchJobs(null, null, null, 20000, 10000, null, null, 0, 20));
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt());
}
@Test
void search_invalid_sort_shouldThrow() {
BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.searchJobs(null, null, null, null, null, null, "invalid", 0, 20));
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt());
}
// ── 16. SQL 注入防护 ────────────────────────────────────────
@Test
void search_sql_injection_shouldNotAffect() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(Collections.emptyList()));
assertDoesNotThrow(() -> jobService.searchJobs(
"'; DROP TABLE jobs; --", null, null, null, null, null, null, 0, 20));
}
@Test
void search_sql_injection_union_shouldNotAffect() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(Collections.emptyList()));
assertDoesNotThrow(() -> jobService.searchJobs(
"' UNION SELECT * FROM users --", null, null, null, null, null, null, 0, 20));
}
// ── 17. 公开字段脱敏 ────────────────────────────────────────
@Test
void search_public_dto_shouldNotLeakInternalFields() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, null, 0, 20);
JobSearchItem item = result.getContent().get(0);
assertNotNull(item.getCompany());
assertEquals(1L, item.getCompany().getId());
assertEquals("阿里巴巴", item.getCompany().getName());
assertEquals("https://logo1.png", item.getCompany().getLogoUrl());
assertTrue(item.getCompany().getVerified());
}
@Test
void search_company_without_record_shouldNotNPE() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of());
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, null, null, 0, 20);
JobSearchItem item = result.getContent().get(0);
assertNull(item.getCompany());
}
// ── 18. 筛选选项(聚合查询) ────────────────────────────────
@Test
void filters_shouldUseAggregationQueries() {
when(jobRepository.findDistinctCitiesPublished()).thenReturn(List.of("北京", "上海"));
when(jobRepository.findDistinctEmploymentTypesPublished()).thenReturn(List.of("FULL_TIME"));
when(jobRepository.findMinSalaryPublished()).thenReturn(15000);
when(jobRepository.findMaxSalaryPublished()).thenReturn(35000);
Map<String, Object> options = jobService.getFilterOptions();
@SuppressWarnings("unchecked")
List<String> cities = (List<String>) options.get("cities");
@SuppressWarnings("unchecked")
List<String> types = (List<String>) options.get("employmentTypes");
@SuppressWarnings("unchecked")
Map<String, Object> salaryRange = (Map<String, Object>) options.get("salaryRange");
assertTrue(cities.contains("北京"));
assertTrue(cities.contains("上海"));
assertEquals(2, cities.size());
assertTrue(types.contains("FULL_TIME"));
assertEquals(15000, salaryRange.get("min"));
assertEquals(35000, salaryRange.get("max"));
// 验证没有调用 findAll(Specification)
verify(jobRepository, never()).findAll(any(Specification.class));
}
@Test
void filters_empty_shouldReturnEmpty() {
when(jobRepository.findDistinctCitiesPublished()).thenReturn(Collections.emptyList());
when(jobRepository.findDistinctEmploymentTypesPublished()).thenReturn(Collections.emptyList());
when(jobRepository.findMinSalaryPublished()).thenReturn(null);
when(jobRepository.findMaxSalaryPublished()).thenReturn(null);
Map<String, Object> options = jobService.getFilterOptions();
@SuppressWarnings("unchecked")
List<String> cities = (List<String>) options.get("cities");
@SuppressWarnings("unchecked")
List<String> types = (List<String>) options.get("employmentTypes");
@SuppressWarnings("unchecked")
Map<String, Object> salaryRange = (Map<String, Object>) options.get("salaryRange");
assertTrue(cities.isEmpty());
assertTrue(types.isEmpty());
assertNull(salaryRange.get("min"));
assertNull(salaryRange.get("max"));
verify(jobRepository, never()).findAll(any(Specification.class));
}
// ── 19. 旧接口回归 ─────────────────────────────────────────
@Test
void listPublishedJobs_regression_shouldStillWork() {
when(jobRepository.findPublishedJobs(
isNull(), isNull(), isNull(), isNull(), isNull(), any()))
.thenReturn(new PageImpl<>(List.of(pubJob1)));
Page<Map<String, Object>> result = jobService.listPublishedJobs(
null, null, null, null, null, 0, 20);
assertNotNull(result);
assertEquals(1, result.getTotalElements());
}
@Test
void listPublishedJobs_salaryMinGreaterThanMax_shouldStillThrow() {
BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.listPublishedJobs(null, null, null, 15000, 8000, 0, 20));
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt());
}
@Test
void getPublicJob_regression_shouldStillWork() {
when(jobRepository.findById(1L)).thenReturn(Optional.of(pubJob1));
Map<String, Object> result = jobService.getPublicJob(1L);
assertNotNull(result);
assertEquals(1L, result.get("id"));
}
@Test
void getPublicJob_CLOSED_shouldStillBeVisible() {
when(jobRepository.findById(4L)).thenReturn(Optional.of(closedJob));
Map<String, Object> result = jobService.getPublicJob(4L);
assertNotNull(result);
assertEquals("CLOSED", result.get("status"));
}
@Test
void getPublicJob_DRAFT_shouldStillThrow() {
when(jobRepository.findById(3L)).thenReturn(Optional.of(draftJob));
BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.getPublicJob(3L));
assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt());
}
// ── 20. companyId 筛选 ──────────────────────────────────────
@Test
void search_companyId_filter_shouldReturn() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1));
Page<JobSearchItem> result = jobService.searchJobs(
null, null, null, null, null, 1L, null, 0, 20);
assertEquals(1, result.getTotalElements());
assertEquals(1L, result.getContent().get(0).getCompany().getId());
}
// ── 21. 分页稳定性 ──────────────────────────────────────────
@Test
void search_pagination_stability_shouldReturnSameOrder() {
when(jobRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(pubJob2, pubJob1)));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2));
Page<JobSearchItem> result1 = jobService.searchJobs(
null, null, null, null, null, null, "latest", 0, 20);
Page<JobSearchItem> result2 = jobService.searchJobs(
null, null, null, null, null, null, "latest", 0, 20);
assertEquals(result1.getContent().get(0).getId(), result2.getContent().get(0).getId());
assertEquals(result1.getContent().get(1).getId(), result2.getContent().get(1).getId());
}
}