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)。 * *

本类把 {@code JobRepository.findAll(Specification, Pageable)} 与 * {@code CompanyRepository} 整体 Mock 掉,因此它只能证明参数校验、分页对象、 * 排序参数传递和 DTO 组装的编排逻辑;**不能证明 Criteria 谓词、SQL 过滤、 * 排序与分页在真实数据库上的行为**。

* *

真实查询覆盖见 Q01 补充的 * {@link JobSearchSpecificationIntegrationTest}(H2 真实 Repository)、 * {@link JobSearchServiceIntegrationTest}(H2 真实 Service/EntityManager)、 * {@link JobSearchSpecificationMySqlIntegrationTest}(真实 MySQL 8)。

*/ @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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 options = jobService.getFilterOptions(); @SuppressWarnings("unchecked") List cities = (List) options.get("cities"); @SuppressWarnings("unchecked") List types = (List) options.get("employmentTypes"); @SuppressWarnings("unchecked") Map salaryRange = (Map) 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 options = jobService.getFilterOptions(); @SuppressWarnings("unchecked") List cities = (List) options.get("cities"); @SuppressWarnings("unchecked") List types = (List) options.get("employmentTypes"); @SuppressWarnings("unchecked") Map salaryRange = (Map) 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> 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 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 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 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 result1 = jobService.searchJobs( null, null, null, null, null, null, "latest", 0, 20); Page 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()); } }