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.
 
 
 

418 lines
22 KiB

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<Long> mockCountQuery(long total) {
TypedQuery<Long> q = mock(TypedQuery.class);
when(q.getSingleResult()).thenReturn(total);
return q;
}
@SuppressWarnings("unchecked")
private TypedQuery<Object[]> mockDataQuery(Object[]... rows) {
TypedQuery<Object[]> 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<Long> countQ = mockCountQuery(1L);
TypedQuery<Object[]> 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<Long> countQ = mockCountQuery(1L);
TypedQuery<Object[]> 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<Long> countQ = mockCountQuery(1L);
TypedQuery<Object[]> 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<Long> countQ = mockCountQuery(0L);
TypedQuery<Object[]> 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<Long> countQ = mockCountQuery(1L);
TypedQuery<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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));
}
}