package com.mydreamjob.talentinvitation; import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.SecurityAuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobRepository; import com.mydreamjob.recommendation.JobRecommendationProfile; import com.mydreamjob.recommendation.JobRecommendationProfileRepository; import com.mydreamjob.review.ReviewModerationService; import com.mydreamjob.safety.BlockRelationRepository; import com.mydreamjob.talentinvitation.dto.CreateTalentInvitationRequest; 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.util.Optional; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) class TalentInvitationServiceTest { @Mock TalentInvitationRepository invitationRepository; @Mock TalentInvitationHistoryRepository historyRepository; @Mock UserRepository userRepository; @Mock CompanyRepository companyRepository; @Mock JobRepository jobRepository; @Mock JobRecommendationProfileRepository profileRepository; @Mock TalentInvitationCandidateResolver candidateResolver; @Mock TalentInvitationConversationService conversationService; @Mock TalentInvitationExpiryService expiryService; @Mock BlockRelationRepository blockRelationRepository; @Mock ReviewModerationService moderationService; @Mock AuditService auditService; @Mock SecurityAuditService securityAuditService; @InjectMocks TalentInvitationService service; private final User companyUser = User.builder().id(1L).role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).build(); private final Company company = Company.builder().id(10L).ownerUserId(1L).verified(true).build(); private final Job publishedJob = Job.builder().id(20L).companyId(10L).status("PUBLISHED").title("后端工程师").build(); private void commonSetup() { when(userRepository.findById(1L)).thenReturn(Optional.of(companyUser)); when(companyRepository.findByOwnerUserId(1L)).thenReturn(Optional.of(company)); when(companyRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(company)); when(jobRepository.findById(20L)).thenReturn(Optional.of(publishedJob)); when(profileRepository.findByJobId(20L)).thenReturn(Optional.of(JobRecommendationProfile.builder().jobId(20L) .enabled(true).reviewStatus(JobRecommendationProfile.APPROVED).build())); when(candidateResolver.resolve(eq(10L), eq(20L), any(), any())) .thenReturn(new TalentInvitationCandidateResolver.Resolution(101L, true)); when(invitationRepository.findByCompanyIdAndIdempotencyKey(eq(10L), any())).thenReturn(Optional.empty()); when(invitationRepository.findByCompanyIdAndJobIdAndStudentUserIdAndStatus(10L, 20L, 101L, TalentInvitation.PENDING)) .thenReturn(Optional.empty()); when(invitationRepository.existsInCooldown(eq(10L), eq(101L), any())).thenReturn(false); when(invitationRepository.countJobCreatedBetween(eq(10L), eq(20L), any(), any())).thenReturn(0L); when(invitationRepository.countCreatedBetween(eq(10L), any(), any())).thenReturn(0L); when(moderationService.moderateCompanyReply(any())).thenReturn( new ReviewModerationService.ModerationResult(TalentInvitation.PENDING, java.util.List.of(), java.math.BigDecimal.ZERO, null)); when(invitationRepository.saveAndFlush(any())).thenAnswer(invocation -> { TalentInvitation value = invocation.getArgument(0); value.setId(55L); return value; }); when(invitationRepository.attachConversation(anyLong(), anyLong(), anyLong(), anyLong())).thenReturn(1); } private CreateTalentInvitationRequest request(String key, String message, String idempotency) { CreateTalentInvitationRequest request = new CreateTalentInvitationRequest(); request.setCandidateKey(key); request.setMessage(message); request.setIdempotencyKey(idempotency); return request; } @Test void unverifiedCompanyCannotResolveCandidateOrCreateInvitation() { when(userRepository.findById(1L)).thenReturn(Optional.of(companyUser)); when(companyRepository.findByOwnerUserId(1L)).thenReturn(Optional.of( Company.builder().id(10L).ownerUserId(1L).verified(false).build())); assertThatThrownBy(() -> service.create(1L, 20L, request("candidate-key", "欢迎了解岗位", "3c989a31-6774-4520-9700-e58416cc1890"), null)) .isInstanceOf(BusinessException.class) .extracting("errorCode").isEqualTo(ErrorCode.COMPANY_NOT_VERIFIED.getCode()); verifyNoInteractions(jobRepository, profileRepository, candidateResolver, moderationService, invitationRepository, historyRepository, conversationService, expiryService, blockRelationRepository, auditService, securityAuditService); } @Test void safeMessage_createsPendingInvitationAndHistory() { commonSetup(); var response = service.create(1L, 20L, request("candidate-key", "欢迎了解岗位", "3c989a31-6774-4520-9700-e58416cc1845"), null); assertThat(response.getInvitationId()).isEqualTo(55L); assertThat(response.getStatus()).isEqualTo(TalentInvitation.PENDING); verify(historyRepository).save(any(TalentInvitationHistory.class)); verify(auditService).log(eq(1L), eq("TALENT_INVITATION_CREATED"), any(), eq(55L), any(), any()); } @Test void riskyMessage_isRejectedBeforeInsert() { commonSetup(); when(moderationService.moderateCompanyReply(any())).thenReturn( new ReviewModerationService.ModerationResult("PENDING", java.util.List.of(ReviewModerationService.REASON_CONTACT_INFO), java.math.BigDecimal.valueOf(2), "MEDIUM")); assertThatThrownBy(() -> service.create(1L, 20L, request("candidate-key", "请加微信wx_12345", "3c989a31-6774-4520-9700-e58416cc1846"), null)) .isInstanceOf(BusinessException.class) .extracting("errorCode").isEqualTo(ErrorCode.TALENT_INVITATION_CONTENT_INVALID.getCode()); verify(invitationRepository, never()).saveAndFlush(any()); verify(securityAuditService).logRejected(eq(1L), eq("TALENT_INVITATION_CREATE_REJECTED"), eq("TALENT_INVITATION"), isNull(), contains("\"jobId\":20"), any()); } @Test void idempotentReplayReturnsOriginalBeforeCurrentJobStatusGate() { commonSetup(); publishedJob.setStatus("DRAFT"); TalentInvitation existing = TalentInvitation.builder().id(77L).companyId(10L).jobId(20L) .candidateKeyDigest(sha256("candidate-key")).message("欢迎了解岗位") .status(TalentInvitation.PENDING).idempotencyKey("3c989a31-6774-4520-9700-e58416cc1847").build(); when(invitationRepository.findByCompanyIdAndIdempotencyKey(10L, existing.getIdempotencyKey())).thenReturn(Optional.of(existing)); var response = service.create(1L, 20L, request("candidate-key", "欢迎了解岗位", existing.getIdempotencyKey()), null); assertThat(response.getInvitationId()).isEqualTo(77L); verifyNoInteractions(candidateResolver, moderationService); } @Test void nonPositiveJobId_isBadRequestBeforeAnyLookup() { assertThatThrownBy(() -> service.create(1L, 0L, request("candidate-key", "欢迎了解岗位", "3c989a31-6774-4520-9700-e58416cc1850"), null)) .isInstanceOf(BusinessException.class) .extracting("errorCode").isEqualTo(ErrorCode.BAD_REQUEST.getCode()); verifyNoInteractions(userRepository, companyRepository, jobRepository, invitationRepository); } private String sha256(String value) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); StringBuilder result = new StringBuilder(); for (byte b : bytes) result.append(String.format("%02x", b)); return result.toString(); } catch (Exception e) { throw new AssertionError(e); } } }