package com.mydreamjob.messaging; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.SecurityAuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository; 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.messaging.dto.CreateConversationRequest; import com.mydreamjob.messaging.dto.SendMessageRequest; import com.mydreamjob.safety.BlockRelationRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class MessagingServiceTest { @Mock private ConversationRepository conversationRepository; @Mock private ConversationMemberRepository memberRepository; @Mock private MessageRepository messageRepository; @Mock private MessageReadRepository readRepository; @Mock private UserRepository userRepository; @Mock private CompanyRepository companyRepository; @Mock private ApplicationMatchRecordRepository applicationMatchRecordRepository; @Mock private BlockRelationRepository blockRelationRepository; @Mock private SecurityAuditService securityAuditService; @org.mockito.Spy private MessageViewMapper messageViewMapper = new MessageViewMapper(); @org.mockito.Spy private com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); @InjectMocks private MessagingService messagingService; private static final Long USER_A = 100L; private static final Long USER_B = 200L; private static final Long USER_C = 300L; private static final Long CONV_ID = 1L; @BeforeEach void setUp() { } private Conversation conv(Long id) { return Conversation.builder().id(id).createdBy(USER_A).type("DIRECT").build(); } private void stubConversationExists() { when(conversationRepository.findById(CONV_ID)).thenReturn(Optional.of(conv(CONV_ID))); } // ── 创建会话 ───────────────────────────────────────────── @Test void createConversation_withNewUser_shouldCreate() { CreateConversationRequest req = new CreateConversationRequest(); req.setParticipantUserId(USER_B); when(memberRepository.findByUserId(USER_A)).thenReturn(List.of()); when(userRepository.existsById(USER_B)).thenReturn(true); when(conversationRepository.save(any())).thenAnswer(inv -> { Conversation c = inv.getArgument(0); if (c.getId() == null) c.setId(99L); return c; }); when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); var r = messagingService.createOrGetDirectConversation(USER_A, req); assertNotNull(r.get("id")); assertEquals("DIRECT", r.get("type")); verify(memberRepository, times(2)).save(any()); } @Test void createConversation_withSelf_shouldReturn400() { CreateConversationRequest req = new CreateConversationRequest(); req.setParticipantUserId(USER_A); assertThrows(BusinessException.class, () -> messagingService.createOrGetDirectConversation(USER_A, req)); } // ── 防绕过守卫:企业-求职者新建会话需 MATCHED ────────────── private User user(Long id, User.Role role) { User u = User.builder().id(id).phone("1").role(role).build(); u.setRoles(new HashSet<>(Set.of(role))); return u; } private void stubNewConversationCreation() { when(memberRepository.findByUserId(any())).thenReturn(List.of()); when(conversationRepository.save(any())).thenAnswer(inv -> { Conversation c = inv.getArgument(0); if (c.getId() == null) c.setId(99L); return c; }); when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); } @Test void createConversation_companyToStudent_withoutMatched_shouldReturn403() { when(userRepository.findById(USER_A)).thenReturn(Optional.of(user(USER_A, User.Role.COMPANY))); when(userRepository.findById(USER_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT))); when(companyRepository.findByOwnerUserId(USER_A)) .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(USER_A).verified(true).build())); when(applicationMatchRecordRepository.existsMatchedApplication(1L, USER_B)).thenReturn(false); when(memberRepository.findByUserId(USER_A)).thenReturn(List.of()); when(userRepository.existsById(USER_B)).thenReturn(true); CreateConversationRequest req = new CreateConversationRequest(); req.setParticipantUserId(USER_B); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.createOrGetDirectConversation(USER_A, req)); assertEquals(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode(), ex.getErrorCodeInt()); verify(conversationRepository, never()).save(any()); } @Test void createConversation_companyToStudent_withMatched_shouldCreate() { when(userRepository.findById(USER_A)).thenReturn(Optional.of(user(USER_A, User.Role.COMPANY))); when(userRepository.findById(USER_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT))); when(companyRepository.findByOwnerUserId(USER_A)) .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(USER_A).verified(true).build())); when(applicationMatchRecordRepository.existsMatchedApplication(1L, USER_B)).thenReturn(true); stubNewConversationCreation(); when(userRepository.existsById(USER_B)).thenReturn(true); CreateConversationRequest req = new CreateConversationRequest(); req.setParticipantUserId(USER_B); var r = messagingService.createOrGetDirectConversation(USER_A, req); assertNotNull(r.get("id")); verify(conversationRepository, times(1)).save(any()); } @Test void createConversation_studentToStudent_shouldKeepOriginalSemantics() { when(userRepository.findById(USER_A)).thenReturn(Optional.of(user(USER_A, User.Role.STUDENT))); when(userRepository.findById(USER_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT))); stubNewConversationCreation(); when(userRepository.existsById(USER_B)).thenReturn(true); CreateConversationRequest req = new CreateConversationRequest(); req.setParticipantUserId(USER_B); var r = messagingService.createOrGetDirectConversation(USER_A, req); assertNotNull(r.get("id")); // 学生-学生不受匹配守卫限制 verify(conversationRepository, times(1)).save(any()); } // ── 发送消息 ───────────────────────────────────────────── @Test void sendMessage_asMember_shouldSucceed() { when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); when(messageRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(conversationRepository.findById(CONV_ID)).thenReturn(Optional.of( Conversation.builder().id(CONV_ID).createdBy(USER_A).type("DIRECT").build())); when(conversationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); SendMessageRequest req = new SendMessageRequest(); req.setConversationId(CONV_ID); req.setContent("Hello"); var r = messagingService.sendMessage(USER_A, req); assertEquals("Hello", r.get("content")); } @Test void sendMessage_asNonMember_shouldReturn403() { stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); SendMessageRequest req = new SendMessageRequest(); req.setConversationId(CONV_ID); req.setContent("Hack"); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.sendMessage(USER_C, req)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @Test void sendMessage_tooLong_shouldReturn400() { stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); SendMessageRequest req = new SendMessageRequest(); req.setConversationId(CONV_ID); req.setContent("x".repeat(2001)); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.sendMessage(USER_A, req)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } // ── V33 屏蔽拒发:拒绝型安全审计仍需落库(即使外层返回 403) ──── @Test void sendMessage_blocked_shouldLogSecurityAudit() { stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); // 会话里存在另一成员 B,且 A 屏蔽了 B when(memberRepository.findByConversationId(CONV_ID)) .thenReturn(List.of(ConversationMember.builder().conversationId(CONV_ID).userId(USER_B).build())); when(blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(USER_A, USER_B)).thenReturn(true); SendMessageRequest req = new SendMessageRequest(); req.setConversationId(CONV_ID); req.setContent("blocked"); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.sendMessage(USER_A, req)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); // 即使请求被拒,拒绝型安全审计也必须被记录 verify(securityAuditService).logRejected(eq(USER_A), eq("BLOCKED_SEND_PROHIBITED"), eq("USER"), eq(USER_B), anyString(), any(AuditContext.class)); } @Test void createConversation_blocked_shouldLogSecurityAudit() { CreateConversationRequest req = new CreateConversationRequest(); req.setParticipantUserId(USER_B); when(userRepository.existsById(USER_B)).thenReturn(true); when(memberRepository.findByUserId(USER_A)).thenReturn(List.of()); when(blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(USER_A, USER_B)).thenReturn(true); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.createOrGetDirectConversation(USER_A, req)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); // 即使请求被拒,拒绝型安全审计也必须被记录 verify(securityAuditService).logRejected(eq(USER_A), eq("BLOCKED_SEND_PROHIBITED"), eq("USER"), eq(USER_B), anyString(), any(AuditContext.class)); } @Test void sendMessage_unblocked_shouldNotLogSecurityAudit() { stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); when(memberRepository.findByConversationId(CONV_ID)) .thenReturn(List.of(ConversationMember.builder().conversationId(CONV_ID).userId(USER_B).build())); when(blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(USER_A, USER_B)).thenReturn(false); when(blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(USER_B, USER_A)).thenReturn(false); when(messageRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(conversationRepository.findById(CONV_ID)).thenReturn(Optional.of(conv(CONV_ID))); when(conversationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); SendMessageRequest req = new SendMessageRequest(); req.setConversationId(CONV_ID); req.setContent("ok"); assertDoesNotThrow(() -> messagingService.sendMessage(USER_A, req)); verify(securityAuditService, never()).logRejected(any(), any(), any(), any(), any(), any()); } // ── 消息列表 ───────────────────────────────────────────── @Test void listMessages_asMember_shouldSucceed() { stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); Message msg = Message.builder().id(1L).conversationId(CONV_ID).senderUserId(USER_A).content("Hi").build(); when(messageRepository.findByConversationIdOrderByCreatedAtDesc(eq(CONV_ID), any())) .thenReturn(new PageImpl<>(List.of(msg))); when(readRepository.findByMessageIdInAndReaderUserId(List.of(1L), USER_A)).thenReturn(List.of()); var r = messagingService.listMessages(CONV_ID, USER_A, 0, 20); assertEquals(1, r.getTotal()); assertEquals(false, r.getItems().get(0).getIsRead()); } @Test void listMessages_asNonMember_shouldReturn403() { stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.listMessages(CONV_ID, USER_C, 0, 20)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 标记已读 ───────────────────────────────────────────── @Test void markRead_shouldCreateReadRecords() { stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); Message msg = Message.builder().id(1L).conversationId(CONV_ID).senderUserId(USER_B).content("Hi").build(); when(messageRepository.findByConversationIdOrderByCreatedAtDesc(eq(CONV_ID), any())) .thenReturn(new PageImpl<>(List.of(msg))); when(readRepository.findByMessageIdInAndReaderUserId(List.of(1L), USER_A)).thenReturn(List.of()); when(readRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); assertDoesNotThrow(() -> messagingService.markRead(CONV_ID, USER_A)); verify(readRepository, times(1)).save(any()); } @Test void markRead_asNonMember_shouldReturn403() { stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.markRead(CONV_ID, USER_C)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 会话存在性检查(不存在 → CONVERSATION_NOT_FOUND) ──────── @Test void getConversation_conversationNotFound_shouldReturn404() { when(conversationRepository.findById(999L)).thenReturn(Optional.empty()); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.getConversation(999L, USER_A)); assertEquals(ErrorCode.CONVERSATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } @Test void getConversation_asMember_shouldSucceed() { stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); when(messageRepository.findByConversationIdOrderByCreatedAtDesc(eq(CONV_ID), any())) .thenReturn(new PageImpl<>(List.of())); var v = messagingService.getConversation(CONV_ID, USER_A); assertEquals(CONV_ID, v.get("id")); assertEquals(0L, v.get("unreadCount")); } @Test void getConversation_asNonMember_shouldReturn403() { stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.getConversation(CONV_ID, USER_C)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @Test void listMessages_conversationNotFound_shouldReturn404() { when(conversationRepository.findById(999L)).thenReturn(Optional.empty()); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.listMessages(999L, USER_A, 0, 20)); assertEquals(ErrorCode.CONVERSATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } @Test void sendMessage_conversationNotFound_shouldReturn404() { when(conversationRepository.findById(999L)).thenReturn(Optional.empty()); SendMessageRequest req = new SendMessageRequest(); req.setConversationId(999L); req.setContent("Hi"); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.sendMessage(USER_A, req)); assertEquals(ErrorCode.CONVERSATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } @Test void markRead_conversationNotFound_shouldReturn404() { when(conversationRepository.findById(999L)).thenReturn(Optional.empty()); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.markRead(999L, USER_A)); assertEquals(ErrorCode.CONVERSATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } }