package com.mydreamjob.careerprogress; 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.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.careerprogress.dto.CareerProgressResponse; 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.competition.CompetitionRankingCalculator; import com.mydreamjob.interview.Interview; import com.mydreamjob.interview.InterviewRepository; import com.mydreamjob.interviewscore.InterviewScore; import com.mydreamjob.interviewscore.InterviewScoreRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobRepository; import com.mydreamjob.notification.NotificationRepository; import com.mydreamjob.offer.Offer; import com.mydreamjob.offer.OfferRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; 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.*; /** * 统一求职进度聚合服务单测(V34,编排 + 脱敏 + 聚合口径 + 无 N+1)。 * *

排名/排序/百分位由真实 {@link CompetitionRankingCalculator}(配合 mock 的 * 快照仓库与综合分计算器)承载,与 V32 共用同一组件,保证口径一致。本类聚焦断言 * 权限、参数、投递分页、todo 计数、nextInterview、评分摘要、Offer 优先级、 * competition 摘要、通知仅计数与批量查询(无 N+1)。

*/ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) class CareerProgressServiceTest { private static final Long ME = 100L; private static final Long JOB = 9L; private static final Long COMPANY = 1L; private static final String PERIOD = yearMonth(LocalDate.now()); @Mock private ApplicationRepository applicationRepository; @Mock private InterviewRepository interviewRepository; @Mock private InterviewScoreRepository interviewScoreRepository; @Mock private OfferRepository offerRepository; @Mock private NotificationRepository notificationRepository; @Mock private JobRepository jobRepository; @Mock private CompanyRepository companyRepository; @Mock private UserRepository userRepository; @Mock private CandidateDimensionScoreRepository dimensionScoreRepository; @Mock private CandidateScoringCalculator scoringCalculator; private CompetitionRankingCalculator calculator; private CareerProgressService service; private Map overallByStudent = Map.of(); @BeforeEach void setUp() { calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator)); service = new CareerProgressService(applicationRepository, interviewRepository, interviewScoreRepository, offerRepository, notificationRepository, jobRepository, companyRepository, userRepository, calculator, scoringCalculator); when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> { List snaps = inv.getArgument(0); if (snaps == null || snaps.isEmpty()) return null; Long sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId(); return overallByStudent.get(sid); }); } // ── 权限与参数 ──────────────────────────────────────────── @Test void companyRole_throwsForbidden() { mockUser(ME, User.Role.COMPANY); BusinessException ex = assertThrows(BusinessException.class, () -> service.getCareerProgress(ME, 0, 20, null)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); } @Test void adminRole_throwsForbidden() { mockUser(ME, User.Role.ADMIN); BusinessException ex = assertThrows(BusinessException.class, () -> service.getCareerProgress(ME, 0, 20, null)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); } @Test void negativePage_throwsBadRequest() { mockUser(ME, User.Role.STUDENT); BusinessException ex = assertThrows(BusinessException.class, () -> service.getCareerProgress(ME, -1, 20, null)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); } @Test void sizeOutOfRange_throwsBadRequest() { mockUser(ME, User.Role.STUDENT); assertThrows(BusinessException.class, () -> service.getCareerProgress(ME, 0, 0, null)); assertThrows(BusinessException.class, () -> service.getCareerProgress(ME, 0, 51, null)); } @Test void invalidStatus_throwsBadRequest() { mockUser(ME, User.Role.STUDENT); BusinessException ex = assertThrows(BusinessException.class, () -> service.getCareerProgress(ME, 0, 20, "NONEXISTENT")); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); } // ── 空数据 ────────────────────────────────────────────── @Test void noApplications_returnsEmptyPage_andZeroTodo() { mockUser(ME, User.Role.STUDENT); when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) .thenReturn(List.of()); when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) .thenReturn(List.of()); when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); assertEquals(0L, resp.getApplications().getItems().size()); assertEquals(0L, resp.getApplications().getTotal()); assertEquals(0L, resp.getTodo().getPendingInterviewResponseCount()); assertEquals(0L, resp.getTodo().getUpcomingConfirmedInterviewCount()); assertEquals(0L, resp.getTodo().getPendingOfferResponseCount()); assertEquals(0L, resp.getTodo().getUnreadNotificationCount()); assertEquals(0L, resp.getTodo().getUnreadReportDecisionCount()); } // ── 聚合口径(完整 happy path)──────────────────────────── @Test void fullAggregation_returnsCorrectSummaries() { mockUser(ME, User.Role.STUDENT); Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00"); when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) .thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1)); when(jobRepository.findAllById(anyCollection())) .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("Java 后端工程师") .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build())); when(companyRepository.findAllById(anyCollection())) .thenReturn(List.of(Company.builder().id(COMPANY).ownerUserId(999L).name("示例科技").build())); // 面试:两场 SCHEDULED(取最早为 nextInterview)+ 一场 CONFIRMED(未来) Interview later = interview(2L, app.getId(), "SCHEDULED", "2026-08-30T14:00:00"); Interview earlier = interview(1L, app.getId(), "SCHEDULED", "2026-08-28T10:00:00"); Interview confirmed = interview(3L, app.getId(), "CONFIRMED", LocalDateTime.now().plusDays(3).toString()); when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) .thenReturn(List.of(earlier, later, confirmed)); // 已提交评分:两条,取 submittedAt 最新 InterviewScore s1 = score(22L, app.getId(), "2026-08-25T16:00:00", new BigDecimal("3.60")); InterviewScore s2 = score(23L, app.getId(), "2026-08-24T16:00:00", new BigDecimal("3.00")); when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( anyLong(), anyCollection(), anyString())).thenReturn(List.of(s1, s2)); // Offer:一条 SENT 有效 + 一条 DRAFT(不应返回 DRAFT) Offer sent = offer(14L, app.getId(), "SENT", "2026-09-05T23:59:59"); when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) .thenReturn(List.of(sent)); // 竞争力:同岗位 cohort 含本人 + 另一有分者 Application other = app(2L, 200L, JOB, "SUBMITTED", "2026-08-19T10:00:00"); when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) .thenReturn(List.of(other, app)); overallByStudent = Map.of(ME, new BigDecimal("77.50"), 200L, new BigDecimal("90.00")); when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())) .thenReturn(List.of(dim(ME, "77.50"), dim(200L, "90.00"))); when(notificationRepository.countUnreadByUserId(ME)).thenReturn(5L); when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(2L); CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); // todo assertEquals(2L, resp.getTodo().getPendingInterviewResponseCount()); assertEquals(1L, resp.getTodo().getUpcomingConfirmedInterviewCount()); assertEquals(1L, resp.getTodo().getPendingOfferResponseCount()); assertEquals(5L, resp.getTodo().getUnreadNotificationCount()); assertEquals(2L, resp.getTodo().getUnreadReportDecisionCount()); // item assertEquals(1L, resp.getApplications().getItems().size()); CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); assertEquals(1L, item.getApplicationId()); assertEquals("SUBMITTED", item.getApplicationStatus()); assertEquals("Java 后端工程师", item.getJob().getTitle()); assertEquals("示例科技", item.getJob().getCompanyName()); assertEquals("杭州", item.getJob().getCity()); assertEquals("FULL_TIME", item.getJob().getEmploymentType()); assertEquals("PUBLISHED", item.getJob().getJobStatus()); // nextInterview:取 startAt 最早 1L assertEquals(1L, item.getNextInterview().getInterviewId()); // latest score:22(submittedAt 最新) assertEquals(22L, item.getLatestSubmittedInterviewScore().getInterviewId()); assertEquals(new BigDecimal("3.60"), item.getLatestSubmittedInterviewScore().getOverallScore()); assertEquals("HIRE", item.getLatestSubmittedInterviewScore().getRecommendation()); // offer assertEquals(14L, item.getOffer().getOfferId()); assertEquals("SENT", item.getOffer().getStatus()); // competition:cohort=2(other 90.00、me 77.50),me 排名 2; // percentile = 严格低于 me 的人数(0) / (有分人数-1=1) * 100 = 0.00 assertTrue(item.getCompetition().isEligible()); assertEquals(2, item.getCompetition().getCohortSize()); assertEquals(2, item.getCompetition().getRank()); assertEquals(new BigDecimal("77.50"), item.getCompetition().getOverallScore()); assertEquals(new BigDecimal("0.00"), item.getCompetition().getPercentile()); assertEquals(PERIOD, item.getCompetition().getMySnapshotPeriod()); } // ── DRAFT 不泄露 ──────────────────────────────────────── @Test void draftScore_notReturned_andDraftOffer_notReturned() { mockUser(ME, User.Role.STUDENT); Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00"); when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) .thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1)); when(jobRepository.findAllById(anyCollection())) .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d") .city("c").employmentType("FULL_TIME").status("PUBLISHED").build())); when(companyRepository.findAllById(anyCollection())).thenReturn(List.of()); when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) .thenReturn(List.of()); // 只有 DRAFT 评分 → 不应出现在 latestSubmittedInterviewScore InterviewScore draft = score(1L, app.getId(), null, null); draft.setStatus(InterviewScore.STATUS_DRAFT); when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( anyLong(), anyCollection(), anyString())).thenReturn(List.of()); // 只有 DRAFT offer → offer 为 null when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) .thenReturn(List.of()); when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) .thenReturn(List.of(app)); when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); assertNull(item.getLatestSubmittedInterviewScore()); assertNull(item.getOffer()); } // ── WITHDRAWN/REJECTED 保留历史但 eligible=false ───────── @Test void withdrawnApplication_eligibleFalse_andNoCompetitionFields() { mockUser(ME, User.Role.STUDENT); Application withdrawn = app(1L, ME, JOB, "WITHDRAWN", "2026-08-20T10:00:00"); when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) .thenReturn(new PageImpl<>(List.of(withdrawn), PageRequest.of(0, 20), 1)); when(jobRepository.findAllById(anyCollection())) .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d") .city("c").employmentType("FULL_TIME").status("PUBLISHED").build())); when(companyRepository.findAllById(anyCollection())).thenReturn(List.of()); when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) .thenReturn(List.of()); when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( anyLong(), anyCollection(), anyString())).thenReturn(List.of()); when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) .thenReturn(List.of()); when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); // 保留历史 assertEquals(1L, item.getApplicationId()); assertEquals("WITHDRAWN", item.getApplicationStatus()); // competition 只返回 eligible=false,不返回其它排名字段 assertFalse(item.getCompetition().isEligible()); assertNull(item.getCompetition().getRank()); assertNull(item.getCompetition().getCohortSize()); assertNull(item.getCompetition().getOverallScore()); assertNull(item.getCompetition().getPercentile()); assertNull(item.getCompetition().getStatus()); assertNull(item.getCompetition().getMySnapshotPeriod()); assertNull(item.getCompetition().getRulesVersion()); } // ── 有有效投递但无快照 → competition 分数/排名/百分位为 null ─ @Test void validApplication_noSnapshot_competitionScoresNull_cohortSizeReturned() { mockUser(ME, User.Role.STUDENT); Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00"); when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) .thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1)); when(jobRepository.findAllById(anyCollection())) .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d") .city("c").employmentType("FULL_TIME").status("PUBLISHED").build())); when(companyRepository.findAllById(anyCollection())).thenReturn(List.of()); when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) .thenReturn(List.of()); when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( anyLong(), anyCollection(), anyString())).thenReturn(List.of()); when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) .thenReturn(List.of()); when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) .thenReturn(List.of(app)); when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); assertTrue(item.getCompetition().isEligible()); assertNull(item.getCompetition().getRank()); assertNull(item.getCompetition().getOverallScore()); assertNull(item.getCompetition().getPercentile()); assertNull(item.getCompetition().getMySnapshotPeriod()); assertEquals(1, item.getCompetition().getCohortSize()); } // ── 无 N+1:所有批量查询各执行一次 ──────────────────────── @Test void multipleApplications_noNPlusOne_batchQueriesSingleCallEach() { mockUser(ME, User.Role.STUDENT); Application a1 = app(1L, ME, 9L, "SUBMITTED", "2026-08-20T10:00:00"); Application a2 = app(2L, ME, 10L, "INTERVIEW", "2026-08-19T10:00:00"); Application a3 = app(3L, ME, 11L, "VIEWED", "2026-08-18T10:00:00"); when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) .thenReturn(new PageImpl<>(List.of(a1, a2, a3), PageRequest.of(0, 20), 3)); when(jobRepository.findAllById(anyCollection())).thenReturn(List.of( Job.builder().id(9L).companyId(COMPANY).title("t1").description("d").city("c") .employmentType("FULL_TIME").status("PUBLISHED").build(), Job.builder().id(10L).companyId(COMPANY).title("t2").description("d").city("c") .employmentType("FULL_TIME").status("PUBLISHED").build(), Job.builder().id(11L).companyId(COMPANY).title("t3").description("d").city("c") .employmentType("FULL_TIME").status("PUBLISHED").build())); when(companyRepository.findAllById(anyCollection())) .thenReturn(List.of(Company.builder().id(COMPANY).ownerUserId(9L).name("c").build())); when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) .thenReturn(List.of()); when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( anyLong(), anyCollection(), anyString())).thenReturn(List.of()); when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) .thenReturn(List.of()); when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) .thenReturn(List.of(a1, a2, a3)); when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); service.getCareerProgress(ME, 0, 20, null); verify(jobRepository, times(1)).findAllById(anyCollection()); verify(companyRepository, times(1)).findAllById(anyCollection()); verify(interviewRepository, times(1)).findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()); verify(interviewScoreRepository, times(1)).findByStudentUserIdAndApplicationIdInAndStatus(anyLong(), anyCollection(), anyString()); verify(offerRepository, times(1)).findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()); verify(applicationRepository, times(1)).findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()); verify(dimensionScoreRepository, times(1)).findUpToPeriod(anyCollection(), anyString()); verify(notificationRepository, times(1)).countUnreadByUserId(ME); verify(notificationRepository, times(1)).countUnreadByUserIdAndType(ME, "REPORT_DECISION"); // 未按投递/面试/Offer 循环查询 verify(interviewRepository, never()).findByApplicationIdOrderByCreatedAtDesc(anyLong()); } // ── 辅助 ──────────────────────────────────────────────── private void mockUser(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 Application app(Long id, Long sid, Long jobId, String status, String created) { return Application.builder().id(id).jobId(jobId).studentUserId(sid).status(status) .createdAt(LocalDateTime.parse(created)).updatedAt(LocalDateTime.parse(created)).build(); } private Interview interview(Long id, Long appId, String status, String startAt) { return Interview.builder().id(id).applicationId(appId).companyUserId(999L).studentUserId(ME) .roundNo(1).title("技术面试").interviewType("ONLINE") .startAt(LocalDateTime.parse(startAt)).endAt(LocalDateTime.parse(startAt).plusHours(1)) .status(status).build(); } private InterviewScore score(Long interviewId, Long appId, String submittedAt, BigDecimal overall) { LocalDateTime submitted = submittedAt == null ? null : LocalDateTime.parse(submittedAt); return InterviewScore.builder().id(interviewId).interviewId(interviewId).applicationId(appId) .companyUserId(999L).studentUserId(ME).status(InterviewScore.STATUS_SUBMITTED) .overallScore(overall).recommendation("HIRE").submittedAt(submitted).version(0L).build(); } private Offer offer(Long id, Long appId, String status, String expireAt) { return Offer.builder().id(id).applicationId(appId).jobId(JOB).companyId(COMPANY).candidateId(ME) .title("Java 后端工程师").description("d").expireAt(LocalDateTime.parse(expireAt)) .createdBy(999L).status(status).version(0L) .createdAt(LocalDateTime.of(2026, 8, 25, 12, 0)).build(); } private CandidateDimensionScore dim(Long sid, String score) { return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(PERIOD) .score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50")) .rulesVersion("candidate-scoring-v0.1").build(); } private static String yearMonth(LocalDate d) { return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); } }