package com.mydreamjob.application; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest; import com.mydreamjob.application.dto.CandidateDetailView; import com.mydreamjob.application.dto.CandidateListResponse; import com.mydreamjob.profile.StudentProfile; import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; 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.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class ApplicationServiceTest { @Mock private ApplicationRepository applicationRepository; @Mock private com.mydreamjob.job.JobService jobService; @Mock private com.mydreamjob.auth.UserRepository userRepository; @Mock private com.mydreamjob.company.CompanyRepository companyRepository; @Mock private AuditService auditService; @Mock private ApplicationStatusHistoryRepository historyRepository; @Mock private com.mydreamjob.notification.NotificationService notificationService; @Mock private EntityManager em; @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; @InjectMocks private ApplicationService applicationService; private static final Long STUDENT_ID = 100L; private static final Long COMPANY_ID = 1L; private static final Long JOB_ID = 10L; private static final Long APP_ID = 200L; private com.mydreamjob.job.Job publishedJob; private Application application; @BeforeEach void setUp() { ReflectionTestUtils.setField(applicationService, "em", em); publishedJob = com.mydreamjob.job.Job.builder().id(JOB_ID).companyId(COMPANY_ID).title("Java").status("PUBLISHED").build(); application = Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).status("SUBMITTED").build(); } private void mockStudentRole() { com.mydreamjob.auth.User user = com.mydreamjob.auth.User.builder().id(STUDENT_ID).phone("13800000001") .role(com.mydreamjob.auth.User.Role.STUDENT).active(true).verified(true).build(); user.setRoles(new HashSet<>(Set.of(com.mydreamjob.auth.User.Role.STUDENT))); when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(user)); } private void mockStudentRoleWithRoles() { com.mydreamjob.auth.User user = com.mydreamjob.auth.User.builder().id(STUDENT_ID).phone("TEST-STUDENT") .role(com.mydreamjob.auth.User.Role.STUDENT).active(true).verified(true).build(); user.setRoles(new HashSet<>(Set.of(com.mydreamjob.auth.User.Role.STUDENT))); when(userRepository.findByIdWithRoles(STUDENT_ID)).thenReturn(Optional.of(user)); } // ── 撤回 ───────────────────────────────────────────────── @Test void withdraw_submitted_shouldSucceed() { mockStudentRole(); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); Application result = applicationService.withdraw(STUDENT_ID, APP_ID); assertEquals("WITHDRAWN", result.getStatus()); } @Test void withdraw_wrongStudent_shouldReturn403() { mockStudentRole(); application.setStudentUserId(999L); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); BusinessException ex = assertThrows(BusinessException.class, () -> applicationService.withdraw(STUDENT_ID, APP_ID)); assertEquals(ErrorCode.APPLICATION_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); } @Test void withdraw_alreadyWithdrawn_shouldReturn409() { mockStudentRole(); application.setStatus("WITHDRAWN"); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); BusinessException ex = assertThrows(BusinessException.class, () -> applicationService.withdraw(STUDENT_ID, APP_ID)); assertEquals(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); } @Test void withdraw_rejected_shouldReturn409() { mockStudentRole(); application.setStatus("REJECTED"); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); BusinessException ex = assertThrows(BusinessException.class, () -> applicationService.withdraw(STUDENT_ID, APP_ID)); assertEquals(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); } // ── 时间线 ─────────────────────────────────────────────── @Test void getTimeline_asStudent_shouldSucceed() { mockStudentRoleWithRoles(); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(historyRepository.findByApplicationIdOrderByCreatedAtAsc(APP_ID)).thenReturn(List.of()); var result = applicationService.getTimeline(APP_ID, STUDENT_ID); assertNotNull(result); } @Test void getTimeline_wrongUser_shouldReturn403() { com.mydreamjob.auth.User other = com.mydreamjob.auth.User.builder().id(999L).phone("13800000999") .role(com.mydreamjob.auth.User.Role.STUDENT).active(true).verified(true).build(); other.setRoles(new HashSet<>(Set.of(com.mydreamjob.auth.User.Role.STUDENT))); when(userRepository.findByIdWithRoles(999L)).thenReturn(Optional.of(other)); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); BusinessException ex = assertThrows(BusinessException.class, () -> applicationService.getTimeline(APP_ID, 999L)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 状态历史记录 ───────────────────────────────────────── @Test void apply_shouldRecordHistory() { mockStudentRole(); ApplicationCreateRequest req = new ApplicationCreateRequest(); req.setJobId(JOB_ID); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(applicationRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false); when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); applicationService.apply(STUDENT_ID, req); verify(historyRepository, times(1)).save(any()); } // ── 企业状态更新记录历史 ───────────────────────────────── @Test void updateStatus_shouldRecordHistory() { ApplicationStatusRequest req = new ApplicationStatusRequest(); req.setStatus("VIEWED"); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); applicationService.updateStatus(COMPANY_ID, APP_ID, req); verify(historyRepository, times(1)).save(any()); } // ── 原有测试兼容 ───────────────────────────────────────── @Test void apply_shouldSucceed() { mockStudentRole(); ApplicationCreateRequest req = new ApplicationCreateRequest(); req.setJobId(JOB_ID); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(applicationRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false); when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); Application result = applicationService.apply(STUDENT_ID, req); assertEquals("SUBMITTED", result.getStatus()); } // ── 企业候选人列表 ───────────────────────────────────────── @SuppressWarnings("unchecked") private TypedQuery mockCountQuery(long total) { TypedQuery q = mock(TypedQuery.class); when(q.getSingleResult()).thenReturn(total); return q; } @SuppressWarnings("unchecked") private TypedQuery mockDataQuery(Object[]... rows) { TypedQuery q = mock(TypedQuery.class); when(q.getResultList()).thenReturn(List.of(rows)); return q; } private Object[] buildRow(Long appId, Long jobId, String jobTitle, String status, String name, String school, String edu, Short gradYear, String avatarUrl) { return new Object[]{appId, jobId, jobTitle, status, name, school, edu, gradYear, avatarUrl, java.time.LocalDateTime.now(), java.time.LocalDateTime.now()}; } @Test void listCompanyCandidates_shouldReturnPaginatedResults() { Object[] row = buildRow(200L, JOB_ID, "Java工程师", "SUBMITTED", "张三", "清华大学", "本科", (short)2026, "https://img/1.png"); TypedQuery countQ = mockCountQuery(1L); TypedQuery dataQ = mockDataQuery(row); when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); CandidateListResponse resp = applicationService.listCompanyCandidates( COMPANY_ID, null, null, null, null, null, 0, 20); assertEquals(1, resp.getItems().size()); assertEquals("张三", resp.getItems().get(0).getCandidateName()); assertEquals(1, resp.getTotal()); } @Test void listCompanyCandidates_shouldFilterByJobId() { Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", "张三", "清华", "本科", null, null); TypedQuery countQ = mockCountQuery(1L); TypedQuery dataQ = mockDataQuery(row); when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); CandidateListResponse resp = applicationService.listCompanyCandidates( COMPANY_ID, JOB_ID, null, null, null, null, 0, 20); assertEquals(1, resp.getItems().size()); assertEquals(JOB_ID, resp.getItems().get(0).getJobId()); } @Test void listCompanyCandidates_shouldFilterByStatus() { Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", "张三", "清华", "本科", null, null); TypedQuery countQ = mockCountQuery(1L); TypedQuery dataQ = mockDataQuery(row); when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); CandidateListResponse resp = applicationService.listCompanyCandidates( COMPANY_ID, null, "SUBMITTED", null, null, null, 0, 20); assertEquals("SUBMITTED", resp.getItems().get(0).getStatus()); } @Test void listCompanyCandidates_shouldReturnEmptyForNoResults() { TypedQuery countQ = mockCountQuery(0L); TypedQuery dataQ = mockDataQuery(); when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); CandidateListResponse resp = applicationService.listCompanyCandidates( COMPANY_ID, null, null, null, null, null, 0, 20); assertEquals(0, resp.getItems().size()); assertEquals(0, resp.getTotal()); } @Test void listCompanyCandidates_invalidPage_shouldThrow() { var ex = assertThrows(BusinessException.class, () -> applicationService.listCompanyCandidates(COMPANY_ID, null, null, null, null, null, -1, 20)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void listCompanyCandidates_invalidSize_shouldThrow() { var ex = assertThrows(BusinessException.class, () -> applicationService.listCompanyCandidates(COMPANY_ID, null, null, null, null, null, 0, 0)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void listCompanyCandidates_sizeTooLarge_shouldThrow() { var ex = assertThrows(BusinessException.class, () -> applicationService.listCompanyCandidates(COMPANY_ID, null, null, null, null, null, 0, 101)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void listCompanyCandidates_invalidStatus_shouldThrow() { var ex = assertThrows(BusinessException.class, () -> applicationService.listCompanyCandidates(COMPANY_ID, null, "INVALID", null, null, null, 0, 20)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void listCompanyCandidates_invalidSort_shouldThrow() { var ex = assertThrows(BusinessException.class, () -> applicationService.listCompanyCandidates(COMPANY_ID, null, null, null, "invalid", null, 0, 20)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void listCompanyCandidates_invalidOrder_shouldThrow() { var ex = assertThrows(BusinessException.class, () -> applicationService.listCompanyCandidates(COMPANY_ID, null, null, null, null, "invalid", 0, 20)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void listCompanyCandidates_shouldSortByUpdatedAt() { Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", "张三", "清华", "本科", null, null); TypedQuery countQ = mockCountQuery(1L); TypedQuery dataQ = mockDataQuery(row); when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); CandidateListResponse resp = applicationService.listCompanyCandidates( COMPANY_ID, null, null, null, "updatedAt", "asc", 0, 20); assertEquals(1, resp.getItems().size()); } // ── 企业候选人详情 ───────────────────────────────────────── @Test void getCompanyCandidateDetail_shouldReturnDetail() { Application app = Application.builder().id(APP_ID).jobId(JOB_ID) .studentUserId(STUDENT_ID).status("SUBMITTED").coverLetter("cover").build(); com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder() .id(JOB_ID).companyId(COMPANY_ID).title("Java").city("北京") .employmentType("全职").build(); StudentProfile sp = StudentProfile.builder().userId(STUDENT_ID) .realName("张三").school("清华").education("本科").bio("bio") .location("北京").graduationYear((short)2026).skillsJson("[\"Java\",\"Spring\"]").build(); com.mydreamjob.auth.User u = mock(com.mydreamjob.auth.User.class); when(u.getAvatarUrl()).thenReturn("https://img/1.png"); Object[] row = new Object[]{app, job, sp, u}; @SuppressWarnings("unchecked") TypedQuery detailQuery = mock(TypedQuery.class); when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); when(detailQuery.getResultList()).thenReturn(Collections.singletonList(row)); when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); CandidateDetailView detail = applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID); assertEquals("张三", detail.getCandidateName()); assertEquals("cover", detail.getCoverLetter()); assertEquals("北京", detail.getJobCity()); assertEquals(2, detail.getCandidateSkills().size()); assertEquals("https://img/1.png", detail.getCandidateAvatarUrl()); // 成功详情查询不产生额外 Repository 查询(无 N+1) verify(applicationRepository, never()).findById(anyLong()); } @Test void getCompanyCandidateDetail_wrongCompany_shouldThrow() { // 投递存在,但岗位 companyId 与当前企业不匹配 → APPLICATION_OWNER_MISMATCH Application app = Application.builder().id(APP_ID).jobId(JOB_ID) .studentUserId(STUDENT_ID).status("SUBMITTED").build(); com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder() .id(JOB_ID).companyId(COMPANY_ID).title("Java").build(); Object[] row = new Object[]{app, job, null, null}; @SuppressWarnings("unchecked") TypedQuery detailQuery = mock(TypedQuery.class); when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); when(detailQuery.getResultList()).thenReturn(Collections.singletonList(row)); when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); var ex = assertThrows(BusinessException.class, () -> applicationService.getCompanyCandidateDetail(999L, APP_ID)); assertEquals(ErrorCode.APPLICATION_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); // 越权分支不触发额外的 Repository 查询 verify(applicationRepository, never()).findById(anyLong()); } @Test void getCompanyCandidateDetail_appNotFound_shouldThrow() { @SuppressWarnings("unchecked") TypedQuery detailQuery = mock(TypedQuery.class); when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); when(detailQuery.getResultList()).thenReturn(Collections.emptyList()); when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); var ex = assertThrows(BusinessException.class, () -> applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID)); assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); // 不存在分支不触发额外的 Repository 查询 verify(applicationRepository, never()).findById(anyLong()); } @Test void getCompanyCandidateDetail_noStudentProfile_shouldReturnNullFields() { Application app = Application.builder().id(APP_ID).jobId(JOB_ID) .studentUserId(STUDENT_ID).status("SUBMITTED").coverLetter(null).build(); com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder() .id(JOB_ID).companyId(COMPANY_ID).title("Java").build(); Object[] row = new Object[]{app, job, null, null}; @SuppressWarnings("unchecked") TypedQuery detailQuery = mock(TypedQuery.class); when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); when(detailQuery.getResultList()).thenReturn(Collections.singletonList(row)); when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); CandidateDetailView detail = applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID); assertNull(detail.getCandidateName()); assertNull(detail.getCandidateSchool()); assertNull(detail.getCoverLetter()); } // ── getTimeline 已知行为回归 ──────────────────────────────── @Test void getTimeline_companyOwner_canReadWhenVerified() { Long companyUserId = 300L; com.mydreamjob.auth.User companyUser = com.mydreamjob.auth.User.builder().id(companyUserId).phone("13800000300") .role(com.mydreamjob.auth.User.Role.COMPANY).active(true).verified(true).build(); companyUser.setRoles(new HashSet<>(Set.of(com.mydreamjob.auth.User.Role.COMPANY))); when(userRepository.findByIdWithRoles(companyUserId)).thenReturn(Optional.of(companyUser)); when(companyRepository.findByOwnerUserId(companyUserId)).thenReturn(Optional.of( com.mydreamjob.company.Company.builder().id(COMPANY_ID).ownerUserId(companyUserId).verified(true).build())); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(historyRepository.findByApplicationIdOrderByCreatedAtAsc(APP_ID)).thenReturn(List.of()); var result = applicationService.getTimeline(APP_ID, companyUserId); assertNotNull(result); } // ── LIKE 转义 ────────────────────────────────────────────── @Test void escapeLike_shouldEscapeSpecialChars() { assertEquals("test\\_key\\%word", applicationService.escapeLike("test_key%word")); assertEquals("test\\\\slash", applicationService.escapeLike("test\\slash")); } @Test void escapeLike_null_shouldReturnNull() { assertNull(applicationService.escapeLike(null)); } }