diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java new file mode 100644 index 0000000..155b4b8 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java @@ -0,0 +1,195 @@ +package cn.iocoder.yudao.module.im.dal.mysql.group; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 群成员 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupMemberMapperTest extends BaseDbUnitTest { + + @Resource + private ImGroupMemberMapper mapper; + + // ========== selectListByGroupIdAndUserIds ========== + + @Test + public void testSelectListByGroupIdAndUserIds() { + // 准备:同一群 3 个成员 + 另一群 1 个成员 + ImGroupMemberDO u1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE); + mapper.insert(u1); + ImGroupMemberDO u2 = buildMember(10L, 2L, CommonStatusEnum.ENABLE); + mapper.insert(u2); + ImGroupMemberDO u3 = buildMember(10L, 3L, CommonStatusEnum.DISABLE); + mapper.insert(u3); + // 不相关的另一个群 + mapper.insert(buildMember(20L, 1L, CommonStatusEnum.ENABLE)); + + List result = mapper.selectListByGroupIdAndUserIds(10L, List.of(1L, 2L, 3L)); + assertEquals(3, result.size()); + } + + @Test + public void testSelectListByGroupIdAndUserIds_mixedUserIds() { + mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); + ImGroupMemberDO u2 = buildMember(10L, 2L, CommonStatusEnum.ENABLE); + mapper.insert(u2); + + // 查询不存在的用户 99,返回为空 + List result = mapper.selectListByGroupIdAndUserIds(10L, List.of(99L)); + assertTrue(result.isEmpty()); + + // 查询包含存在和不存在混合,只返回存在的 + List result2 = mapper.selectListByGroupIdAndUserIds(10L, List.of(2L, 99L)); + assertEquals(1, result2.size()); + assertEquals(u2.getId(), result2.get(0).getId()); + } + + // ========== selectListByGroupIdAndStatus ========== + + @Test + public void testSelectListByGroupIdAndStatus_onlyMatchingStatus() { + mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); + mapper.insert(buildMember(10L, 2L, CommonStatusEnum.ENABLE)); + mapper.insert(buildMember(10L, 3L, CommonStatusEnum.DISABLE)); + + List active = mapper.selectListByGroupIdAndStatus( + 10L, CommonStatusEnum.ENABLE.getStatus()); + assertEquals(2, active.size()); + + List disabled = mapper.selectListByGroupIdAndStatus( + 10L, CommonStatusEnum.DISABLE.getStatus()); + assertEquals(1, disabled.size()); + } + + // ========== selectListByUserIdAndStatus ========== + + @Test + public void testSelectListByUserIdAndStatus() { + // 用户 1 在群 10、20 都有记录;在群 30 已退群 + mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); + mapper.insert(buildMember(20L, 1L, CommonStatusEnum.ENABLE)); + mapper.insert(buildMember(30L, 1L, CommonStatusEnum.DISABLE)); + // 另一个用户的记录 + mapper.insert(buildMember(10L, 2L, CommonStatusEnum.ENABLE)); + + List active = mapper.selectListByUserIdAndStatus( + 1L, CommonStatusEnum.ENABLE.getStatus()); + assertEquals(2, active.size()); + assertTrue(active.stream().allMatch(m -> m.getUserId().equals(1L))); + } + + // ========== selectQuitListByUserId ========== + + @Test + public void testSelectQuitListByUserId_onlyDisabled() { + // 准备:一条 ENABLE + 一条 DISABLE + mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); + ImGroupMemberDO quit = buildMember(20L, 1L, CommonStatusEnum.DISABLE); + quit.setQuitTime(LocalDateTime.now().minusDays(1)); + mapper.insert(quit); + + // 调用:minQuitTime 为 null,不按时间过滤 + List result = mapper.selectQuitListByUserId(1L, null); + + // 断言:只返回退群记录 + assertEquals(1, result.size()); + assertEquals(quit.getId(), result.get(0).getId()); + } + + @Test + public void testSelectQuitListByUserId_filterByMinQuitTime() { + // 准备:两条退群记录,时间一早一晚 + LocalDateTime old = LocalDateTime.now().minusDays(10); + LocalDateTime recent = LocalDateTime.now().minusDays(1); + ImGroupMemberDO oldQuit = buildMember(10L, 1L, CommonStatusEnum.DISABLE); + oldQuit.setQuitTime(old); + mapper.insert(oldQuit); + ImGroupMemberDO recentQuit = buildMember(20L, 1L, CommonStatusEnum.DISABLE); + recentQuit.setQuitTime(recent); + mapper.insert(recentQuit); + + // 调用:minQuitTime = now - 5 天,只返回近期退群 + List result = mapper.selectQuitListByUserId(1L, LocalDateTime.now().minusDays(5)); + assertEquals(1, result.size()); + assertEquals(recentQuit.getId(), result.get(0).getId()); + + // 调用:minQuitTime 为 null,不过滤 + List all = mapper.selectQuitListByUserId(1L, null); + assertEquals(2, all.size()); + } + + // ========== updateByGroupIdAndUserIdsAndStatus ========== + + @Test + public void testUpdateByGroupIdAndUserIdsAndStatus_onlyUpdatesOldStatusMatches() { + // 准备:u1 ENABLE,u2 DISABLE(已退群,不应更新) + ImGroupMemberDO u1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE); + mapper.insert(u1); + ImGroupMemberDO u2 = buildMember(10L, 2L, CommonStatusEnum.DISABLE); + u2.setQuitTime(LocalDateTime.now().minusDays(5)); + mapper.insert(u2); + + // 调用:把 ENABLE 状态的成员移除 + LocalDateTime now = LocalDateTime.now(); + mapper.updateByGroupIdAndUserIdsAndStatus(10L, List.of(1L, 2L), + CommonStatusEnum.ENABLE.getStatus(), + new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()).setQuitTime(now)); + + // 断言:u1 被更新 + ImGroupMemberDO after1 = mapper.selectById(u1.getId()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), after1.getStatus()); + assertNotNull(after1.getQuitTime()); + // 断言:u2 的 quitTime 不变(仍为 5 天前) + ImGroupMemberDO after2 = mapper.selectById(u2.getId()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), after2.getStatus()); + assertTrue(after2.getQuitTime().isBefore(now.minusDays(1))); + } + + // ========== updateByGroupIdAndStatus ========== + + @Test + public void testUpdateByGroupIdAndStatus_allActiveInGroup() { + // 准备:群 10 有 2 个活跃 + 1 个已退;群 20 有 1 个活跃 + ImGroupMemberDO a1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE); + mapper.insert(a1); + ImGroupMemberDO a2 = buildMember(10L, 2L, CommonStatusEnum.ENABLE); + mapper.insert(a2); + ImGroupMemberDO a3 = buildMember(10L, 3L, CommonStatusEnum.DISABLE); + a3.setQuitTime(LocalDateTime.now().minusDays(5)); + mapper.insert(a3); + ImGroupMemberDO b1 = buildMember(20L, 1L, CommonStatusEnum.ENABLE); + mapper.insert(b1); + + // 调用:解散群 10 + mapper.updateByGroupIdAndStatus(10L, CommonStatusEnum.ENABLE.getStatus(), + new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) + .setQuitTime(LocalDateTime.now())); + + // 断言:群 10 的活跃成员全部变为 DISABLE,群 20 的不受影响 + assertEquals(CommonStatusEnum.DISABLE.getStatus(), mapper.selectById(a1.getId()).getStatus()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), mapper.selectById(a2.getId()).getStatus()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), mapper.selectById(b1.getId()).getStatus()); + } + + // ========== 工具方法 ========== + + private ImGroupMemberDO buildMember(Long groupId, Long userId, CommonStatusEnum status) { + return ImGroupMemberDO.builder() + .groupId(groupId).userId(userId) + .status(status.getStatus()) + .joinTime(LocalDateTime.now().minusDays(30)) + .build(); + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java new file mode 100644 index 0000000..4e075ae --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java @@ -0,0 +1,302 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 群聊消息 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupMessageMapperTest extends BaseDbUnitTest { + + @Resource + private ImGroupMessageMapper mapper; + + private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0); + + // ========== selectListByMinId ========== + + @Test + public void testSelectListByMinId_onlyReturnsGroupsInIdList() { + // 准备:群 10 的消息 + 群 20 的消息 + ImGroupMessageDO msg10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg10); + ImGroupMessageDO msg20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg20); + + // 调用:只查群 10 + List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100); + + // 断言:只有 msg10 + assertEquals(1, result.size()); + assertEquals(msg10.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByMinId_excludeRecall() { + // 准备:一条正常 + 一条撤回 + ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(normal); + ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); + mapper.insert(recalled); + + List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100); + + assertEquals(1, result.size()); + assertEquals(normal.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByMinId_sendTimeWindow() { + // 准备:一条在窗口内,一条在窗口外 + ImGroupMessageDO inWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + inWindow.setSendTime(LocalDateTime.now().minusDays(1)); + mapper.insert(inWindow); + ImGroupMessageDO outWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + outWindow.setSendTime(LocalDateTime.now().minusDays(40)); + mapper.insert(outWindow); + + List result = mapper.selectListByMinId(List.of(10L), 0L, + LocalDateTime.now().minusDays(30), 100); + + assertEquals(1, result.size()); + assertEquals(inWindow.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByMinId_sortAscLimit() { + // 准备:插入 3 条 + ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m1); + ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m2); + ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m3); + + // 调用:size=2,返回 id 最小的 2 条 + List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 2); + + assertEquals(2, result.size()); + assertTrue(result.get(0).getId() < result.get(1).getId()); + assertEquals(m1.getId(), result.get(0).getId()); + assertEquals(m2.getId(), result.get(1).getId()); + } + + // ========== selectListByGroupIdAndMinIdAndQuitTimeBefore ========== + + @Test + public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_onlyReturnsBeforeQuit() { + // 准备:退群时间 = now-1 day,插入一条"退群前",一条"退群后" + LocalDateTime quitTime = LocalDateTime.now().minusDays(1); + ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + before.setSendTime(LocalDateTime.now().minusDays(2)); + mapper.insert(before); + ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + after.setSendTime(LocalDateTime.now().minusHours(1)); + mapper.insert(after); + + List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + 10L, 0L, FAR_PAST, quitTime, 100); + + // 断言:只返回退群前消息 + assertEquals(1, result.size()); + assertEquals(before.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_boundaryEqualsQuitTime() { + // 准备:消息发送时间恰好等于退群时间(le 语义应包含该条) + LocalDateTime quitTime = LocalDateTime.now().minusDays(1).withNano(0); + ImGroupMessageDO boundary = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + boundary.setSendTime(quitTime); + mapper.insert(boundary); + + List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + 10L, 0L, FAR_PAST, quitTime, 100); + + assertEquals(1, result.size()); + } + + @Test + public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_otherGroupExcluded() { + // 准备:群 10 和群 20 各一条消息 + LocalDateTime quitTime = LocalDateTime.now(); + ImGroupMessageDO in10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + in10.setSendTime(LocalDateTime.now().minusHours(2)); + mapper.insert(in10); + ImGroupMessageDO in20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + in20.setSendTime(LocalDateTime.now().minusHours(2)); + mapper.insert(in20); + + List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + 10L, 0L, FAR_PAST, quitTime, 100); + + assertEquals(1, result.size()); + assertEquals(in10.getId(), result.get(0).getId()); + } + + // ========== selectHistoryList ========== + + @Test + public void testSelectHistoryList_basic() { + ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m1); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(m2); + // 别的群 + ImGroupMessageDO other = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(other); + + List result = mapper.selectHistoryList(10L, null, 100, null); + + // 断言:只返回 10 群的,按 id 倒序 + assertEquals(2, result.size()); + assertTrue(result.get(0).getId() > result.get(1).getId()); + } + + @Test + public void testSelectHistoryList_excludeRecall() { + ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(normal); + ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); + mapper.insert(recalled); + + List result = mapper.selectHistoryList(10L, null, 100, null); + + assertEquals(1, result.size()); + assertEquals(normal.getId(), result.get(0).getId()); + } + + @Test + public void testSelectHistoryList_maxIdCursor() { + ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m1); + ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m2); + ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m3); + + List result = mapper.selectHistoryList(10L, m3.getId(), 100, null); + + // 断言:只返回 id < m3.id 的,即 m1、m2 + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(m -> m.getId() < m3.getId())); + } + + @Test + public void testSelectHistoryList_filterByJoinTime() { + // 准备:一条在入群前,一条在入群后 + LocalDateTime joinTime = LocalDateTime.now().minusHours(1); + ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + before.setSendTime(LocalDateTime.now().minusHours(2)); + mapper.insert(before); + ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + after.setSendTime(LocalDateTime.now().minusMinutes(30)); + mapper.insert(after); + + List result = mapper.selectHistoryList(10L, null, 100, joinTime); + + // 断言:入群前消息不可见 + assertEquals(1, result.size()); + assertEquals(after.getId(), result.get(0).getId()); + } + + // ========== selectBySenderIdAndClientMessageId ========== + + @Test + public void testSelectBySenderIdAndClientMessageId() { + ImGroupMessageDO msg = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + msg.setClientMessageId("group-uuid-001"); + mapper.insert(msg); + + ImGroupMessageDO result = mapper.selectBySenderIdAndClientMessageId(1L, "group-uuid-001"); + assertNotNull(result); + assertEquals(msg.getId(), result.getId()); + + assertNull(mapper.selectBySenderIdAndClientMessageId(1L, "not-exist")); + } + + // ========== selectListByGroupIdAndPendingReceipt ========== + + @Test + public void testSelectListByGroupIdAndPendingReceipt_basic() { + // 准备:待回执 + 已完成 + 无需回执,三种状态 + ImGroupMessageDO pending = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + pending.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(pending); + ImGroupMessageDO done = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + done.setReceiptStatus(ImGroupMessageReceiptStatusEnum.DONE.getStatus()); + mapper.insert(done); + ImGroupMessageDO noReceipt = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + noReceipt.setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); + mapper.insert(noReceipt); + + List result = mapper.selectListByGroupIdAndPendingReceipt(10L, null, Long.MAX_VALUE); + + // 断言:只返回 pending 记录 + assertEquals(1, result.size()); + assertEquals(pending.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByGroupIdAndPendingReceipt_windowAndRecall() { + // 准备:窗口外 + 撤回 + 窗口内 pending + ImGroupMessageDO belowRange = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + belowRange.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(belowRange); + ImGroupMessageDO inRange = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + inRange.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(inRange); + ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); + recalled.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(recalled); + + // 调用:minId = belowRange.id(排除), maxId = inRange.id(包含) + List result = mapper.selectListByGroupIdAndPendingReceipt( + 10L, belowRange.getId(), inRange.getId()); + + // 断言:只返回窗口内的非撤回 pending,即 inRange + assertEquals(1, result.size()); + assertEquals(inRange.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByGroupIdAndPendingReceipt_otherGroupExcluded() { + ImGroupMessageDO g10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + g10.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(g10); + ImGroupMessageDO g20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + g20.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(g20); + + List result = mapper.selectListByGroupIdAndPendingReceipt(10L, null, Long.MAX_VALUE); + assertEquals(1, result.size()); + assertEquals(g10.getId(), result.get(0).getId()); + } + + // ========== 工具方法 ========== + + private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status) { + return ImGroupMessageDO.builder() + .clientMessageId("uuid-" + System.nanoTime()) + .senderId(senderId) + .groupId(groupId) + .type(0) + .content("{\"content\":\"test\"}") + .status(status.getStatus()) + .sendTime(LocalDateTime.now()) + .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) + .build(); + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java new file mode 100644 index 0000000..c6b1856 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java @@ -0,0 +1,263 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper; +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.dao.DuplicateKeyException; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_ADD_SELF; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 好友关系 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFriendServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFriendServiceImpl friendService; + + @Mock + private ImFriendMapper imFriendMapper; + @Mock + private AdminUserApi adminUserApi; + @Mock + private ImWebSocketService imWebSocketService; + @Mock + private ImPrivateMessageService privateMessageService; + + // ========== isFriend ========== + + @Test + public void testIsFriend_enabled() { + ImFriendDO friend = ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend); + + assertTrue(friendService.isFriend(1L, 2L)); + } + + @Test + public void testIsFriend_disabledOrAbsent() { + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); + assertFalse(friendService.isFriend(1L, 2L)); + + ImFriendDO disabled = ImFriendDO.builder().userId(1L).friendUserId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 3L)).thenReturn(disabled); + assertFalse(friendService.isFriend(1L, 3L)); + } + + // ========== updateFriend ========== + + @Test + public void testUpdateFriend_success() { + // 准备 + ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); + reqVO.setFriendUserId(2L); + reqVO.setMuted(true); + ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L) + .muted(false).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend); + + // 调用 + friendService.updateFriend(1L, reqVO); + + // 断言:更新了 muted 字段 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); + verify(imFriendMapper).updateById(captor.capture()); + assertEquals(100L, captor.getValue().getId()); + assertTrue(captor.getValue().getMuted()); + // 断言:推送了好友更新通知 + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testUpdateFriend_notFriend() { + // 准备 + ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); + reqVO.setFriendUserId(2L); + reqVO.setMuted(true); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendService.updateFriend(1L, reqVO)); + assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode()); + } + + // ========== addFriend ========== + + @Test + public void testAddFriend_self() { + ServiceException exception = assertThrows(ServiceException.class, + () -> friendService.addFriend(1L, 1L)); + assertEquals(FRIEND_ADD_SELF.getCode(), exception.getCode()); + } + + @Test + public void testAddFriend_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImFriendServiceImpl.class))) + .thenReturn(friendService); + + // 准备 + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); + when(imFriendMapper.selectByUserIdAndFriendUserId(2L, 1L)).thenReturn(null); + + // 调用 + friendService.addFriend(1L, 2L); + + // 断言:校验对方存在 + verify(adminUserApi).validateUser(2L); + // 断言:双向插入 2 条好友关系 + verify(imFriendMapper, times(2)).insert(any(ImFriendDO.class)); + // 断言:发送提示消息 + 给双方各推送一次好友添加事件 + verify(privateMessageService).sendTipPrivateMessage(eq(1L), eq(2L), anyString()); + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + } + + @Test + public void testAddFriend0_existingEnabledSkipsUpdate() { + // 准备:已存在且启用,第二步不应触发更新 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + // 调用 + friendService.addFriend0(1L, 2L); + + // 断言:不插入,也不更新 + verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); + verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); + } + + @Test + public void testAddFriend0_existingDisabledRecovers() { + // 准备:已存在且 DISABLE,应当恢复状态 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + // 调用 + friendService.addFriend0(1L, 2L); + + // 断言:更新 status 为 ENABLE + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); + verify(imFriendMapper).updateById(captor.capture()); + assertEquals(10L, captor.getValue().getId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus()); + verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); + } + + @Test + public void testAddFriend0_duplicateKeySwallowed() { + // 准备:mapper 抛并发冲突 + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); + when(imFriendMapper.insert(any(ImFriendDO.class))) + .thenThrow(new DuplicateKeyException("concurrent insert")); + + // 调用:应被吞掉,不抛异常 + friendService.addFriend0(1L, 2L); + + verify(imFriendMapper).insert(any(ImFriendDO.class)); + } + + // ========== deleteFriend ========== + + @Test + public void testDeleteFriend_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImFriendServiceImpl.class))) + .thenReturn(friendService); + + ImFriendDO friend12 = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + ImFriendDO friend21 = ImFriendDO.builder().id(11L).userId(2L).friendUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend12); + when(imFriendMapper.selectByUserIdAndFriendUserId(2L, 1L)).thenReturn(friend21); + + friendService.deleteFriend(1L, 2L); + + // 断言:双向更新为 DISABLE + verify(imFriendMapper, times(2)).updateById(any(ImFriendDO.class)); + // 断言:推送提示消息 + 双方各推送一次好友删除事件 + verify(privateMessageService).sendTipPrivateMessage(eq(1L), eq(2L), anyString()); + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + } + + @Test + public void testDeleteFriend0_alreadyDisabled() { + // 准备:已经是 DISABLE,不再更新 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + friendService.deleteFriend0(1L, 2L); + + verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); + } + + @Test + public void testDeleteFriend0_enabledGetsDisabled() { + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + friendService.deleteFriend0(1L, 2L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); + verify(imFriendMapper).updateById(captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getDeleteTime()); + } + + // ========== 其它读方法 ========== + + @Test + public void testGetFriendList() { + List list = List.of( + ImFriendDO.builder().id(1L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().id(2L).userId(1L).friendUserId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .deleteTime(LocalDateTime.now()).build() + ); + when(imFriendMapper.selectListByUserId(1L)).thenReturn(list); + + List result = friendService.getFriendList(1L); + assertEquals(2, result.size()); + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java new file mode 100644 index 0000000..185aaa8 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java @@ -0,0 +1,251 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.DuplicateKeyException; + +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 群成员 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImGroupMemberServiceImpl groupMemberService; + + @Mock + private ImGroupMemberMapper groupMemberMapper; + @Mock + private ImWebSocketService webSocketService; + + // ========== addGroupMember ========== + + @Test + public void testAddGroupMember_newInsert() { + // 准备:成员记录不存在 + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); + + // 调用 + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + // 断言:执行了 insert,返回记录的 status 为 ENABLE + assertNotNull(result); + assertEquals(10L, result.getGroupId()); + assertEquals(1L, result.getUserId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); + assertNotNull(result.getJoinTime()); + verify(groupMemberMapper).insert(any(ImGroupMemberDO.class)); + } + + @Test + public void testAddGroupMember_existingEnabledReturns() { + // 准备:已存在且 ENABLE,只返回已有记录,不做其它操作 + ImGroupMemberDO exists = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(exists); + when(groupMemberMapper.selectById(50L)).thenReturn(exists); + + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + assertEquals(50L, result.getId()); + verify(groupMemberMapper, never()).insert(any(ImGroupMemberDO.class)); + verify(groupMemberMapper, never()).updateById(any(ImGroupMemberDO.class)); + } + + @Test + public void testAddGroupMember_existingDisabledRecovers() { + // 准备:已存在且 DISABLE,应更新为 ENABLE + ImGroupMemberDO exists = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(exists); + ImGroupMemberDO recovered = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectById(50L)).thenReturn(recovered); + + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateById(captor.capture()); + assertEquals(50L, captor.getValue().getId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getJoinTime()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); + } + + @Test + public void testAddGroupMember_duplicateKeyFallsBackToSelect() { + // 准备:第一次 select 返回 null,insert 抛 DuplicateKey,再次 select 返回已插入记录 + ImGroupMemberDO inserted = ImGroupMemberDO.builder().id(80L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null).thenReturn(inserted); + when(groupMemberMapper.insert(any(ImGroupMemberDO.class))) + .thenThrow(new DuplicateKeyException("concurrent insert")); + + // 调用:冲突后降级 select + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + assertNotNull(result); + assertEquals(80L, result.getId()); + } + + // ========== addGroupMembers ========== + + @Test + public void testAddGroupMembers_mixedInsertAndUpdate() { + // 准备:用户 2 不存在(新增),用户 3 已存在且 DISABLE(恢复),用户 4 已存在且 ENABLE(跳过) + ImGroupMemberDO exist3 = ImGroupMemberDO.builder().id(30L).groupId(10L).userId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + ImGroupMemberDO exist4 = ImGroupMemberDO.builder().id(40L).groupId(10L).userId(4L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(List.of(exist3, exist4)); + + // 调用 + groupMemberService.addGroupMembers(10L, List.of(2L, 3L, 4L)); + + // 断言:updates 只有用户 3;inserts 只有用户 2 + verify(groupMemberMapper).updateBatch(argThat((List list) -> + list.size() == 1 && list.get(0).getId().equals(30L) + && CommonStatusEnum.ENABLE.getStatus().equals(list.get(0).getStatus()))); + verify(groupMemberMapper).insertBatch(argThat((List list) -> + list.size() == 1 && list.get(0).getUserId().equals(2L))); + } + + @Test + public void testAddGroupMembers_batchInsertDuplicateFallback() { + // 准备:两个新增成员,批量插入失败时降级为逐个 addGroupMember + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(List.of()); + doThrow(new DuplicateKeyException("concurrent batch insert")) + .when(groupMemberMapper).insertBatch(anyList()); + // addGroupMember 单条兜底逻辑 + when(groupMemberMapper.selectByGroupIdAndUserId(eq(10L), anyLong())).thenReturn(null); + + // 调用 + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); + + // 断言:降级为逐条调用 insert + verify(groupMemberMapper, times(2)).insert(any(ImGroupMemberDO.class)); + } + + // ========== validateMemberInGroup ========== + + @Test + public void testValidateMemberInGroup_notInGroup() { + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMemberService.validateMemberInGroup(10L, 1L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + @Test + public void testValidateMemberInGroup_disabledEqualsNotInGroup() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMemberService.validateMemberInGroup(10L, 1L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + @Test + public void testValidateMemberInGroup_success() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + ImGroupMemberDO result = groupMemberService.validateMemberInGroup(10L, 1L); + assertEquals(50L, result.getId()); + } + + // ========== updateGroupMember ========== + + @Test + public void testUpdateGroupMember_success() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + ImGroupMemberUpdateReqVO reqVO = new ImGroupMemberUpdateReqVO() + .setGroupId(10L).setMuted(true).setDisplayUserName("昵称"); + + groupMemberService.updateGroupMember(1L, reqVO); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateById(captor.capture()); + assertEquals(50L, captor.getValue().getId()); + assertTrue(captor.getValue().getMuted()); + verify(webSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); + } + + // ========== removeGroupMember ========== + + @Test + public void testRemoveGroupMember_success() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + groupMemberService.removeGroupMember(10L, 1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateById(captor.capture()); + assertEquals(50L, captor.getValue().getId()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getQuitTime()); + } + + @Test + public void testRemoveGroupMember_notInGroup() { + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMemberService.removeGroupMember(10L, 1L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + // ========== removeGroupMembers ========== + + @Test + public void testRemoveGroupMembers_batch() { + groupMemberService.removeGroupMembers(10L, List.of(2L, 3L)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateByGroupIdAndUserIdsAndStatus(eq(10L), anyCollection(), + eq(CommonStatusEnum.ENABLE.getStatus()), captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getQuitTime()); + } + + @Test + public void testRemoveGroupMembersByGroupId() { + groupMemberService.removeGroupMembersByGroupId(10L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateByGroupIdAndStatus(eq(10L), + eq(CommonStatusEnum.ENABLE.getStatus()), captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getQuitTime()); + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java new file mode 100644 index 0000000..ae6150d --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java @@ -0,0 +1,472 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 群 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImGroupServiceImpl groupService; + + @Mock + private ImGroupMapper groupMapper; + @Mock + private ImGroupMemberService groupMemberService; + @Mock + private ImGroupMessageService groupMessageService; + @Mock + private ImWebSocketService webSocketService; + @Mock + private ImFriendService friendService; + @Mock + private AdminUserApi adminUserApi; + + // ========== createGroup ========== + + @Test + public void testCreateGroup_success() { + // 准备 + ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO(); + reqVO.setName("测试群"); + when(groupMapper.insert(any(ImGroupDO.class))).thenAnswer(invocation -> { + ImGroupDO group = invocation.getArgument(0); + group.setId(100L); + return 1; + }); + + // 调用 + ImGroupDO result = groupService.createGroup(reqVO, 1L); + + // 断言:群主 + 状态 + assertEquals(100L, result.getId()); + assertEquals(1L, result.getOwnerUserId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); + // 验证:群主加入群 + 推送群创建事件 + verify(groupMemberService).addGroupMember(100L, 1L); + verify(webSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); + } + + // ========== updateGroup ========== + + @Test + public void testUpdateGroup_notOwner() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:当前用户不是群主 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ImGroupUpdateReqVO reqVO = new ImGroupUpdateReqVO(); + reqVO.setId(10L); + reqVO.setName("新名字"); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.updateGroup(reqVO, 1L)); + assertEquals(GROUP_NOT_OWNER.getCode(), exception.getCode()); + } + } + + @Test + public void testUpdateGroup_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备 + ImGroupDO group = ImGroupDO.builder().id(10L).name("旧名字").ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + List members = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members); + + ImGroupUpdateReqVO reqVO = new ImGroupUpdateReqVO(); + reqVO.setId(10L); + reqVO.setName("新名字"); + + // 调用 + ImGroupDO result = groupService.updateGroup(reqVO, 1L); + + // 断言:更新了数据库 + verify(groupMapper).updateById(any(ImGroupDO.class)); + assertEquals("新名字", result.getName()); + // 推送给所有成员 + verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + // ========== dissolveGroup ========== + + @Test + public void testDissolveGroup_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + List members = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members); + AdminUserRespDTO user = new AdminUserRespDTO(); + user.setId(1L); + user.setNickname("群主"); + when(adminUserApi.getUser(1L)).thenReturn(user); + + // 调用 + groupService.dissolveGroup(10L, 1L); + + // 断言:群状态变为 DISABLE + 群成员全部移除 + 清理已读缓存 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupDO.class); + verify(groupMapper).updateById(captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getDissolvedTime()); + verify(groupMemberService).removeGroupMembersByGroupId(10L); + verify(groupMessageService).deleteReadMaxMessageIdMap(10L); + // 发送解散提示消息 + 推送群删除事件 + verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); + verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + @Test + public void testDissolveGroup_notOwner() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.dissolveGroup(10L, 1L)); + assertEquals(GROUP_NOT_OWNER.getCode(), exception.getCode()); + } + } + + // ========== inviteGroupMember ========== + + @Test + public void testInviteGroupMember_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:群存在 + 当前用户是群成员 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + // 当前成员只有群主 + List activeMembers = new ArrayList<>(); + activeMembers.add(ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + + // 被邀请人 2 和 3 都是好友 + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + List friends = List.of( + ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().userId(1L).friendUserId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(friends); + + // 用户昵称 + AdminUserRespDTO u1 = new AdminUserRespDTO(); u1.setId(1L); u1.setNickname("群主"); + AdminUserRespDTO u2 = new AdminUserRespDTO(); u2.setId(2L); u2.setNickname("张三"); + AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四"); + Map userMap23 = new HashMap<>(); + userMap23.put(2L, u2); + userMap23.put(3L, u3); + Map userMap1 = Map.of(1L, u1); + when(adminUserApi.getUserMap(List.of(2L, 3L))).thenReturn(userMap23); + when(adminUserApi.getUserMap(List.of(1L))).thenReturn(userMap1); + + // 调用 + groupService.inviteGroupMember(1L, reqVO); + + // 断言:校验群成员 + 批量添加成员 + 推送事件 + 发送提示消息 + verify(groupMemberService).validateMemberInGroup(10L, 1L); + verify(groupMemberService).addGroupMembers(eq(10L), anyCollection()); + verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); + } + } + + @Test + public void testInviteGroupMember_notFriend() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)) + .thenReturn(List.of(ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + // 只有 2 是好友,3 不是 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( + ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四"); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(3L, u3)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.inviteGroupMember(1L, reqVO)); + assertEquals(GROUP_INVITE_NOT_FRIEND.getCode(), exception.getCode()); + } + } + + @Test + public void testInviteGroupMember_skipsMembersAlreadyInGroup() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + // 用户 2 已在群中 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L))); // 只邀请 2,他已在群中 + + // 调用 + groupService.inviteGroupMember(1L, reqVO); + + // 断言:不会触发添加、不会推送 + verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection()); + verify(webSocketService, never()).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + // ========== quitGroup ========== + + @Test + public void testQuitGroup_ownerCannotQuit() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.quitGroup(10L, 1L)); + assertEquals(GROUP_OWNER_CANNOT_QUIT.getCode(), exception.getCode()); + } + } + + @Test + public void testQuitGroup_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + groupService.quitGroup(10L, 1L); + + verify(groupMemberService).removeGroupMember(10L, 1L); + verify(groupMessageService).deleteReadMaxMessageId(10L, 1L); + verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); + verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + // ========== removeGroupMember ========== + + @Test + public void testRemoveGroupMember_cannotRemoveSelf() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(List.of(1L, 2L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.removeGroupMember(1L, reqVO)); + assertEquals(GROUP_CANNOT_REMOVE_SELF.getCode(), exception.getCode()); + } + } + + @Test + public void testRemoveGroupMember_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(List.of(2L, 3L)); + + groupService.removeGroupMember(1L, reqVO); + + verify(groupMemberService).removeGroupMembers(eq(10L), anyCollection()); + verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), anyCollection()); + verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); + verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + // ========== validateGroupExists ========== + + @Test + public void testValidateGroupExists_notExists() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + when(groupMapper.selectById(10L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.validateGroupExists(10L)); + assertEquals(GROUP_NOT_EXISTS.getCode(), exception.getCode()); + } + } + + @Test + public void testValidateGroupExists_banned() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).banned(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.validateGroupExists(10L)); + assertEquals(GROUP_BANNED.getCode(), exception.getCode()); + } + } + + @Test + public void testValidateGroupExists_dissolved() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.validateGroupExists(10L)); + assertEquals(GROUP_DISSOLVED.getCode(), exception.getCode()); + } + } + + // ========== getActiveGroupList ========== + + @Test + public void testGetActiveGroupList_noMembers() { + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of()); + + List result = groupService.getActiveGroupList(1L); + assertTrue(result.isEmpty()); + verify(groupMapper, never()).selectListByIds(anyCollection(), any(), any()); + } + + @Test + public void testGetActiveGroupList_success() { + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(20L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + List groups = List.of( + ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMapper.selectListByIds(anyCollection(), eq(CommonStatusEnum.ENABLE.getStatus()), eq(false))) + .thenReturn(groups); + + List result = groupService.getActiveGroupList(1L); + assertEquals(2, result.size()); + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java index 3ed21e2..d538594 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -4,7 +4,6 @@ import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; @@ -16,12 +15,15 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; import cn.iocoder.yudao.module.im.service.group.ImGroupService; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; import java.time.LocalDateTime; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -52,7 +54,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @Mock private GroupMessageReadRedisDAO groupMessageReadRedisDAO; @Mock - private WebSocketMessageSender webSocketMessageSender; + private ImWebSocketService imWebSocketService; private ImGroupMessageSendReqVO buildSendReqVO() { ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO(); @@ -109,7 +111,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus()); // 验证推送给 3 个群成员(含发送者自己,用于多端同步) - verify(webSocketMessageSender, times(3)).sendObject(anyInt(), anyLong(), anyString(), any()); + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 3 && ids.contains(1L) && ids.contains(2L) && ids.contains(3L)), + any(ImGroupMessageDTO.class)); } } @@ -571,8 +575,10 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 验证:更新原消息状态 + 插入 tipMessage verify(groupMessageMapper).updateById(any(ImGroupMessageDO.class)); verify(groupMessageMapper).insert(any(ImGroupMessageDO.class)); - // 验证:只给 2 个活跃成员推送撤回提示(userId=3 已退群) - verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any()); + // 验证:给 2 个活跃成员推送撤回提示 + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 2 && ids.contains(1L) && ids.contains(2L)), + any(ImGroupMessageDTO.class)); } } diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java index bfb4c1b..db41b08 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java @@ -1,19 +1,20 @@ package cn.iocoder.yudao.module.im.service.message; -import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.service.friend.ImFriendService; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.MockedStatic; import java.time.LocalDateTime; import java.util.List; @@ -34,13 +35,13 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { private ImPrivateMessageServiceImpl privateMessageService; @Mock - private ImPrivateMessageMapper imPrivateMessageMapper; + private ImPrivateMessageMapper privateMessageMapper; @Mock - private ImFriendService imFriendService; + private ImFriendService friendService; @Mock - private ImSensitiveWordService imSensitiveWordService; + private ImSensitiveWordService sensitiveWordService; @Mock - private WebSocketMessageSender webSocketMessageSender; + private ImWebSocketService imWebSocketService; private ImPrivateMessageSendReqVO buildSendReqVO() { ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); @@ -55,37 +56,35 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testSendMessage_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class))) - .thenReturn(privateMessageService); - - // 准备 - ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); - when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) - .thenReturn(null); - when(imPrivateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> { - ImPrivateMessageDO msg = invocation.getArgument(0); - msg.setId(99L); - return 1; - }); - - // 调用 - ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO); - - // 断言 - assertNotNull(result); - assertEquals(1L, result.getSenderId()); - assertEquals(2L, result.getReceiverId()); - assertEquals(0, result.getType()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); - assertNotNull(result.getSendTime()); - - // 验证调用 - verify(imFriendService).isFriend(1L, 2L); - verify(imSensitiveWordService).validateText(reqVO.getContent()); - verify(imPrivateMessageMapper).insert(any(ImPrivateMessageDO.class)); - verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any()); - } + // 准备 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(null); + when(friendService.isFriend(1L, 2L)).thenReturn(true); + when(privateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> { + ImPrivateMessageDO msg = invocation.getArgument(0); + msg.setId(99L); + return 1; + }); + + // 调用 + ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO); + + // 断言 + assertNotNull(result); + assertEquals(1L, result.getSenderId()); + assertEquals(2L, result.getReceiverId()); + assertEquals(0, result.getType()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); + assertNotNull(result.getSendTime()); + + // 验证调用 + verify(friendService).isFriend(1L, 2L); + verify(sensitiveWordService).validateText(reqVO.getContent()); + verify(privateMessageMapper).insert(any(ImPrivateMessageDO.class)); + // 验证推送给接收方和发送方 + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); } @Test @@ -96,7 +95,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { .id(100L).clientMessageId("test-uuid-001").senderId(1L).receiverId(2L) .type(0).content("{\"content\":\"你好\"}").status(0) .sendTime(LocalDateTime.now()).build(); - when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) .thenReturn(existingMessage); // 调用 @@ -105,16 +104,16 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { // 断言:返回已存在的消息 assertEquals(100L, result.getId()); // 验证不会重复插入 - verify(imPrivateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); } @Test public void testSendMessage_notFriend() { // 准备 ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); - when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) .thenReturn(null); - when(imFriendService.isFriend(1L, 2L)).thenReturn(false); + when(friendService.isFriend(1L, 2L)).thenReturn(false); // 调用并断言 ServiceException exception = assertThrows(ServiceException.class, @@ -131,7 +130,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(), ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L).build() ); - when(imPrivateMessageMapper.selectListByMinId(eq(1L), eq(0L), any(LocalDateTime.class), eq(100))) + when(privateMessageMapper.selectListByMinId(eq(1L), eq(0L), any(LocalDateTime.class), eq(100))) .thenReturn(mockMessages); // 调用 @@ -153,58 +152,67 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testReadMessages_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class))) - .thenReturn(privateMessageService); - - // 准备:mock 未读消息列表 - List unreadMessages = List.of( - ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L) - .status(ImMessageStatusEnum.UNREAD.getStatus()).build(), - ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L) - .status(ImMessageStatusEnum.UNREAD.getStatus()).build() - ); - when(imPrivateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, - ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(unreadMessages); - - // 调用 - privateMessageService.readPrivateMessages(1L, 2L); - - // 断言:更新了消息状态 + 发送了 READ 和 RECEIPT 事件 - verify(imPrivateMessageMapper).updateByIdsAndStatus(eq(List.of(1L, 2L)), - eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)); - verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any()); - } + // 准备:mock 未读消息(id=1 和 id=5,maxReadId 应为 5) + List unreadMessages = List.of( + ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(), + ImPrivateMessageDO.builder().id(5L).senderId(2L).receiverId(1L) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build() + ); + when(privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, + ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(unreadMessages); + + // 调用 + privateMessageService.readPrivateMessages(1L, 2L); + + // 断言:更新了消息状态 + verify(privateMessageMapper).updateByIdsAndStatus(eq(List.of(1L, 5L)), + eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)); + + // 断言:发送了 READ + RECEIPT 事件,payload 字段正确 + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(ImPrivateMessageDTO.class); + verify(imWebSocketService, times(2)).sendPrivateMessageAsync( + userCaptor.capture(), contentCaptor.capture()); + + // 第一次:发给自己的 READ 事件 + assertEquals(1L, userCaptor.getAllValues().get(0)); + ImPrivateMessageDTO readPayload = contentCaptor.getAllValues().get(0); + assertEquals(ImMessageTypeEnum.READ.getType(), readPayload.getType()); + assertEquals(1L, readPayload.getSenderId()); + assertEquals(2L, readPayload.getReceiverId()); + assertEquals(5L, readPayload.getId(), "READ id 应为 maxReadId"); + + // 第二次:发给对方的 RECEIPT 事件 + assertEquals(2L, userCaptor.getAllValues().get(1)); + ImPrivateMessageDTO receiptPayload = contentCaptor.getAllValues().get(1); + assertEquals(ImMessageTypeEnum.RECEIPT.getType(), receiptPayload.getType()); + assertEquals(5L, receiptPayload.getId(), "RECEIPT id 应为 maxReadId"); } // ========== 撤回测试 ========== @Test public void testRecallMessage_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class))) - .thenReturn(privateMessageService); - - // 准备 - ImPrivateMessageDO message = ImPrivateMessageDO.builder() - .id(10L).senderId(1L).receiverId(2L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .sendTime(LocalDateTime.now()).build(); // 刚发送,5 分钟内 - when(imPrivateMessageMapper.selectById(10L)).thenReturn(message); - when(imPrivateMessageMapper.updateById(any(ImPrivateMessageDO.class))).thenReturn(1); - when(imPrivateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenReturn(1); - - // 调用 - ImPrivateMessageDO result = privateMessageService.recallPrivateMessage(1L, 10L); - - // 断言:返回 TIP_TEXT 消息 - assertNotNull(result); - // 验证:更新原消息状态 + 插入 tipMessage - verify(imPrivateMessageMapper).updateById(any(ImPrivateMessageDO.class)); - verify(imPrivateMessageMapper).insert(any(ImPrivateMessageDO.class)); - // 验证推送了消息(给接收方和发送方) - verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any()); - } + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); // 刚发送,5 分钟内 + when(privateMessageMapper.selectById(10L)).thenReturn(message); + when(privateMessageMapper.updateById(any(ImPrivateMessageDO.class))).thenReturn(1); + when(privateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenReturn(1); + + // 调用 + ImPrivateMessageDO result = privateMessageService.recallPrivateMessage(1L, 10L); + + // 断言:返回 TIP_TEXT 消息 + assertNotNull(result); + // 验证:更新原消息状态 + 插入 tipMessage + verify(privateMessageMapper).updateById(any(ImPrivateMessageDO.class)); + verify(privateMessageMapper).insert(any(ImPrivateMessageDO.class)); + // 验证推送了消息(给接收方和发送方) + verify(imWebSocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); } @Test @@ -214,7 +222,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { .id(10L).senderId(2L).receiverId(1L) .status(ImMessageStatusEnum.UNREAD.getStatus()) .sendTime(LocalDateTime.now()).build(); - when(imPrivateMessageMapper.selectById(10L)).thenReturn(message); + when(privateMessageMapper.selectById(10L)).thenReturn(message); // 调用并断言 ServiceException exception = assertThrows(ServiceException.class, @@ -229,7 +237,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { .id(10L).senderId(1L).receiverId(2L) .status(ImMessageStatusEnum.RECALL.getStatus()) .sendTime(LocalDateTime.now()).build(); - when(imPrivateMessageMapper.selectById(10L)).thenReturn(message); + when(privateMessageMapper.selectById(10L)).thenReturn(message); // 调用并断言 ServiceException exception = assertThrows(ServiceException.class, diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java deleted file mode 100644 index 3dd887a..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImWebSocketEventTest.java +++ /dev/null @@ -1,162 +0,0 @@ -package cn.iocoder.yudao.module.im.service.message; - -import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; -import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; -import cn.iocoder.yudao.module.im.service.friend.ImFriendService; -import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -/** - * IM WebSocket 事件单元测试 - *

- * 验证各事件的 payload 字段是否正确 - * - * @author 芋道源码 - */ -public class ImWebSocketEventTest extends BaseMockitoUnitTest { - - @InjectMocks - private ImPrivateMessageServiceImpl privateMessageService; - - @Mock - private ImPrivateMessageMapper imPrivateMessageMapper; - @Mock - private ImFriendService imFriendService; - @Mock - private ImSensitiveWordService imSensitiveWordService; - @Mock - private WebSocketMessageSender webSocketMessageSender; - - @Test - public void testSendMessage_websocketEvent() { - try (var springUtilMockedStatic = mockStatic(cn.hutool.extra.spring.SpringUtil.class)) { - springUtilMockedStatic.when(() -> cn.hutool.extra.spring.SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class))) - .thenReturn(privateMessageService); - - // 准备 - ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); - reqVO.setClientMessageId("evt-uuid"); - reqVO.setReceiverId(2L); - reqVO.setType(0); - reqVO.setContent("{\"content\":\"事件测试\"}"); - - when(imPrivateMessageMapper.selectBySenderIdAndClientMessageId(1L, "evt-uuid")).thenReturn(null); - when(imPrivateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> { - ImPrivateMessageDO msg = invocation.getArgument(0); - msg.setId(99L); - return 1; - }); - - // 调用 - privateMessageService.sendPrivateMessage(1L, reqVO); - - // 捕获 WebSocket 调用 - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Object.class); - verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), - typeCaptor.capture(), contentCaptor.capture()); - - // 验证消息事件类型:统一为 im-private-message - assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(0)); - assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(1)); - - // 验证 payload 是 DTO 类型 - ImPrivateMessageDTO payload = (ImPrivateMessageDTO) contentCaptor.getAllValues().get(0); - assertNotNull(payload.getId(), "id 不应为空"); - assertEquals(1L, payload.getSenderId()); - assertEquals(2L, payload.getReceiverId()); - } - } - - @Test - public void testReadMessage_readAndReceiptEvents() { - try (var springUtilMockedStatic = mockStatic(cn.hutool.extra.spring.SpringUtil.class)) { - springUtilMockedStatic.when(() -> cn.hutool.extra.spring.SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class))) - .thenReturn(privateMessageService); - - // 准备:mock 未读消息(id=1 和 id=5,maxReadId 应为 5) - List unreadMessages = List.of( - ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L) - .status(ImMessageStatusEnum.UNREAD.getStatus()).build(), - ImPrivateMessageDO.builder().id(5L).senderId(2L).receiverId(1L) - .status(ImMessageStatusEnum.UNREAD.getStatus()).build() - ); - when(imPrivateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, - ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(unreadMessages); - - // 调用 - privateMessageService.readPrivateMessages(1L, 2L); - - // 捕获 - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(Long.class); - ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Object.class); - verify(webSocketMessageSender, times(2)).sendObject(anyInt(), userCaptor.capture(), - typeCaptor.capture(), contentCaptor.capture()); - - // 第一次:发给自己的 READ 事件(统一 type = im-private-message) - assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(0)); - assertEquals(1L, userCaptor.getAllValues().get(0)); - ImPrivateMessageDTO readPayload = (ImPrivateMessageDTO) contentCaptor.getAllValues().get(0); - assertEquals(ImMessageTypeEnum.READ.getType(), readPayload.getType()); - assertEquals(1L, readPayload.getSenderId()); - assertEquals(2L, readPayload.getReceiverId()); - assertEquals(5L, readPayload.getId(), "READ id 应为 maxReadId"); - - // 第二次:发给对方的 RECEIPT 事件(统一 type = im-private-message) - assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(1)); - assertEquals(2L, userCaptor.getAllValues().get(1)); - ImPrivateMessageDTO receiptPayload = (ImPrivateMessageDTO) contentCaptor.getAllValues().get(1); - assertEquals(ImMessageTypeEnum.RECEIPT.getType(), receiptPayload.getType()); - assertEquals(1L, receiptPayload.getSenderId()); - assertEquals(2L, receiptPayload.getReceiverId()); - assertEquals(5L, receiptPayload.getId(), "RECEIPT id 应为 maxReadId"); - } - } - - @Test - public void testRecallMessage_tipTextEvent() { - try (var springUtilMockedStatic = mockStatic(cn.hutool.extra.spring.SpringUtil.class)) { - springUtilMockedStatic.when(() -> cn.hutool.extra.spring.SpringUtil.getBean(eq(ImPrivateMessageServiceImpl.class))) - .thenReturn(privateMessageService); - - // 准备 - ImPrivateMessageDO message = ImPrivateMessageDO.builder() - .id(10L).senderId(1L).receiverId(2L).status(0) - .sendTime(java.time.LocalDateTime.now()).build(); - when(imPrivateMessageMapper.selectById(10L)).thenReturn(message); - when(imPrivateMessageMapper.updateById(any(ImPrivateMessageDO.class))).thenReturn(1); - when(imPrivateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenReturn(1); - - // 调用 - privateMessageService.recallPrivateMessage(1L, 10L); - - // 捕获 - ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Object.class); - verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), - typeCaptor.capture(), contentCaptor.capture()); - - // 验证:推送的是 TIP_TEXT 消息(统一 type = im-private-message) - assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(0)); - assertEquals(ImPrivateMessageDTO.TYPE, typeCaptor.getAllValues().get(1)); - } - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java new file mode 100644 index 0000000..3c2965c --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java @@ -0,0 +1,97 @@ +package cn.iocoder.yudao.module.im.service.sensitiveword; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * IM 敏感词 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImSensitiveWordServiceImpl sensitiveWordService; + + @Mock + private ImSensitiveWordMapper imSensitiveWordMapper; + + @BeforeEach + public void setUp() { + // 模拟 @PostConstruct 初始化:加载 2 个启用的敏感词 + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(List.of( + ImSensitiveWordDO.builder().id(1L).word("badword") + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImSensitiveWordDO.builder().id(2L).word("违禁词") + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + sensitiveWordService.init(); + } + + @Test + public void testValidateText_null() { + // null 直接返回,不抛异常 + assertDoesNotThrow(() -> sensitiveWordService.validateText(null)); + } + + @Test + public void testValidateText_empty() { + assertDoesNotThrow(() -> sensitiveWordService.validateText("")); + } + + @Test + public void testValidateText_clean() { + // 正常文本不应命中 + assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world")); + } + + @Test + public void testValidateText_hitEnglish() { + ServiceException exception = assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("this contains badword here")); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + } + + @Test + public void testValidateText_hitChinese() { + ServiceException exception = assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("这条消息里有违禁词哦")); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + } + + @Test + public void testRefresh_reloadsFromMapper() { + // 刷新前:第一次调用 mapper + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + + // 准备:新列表不含原词 + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(List.of( + ImSensitiveWordDO.builder().id(3L).word("new").status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + // 调用:刷新 + sensitiveWordService.refresh(); + + // 断言:原敏感词不再命中,新词命中 + assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword")); + assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("contains new")); + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java new file mode 100644 index 0000000..9e26b7e --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java @@ -0,0 +1,155 @@ +package cn.iocoder.yudao.module.im.service.websocket; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * IM WebSocket 推送 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImWebSocketServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImWebSocketServiceImpl imWebSocketService; + + @Mock + private WebSocketMessageSender webSocketMessageSender; + + @AfterEach + public void tearDown() { + // 清理事务同步上下文,避免串扰其它用例 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + // ========== sendPrivateMessageAsync ========== + + @Test + public void testSendPrivateMessageAsync_noTransactionSendsImmediately() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + // 准备 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); + + // 调用:无事务,应立即发送 + imWebSocketService.sendPrivateMessageAsync(2L, dto); + + // 断言 + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImPrivateMessageDTO.TYPE), eq(dto)); + } + } + + @Test + public void testSendPrivateMessageAsync_inTransactionDeferredUntilCommit() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + // 准备:开启事务同步 + TransactionSynchronizationManager.initSynchronization(); + try { + ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); + + // 调用 + imWebSocketService.sendPrivateMessageAsync(2L, dto); + + // 断言:事务未提交,未推送 + verify(webSocketMessageSender, never()).sendObject(anyInt(), anyLong(), anyString(), any()); + + // 模拟事务提交 + List syncs = + TransactionSynchronizationManager.getSynchronizations(); + assertEquals(1, syncs.size()); + syncs.forEach(TransactionSynchronization::afterCommit); + + // 断言:提交后推送 + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImPrivateMessageDTO.TYPE), eq(dto)); + } finally { + TransactionSynchronizationManager.clear(); + } + } + } + + // ========== sendGroupMessageAsync ========== + + @Test + public void testSendGroupMessageAsync_fanOutToAllUsers() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + ImGroupMessageDTO dto = new ImGroupMessageDTO(); + dto.setGroupId(10L); + dto.setSenderId(1L); + + imWebSocketService.sendGroupMessageAsync(List.of(1L, 2L, 3L), dto); + + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(1L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(3L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + } + } + + @Test + public void testSendGroupMessageAsync_senderExceptionDoesNotBreakOthers() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + ImGroupMessageDTO dto = new ImGroupMessageDTO(); + dto.setGroupId(10L); + // 给 1 号用户推送时抛异常,不能影响 2/3 号 + doThrow(new RuntimeException("user offline")) + .when(webSocketMessageSender).sendObject(anyInt(), eq(1L), anyString(), any()); + + imWebSocketService.sendGroupMessageAsync(List.of(1L, 2L, 3L), dto); + + // 2L 和 3L 也都被推送 + verify(webSocketMessageSender).sendObject(anyInt(), eq(2L), anyString(), any()); + verify(webSocketMessageSender).sendObject(anyInt(), eq(3L), anyString(), any()); + } + } + + @Test + public void testSendGroupMessageAsync_singleUserDefaultOverload() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + ImGroupMessageDTO dto = new ImGroupMessageDTO(); + dto.setGroupId(10L); + + imWebSocketService.sendGroupMessageAsync(42L, dto); + + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(42L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + } + } + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/clean.sql b/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/clean.sql index cc76de2..9101fff 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/clean.sql +++ b/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/clean.sql @@ -1 +1,5 @@ DELETE FROM "im_private_message"; +DELETE FROM "im_group_message"; +DELETE FROM "im_group"; +DELETE FROM "im_group_member"; +DELETE FROM "im_friend"; diff --git a/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/create_tables.sql b/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/create_tables.sql index 30e95e7..924c93c 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/create_tables.sql +++ b/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/create_tables.sql @@ -15,3 +15,80 @@ CREATE TABLE IF NOT EXISTS "im_private_message" ( "tenant_id" bigint NOT NULL DEFAULT 0, PRIMARY KEY ("id") ) COMMENT 'IM 私聊消息表'; + +CREATE TABLE IF NOT EXISTS "im_group_message" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', + "sender_id" bigint NOT NULL COMMENT '发送人编号', + "group_id" bigint NOT NULL COMMENT '群编号', + "type" tinyint NOT NULL COMMENT '消息类型', + "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', + "status" tinyint NOT NULL COMMENT '消息状态', + "send_time" timestamp NOT NULL COMMENT '发送时间', + "receiver_user_ids" varchar(1024) DEFAULT NULL COMMENT '定向接收用户编号列表', + "at_user_ids" varchar(1024) DEFAULT NULL COMMENT '@ 目标用户编号列表', + "receipt_status" tinyint NOT NULL DEFAULT 0 COMMENT '回执状态', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 群聊消息表'; + +CREATE TABLE IF NOT EXISTS "im_group" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "name" varchar(64) NOT NULL COMMENT '群名称', + "owner_user_id" bigint NOT NULL COMMENT '群主用户编号', + "avatar" varchar(512) DEFAULT NULL COMMENT '群头像', + "notice" varchar(2048) DEFAULT NULL COMMENT '群公告', + "banned" bit DEFAULT FALSE COMMENT '是否封禁', + "banned_reason" varchar(512) DEFAULT NULL COMMENT '封禁原因', + "banned_time" timestamp DEFAULT NULL COMMENT '封禁时间', + "status" tinyint NOT NULL COMMENT '群状态', + "dissolved_time" timestamp DEFAULT NULL COMMENT '解散时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 群信息表'; + +CREATE TABLE IF NOT EXISTS "im_group_member" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "group_id" bigint NOT NULL COMMENT '群编号', + "user_id" bigint NOT NULL COMMENT '用户编号', + "display_user_name" varchar(64) DEFAULT NULL COMMENT '组内显示名', + "display_group_name" varchar(64) DEFAULT NULL COMMENT '群显示备注', + "muted" bit DEFAULT FALSE COMMENT '是否免打扰', + "status" tinyint NOT NULL COMMENT '成员状态', + "join_time" timestamp DEFAULT NULL COMMENT '入群时间', + "quit_time" timestamp DEFAULT NULL COMMENT '退群时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 群成员表'; + +CREATE TABLE IF NOT EXISTS "im_friend" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "user_id" bigint NOT NULL COMMENT '用户编号', + "friend_user_id" bigint NOT NULL COMMENT '好友用户编号', + "muted" bit DEFAULT FALSE COMMENT '是否免打扰', + "status" tinyint NOT NULL COMMENT '好友状态', + "add_time" timestamp DEFAULT NULL COMMENT '添加好友时间', + "delete_time" timestamp DEFAULT NULL COMMENT '删除好友时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 好友关系表';