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.
349 lines
18 KiB
349 lines
18 KiB
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());
|
|
}
|
|
}
|
|
|