From 6b1a0cfce2c793e495c7512ed39d9cf212f729e0 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Mon, 18 May 2026 15:17:08 +0800 Subject: [PATCH] ✨ feat(im): 更多单元测试 --- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapperTest.java | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java | 127 ------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java | 66 ++++-------------------------------------------------------------- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java | 243 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java | 278 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImplTest.java | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImplTest.java | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java | 290 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java | 35 ++++++++++++++++++++--------------- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java | 8 +++++++- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java | 4 ++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java | 6 +++--- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java | 10 +++++----- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImplTest.java | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/util/ImMessageUtilsTest.java | 184 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/resources/application-unit-test.yaml | 2 +- yudao-module-im/src/test/resources/sql/clean.sql | 1 + yudao-module-im/src/test/resources/sql/create_tables.sql | 24 ++++++++++++++++++++++++ 19 files changed, 1528 insertions(+), 457 deletions(-) create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapperTest.java delete mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java delete mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/util/ImMessageUtilsTest.java diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapperTest.java new file mode 100644 index 0000000..b2b8d5b --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapperTest.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.im.dal.mysql.friend; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 好友申请记录 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImFriendRequestMapperTest extends BaseDbUnitTest { + + @Resource + private ImFriendRequestMapper mapper; + + // ========== selectMyList ========== + + @Test + public void testSelectMyList_bidirectional() { + // 准备:1 既作为 from 又作为 to;3 是无关用户 + mapper.insert(buildRequest(1L, 2L)); + mapper.insert(buildRequest(2L, 1L)); + mapper.insert(buildRequest(3L, 4L)); + + // 调用:lastRequestId=null 拉首页 + List list = mapper.selectMyList(1L, null, 10); + + // 断言:双向 OR 命中两条,无关用户被排除 + assertEquals(2, list.size()); + list.forEach(r -> assertTrue(r.getFromUserId().equals(1L) || r.getToUserId().equals(1L))); + } + + @Test + public void testSelectMyList_cursorPaging() { + // 准备:三条 1 相关的申请 + ImFriendRequestDO r1 = buildRequest(1L, 2L); + ImFriendRequestDO r2 = buildRequest(1L, 3L); + ImFriendRequestDO r3 = buildRequest(1L, 4L); + mapper.insert(r1); + mapper.insert(r2); + mapper.insert(r3); + + // 调用:lastRequestId = r3.id,拉 id < r3.id 的下一页 + List next = mapper.selectMyList(1L, r3.getId(), 10); + + // 断言:仅含 r1 / r2,按 id 倒序 + assertEquals(2, next.size()); + assertEquals(r2.getId(), next.get(0).getId()); + assertEquals(r1.getId(), next.get(1).getId()); + } + + @Test + public void testSelectMyList_limit() { + // 准备:插入 3 条 + for (int i = 0; i < 3; i++) { + mapper.insert(buildRequest(1L, (long) (10 + i))); + } + + // 调用:limit = 2 + List list = mapper.selectMyList(1L, null, 2); + + // 断言:手写 LIMIT 真生效 + assertEquals(2, list.size()); + } + + private static ImFriendRequestDO buildRequest(Long fromUserId, Long toUserId) { + return new ImFriendRequestDO() + .setFromUserId(fromUserId).setToUserId(toUserId) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java deleted file mode 100644 index 2ce7dec..0000000 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java +++ /dev/null @@ -1,127 +0,0 @@ -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; - - // ========== 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/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java index c14b761..735afbb 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java @@ -43,8 +43,8 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { } @Test - public void testSelectListByMinId_excludeRecall() { - // 准备:一条正常 + 一条撤回 + public void testSelectListByMinId_includesRecall() { + // 准备:一条正常 + 一条撤回;客户端拉离线消息需要拿到撤回状态以渲染「此消息已撤回」占位 ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); mapper.insert(normal); ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); @@ -52,8 +52,8 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100); - assertEquals(1, result.size()); - assertEquals(normal.getId(), result.get(0).getId()); + // 断言:撤回消息一并返回,由客户端按 status 切换渲染 + assertEquals(2, result.size()); } @Test @@ -211,64 +211,6 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { assertEquals(after.getId(), result.get(0).getId()); } - // ========== 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) { diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java deleted file mode 100644 index f5ade6d..0000000 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java +++ /dev/null @@ -1,243 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.mysql.rtc; - -import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; -import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; -import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum; -import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; -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.*; - -public class ImRtcParticipantMapperTest extends BaseDbUnitTest { - - @Resource - private ImRtcParticipantMapper mapper; - - // ========== selectByRoomAndUserId ========== - - @Test - public void testSelectByRoomAndUserId_match() { - // 准备:room=r1 / userId=100 命中;同 room 其他 user 不应误命中 - ImRtcParticipantDO target = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING); - insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING); - insert("r2", 100L, ImRtcParticipantStatusEnum.INVITING); - - // 调用 + 断言 - ImRtcParticipantDO got = mapper.selectByRoomAndUserId("r1", 100L); - assertNotNull(got); - assertEquals(target.getId(), got.getId()); - } - - @Test - public void testSelectByRoomAndUserId_miss() { - // 准备:room 不存在的查询返 null - insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING); - - // 调用 + 断言 - assertNull(mapper.selectByRoomAndUserId("r2", 100L)); - assertNull(mapper.selectByRoomAndUserId("r1", 999L)); - } - - // ========== selectListByRoom ========== - - @Test - public void testSelectListByRoom_filterByRoom() { - // 准备:r1 两条、r2 一条 - insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING); - insert("r1", 101L, ImRtcParticipantStatusEnum.JOINED); - insert("r2", 200L, ImRtcParticipantStatusEnum.INVITING); - - // 调用 + 断言:仅 r1 命中两条 - List result = mapper.selectListByRoom("r1"); - assertEquals(2, result.size()); - assertTrue(result.stream().allMatch(p -> "r1".equals(p.getRoom()))); - } - - // ========== selectListByStatusAndInviteTimeBefore ========== - - @Test - public void testSelectListByStatusAndInviteTimeBefore_filterStatusAndTime() { - // 准备:3 条 INVITING(一旧两新)+ 1 条 JOINED(旧,状态不匹配) - LocalDateTime now = LocalDateTime.now(); - ImRtcParticipantDO oldInviting = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5)); - insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10)); - insert("r2", 102L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10)); - insert("r3", 103L, ImRtcParticipantStatusEnum.JOINED, now.minusMinutes(5)); - - // 调用:cutoff = now - 1 分钟,只命中早于该时间的 INVITING - List result = mapper.selectListByStatusAndInviteTimeBefore( - ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1)); - - // 断言:仅 oldInviting 命中(status + time 双重过滤) - assertEquals(1, result.size()); - assertEquals(oldInviting.getId(), result.get(0).getId()); - } - - @Test - public void testSelectListByStatusAndInviteTimeBefore_emptyWhenNoMatch() { - // 准备:所有 INVITING 都是新鲜的 - LocalDateTime now = LocalDateTime.now(); - insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10)); - - // 调用 + 断言:cutoff 在更早的时间,无命中 - List result = mapper.selectListByStatusAndInviteTimeBefore( - ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(5)); - assertTrue(result.isEmpty()); - } - - // ========== selectListByRoomAndStatusAndInviteTimeBefore ========== - - @Test - public void testSelectListByRoomAndStatusAndInviteTimeBefore_limitToRoom() { - // 准备:r1 一旧 INVITING;r2 一旧 INVITING;r1 一新 INVITING - LocalDateTime now = LocalDateTime.now(); - ImRtcParticipantDO r1Old = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5)); - insert("r2", 101L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5)); - insert("r1", 102L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10)); - - // 调用:限定 room=r1,cutoff = now - 1 分钟 - List result = mapper.selectListByRoomAndStatusAndInviteTimeBefore( - "r1", ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1)); - - // 断言:r2 的旧 INVITING 被 room 过滤掉;r1 的新 INVITING 被 time 过滤掉 - assertEquals(1, result.size()); - assertEquals(r1Old.getId(), result.get(0).getId()); - } - - @Test - public void testSelectListByRoomAndStatusAndInviteTimeBefore_ignoresOtherStatus() { - // 准备:r1 一旧 JOINED(虽然 time 满足,status 不匹配)+ 一旧 REJECTED - LocalDateTime now = LocalDateTime.now(); - insert("r1", 100L, ImRtcParticipantStatusEnum.JOINED, now.minusMinutes(5)); - insert("r1", 101L, ImRtcParticipantStatusEnum.REJECTED, now.minusMinutes(5)); - - // 调用 + 断言:仅扫 INVITING 状态,无命中 - List result = mapper.selectListByRoomAndStatusAndInviteTimeBefore( - "r1", ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1)); - assertTrue(result.isEmpty()); - } - - // ========== selectLastOneByUserIdAndStatus ========== - - @Test - public void testSelectLastOneByUserIdAndStatus_returnsLatestActive() { - // 准备:user 100 两条 INVITING(不同 room)+ 一条 LEFT - insert("r1", 100L, ImRtcParticipantStatusEnum.LEFT); - insert("r2", 100L, ImRtcParticipantStatusEnum.INVITING); - ImRtcParticipantDO latest = insert("r3", 100L, ImRtcParticipantStatusEnum.JOINED); - - // 调用:忙线检测取 ACTIVE_STATUSES,应取 id 最大那条 - ImRtcParticipantDO got = mapper.selectLastOneByUserIdAndStatus(100L, - ImRtcParticipantStatusEnum.ACTIVE_STATUSES); - - // 断言:返回最新的 JOINED 记录(id 最大) - assertNotNull(got); - assertEquals(latest.getId(), got.getId()); - } - - @Test - public void testSelectLastOneByUserIdAndStatus_missWhenAllTerminal() { - // 准备:user 100 只有终态记录 - insert("r1", 100L, ImRtcParticipantStatusEnum.LEFT); - insert("r2", 100L, ImRtcParticipantStatusEnum.REJECTED); - - // 调用 + 断言:忙线检测无命中 - assertNull(mapper.selectLastOneByUserIdAndStatus(100L, - ImRtcParticipantStatusEnum.ACTIVE_STATUSES)); - } - - // ========== updateByIdAndStatus(振铃超时 CAS 路径关键) ========== - - @Test - public void testUpdateByIdAndStatus_inviting2NoAnswer_success() { - // 准备:一条 INVITING 候选 - ImRtcParticipantDO p = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, LocalDateTime.now().minusMinutes(5)); - - // 调用:CAS INVITING → NO_ANSWER;模拟 Job 单参与者抢占 - int updated = mapper.updateByIdAndStatus(p.getId(), - ImRtcParticipantStatusEnum.INVITING.getStatus(), - new ImRtcParticipantDO().setId(p.getId()) - .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()) - .setLeaveTime(LocalDateTime.now())); - - // 断言:CAS 成功 + 状态真的落地 - assertEquals(1, updated); - assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(), - mapper.selectById(p.getId()).getStatus()); - } - - @Test - public void testUpdateByIdAndStatus_concurrentReject_casFails() { - // 准备:一条记录已经被用户主动 reject(状态变成 REJECTED) - ImRtcParticipantDO p = insert("r1", 100L, ImRtcParticipantStatusEnum.REJECTED, LocalDateTime.now().minusMinutes(5)); - - // 调用:Job 仍按 INVITING 抢占;并发已变更应失败 - int updated = mapper.updateByIdAndStatus(p.getId(), - ImRtcParticipantStatusEnum.INVITING.getStatus(), - new ImRtcParticipantDO().setId(p.getId()) - .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()) - .setLeaveTime(LocalDateTime.now())); - - // 断言:CAS 失败 + 原状态保留 - assertEquals(0, updated); - assertEquals(ImRtcParticipantStatusEnum.REJECTED.getStatus(), - mapper.selectById(p.getId()).getStatus()); - } - - // ========== updateByRoomAndStatus(endSession 批量改 INVITING → NO_ANSWER 路径关键) ========== - - @Test - public void testUpdateByRoomAndStatus_inviting2NoAnswer_batchUpdate() { - // 准备:r1 两条 INVITING + 一条 JOINED(状态不匹配,不应被改);r2 一条 INVITING(room 不匹配,不应被改) - ImRtcParticipantDO inv1 = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING); - ImRtcParticipantDO inv2 = insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING); - ImRtcParticipantDO joined = insert("r1", 102L, ImRtcParticipantStatusEnum.JOINED); - ImRtcParticipantDO otherRoom = insert("r2", 103L, ImRtcParticipantStatusEnum.INVITING); - - // 调用:模拟 endSession 把残留 INVITING 批量改 NO_ANSWER - int updated = mapper.updateByRoomAndStatus("r1", - ImRtcParticipantStatusEnum.INVITING.getStatus(), - new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())); - - // 断言:仅 r1 的 2 条 INVITING 被改,其它保持 - assertEquals(2, updated); - assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(), mapper.selectById(inv1.getId()).getStatus()); - assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(), mapper.selectById(inv2.getId()).getStatus()); - assertEquals(ImRtcParticipantStatusEnum.JOINED.getStatus(), mapper.selectById(joined.getId()).getStatus()); - assertEquals(ImRtcParticipantStatusEnum.INVITING.getStatus(), mapper.selectById(otherRoom.getId()).getStatus()); - } - - @Test - public void testUpdateByRoomAndStatus_noMatch_returnsZero() { - // 准备:r1 只有 JOINED 状态 - insert("r1", 100L, ImRtcParticipantStatusEnum.JOINED); - - // 调用:找 INVITING 应无命中 - int updated = mapper.updateByRoomAndStatus("r1", - ImRtcParticipantStatusEnum.INVITING.getStatus(), - new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())); - - // 断言:0 行受影响 - assertEquals(0, updated); - } - - private ImRtcParticipantDO insert(String room, Long userId, ImRtcParticipantStatusEnum status) { - return insert(room, userId, status, LocalDateTime.now()); - } - - private ImRtcParticipantDO insert(String room, Long userId, ImRtcParticipantStatusEnum status, LocalDateTime inviteTime) { - ImRtcParticipantDO p = new ImRtcParticipantDO() - .setRoom(room) - .setUserId(userId) - .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole()) - .setStatus(status.getStatus()) - .setInviteTime(inviteTime); - mapper.insert(p); - return p; - } - -} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java new file mode 100644 index 0000000..4808da4 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java @@ -0,0 +1,278 @@ +package cn.iocoder.yudao.module.im.dal.mysql.statistics; + +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.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; +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 jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 数据看板 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImStatisticsManagerMapperTest extends BaseDbUnitTest { + + private static final LocalDateTime WINDOW_BEGIN = LocalDateTime.of(2026, 1, 1, 0, 0); + private static final LocalDateTime WINDOW_END = LocalDateTime.of(2026, 2, 1, 0, 0); + + @Resource + private ImStatisticsManagerMapper mapper; + + @Resource + private ImPrivateMessageMapper privateMessageMapper; + @Resource + private ImGroupMessageMapper groupMessageMapper; + @Resource + private ImGroupMapper groupMapper; + @Resource + private ImGroupMemberMapper groupMemberMapper; + @Resource + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void initJdbcTemplate() { + jdbcTemplate = new JdbcTemplate(dataSource); + } + + // ========== 用户 ========== + + @Test + public void testSelectTotalUserCount() { + // 准备:3 个未删除用户 + insertUser(1L, LocalDateTime.now()); + insertUser(2L, LocalDateTime.now()); + insertUser(3L, LocalDateTime.now()); + + // 调用 + 断言 + assertEquals(3L, mapper.selectTotalUserCount()); + } + + @Test + public void testSelectNewUserCount_windowFilter() { + // 准备:1 个在窗口内 + 2 个在窗口外 + insertUser(1L, WINDOW_BEGIN.plusDays(3)); + insertUser(2L, WINDOW_BEGIN.minusDays(1)); + insertUser(3L, WINDOW_END.plusDays(1)); + + // 调用 + 断言:只命中窗口内 + assertEquals(1L, mapper.selectNewUserCount(WINDOW_BEGIN, WINDOW_END)); + } + + @Test + public void testSelectActiveUserCount_distinctAcrossPrivateAndGroup() { + // 准备:1 私聊;2 群聊;3 在窗口外;1 既发私聊又发群聊(去重) + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1))); + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(2))); + groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(3))); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(4))); // 用户 1 已计一次 + privateMessageMapper.insert(buildPrivate(3L, 4L, WINDOW_BEGIN.minusDays(1))); // 窗口外 + + // 调用 + 断言:去重后 = 2 + assertEquals(2L, mapper.selectActiveUserCount(WINDOW_BEGIN, WINDOW_END)); + } + + @Test + public void testSelectNewUserDailyCount_groupByDay() { + // 准备:第 1 天 2 个;第 2 天 1 个;窗口外 1 个 + insertUser(1L, WINDOW_BEGIN.plusDays(1)); + insertUser(2L, WINDOW_BEGIN.plusDays(1)); + insertUser(3L, WINDOW_BEGIN.plusDays(2)); + insertUser(4L, WINDOW_BEGIN.minusDays(1)); + + // 调用 + List> list = mapper.selectNewUserDailyCount(WINDOW_BEGIN, WINDOW_END); + + // 断言:返回两个分组、总数 = 3 + assertEquals(2, list.size()); + long sum = list.stream().mapToLong(m -> ((Number) m.get("count")).longValue()).sum(); + assertEquals(3L, sum); + } + + @Test + public void testSelectActiveUserDailyCount_distinctPerDay() { + // 准备:同一天里用户 1 在私聊与群聊都发了;第二天用户 2 发了 + privateMessageMapper.insert(buildPrivate(1L, 9L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(2))); + + // 调用 + List> list = mapper.selectActiveUserDailyCount(WINDOW_BEGIN, WINDOW_END); + + // 断言:两天合计 2 名活跃用户(同一天 user 1 去重 + 第二天 user 2) + assertEquals(2, list.size()); + long sum = list.stream().mapToLong(m -> ((Number) m.get("count")).longValue()).sum(); + assertEquals(2L, sum); + } + + // ========== 群 ========== + + @Test + public void testSelectTotalGroupCount_onlyEnabled() { + // 准备:2 个正常 + 1 个已解散 + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); + groupMapper.insert(buildGroup(CommonStatusEnum.DISABLE)); + + // 调用 + 断言 + assertEquals(2L, mapper.selectTotalGroupCount()); + } + + @Test + public void testSelectNewGroupCount_windowFilter() { + // 准备:1 个窗口内 + 1 个窗口外 + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); + // 窗口窄到只覆盖 1 秒 + LocalDateTime narrowBegin = LocalDateTime.now().plusYears(10); + LocalDateTime narrowEnd = narrowBegin.plusSeconds(1); + + // 调用 + 断言:极窄窗口不命中任何群 + assertEquals(0L, mapper.selectNewGroupCount(narrowBegin, narrowEnd)); + } + + @Test + public void testSelectGroupSizeDistribution_bucketing() { + // 准备:一个 5 人群、一个 15 人群 + Long groupSmall = insertGroupAndMembers(5); + Long groupMid = insertGroupAndMembers(15); + assertNotNull(groupSmall); + assertNotNull(groupMid); + + // 调用 + List> dist = mapper.selectGroupSizeDistribution(); + + // 断言:分桶包含「1-9 人」和「10-49 人」 + Map byRange = dist.stream().collect(java.util.stream.Collectors.toMap( + m -> m.get("range"), m -> ((Number) m.get("count")).longValue())); + assertEquals(1L, byRange.get("1-9 人")); + assertEquals(1L, byRange.get("10-49 人")); + } + + // ========== 消息 ========== + + @Test + public void testSelectPrivateMessageCount_windowFilter() { + // 准备:1 在窗口内 + 1 在窗口外 + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1))); + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.minusDays(1))); + + // 调用 + 断言 + assertEquals(1L, mapper.selectPrivateMessageCount(WINDOW_BEGIN, WINDOW_END)); + } + + @Test + public void testSelectGroupMessageCount_windowFilter() { + // 准备:1 在窗口内 + 1 在窗口外 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.minusDays(1))); + + // 调用 + 断言 + assertEquals(1L, mapper.selectGroupMessageCount(WINDOW_BEGIN, WINDOW_END)); + } + + @Test + public void testSelectMessageTypeDistribution_mergePrivateAndGroup() { + // 准备:私聊 type=0 ×1,群聊 type=0 ×2、type=1 ×1 + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.TEXT.getType())); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.TEXT.getType())); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.TEXT.getType())); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.IMAGE.getType())); + + // 调用 + List> dist = mapper.selectMessageTypeDistribution(WINDOW_BEGIN, WINDOW_END); + + // 断言:type=TEXT(0) → 3;type=IMAGE(1) → 1 + Map byType = dist.stream().collect(java.util.stream.Collectors.toMap( + m -> ((Number) m.get("type")).intValue(), m -> ((Number) m.get("count")).longValue())); + assertEquals(3L, byType.get(ImMessageTypeEnum.TEXT.getType())); + assertEquals(1L, byType.get(ImMessageTypeEnum.IMAGE.getType())); + } + + @Test + public void testSelectTopSenders_orderByCountDescAndLimit() { + // 准备:user 1 共 3 条;user 2 共 2 条;user 3 共 1 条 + privateMessageMapper.insert(buildPrivate(1L, 9L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); + privateMessageMapper.insert(buildPrivate(2L, 9L, WINDOW_BEGIN.plusDays(1))); + groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(1))); + privateMessageMapper.insert(buildPrivate(3L, 9L, WINDOW_BEGIN.plusDays(1))); + + // 调用:取 TOP 2 + List> tops = mapper.selectTopSenders(WINDOW_BEGIN, WINDOW_END, 2); + + // 断言:返回 2 条,按消息数倒序,user1 > user2 + assertEquals(2, tops.size()); + assertEquals(1L, ((Number) tops.get(0).get("userId")).longValue()); + assertEquals(3L, ((Number) tops.get(0).get("messageCount")).longValue()); + assertEquals(2L, ((Number) tops.get(1).get("userId")).longValue()); + assertEquals(2L, ((Number) tops.get(1).get("messageCount")).longValue()); + } + + // ========== 工具方法 ========== + + private void insertUser(Long id, LocalDateTime createTime) { + jdbcTemplate.update( + "INSERT INTO system_users (id, username, password, nickname, status, create_time, update_time, deleted, tenant_id) " + + "VALUES (?, ?, '', ?, 0, ?, ?, FALSE, 0)", + id, "u" + id, "n" + id, createTime, createTime); + } + + private static ImPrivateMessageDO buildPrivate(Long senderId, Long receiverId, LocalDateTime sendTime) { + return ImPrivateMessageDO.builder() + .clientMessageId("uuid-" + System.nanoTime()) + .senderId(senderId).receiverId(receiverId) + .type(ImMessageTypeEnum.TEXT.getType()) + .content("{}") + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(sendTime).build(); + } + + private static ImGroupMessageDO buildGroupMessage(Long senderId, Long groupId, LocalDateTime sendTime) { + return new ImGroupMessageDO() + .setClientMessageId("uuid-" + System.nanoTime()) + .setSenderId(senderId).setGroupId(groupId) + .setType(ImMessageTypeEnum.TEXT.getType()) + .setContent("{}") + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()) + .setSendTime(sendTime); + } + + private static ImGroupDO buildGroup(CommonStatusEnum status) { + return ImGroupDO.builder().name("g" + System.nanoTime()).ownerUserId(1L) + .status(status.getStatus()).build(); + } + + private Long insertGroupAndMembers(int memberCount) { + ImGroupDO group = buildGroup(CommonStatusEnum.ENABLE); + groupMapper.insert(group); + for (int i = 0; i < memberCount; i++) { + groupMemberMapper.insert(new ImGroupMemberDO() + .setGroupId(group.getId()).setUserId((long) (1000 + i)) + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setRole(3)); + } + return group.getId(); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImplTest.java new file mode 100644 index 0000000..448d2b1 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImplTest.java @@ -0,0 +1,164 @@ +package cn.iocoder.yudao.module.im.service.face; + +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.manager.face.vo.item.ImFacePackItemSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackItemDO; +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFacePackItemMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_ITEM_NOT_EXISTS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 表情包项 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFacePackItemServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFacePackItemServiceImpl service; + + @Mock + private ImFacePackItemMapper facePackItemMapper; + @Mock + private ImFacePackService facePackService; + + // ========== getEnabledItemListByPackIds ========== + + @Test + public void testGetEnabledItemListByPackIds_emptyShortReturn() { + // 调用 + 断言:空入参直接返回空列表,不查 DB + assertTrue(service.getEnabledItemListByPackIds(Collections.emptyList()).isEmpty()); + verify(facePackItemMapper, never()).selectListByPackIdsAndStatus(any(), any()); + } + + @Test + public void testGetEnabledItemListByPackIds_passesEnableStatus() { + // 准备 + List packIds = Arrays.asList(1L, 2L); + ImFacePackItemDO item = new ImFacePackItemDO(); + when(facePackItemMapper.selectListByPackIdsAndStatus(packIds, CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(Collections.singletonList(item)); + + // 调用 + 断言 + assertEquals(1, service.getEnabledItemListByPackIds(packIds).size()); + } + + // ========== createFacePackItem ========== + + @Test + public void testCreateFacePackItem_success() { + // 准备 + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO(); + reqVO.setPackId(10L).setUrl("a.png").setName("dog").setWidth(100).setHeight(100) + .setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackService.validateFacePackExists(10L)).thenReturn(new ImFacePackDO()); + + // 调用 + service.createFacePackItem(reqVO); + + // 断言 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFacePackItemDO.class); + verify(facePackItemMapper).insert(captor.capture()); + assertEquals(10L, captor.getValue().getPackId()); + assertEquals("a.png", captor.getValue().getUrl()); + } + + @Test + public void testCreateFacePackItem_packNotExists() { + // 准备:所属表情包不存在 + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO(); + reqVO.setPackId(99L).setUrl("a.png").setWidth(100).setHeight(100).setSort(0) + .setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackService.validateFacePackExists(99L)) + .thenThrow(new ServiceException(FACE_PACK_NOT_EXISTS.getCode(), FACE_PACK_NOT_EXISTS.getMsg())); + + // 调用 + 断言:不落库 + assertThrows(ServiceException.class, () -> service.createFacePackItem(reqVO)); + verify(facePackItemMapper, never()).insert(any(ImFacePackItemDO.class)); + } + + // ========== updateFacePackItem ========== + + @Test + public void testUpdateFacePackItem_success() { + // 准备 + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO(); + reqVO.setId(1L).setPackId(10L).setUrl("a.png").setWidth(100).setHeight(100) + .setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackItemMapper.selectById(1L)).thenReturn(new ImFacePackItemDO()); + when(facePackService.validateFacePackExists(10L)).thenReturn(new ImFacePackDO()); + + // 调用 + service.updateFacePackItem(reqVO); + + // 断言 + verify(facePackItemMapper).updateById(any(ImFacePackItemDO.class)); + } + + @Test + public void testUpdateFacePackItem_itemNotExists() { + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO(); + reqVO.setId(99L).setPackId(10L).setUrl("a.png").setWidth(100).setHeight(100).setSort(0) + .setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackItemMapper.selectById(99L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.updateFacePackItem(reqVO)); + assertEquals(FACE_PACK_ITEM_NOT_EXISTS.getCode(), exception.getCode()); + verify(facePackItemMapper, never()).updateById(any(ImFacePackItemDO.class)); + } + + // ========== deleteFacePackItem ========== + + @Test + public void testDeleteFacePackItem_success() { + when(facePackItemMapper.selectById(1L)).thenReturn(new ImFacePackItemDO()); + + service.deleteFacePackItem(1L); + + verify(facePackItemMapper).deleteById(1L); + } + + @Test + public void testDeleteFacePackItem_notExists() { + when(facePackItemMapper.selectById(99L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.deleteFacePackItem(99L)); + assertEquals(FACE_PACK_ITEM_NOT_EXISTS.getCode(), exception.getCode()); + verify(facePackItemMapper, never()).deleteById(anyLong()); + } + + @Test + public void testDeleteFacePackItemList_emptySkip() { + service.deleteFacePackItemList(Collections.emptyList()); + + verify(facePackItemMapper, never()).deleteByIds(any()); + } + + @Test + public void testDeleteFacePackItemList_success() { + service.deleteFacePackItemList(Arrays.asList(1L, 2L)); + + verify(facePackItemMapper).deleteByIds(Arrays.asList(1L, 2L)); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImplTest.java new file mode 100644 index 0000000..2cf1f01 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImplTest.java @@ -0,0 +1,177 @@ +package cn.iocoder.yudao.module.im.service.face; + +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.manager.face.vo.pack.ImFacePackSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO; +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFacePackMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_HAS_ITEMS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 表情包 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFacePackServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFacePackServiceImpl service; + + @Mock + private ImFacePackMapper facePackMapper; + @Mock + private ImFacePackItemService facePackItemService; + + // ========== validateFacePackExists ========== + + @Test + public void testValidateFacePackExists_notFound() { + when(facePackMapper.selectById(1L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.validateFacePackExists(1L)); + assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode()); + } + + @Test + public void testValidateFacePackExists_returnsDo() { + ImFacePackDO pack = ImFacePackDO.builder().id(1L).name("猫主子").build(); + when(facePackMapper.selectById(1L)).thenReturn(pack); + + assertEquals(pack, service.validateFacePackExists(1L)); + } + + // ========== createFacePack ========== + + @Test + public void testCreateFacePack_insert() { + // 准备 + ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO(); + reqVO.setName("猫主子").setIcon("icon.png").setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus()); + + // 调用 + service.createFacePack(reqVO); + + // 断言:mapper.insert 被调用一次 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFacePackDO.class); + verify(facePackMapper).insert(captor.capture()); + assertEquals("猫主子", captor.getValue().getName()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus()); + } + + // ========== updateFacePack ========== + + @Test + public void testUpdateFacePack_success() { + // 准备:存在 + ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO(); + reqVO.setId(1L).setName("新名").setSort(1).setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build()); + + // 调用 + service.updateFacePack(reqVO); + + // 断言 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFacePackDO.class); + verify(facePackMapper).updateById(captor.capture()); + assertEquals(1L, captor.getValue().getId()); + assertEquals("新名", captor.getValue().getName()); + } + + @Test + public void testUpdateFacePack_notExists() { + // 准备 + ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO(); + reqVO.setId(99L).setName("x").setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus()); + when(facePackMapper.selectById(99L)).thenReturn(null); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> service.updateFacePack(reqVO)); + assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode()); + verify(facePackMapper, never()).updateById(any(ImFacePackDO.class)); + } + + // ========== deleteFacePack ========== + + @Test + public void testDeleteFacePack_success() { + when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build()); + when(facePackItemService.getFacePackItemCount(1L)).thenReturn(0L); + + service.deleteFacePack(1L); + + verify(facePackMapper).deleteById(1L); + } + + @Test + public void testDeleteFacePack_hasItems() { + // 准备:包下仍有表情 + when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build()); + when(facePackItemService.getFacePackItemCount(1L)).thenReturn(3L); + + ServiceException exception = assertThrows(ServiceException.class, () -> service.deleteFacePack(1L)); + assertEquals(FACE_PACK_HAS_ITEMS.getCode(), exception.getCode()); + verify(facePackMapper, never()).deleteById(anyLong()); + } + + @Test + public void testDeleteFacePack_notExists() { + when(facePackMapper.selectById(99L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, () -> service.deleteFacePack(99L)); + assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode()); + } + + // ========== deleteFacePackList ========== + + @Test + public void testDeleteFacePackList_emptySkip() { + // 调用:空 ids 列表直接返回,不查询、不删除 + service.deleteFacePackList(Collections.emptyList()); + + verify(facePackItemService, never()).getFacePackItemCount(any(java.util.Collection.class)); + verify(facePackMapper, never()).deleteByIds(any()); + } + + @Test + public void testDeleteFacePackList_anyHasItemsRejectAll() { + // 准备:批量中存在表情 + when(facePackItemService.getFacePackItemCount(Arrays.asList(1L, 2L))).thenReturn(1L); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.deleteFacePackList(Arrays.asList(1L, 2L))); + assertEquals(FACE_PACK_HAS_ITEMS.getCode(), exception.getCode()); + verify(facePackMapper, never()).deleteByIds(any()); + } + + @Test + public void testDeleteFacePackList_success() { + // 准备 + when(facePackItemService.getFacePackItemCount(Arrays.asList(1L, 2L))).thenReturn(0L); + + // 调用 + service.deleteFacePackList(Arrays.asList(1L, 2L)); + + // 断言 + verify(facePackMapper).deleteByIds(Arrays.asList(1L, 2L)); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java new file mode 100644 index 0000000..ae4026b --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java @@ -0,0 +1,126 @@ +package cn.iocoder.yudao.module.im.service.face; + +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.face.vo.useritem.ImFaceUserItemSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 用户私有表情 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFaceUserItemServiceImpl service; + + @Mock + private ImFaceUserItemMapper faceUserItemMapper; + + // ========== createFaceUserItem ========== + + @Test + public void testCreateFaceUserItem_success() { + // 准备 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO(); + reqVO.setUrl("a.png").setName("doge").setWidth(100).setHeight(100); + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null); + + // 调用 + service.createFaceUserItem(1L, reqVO); + + // 断言:写入用户编号 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFaceUserItemDO.class); + verify(faceUserItemMapper).insert(captor.capture()); + assertEquals(1L, captor.getValue().getUserId()); + assertEquals("a.png", captor.getValue().getUrl()); + } + + @Test + public void testCreateFaceUserItem_duplicateUrl() { + // 准备:相同 (userId, url) 已存在 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO(); + reqVO.setUrl("a.png").setWidth(100).setHeight(100); + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")) + .thenReturn(new ImFaceUserItemDO().setUserId(1L)); + + // 调用 + 断言:抛重复异常,不落库 + ServiceException exception = assertThrows(ServiceException.class, + () -> service.createFaceUserItem(1L, reqVO)); + assertEquals(FACE_USER_ITEM_DUPLICATED.getCode(), exception.getCode()); + verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class)); + } + + // ========== deleteFaceUserItem(用户端) ========== + + @Test + public void testDeleteFaceUserItem_userOwn_success() { + // 准备:归属当前用户 + when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(1L)); + + // 调用 + service.deleteFaceUserItem(1L, 10L); + + // 断言 + verify(faceUserItemMapper).deleteById(10L); + } + + @Test + public void testDeleteFaceUserItem_notExists() { + when(faceUserItemMapper.selectById(99L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.deleteFaceUserItem(1L, 99L)); + assertEquals(FACE_USER_ITEM_NOT_EXISTS.getCode(), exception.getCode()); + verify(faceUserItemMapper, never()).deleteById(anyLong()); + } + + @Test + public void testDeleteFaceUserItem_notOwn() { + // 准备:表情归属 user 2,但 user 1 来删 + when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(2L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.deleteFaceUserItem(1L, 10L)); + assertEquals(FACE_USER_ITEM_NOT_OWN.getCode(), exception.getCode()); + verify(faceUserItemMapper, never()).deleteById(anyLong()); + } + + // ========== deleteFaceUserItem(管理后台 by id) ========== + + @Test + public void testDeleteFaceUserItemAdmin_success() { + when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(2L)); + + service.deleteFaceUserItem(10L); + + verify(faceUserItemMapper).deleteById(10L); + } + + @Test + public void testDeleteFaceUserItemAdmin_notExists() { + when(faceUserItemMapper.selectById(99L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.deleteFaceUserItem(99L)); + assertEquals(FACE_USER_ITEM_NOT_EXISTS.getCode(), exception.getCode()); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java new file mode 100644 index 0000000..ab7363e --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java @@ -0,0 +1,290 @@ +package cn.iocoder.yudao.module.im.service.friend; + +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.request.ImFriendRequestApplyReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO; +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendRequestMapper; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum; +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; +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 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 static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 好友申请 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFriendRequestServiceImpl friendRequestService; + + @Mock + private ImFriendRequestMapper friendRequestMapper; + @Mock + private ImFriendService friendService; + @Mock + private ImWebSocketService websocketService; + @Mock + private ImProperties imProperties; + @Mock + private AdminUserApi adminUserApi; + + // ========== applyFriend ========== + + @Test + public void testApplyFriend_addSelf() { + // 准备:发起人 = 接收人 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(1L); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.applyFriend(1L, reqVO)); + assertEquals(FRIEND_ADD_SELF.getCode(), exception.getCode()); + } + + @Test + public void testApplyFriend_alreadyFriend() { + // 准备 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.FRIEND.getState()); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.applyFriend(1L, reqVO)); + assertEquals(FRIEND_REQUEST_ALREADY_FRIEND.getCode(), exception.getCode()); + } + + @Test + public void testApplyFriend_blockedByPeer() { + // 准备 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.BLOCKED.getState()); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.applyFriend(1L, reqVO)); + assertEquals(FRIEND_REQUEST_BLOCKED_BY_PEER.getCode(), exception.getCode()); + } + + @Test + public void testApplyFriend_silentReAdd() { + // 准备:单向好友 — 我已删除,但对方仍把我当好友 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L).setDisplayName("老张").setAddSource(1); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState()); + ImFriendDO peerFriend = ImFriendDO.builder().userId(2L).friendUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(friendService.getFriend(2L, 1L)).thenReturn(peerFriend); + + // 调用 + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO); + + // 断言:走静默重新加好友,不落申请记录 + assertNull(result); + verify(friendService).silentReAddFriend(eq(1L), eq(2L), eq("老张"), eq(1)); + verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class)); + verify(websocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testApplyFriend_insertNew() { + // 准备:双方都无关系,且无历史申请 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L).setApplyContent("加个好友").setDisplayName("老张").setAddSource(1); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState()); + when(friendService.getFriend(2L, 1L)).thenReturn(null); + when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(null); + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setNickname("张三").setAvatar("a.png")); + when(imProperties.getFriend()).thenReturn(new ImProperties.Friend()); + + // 调用 + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO); + + // 断言:落新申请记录 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendRequestDO.class); + verify(friendRequestMapper).insert(captor.capture()); + ImFriendRequestDO saved = captor.getValue(); + assertEquals(1L, saved.getFromUserId()); + assertEquals(2L, saved.getToUserId()); + assertEquals("加个好友", saved.getApplyContent()); + assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), saved.getHandleResult()); + assertSame(saved, result); + // 断言:推送给接收方 + verify(websocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testApplyFriend_reuseOldRequest() { + // 准备:双方都无关系,但存在历史申请 — 走 reset 复用 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); + reqVO.setToUserId(2L).setApplyContent("再来一次").setDisplayName("老张").setAddSource(2); + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState()); + when(friendService.getFriend(2L, 1L)).thenReturn(null); + ImFriendRequestDO old = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.REFUSED.getResult()) + .setHandleContent("旧拒绝").setApplyContent("旧内容"); + when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(old); + when(adminUserApi.getUser(1L)).thenReturn(null); + when(imProperties.getFriend()).thenReturn(new ImProperties.Friend()); + + // 调用 + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO); + + // 断言:复用旧记录,未触发 insert + verify(friendRequestMapper).updateByIdReset(eq(100L), eq("再来一次"), eq("老张"), eq(2)); + verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class)); + assertEquals(100L, result.getId()); + assertEquals("再来一次", result.getApplyContent()); + assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult()); + assertNull(result.getHandleContent()); + assertNull(result.getHandleTime()); + } + + // ========== agreeFriendRequest ========== + + @Test + public void testAgreeFriendRequest_success() { + // 准备 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + when(friendRequestMapper.updateByIdAndHandleResult(eq(100L), + eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), any(ImFriendRequestDO.class))).thenReturn(1); + + // 调用 + friendRequestService.agreeFriendRequest(2L, 100L); + + // 断言:双向建立好友 + 推 APPROVED 给发起方 + verify(friendService).becomeFriends(request); + verify(websocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testAgreeFriendRequest_notExists() { + // 准备:申请不存在 + when(friendRequestMapper.selectById(100L)).thenReturn(null); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.agreeFriendRequest(2L, 100L)); + assertEquals(FRIEND_REQUEST_NOT_EXISTS.getCode(), exception.getCode()); + } + + @Test + public void testAgreeFriendRequest_notToMe() { + // 准备:操作人不是接收方 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + + // 调用 + 断言:3L 不是接收方 2L + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.agreeFriendRequest(3L, 100L)); + assertEquals(FRIEND_REQUEST_NOT_TO_ME.getCode(), exception.getCode()); + } + + @Test + public void testAgreeFriendRequest_alreadyHandled() { + // 准备:申请已被处理 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + + // 调用 + 断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.agreeFriendRequest(2L, 100L)); + assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode()); + } + + @Test + public void testAgreeFriendRequest_concurrentCasFail() { + // 准备:fail-fast 校验通过,但乐观锁 CAS 被并发抢先 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + when(friendRequestMapper.updateByIdAndHandleResult(anyLong(), anyInt(), any(ImFriendRequestDO.class))).thenReturn(0); + + // 调用 + 断言:返回 FRIEND_REQUEST_HANDLED + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.agreeFriendRequest(2L, 100L)); + assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode()); + verify(friendService, never()).becomeFriends(any(ImFriendRequestDO.class)); + } + + // ========== refuseFriendRequest ========== + + @Test + public void testRefuseFriendRequest_success() { + // 准备 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + when(friendRequestMapper.updateByIdAndHandleResult(eq(100L), + eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), any(ImFriendRequestDO.class))).thenReturn(1); + + // 调用 + friendRequestService.refuseFriendRequest(2L, 100L, "不认识"); + + // 断言:handleResult / handleContent 写入;推 REJECTED 给发起方 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendRequestDO.class); + verify(friendRequestMapper).updateByIdAndHandleResult(eq(100L), + eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), captor.capture()); + assertEquals(ImFriendRequestHandleResultEnum.REFUSED.getResult(), captor.getValue().getHandleResult()); + assertEquals("不认识", captor.getValue().getHandleContent()); + verify(websocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testRefuseFriendRequest_concurrentCasFail() { + // 准备:CAS 失败 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); + when(friendRequestMapper.selectById(100L)).thenReturn(request); + when(friendRequestMapper.updateByIdAndHandleResult(anyLong(), anyInt(), any(ImFriendRequestDO.class))).thenReturn(0); + + // 调用 + 断言:抛 HANDLED,且不推 REJECTED + ServiceException exception = assertThrows(ServiceException.class, + () -> friendRequestService.refuseFriendRequest(2L, 100L, "x")); + assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode()); + verify(websocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + // ========== getMyFriendRequestList ========== + + @Test + public void testGetMyFriendRequestList_delegate() { + // 准备:mapper 返回 mock 数据 + ImFriendRequestDO one = new ImFriendRequestDO().setId(1L); + when(friendRequestMapper.selectMyList(1L, 99L, 20)).thenReturn(java.util.Collections.singletonList(one)); + + // 调用 + 断言:参数透传、结果原样返回 + assertEquals(1, friendRequestService.getMyFriendRequestList(1L, 99L, 20).size()); + verify(friendRequestMapper).selectMyList(1L, 99L, 20); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java index f906cba..a489425 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java @@ -22,6 +22,7 @@ import java.util.List; 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.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -109,17 +110,15 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest { @Test public void testUpdateFriend_emptyRequest() { - // 准备:silent 和 displayName 都不传 —— 直接返回,不打 SQL 也不发推送 + // 准备:silent / displayName / pinned 都不传 —— 进入方法立刻返回,不查 mapper 也不发推送 ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); reqVO.setFriendUserId(2L); - ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L) - .silent(false).status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend); // 调用 friendService.updateFriend(1L, reqVO); - // 断言:没触发 SQL 更新 / 没发 WebSocket 推送 + // 断言:没查记录、没触发 SQL 更新 / 没发 WebSocket 推送 + verify(imFriendMapper, never()).selectByUserIdAndFriendUserId(anyLong(), anyLong()); verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); verify(imWebSocketService, never()).sendPrivateMessageAsync(any(Long.class), any(ImPrivateMessageDTO.class)); } @@ -127,18 +126,25 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest { // ========== addFriend0(内部方法,被 becomeFriends / silentReAddFriend 调用) ========== @Test - public void testAddFriend0_existingEnabledSkipsUpdate() { - // 准备:已存在且启用,第二步不应触发更新 + public void testAddFriend0_existingEnabledResetsFields() { + // 准备:已存在且启用 —— 走复用旧记录路径,重置 silent / pinned / blocked ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); + .status(CommonStatusEnum.ENABLE.getStatus()).silent(true).pinned(true).blocked(true).build(); when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); // 调用 friendService.addFriend0(1L, 2L, null, null); - // 断言:不插入,也不更新 + // 断言:不插入;走 update 重置 silent / pinned / blocked 为 false,并刷新 addTime verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); - verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); + verify(imFriendMapper).updateById(captor.capture()); + assertEquals(10L, captor.getValue().getId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus()); + assertFalse(captor.getValue().getSilent()); + assertFalse(captor.getValue().getPinned()); + assertFalse(captor.getValue().getBlocked()); + assertNotNull(captor.getValue().getAddTime()); } @Test @@ -160,15 +166,14 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest { } @Test - public void testAddFriend0_duplicateKeySwallowed() { - // 准备:mapper 抛并发冲突 + public void testAddFriend0_duplicateKeyPropagates() { + // 准备:mapper 抛并发冲突;极端并发下让异常向外抛,由外层事务回滚 when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); when(imFriendMapper.insert(any(ImFriendDO.class))) .thenThrow(new DuplicateKeyException("concurrent insert")); - // 调用:应被吞掉,不抛异常 - friendService.addFriend0(1L, 2L, null, null); - + // 调用 + 断言:异常向外抛 + assertThrows(DuplicateKeyException.class, () -> friendService.addFriend0(1L, 2L, null, null)); verify(imFriendMapper).insert(any(ImFriendDO.class)); } diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java index b316826..2200687 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java @@ -36,6 +36,8 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { private ImGroupMemberMapper groupMemberMapper; @Mock private ImWebSocketService webSocketService; + @Mock + private cn.iocoder.yudao.module.im.service.message.ImGroupMessageService groupMessageService; // ========== addGroupMember ========== @@ -241,7 +243,11 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { verify(groupMemberMapper).updateById(captor.capture()); assertEquals(50L, captor.getValue().getId()); assertTrue(captor.getValue().getSilent()); - verify(webSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); + // 公开字段昵称变化 → 全员广播 GROUP_MEMBER_NICKNAME_UPDATE + verify(groupMessageService).sendGroupMessage(eq(1L), any(cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO.class)); + // 个人字段 silent 变化 → 仅自己多端同步 GROUP_MEMBER_SETTING_UPDATE + verify(groupMessageService).sendGroupMessage(eq(1L), eq(java.util.List.of(1L)), + any(cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO.class)); } // ========== removeGroupMember ========== diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java index 258fe80..4bb9c5f 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java @@ -18,6 +18,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum; import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; 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.message.dto.ImGroupMessageSendDTO; @@ -30,6 +31,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; +import org.mockito.Spy; import java.time.LocalDateTime; import java.util.ArrayList; @@ -66,6 +68,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { private ImGroupRequestService groupRequestService; @Mock private AdminUserApi adminUserApi; + @Spy + private ImProperties imProperties = new ImProperties(); // ========== createGroup ========== diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java index 13b0c74..9ad12e2 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -68,7 +68,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO(); reqVO.setClientMessageId("test-uuid-group-001"); reqVO.setGroupId(10L); - reqVO.setType(0); // TEXT + reqVO.setType(ImMessageTypeEnum.TEXT.getType()); reqVO.setContent("{\"content\":\"群聊你好\"}"); return reqVO; } @@ -1062,9 +1062,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testSendGroupMessage_dto_nonPersistentTypeNotInserted() { - // 准备:persistent=false 类型(GROUP_CREATE)→ 不入库,仅推送 + // 准备:persistent=false 类型(RECEIPT 回执)→ 不入库,仅推送 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() - .setGroupId(10L).setType(ImMessageTypeEnum.GROUP_CREATE.getType()); + .setGroupId(10L).setType(ImMessageTypeEnum.RECEIPT.getType()); when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L)); groupMessageService.sendGroupMessage(1L, dto); diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java index 3bf4b0f..cd6465f 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java @@ -56,7 +56,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); reqVO.setClientMessageId("test-uuid-001"); reqVO.setReceiverId(2L); - reqVO.setType(0); // TEXT + reqVO.setType(ImMessageTypeEnum.TEXT.getType()); reqVO.setContent("{\"content\":\"你好\"}"); return reqVO; } @@ -82,7 +82,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { assertNotNull(result); assertEquals(1L, result.getSenderId()); assertEquals(2L, result.getReceiverId()); - assertEquals(0, result.getType()); + assertEquals(ImMessageTypeEnum.TEXT.getType(), result.getType()); assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); assertNotNull(result.getSendTime()); @@ -395,15 +395,15 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testSendPrivateMessage_dto_nonPersistentTypeNotInserted() { - // 准备:persistent=false 类型(FRIEND_ADD)→ 不入库,仅推送 + // 准备:persistent=false 类型(FRIEND_DELETE 通知)→ 不入库;仅推 sender 多端,receiver 不感知 ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO() - .setReceiverId(2L).setType(ImMessageTypeEnum.FRIEND_ADD.getType()); + .setReceiverId(2L).setType(ImMessageTypeEnum.FRIEND_DELETE.getType()); privateMessageService.sendPrivateMessage(1L, dto); verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); - verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); } // ========== getPrivateMessageList ========== diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImplTest.java new file mode 100644 index 0000000..6c3c9bf --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImplTest.java @@ -0,0 +1,161 @@ +package cn.iocoder.yudao.module.im.service.statistics; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.dal.mysql.statistics.ImStatisticsManagerMapper; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * IM 数据看板 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImStatisticsManagerServiceImplTest extends BaseMockitoUnitTest { + + private static final LocalDateTime BEGIN = LocalDateTime.of(2026, 1, 1, 0, 0); + private static final LocalDateTime END = LocalDateTime.of(2026, 2, 1, 0, 0); + + @InjectMocks + private ImStatisticsManagerServiceImpl service; + + @Mock + private ImStatisticsManagerMapper statisticsMapper; + + // ========== 单值委托 ========== + + @Test + public void testGetTotalUserCount_delegate() { + when(statisticsMapper.selectTotalUserCount()).thenReturn(99L); + assertEquals(99L, service.getTotalUserCount()); + } + + @Test + public void testGetNewUserCount_delegate() { + when(statisticsMapper.selectNewUserCount(BEGIN, END)).thenReturn(10L); + assertEquals(10L, service.getNewUserCount(BEGIN, END)); + } + + @Test + public void testGetActiveUserCount_delegate() { + when(statisticsMapper.selectActiveUserCount(BEGIN, END)).thenReturn(5L); + assertEquals(5L, service.getActiveUserCount(BEGIN, END)); + } + + @Test + public void testGetTotalGroupCount_delegate() { + when(statisticsMapper.selectTotalGroupCount()).thenReturn(33L); + assertEquals(33L, service.getTotalGroupCount()); + } + + @Test + public void testGetPrivateMessageCount_delegate() { + when(statisticsMapper.selectPrivateMessageCount(BEGIN, END)).thenReturn(123L); + assertEquals(123L, service.getPrivateMessageCount(BEGIN, END)); + } + + @Test + public void testGetGroupMessageCount_delegate() { + when(statisticsMapper.selectGroupMessageCount(BEGIN, END)).thenReturn(456L); + assertEquals(456L, service.getGroupMessageCount(BEGIN, END)); + } + + // ========== 每日序列 toDailyCountMap ========== + + @Test + public void testGetNewUserDailyCountMap_dateConvert() { + // 准备:date 字段以 SQL Date / String 混入,count 为 Long + when(statisticsMapper.selectNewUserDailyCount(eq(BEGIN), eq(END))).thenReturn(Arrays.asList( + row("date", java.sql.Date.valueOf("2026-01-10"), "count", 5L), + row("date", "2026-01-11", "count", 8L))); + + // 调用 + Map result = service.getNewUserDailyCountMap(BEGIN, END); + + // 断言:date → LocalDateTime 起始零点 + assertEquals(2, result.size()); + assertEquals(5L, result.get(LocalDate.of(2026, 1, 10).atStartOfDay())); + assertEquals(8L, result.get(LocalDate.of(2026, 1, 11).atStartOfDay())); + } + + @Test + public void testGetActiveUserDailyCountMap_dateConvert() { + when(statisticsMapper.selectActiveUserDailyCount(any(), any())).thenReturn(Arrays.asList( + row("date", "2026-01-10", "count", 3L))); + + Map result = service.getActiveUserDailyCountMap(BEGIN, END); + assertEquals(3L, result.get(LocalDate.of(2026, 1, 10).atStartOfDay())); + } + + @Test + public void testGetPrivateMessageDailyCountMap_dateConvert() { + when(statisticsMapper.selectPrivateMessageDailyCount(any(), any())).thenReturn(Arrays.asList( + row("date", "2026-01-10", "count", 7L))); + assertEquals(7L, service.getPrivateMessageDailyCountMap(BEGIN, END) + .get(LocalDate.of(2026, 1, 10).atStartOfDay())); + } + + @Test + public void testGetGroupMessageDailyCountMap_dateConvert() { + when(statisticsMapper.selectGroupMessageDailyCount(any(), any())).thenReturn(Arrays.asList( + row("date", "2026-01-10", "count", 9L))); + assertEquals(9L, service.getGroupMessageDailyCountMap(BEGIN, END) + .get(LocalDate.of(2026, 1, 10).atStartOfDay())); + } + + // ========== 分桶 / 分布 ========== + + @Test + public void testGetGroupSizeCountMap() { + when(statisticsMapper.selectGroupSizeDistribution()).thenReturn(Arrays.asList( + row("range", "1-9 人", "count", 3L), + row("range", "10-49 人", "count", 2L))); + + Map result = service.getGroupSizeCountMap(); + assertEquals(3L, result.get("1-9 人")); + assertEquals(2L, result.get("10-49 人")); + } + + @Test + public void testGetMessageTypeCountMap() { + when(statisticsMapper.selectMessageTypeDistribution(BEGIN, END)).thenReturn(Arrays.asList( + row("type", 101, "count", 8L), + row("type", 102, "count", 1L))); + + Map result = service.getMessageTypeCountMap(BEGIN, END); + assertEquals(8L, result.get(101)); + assertEquals(1L, result.get(102)); + } + + @Test + public void testGetTopSenderCountMap_passesLimit() { + when(statisticsMapper.selectTopSenders(BEGIN, END, 3)).thenReturn(Arrays.asList( + row("userId", 1L, "messageCount", 10L), + row("userId", 2L, "messageCount", 5L))); + + Map result = service.getTopSenderCountMap(BEGIN, END, 3); + assertEquals(10L, result.get(1L)); + assertEquals(5L, result.get(2L)); + } + + // ========== 工具 ========== + + private static Map row(String k1, Object v1, String k2, Object v2) { + Map map = new HashMap<>(); + map.put(k1, v1); + map.put(k2, v2); + return map; + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/util/ImMessageUtilsTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/util/ImMessageUtilsTest.java new file mode 100644 index 0000000..03f6a14 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/util/ImMessageUtilsTest.java @@ -0,0 +1,184 @@ +package cn.iocoder.yudao.module.im.util; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * IM 消息内容工具类单元测试 + * + * @author 芋道源码 + */ +public class ImMessageUtilsTest { + + // ========== parseQuoteMessageId ========== + + @Test + public void testParseQuoteMessageId_valid() { + // 准备:content 含合法 quote 字段 + String content = "{\"text\":\"你好\",\"quote\":{\"messageId\":1001,\"senderId\":2,\"type\":1}}"; + + // 调用 + 断言 + assertEquals(1001L, ImMessageUtils.parseQuoteMessageId(content)); + } + + @Test + public void testParseQuoteMessageId_invalidJson() { + // 准备:content 非合法 JSON + // 调用 + 断言:解析失败返回 null + assertNull(ImMessageUtils.parseQuoteMessageId("not a json")); + } + + @Test + public void testParseQuoteMessageId_noQuoteField() { + // 准备:content 无 quote 字段 + String content = "{\"text\":\"你好\"}"; + + // 调用 + 断言 + assertNull(ImMessageUtils.parseQuoteMessageId(content)); + } + + @Test + public void testParseQuoteMessageId_quoteIsNotMap() { + // 准备:quote 字段不是对象 + String content = "{\"text\":\"你好\",\"quote\":\"oops\"}"; + + // 调用 + 断言 + assertNull(ImMessageUtils.parseQuoteMessageId(content)); + } + + @Test + public void testParseQuoteMessageId_messageIdMissing() { + // 准备:quote 对象内无 messageId + String content = "{\"quote\":{\"senderId\":2}}"; + + // 调用 + 断言 + assertNull(ImMessageUtils.parseQuoteMessageId(content)); + } + + // ========== appendQuote ========== + + @Test + public void testAppendQuote_existingContent() { + // 准备:已有 text 字段的 content + String content = "{\"text\":\"你好\"}"; + QuoteMessage quote = new QuoteMessage().setMessageId(1001L).setSenderId(2L).setType(1).setContent("{}"); + + // 调用 + String result = ImMessageUtils.appendQuote(content, quote); + + // 断言:保留原字段,注入 quote + Map map = JsonUtils.parseMap(result); + assertEquals("你好", map.get("text")); + assertTrue(map.get("quote") instanceof Map); + assertEquals(1001, ((Map) map.get("quote")).get("messageId")); + } + + @Test + public void testAppendQuote_blankContent() { + // 准备:content 为空字符串,无法解析为 map + QuoteMessage quote = new QuoteMessage().setMessageId(1001L); + + // 调用 + String result = ImMessageUtils.appendQuote("", quote); + + // 断言:内部新建 map,仅含 quote + Map map = JsonUtils.parseMap(result); + assertEquals(1, map.size()); + assertTrue(map.containsKey("quote")); + } + + @Test + public void testAppendQuote_overwriteExistingQuote() { + // 准备:content 已经有一个旧 quote,期望被新 quote 覆盖 + String content = "{\"text\":\"hi\",\"quote\":{\"messageId\":1}}"; + QuoteMessage quote = new QuoteMessage().setMessageId(9999L).setSenderId(2L).setType(1); + + // 调用 + String result = ImMessageUtils.appendQuote(content, quote); + + // 断言:quote.messageId 被覆盖为新值 + Map map = JsonUtils.parseMap(result); + assertEquals(9999, ((Map) map.get("quote")).get("messageId")); + } + + // ========== removeQuote ========== + + @Test + public void testRemoveQuote_blankContent() { + // 调用 + 断言:空内容原样返回 + assertEquals("", ImMessageUtils.removeQuote("")); + assertNull(ImMessageUtils.removeQuote(null)); + } + + @Test + public void testRemoveQuote_noQuoteField() { + // 准备:content 字符串里没有 quote 关键字,提前返回原值 + String content = "{\"text\":\"你好\"}"; + + // 调用 + 断言:同一引用直接返回 + assertEquals(content, ImMessageUtils.removeQuote(content)); + } + + @Test + public void testRemoveQuote_withQuote() { + // 准备:含 quote 的 content + String content = "{\"text\":\"你好\",\"quote\":{\"messageId\":1}}"; + + // 调用 + String result = ImMessageUtils.removeQuote(content); + + // 断言:quote 被移除,保留其它字段 + Map map = JsonUtils.parseMap(result); + assertFalse(map.containsKey("quote")); + assertEquals("你好", map.get("text")); + } + + @Test + public void testRemoveQuote_invalidJsonContainingQuoteToken() { + // 准备:字符串包含 quote 字面量但解析失败 + String content = "not-a-json-but-has-\"quote\"-token"; + + // 调用 + 断言:解析失败返回原值 + assertEquals(content, ImMessageUtils.removeQuote(content)); + } + + // ========== buildQuote ========== + + @Test + public void testBuildQuote_stripsNestedQuote() { + // 准备:被引用消息本身也含有 quote 字段;防止嵌套 + String originalContent = "{\"text\":\"被引用\",\"quote\":{\"messageId\":777}}"; + + // 调用 + QuoteMessage quote = ImMessageUtils.buildQuote(1001L, 2L, 1, originalContent); + + // 断言:基础字段透传,content 已被剥离 quote + assertEquals(1001L, quote.getMessageId()); + assertEquals(2L, quote.getSenderId()); + assertEquals(1, quote.getType()); + Map contentMap = JsonUtils.parseMap(quote.getContent()); + assertFalse(contentMap.containsKey("quote")); + assertEquals("被引用", contentMap.get("text")); + } + + @Test + public void testBuildQuote_originalWithoutQuote() { + // 准备:被引用消息原本就没 quote 字段 + String originalContent = "{\"text\":\"hi\"}"; + + // 调用 + QuoteMessage quote = ImMessageUtils.buildQuote(1001L, 2L, 1, originalContent); + + // 断言:content 原样保留 + assertEquals(originalContent, quote.getContent()); + } + +} diff --git a/yudao-module-im/src/test/resources/application-unit-test.yaml b/yudao-module-im/src/test/resources/application-unit-test.yaml index b7bb631..44be16f 100644 --- a/yudao-module-im/src/test/resources/application-unit-test.yaml +++ b/yudao-module-im/src/test/resources/application-unit-test.yaml @@ -9,7 +9,7 @@ spring: # 数据源配置项 datasource: name: ruoyi-vue-pro - url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value,day; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 driver-class-name: org.h2.Driver username: sa password: diff --git a/yudao-module-im/src/test/resources/sql/clean.sql b/yudao-module-im/src/test/resources/sql/clean.sql index 1c74601..b9bdd05 100644 --- a/yudao-module-im/src/test/resources/sql/clean.sql +++ b/yudao-module-im/src/test/resources/sql/clean.sql @@ -10,3 +10,4 @@ DELETE FROM "im_face_pack_item"; DELETE FROM "im_face_user_item"; DELETE FROM "im_rtc_call"; DELETE FROM "im_rtc_participant"; +DELETE FROM "system_users"; diff --git a/yudao-module-im/src/test/resources/sql/create_tables.sql b/yudao-module-im/src/test/resources/sql/create_tables.sql index 1ea601f..f43a56a 100644 --- a/yudao-module-im/src/test/resources/sql/create_tables.sql +++ b/yudao-module-im/src/test/resources/sql/create_tables.sql @@ -235,3 +235,27 @@ CREATE TABLE IF NOT EXISTS "im_face_user_item" ( "tenant_id" bigint NOT NULL DEFAULT 0, PRIMARY KEY ("id") ) COMMENT 'IM 用户私有表情表'; + +CREATE TABLE IF NOT EXISTS "system_users" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "username" varchar(30) NOT NULL DEFAULT '', + "password" varchar(100) NOT NULL DEFAULT '', + "nickname" varchar(30) NOT NULL DEFAULT '', + "remark" varchar(500) DEFAULT NULL, + "dept_id" bigint DEFAULT NULL, + "post_ids" varchar(255) DEFAULT NULL, + "email" varchar(50) DEFAULT '', + "mobile" varchar(11) DEFAULT '', + "sex" tinyint DEFAULT 0, + "avatar" varchar(100) DEFAULT '', + "status" tinyint NOT NULL DEFAULT 0, + "login_ip" varchar(50) DEFAULT '', + "login_date" timestamp DEFAULT NULL, + "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 '用户信息表'; -- libgit2 0.21.4