package com.mydreamjob.competition; import com.mydreamjob.application.Application; import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.competition.dto.CompetitionRankingResponse; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** * 同岗位竞争力排名服务单测(V32,编排 + 响应组装 + 脱敏)。 * *

排序/百分位/快照装载等算法已下沉到 {@link CompetitionRankingCalculator} * (由 CompetitionRankingCalculatorTest 覆盖),本类用 mock 计算器聚焦断言 * 权限、参数、岗位状态、cohort 委托,以及 Top5 脱敏与维度/缺口的组装。

*/ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) class CompetitionRankingServiceTest { private static final Long ME = 100L; private static final Long JOB = 9L; @Mock private UserRepository userRepository; @Mock private JobService jobService; @Mock private ApplicationRepository applicationRepository; @Mock private CompetitionRankingCalculator rankingCalculator; @Mock private CandidateScoringCalculator candidateScoringCalculator; @InjectMocks private CompetitionRankingService service; // ── 权限与参数校验 ───────────────────────────────────────── @Test void jobIdZeroOrNull_returnsBadRequest() { BusinessException ex = assertThrows(BusinessException.class, () -> service.getRanking(ME, 0L)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); } @Test void nonStudent_returnsForbidden() { mockRole(ME, User.Role.COMPANY); BusinessException ex = assertThrows(BusinessException.class, () -> call()); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); } @Test void jobNotFound_returnsJobNotFound() { mockStudent(ME); when(jobService.getJob(JOB)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); BusinessException ex = assertThrows(BusinessException.class, () -> call()); assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCode()); } @Test void nonPublishedJob_returnsJobNotFound() { mockStudent(ME); Job draft = Job.builder().id(JOB).status("DRAFT").build(); when(jobService.getJob(JOB)).thenReturn(draft); BusinessException ex = assertThrows(BusinessException.class, () -> call()); assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCode()); } @Test void notAppliedStudent_returnsForbidden() { mockPublishedJob(); when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(false); BusinessException ex = assertThrows(BusinessException.class, () -> call()); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); } @Test void effectiveStatus_returns200_andAssemblesResponse() { mockPublishedJob(); when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) .thenReturn(List.of(app(ME, "2026-01-01T10:00:00"))); when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of()); when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME))) .thenReturn(cr(1, 1, new BigDecimal("85.00"), null, "OK", "2026-07", List.of())); CompetitionRankingResponse resp = call(); assertEquals(JOB, resp.getJobId()); assertEquals(1, resp.getCohortSize()); assertEquals(1, resp.getRank()); assertEquals(new BigDecimal("85.00"), resp.getOverallScore()); assertNull(resp.getPercentile()); assertEquals("OK", resp.getStatus()); assertEquals("2026-07", resp.getMySnapshotPeriod()); } // ── Top5 脱敏与上限、维度/缺口组装 ───────────────────────── @Test void topCandidates_cappedAt5_andNoIdentityFields() { mockPublishedJob(); when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) .thenReturn(List.of(app(ME, "2026-01-01T10:00:00"))); when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of()); when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME))) .thenReturn(cr(7, 3, new BigDecimal("77.00"), null, "PARTIAL", "2026-07", List.of())); CompetitionRankingResponse resp = call(); assertEquals(5, resp.getTopCandidates().size()); resp.getTopCandidates().forEach(t -> { assertNotNull(t.getRank()); }); } @Test void dimensionsAndGaps_builtFromSnapshots() { mockPublishedJob(); when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) .thenReturn(List.of(app(ME, "2026-01-01T10:00:00"))); when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of()); when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME))) .thenReturn(cr(1, 1, new BigDecimal("85.00"), null, "OK", "2026-07", List.of(dim(ME, "EDUCATION", "OK", "85.00"), dim(ME, "ABILITY", "PENDING", null)))); CompetitionRankingResponse resp = call(); assertEquals(2, resp.getDimensions().size()); assertFalse(resp.getGaps().isEmpty()); } // ── 辅助 ──────────────────────────────────────────────── private CompetitionRankingResponse call() { return service.getRanking(ME, JOB); } private void mockStudent(Long uid) { User u = User.builder().id(uid).phone("1").role(User.Role.STUDENT).build(); u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); when(userRepository.findById(uid)).thenReturn(Optional.of(u)); } private void mockRole(Long uid, User.Role role) { User u = User.builder().id(uid).phone("1").role(role).build(); u.setRoles(new HashSet<>(Set.of(role))); when(userRepository.findById(uid)).thenReturn(Optional.of(u)); } private void mockPublishedJob() { mockStudent(ME); Job j = Job.builder().id(JOB).status("PUBLISHED").build(); when(jobService.getJob(JOB)).thenReturn(j); } private CompetitionRankingCalculator.CohortRanking cr(int cohortSize, Integer rank, BigDecimal overall, BigDecimal percentile, String status, String period, List snaps) { List rows = new ArrayList<>(); for (int i = 0; i < cohortSize; i++) { BigDecimal s = (i < cohortSize) ? new BigDecimal("90.00") : null; rows.add(new CompetitionRankingCalculator.Row(ME + i, null, s, period)); } CompetitionRankingCalculator.Row me = new CompetitionRankingCalculator.Row(ME, null, overall, period); return new CompetitionRankingCalculator.CohortRanking(cohortSize, rows, me, rank, overall, percentile, status, period, snaps); } private Application app(Long sid, String created) { return Application.builder().studentUserId(sid).status("SUBMITTED") .createdAt(java.time.LocalDateTime.parse(created)).build(); } private CandidateDimensionScore dim(Long sid, String dimension, String status, String score) { return CandidateDimensionScore.builder().studentUserId(sid).dimension(dimension).period("2026-07") .score(score == null ? null : new BigDecimal(score)).status(status) .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1").build(); } }