Commit 6b1a0cfce2c793e495c7512ed39d9cf212f729e0

Authored by YunaiV
1 parent 36c44105

✨ feat(im): 更多单元测试

Showing 19 changed files with 1528 additions and 457 deletions
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapperTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.friend;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
  5 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum;
  6 +import jakarta.annotation.Resource;
  7 +import org.junit.jupiter.api.Test;
  8 +
  9 +import java.util.List;
  10 +
  11 +import static org.junit.jupiter.api.Assertions.*;
  12 +
  13 +/**
  14 + * IM 好友申请记录 Mapper 单元测试
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +public class ImFriendRequestMapperTest extends BaseDbUnitTest {
  19 +
  20 + @Resource
  21 + private ImFriendRequestMapper mapper;
  22 +
  23 + // ========== selectMyList ==========
  24 +
  25 + @Test
  26 + public void testSelectMyList_bidirectional() {
  27 + // 准备:1 既作为 from 又作为 to;3 是无关用户
  28 + mapper.insert(buildRequest(1L, 2L));
  29 + mapper.insert(buildRequest(2L, 1L));
  30 + mapper.insert(buildRequest(3L, 4L));
  31 +
  32 + // 调用:lastRequestId=null 拉首页
  33 + List<ImFriendRequestDO> list = mapper.selectMyList(1L, null, 10);
  34 +
  35 + // 断言:双向 OR 命中两条,无关用户被排除
  36 + assertEquals(2, list.size());
  37 + list.forEach(r -> assertTrue(r.getFromUserId().equals(1L) || r.getToUserId().equals(1L)));
  38 + }
  39 +
  40 + @Test
  41 + public void testSelectMyList_cursorPaging() {
  42 + // 准备:三条 1 相关的申请
  43 + ImFriendRequestDO r1 = buildRequest(1L, 2L);
  44 + ImFriendRequestDO r2 = buildRequest(1L, 3L);
  45 + ImFriendRequestDO r3 = buildRequest(1L, 4L);
  46 + mapper.insert(r1);
  47 + mapper.insert(r2);
  48 + mapper.insert(r3);
  49 +
  50 + // 调用:lastRequestId = r3.id,拉 id < r3.id 的下一页
  51 + List<ImFriendRequestDO> next = mapper.selectMyList(1L, r3.getId(), 10);
  52 +
  53 + // 断言:仅含 r1 / r2,按 id 倒序
  54 + assertEquals(2, next.size());
  55 + assertEquals(r2.getId(), next.get(0).getId());
  56 + assertEquals(r1.getId(), next.get(1).getId());
  57 + }
  58 +
  59 + @Test
  60 + public void testSelectMyList_limit() {
  61 + // 准备:插入 3 条
  62 + for (int i = 0; i < 3; i++) {
  63 + mapper.insert(buildRequest(1L, (long) (10 + i)));
  64 + }
  65 +
  66 + // 调用:limit = 2
  67 + List<ImFriendRequestDO> list = mapper.selectMyList(1L, null, 2);
  68 +
  69 + // 断言:手写 LIMIT 真生效
  70 + assertEquals(2, list.size());
  71 + }
  72 +
  73 + private static ImFriendRequestDO buildRequest(Long fromUserId, Long toUserId) {
  74 + return new ImFriendRequestDO()
  75 + .setFromUserId(fromUserId).setToUserId(toUserId)
  76 + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
  77 + }
  78 +
  79 +}
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java deleted
1 -package cn.iocoder.yudao.module.im.dal.mysql.group;  
2 -  
3 -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;  
4 -import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;  
5 -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;  
6 -import jakarta.annotation.Resource;  
7 -import org.junit.jupiter.api.Test;  
8 -  
9 -import java.time.LocalDateTime;  
10 -import java.util.List;  
11 -  
12 -import static org.junit.jupiter.api.Assertions.*;  
13 -  
14 -/**  
15 - * IM 群成员 Mapper 单元测试  
16 - *  
17 - * @author 芋道源码  
18 - */  
19 -public class ImGroupMemberMapperTest extends BaseDbUnitTest {  
20 -  
21 - @Resource  
22 - private ImGroupMemberMapper mapper;  
23 -  
24 - // ========== selectQuitListByUserId ==========  
25 -  
26 - @Test  
27 - public void testSelectQuitListByUserId_onlyDisabled() {  
28 - // 准备:一条 ENABLE + 一条 DISABLE  
29 - mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE));  
30 - ImGroupMemberDO quit = buildMember(20L, 1L, CommonStatusEnum.DISABLE);  
31 - quit.setQuitTime(LocalDateTime.now().minusDays(1));  
32 - mapper.insert(quit);  
33 -  
34 - // 调用:minQuitTime 为 null,不按时间过滤  
35 - List<ImGroupMemberDO> result = mapper.selectQuitListByUserId(1L, null);  
36 -  
37 - // 断言:只返回退群记录  
38 - assertEquals(1, result.size());  
39 - assertEquals(quit.getId(), result.get(0).getId());  
40 - }  
41 -  
42 - @Test  
43 - public void testSelectQuitListByUserId_filterByMinQuitTime() {  
44 - // 准备:两条退群记录,时间一早一晚  
45 - LocalDateTime old = LocalDateTime.now().minusDays(10);  
46 - LocalDateTime recent = LocalDateTime.now().minusDays(1);  
47 - ImGroupMemberDO oldQuit = buildMember(10L, 1L, CommonStatusEnum.DISABLE);  
48 - oldQuit.setQuitTime(old);  
49 - mapper.insert(oldQuit);  
50 - ImGroupMemberDO recentQuit = buildMember(20L, 1L, CommonStatusEnum.DISABLE);  
51 - recentQuit.setQuitTime(recent);  
52 - mapper.insert(recentQuit);  
53 -  
54 - // 调用:minQuitTime = now - 5 天,只返回近期退群  
55 - List<ImGroupMemberDO> result = mapper.selectQuitListByUserId(1L, LocalDateTime.now().minusDays(5));  
56 - assertEquals(1, result.size());  
57 - assertEquals(recentQuit.getId(), result.get(0).getId());  
58 -  
59 - // 调用:minQuitTime 为 null,不过滤  
60 - List<ImGroupMemberDO> all = mapper.selectQuitListByUserId(1L, null);  
61 - assertEquals(2, all.size());  
62 - }  
63 -  
64 - // ========== updateByGroupIdAndUserIdsAndStatus ==========  
65 -  
66 - @Test  
67 - public void testUpdateByGroupIdAndUserIdsAndStatus_onlyUpdatesOldStatusMatches() {  
68 - // 准备:u1 ENABLE,u2 DISABLE(已退群,不应更新)  
69 - ImGroupMemberDO u1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE);  
70 - mapper.insert(u1);  
71 - ImGroupMemberDO u2 = buildMember(10L, 2L, CommonStatusEnum.DISABLE);  
72 - u2.setQuitTime(LocalDateTime.now().minusDays(5));  
73 - mapper.insert(u2);  
74 -  
75 - // 调用:把 ENABLE 状态的成员移除  
76 - LocalDateTime now = LocalDateTime.now();  
77 - mapper.updateByGroupIdAndUserIdsAndStatus(10L, List.of(1L, 2L),  
78 - CommonStatusEnum.ENABLE.getStatus(),  
79 - new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()).setQuitTime(now));  
80 -  
81 - // 断言:u1 被更新  
82 - ImGroupMemberDO after1 = mapper.selectById(u1.getId());  
83 - assertEquals(CommonStatusEnum.DISABLE.getStatus(), after1.getStatus());  
84 - assertNotNull(after1.getQuitTime());  
85 - // 断言:u2 的 quitTime 不变(仍为 5 天前)  
86 - ImGroupMemberDO after2 = mapper.selectById(u2.getId());  
87 - assertEquals(CommonStatusEnum.DISABLE.getStatus(), after2.getStatus());  
88 - assertTrue(after2.getQuitTime().isBefore(now.minusDays(1)));  
89 - }  
90 -  
91 - // ========== updateByGroupIdAndStatus ==========  
92 -  
93 - @Test  
94 - public void testUpdateByGroupIdAndStatus_allActiveInGroup() {  
95 - // 准备:群 10 有 2 个活跃 + 1 个已退;群 20 有 1 个活跃  
96 - ImGroupMemberDO a1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE);  
97 - mapper.insert(a1);  
98 - ImGroupMemberDO a2 = buildMember(10L, 2L, CommonStatusEnum.ENABLE);  
99 - mapper.insert(a2);  
100 - ImGroupMemberDO a3 = buildMember(10L, 3L, CommonStatusEnum.DISABLE);  
101 - a3.setQuitTime(LocalDateTime.now().minusDays(5));  
102 - mapper.insert(a3);  
103 - ImGroupMemberDO b1 = buildMember(20L, 1L, CommonStatusEnum.ENABLE);  
104 - mapper.insert(b1);  
105 -  
106 - // 调用:解散群 10  
107 - mapper.updateByGroupIdAndStatus(10L, CommonStatusEnum.ENABLE.getStatus(),  
108 - new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus())  
109 - .setQuitTime(LocalDateTime.now()));  
110 -  
111 - // 断言:群 10 的活跃成员全部变为 DISABLE,群 20 的不受影响  
112 - assertEquals(CommonStatusEnum.DISABLE.getStatus(), mapper.selectById(a1.getId()).getStatus());  
113 - assertEquals(CommonStatusEnum.DISABLE.getStatus(), mapper.selectById(a2.getId()).getStatus());  
114 - assertEquals(CommonStatusEnum.ENABLE.getStatus(), mapper.selectById(b1.getId()).getStatus());  
115 - }  
116 -  
117 - // ========== 工具方法 ==========  
118 -  
119 - private ImGroupMemberDO buildMember(Long groupId, Long userId, CommonStatusEnum status) {  
120 - return ImGroupMemberDO.builder()  
121 - .groupId(groupId).userId(userId)  
122 - .status(status.getStatus())  
123 - .joinTime(LocalDateTime.now().minusDays(30))  
124 - .build();  
125 - }  
126 -  
127 -}  
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 { @@ -43,8 +43,8 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
43 } 43 }
44 44
45 @Test 45 @Test
46 - public void testSelectListByMinId_excludeRecall() {  
47 - // 准备:一条正常 + 一条撤回 46 + public void testSelectListByMinId_includesRecall() {
  47 + // 准备:一条正常 + 一条撤回;客户端拉离线消息需要拿到撤回状态以渲染「此消息已撤回」占位
48 ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); 48 ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
49 mapper.insert(normal); 49 mapper.insert(normal);
50 ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); 50 ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL);
@@ -52,8 +52,8 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @@ -52,8 +52,8 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
52 52
53 List<ImGroupMessageDO> result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100); 53 List<ImGroupMessageDO> result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100);
54 54
55 - assertEquals(1, result.size());  
56 - assertEquals(normal.getId(), result.get(0).getId()); 55 + // 断言:撤回消息一并返回,由客户端按 status 切换渲染
  56 + assertEquals(2, result.size());
57 } 57 }
58 58
59 @Test 59 @Test
@@ -211,64 +211,6 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @@ -211,64 +211,6 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
211 assertEquals(after.getId(), result.get(0).getId()); 211 assertEquals(after.getId(), result.get(0).getId());
212 } 212 }
213 213
214 - // ========== selectListByGroupIdAndPendingReceipt ==========  
215 -  
216 - @Test  
217 - public void testSelectListByGroupIdAndPendingReceipt_basic() {  
218 - // 准备:待回执 + 已完成 + 无需回执,三种状态  
219 - ImGroupMessageDO pending = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);  
220 - pending.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus());  
221 - mapper.insert(pending);  
222 - ImGroupMessageDO done = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);  
223 - done.setReceiptStatus(ImGroupMessageReceiptStatusEnum.DONE.getStatus());  
224 - mapper.insert(done);  
225 - ImGroupMessageDO noReceipt = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);  
226 - noReceipt.setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus());  
227 - mapper.insert(noReceipt);  
228 -  
229 - List<ImGroupMessageDO> result = mapper.selectListByGroupIdAndPendingReceipt(10L, null, Long.MAX_VALUE);  
230 -  
231 - // 断言:只返回 pending 记录  
232 - assertEquals(1, result.size());  
233 - assertEquals(pending.getId(), result.get(0).getId());  
234 - }  
235 -  
236 - @Test  
237 - public void testSelectListByGroupIdAndPendingReceipt_windowAndRecall() {  
238 - // 准备:窗口外 + 撤回 + 窗口内 pending  
239 - ImGroupMessageDO belowRange = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);  
240 - belowRange.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus());  
241 - mapper.insert(belowRange);  
242 - ImGroupMessageDO inRange = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);  
243 - inRange.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus());  
244 - mapper.insert(inRange);  
245 - ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL);  
246 - recalled.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus());  
247 - mapper.insert(recalled);  
248 -  
249 - // 调用:minId = belowRange.id(排除), maxId = inRange.id(包含)  
250 - List<ImGroupMessageDO> result = mapper.selectListByGroupIdAndPendingReceipt(  
251 - 10L, belowRange.getId(), inRange.getId());  
252 -  
253 - // 断言:只返回窗口内的非撤回 pending,即 inRange  
254 - assertEquals(1, result.size());  
255 - assertEquals(inRange.getId(), result.get(0).getId());  
256 - }  
257 -  
258 - @Test  
259 - public void testSelectListByGroupIdAndPendingReceipt_otherGroupExcluded() {  
260 - ImGroupMessageDO g10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);  
261 - g10.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus());  
262 - mapper.insert(g10);  
263 - ImGroupMessageDO g20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD);  
264 - g20.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus());  
265 - mapper.insert(g20);  
266 -  
267 - List<ImGroupMessageDO> result = mapper.selectListByGroupIdAndPendingReceipt(10L, null, Long.MAX_VALUE);  
268 - assertEquals(1, result.size());  
269 - assertEquals(g10.getId(), result.get(0).getId());  
270 - }  
271 -  
272 // ========== 工具方法 ========== 214 // ========== 工具方法 ==========
273 215
274 private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status) { 216 private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status) {
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapperTest.java deleted
1 -package cn.iocoder.yudao.module.im.dal.mysql.rtc;  
2 -  
3 -import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;  
4 -import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;  
5 -import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;  
6 -import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum;  
7 -import jakarta.annotation.Resource;  
8 -import org.junit.jupiter.api.Test;  
9 -  
10 -import java.time.LocalDateTime;  
11 -import java.util.List;  
12 -  
13 -import static org.junit.jupiter.api.Assertions.*;  
14 -  
15 -public class ImRtcParticipantMapperTest extends BaseDbUnitTest {  
16 -  
17 - @Resource  
18 - private ImRtcParticipantMapper mapper;  
19 -  
20 - // ========== selectByRoomAndUserId ==========  
21 -  
22 - @Test  
23 - public void testSelectByRoomAndUserId_match() {  
24 - // 准备:room=r1 / userId=100 命中;同 room 其他 user 不应误命中  
25 - ImRtcParticipantDO target = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING);  
26 - insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING);  
27 - insert("r2", 100L, ImRtcParticipantStatusEnum.INVITING);  
28 -  
29 - // 调用 + 断言  
30 - ImRtcParticipantDO got = mapper.selectByRoomAndUserId("r1", 100L);  
31 - assertNotNull(got);  
32 - assertEquals(target.getId(), got.getId());  
33 - }  
34 -  
35 - @Test  
36 - public void testSelectByRoomAndUserId_miss() {  
37 - // 准备:room 不存在的查询返 null  
38 - insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING);  
39 -  
40 - // 调用 + 断言  
41 - assertNull(mapper.selectByRoomAndUserId("r2", 100L));  
42 - assertNull(mapper.selectByRoomAndUserId("r1", 999L));  
43 - }  
44 -  
45 - // ========== selectListByRoom ==========  
46 -  
47 - @Test  
48 - public void testSelectListByRoom_filterByRoom() {  
49 - // 准备:r1 两条、r2 一条  
50 - insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING);  
51 - insert("r1", 101L, ImRtcParticipantStatusEnum.JOINED);  
52 - insert("r2", 200L, ImRtcParticipantStatusEnum.INVITING);  
53 -  
54 - // 调用 + 断言:仅 r1 命中两条  
55 - List<ImRtcParticipantDO> result = mapper.selectListByRoom("r1");  
56 - assertEquals(2, result.size());  
57 - assertTrue(result.stream().allMatch(p -> "r1".equals(p.getRoom())));  
58 - }  
59 -  
60 - // ========== selectListByStatusAndInviteTimeBefore ==========  
61 -  
62 - @Test  
63 - public void testSelectListByStatusAndInviteTimeBefore_filterStatusAndTime() {  
64 - // 准备:3 条 INVITING(一旧两新)+ 1 条 JOINED(旧,状态不匹配)  
65 - LocalDateTime now = LocalDateTime.now();  
66 - ImRtcParticipantDO oldInviting = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5));  
67 - insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10));  
68 - insert("r2", 102L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10));  
69 - insert("r3", 103L, ImRtcParticipantStatusEnum.JOINED, now.minusMinutes(5));  
70 -  
71 - // 调用:cutoff = now - 1 分钟,只命中早于该时间的 INVITING  
72 - List<ImRtcParticipantDO> result = mapper.selectListByStatusAndInviteTimeBefore(  
73 - ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1));  
74 -  
75 - // 断言:仅 oldInviting 命中(status + time 双重过滤)  
76 - assertEquals(1, result.size());  
77 - assertEquals(oldInviting.getId(), result.get(0).getId());  
78 - }  
79 -  
80 - @Test  
81 - public void testSelectListByStatusAndInviteTimeBefore_emptyWhenNoMatch() {  
82 - // 准备:所有 INVITING 都是新鲜的  
83 - LocalDateTime now = LocalDateTime.now();  
84 - insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10));  
85 -  
86 - // 调用 + 断言:cutoff 在更早的时间,无命中  
87 - List<ImRtcParticipantDO> result = mapper.selectListByStatusAndInviteTimeBefore(  
88 - ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(5));  
89 - assertTrue(result.isEmpty());  
90 - }  
91 -  
92 - // ========== selectListByRoomAndStatusAndInviteTimeBefore ==========  
93 -  
94 - @Test  
95 - public void testSelectListByRoomAndStatusAndInviteTimeBefore_limitToRoom() {  
96 - // 准备:r1 一旧 INVITING;r2 一旧 INVITING;r1 一新 INVITING  
97 - LocalDateTime now = LocalDateTime.now();  
98 - ImRtcParticipantDO r1Old = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5));  
99 - insert("r2", 101L, ImRtcParticipantStatusEnum.INVITING, now.minusMinutes(5));  
100 - insert("r1", 102L, ImRtcParticipantStatusEnum.INVITING, now.minusSeconds(10));  
101 -  
102 - // 调用:限定 room=r1,cutoff = now - 1 分钟  
103 - List<ImRtcParticipantDO> result = mapper.selectListByRoomAndStatusAndInviteTimeBefore(  
104 - "r1", ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1));  
105 -  
106 - // 断言:r2 的旧 INVITING 被 room 过滤掉;r1 的新 INVITING 被 time 过滤掉  
107 - assertEquals(1, result.size());  
108 - assertEquals(r1Old.getId(), result.get(0).getId());  
109 - }  
110 -  
111 - @Test  
112 - public void testSelectListByRoomAndStatusAndInviteTimeBefore_ignoresOtherStatus() {  
113 - // 准备:r1 一旧 JOINED(虽然 time 满足,status 不匹配)+ 一旧 REJECTED  
114 - LocalDateTime now = LocalDateTime.now();  
115 - insert("r1", 100L, ImRtcParticipantStatusEnum.JOINED, now.minusMinutes(5));  
116 - insert("r1", 101L, ImRtcParticipantStatusEnum.REJECTED, now.minusMinutes(5));  
117 -  
118 - // 调用 + 断言:仅扫 INVITING 状态,无命中  
119 - List<ImRtcParticipantDO> result = mapper.selectListByRoomAndStatusAndInviteTimeBefore(  
120 - "r1", ImRtcParticipantStatusEnum.INVITING.getStatus(), now.minusMinutes(1));  
121 - assertTrue(result.isEmpty());  
122 - }  
123 -  
124 - // ========== selectLastOneByUserIdAndStatus ==========  
125 -  
126 - @Test  
127 - public void testSelectLastOneByUserIdAndStatus_returnsLatestActive() {  
128 - // 准备:user 100 两条 INVITING(不同 room)+ 一条 LEFT  
129 - insert("r1", 100L, ImRtcParticipantStatusEnum.LEFT);  
130 - insert("r2", 100L, ImRtcParticipantStatusEnum.INVITING);  
131 - ImRtcParticipantDO latest = insert("r3", 100L, ImRtcParticipantStatusEnum.JOINED);  
132 -  
133 - // 调用:忙线检测取 ACTIVE_STATUSES,应取 id 最大那条  
134 - ImRtcParticipantDO got = mapper.selectLastOneByUserIdAndStatus(100L,  
135 - ImRtcParticipantStatusEnum.ACTIVE_STATUSES);  
136 -  
137 - // 断言:返回最新的 JOINED 记录(id 最大)  
138 - assertNotNull(got);  
139 - assertEquals(latest.getId(), got.getId());  
140 - }  
141 -  
142 - @Test  
143 - public void testSelectLastOneByUserIdAndStatus_missWhenAllTerminal() {  
144 - // 准备:user 100 只有终态记录  
145 - insert("r1", 100L, ImRtcParticipantStatusEnum.LEFT);  
146 - insert("r2", 100L, ImRtcParticipantStatusEnum.REJECTED);  
147 -  
148 - // 调用 + 断言:忙线检测无命中  
149 - assertNull(mapper.selectLastOneByUserIdAndStatus(100L,  
150 - ImRtcParticipantStatusEnum.ACTIVE_STATUSES));  
151 - }  
152 -  
153 - // ========== updateByIdAndStatus(振铃超时 CAS 路径关键) ==========  
154 -  
155 - @Test  
156 - public void testUpdateByIdAndStatus_inviting2NoAnswer_success() {  
157 - // 准备:一条 INVITING 候选  
158 - ImRtcParticipantDO p = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING, LocalDateTime.now().minusMinutes(5));  
159 -  
160 - // 调用:CAS INVITING → NO_ANSWER;模拟 Job 单参与者抢占  
161 - int updated = mapper.updateByIdAndStatus(p.getId(),  
162 - ImRtcParticipantStatusEnum.INVITING.getStatus(),  
163 - new ImRtcParticipantDO().setId(p.getId())  
164 - .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())  
165 - .setLeaveTime(LocalDateTime.now()));  
166 -  
167 - // 断言:CAS 成功 + 状态真的落地  
168 - assertEquals(1, updated);  
169 - assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(),  
170 - mapper.selectById(p.getId()).getStatus());  
171 - }  
172 -  
173 - @Test  
174 - public void testUpdateByIdAndStatus_concurrentReject_casFails() {  
175 - // 准备:一条记录已经被用户主动 reject(状态变成 REJECTED)  
176 - ImRtcParticipantDO p = insert("r1", 100L, ImRtcParticipantStatusEnum.REJECTED, LocalDateTime.now().minusMinutes(5));  
177 -  
178 - // 调用:Job 仍按 INVITING 抢占;并发已变更应失败  
179 - int updated = mapper.updateByIdAndStatus(p.getId(),  
180 - ImRtcParticipantStatusEnum.INVITING.getStatus(),  
181 - new ImRtcParticipantDO().setId(p.getId())  
182 - .setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus())  
183 - .setLeaveTime(LocalDateTime.now()));  
184 -  
185 - // 断言:CAS 失败 + 原状态保留  
186 - assertEquals(0, updated);  
187 - assertEquals(ImRtcParticipantStatusEnum.REJECTED.getStatus(),  
188 - mapper.selectById(p.getId()).getStatus());  
189 - }  
190 -  
191 - // ========== updateByRoomAndStatus(endSession 批量改 INVITING → NO_ANSWER 路径关键) ==========  
192 -  
193 - @Test  
194 - public void testUpdateByRoomAndStatus_inviting2NoAnswer_batchUpdate() {  
195 - // 准备:r1 两条 INVITING + 一条 JOINED(状态不匹配,不应被改);r2 一条 INVITING(room 不匹配,不应被改)  
196 - ImRtcParticipantDO inv1 = insert("r1", 100L, ImRtcParticipantStatusEnum.INVITING);  
197 - ImRtcParticipantDO inv2 = insert("r1", 101L, ImRtcParticipantStatusEnum.INVITING);  
198 - ImRtcParticipantDO joined = insert("r1", 102L, ImRtcParticipantStatusEnum.JOINED);  
199 - ImRtcParticipantDO otherRoom = insert("r2", 103L, ImRtcParticipantStatusEnum.INVITING);  
200 -  
201 - // 调用:模拟 endSession 把残留 INVITING 批量改 NO_ANSWER  
202 - int updated = mapper.updateByRoomAndStatus("r1",  
203 - ImRtcParticipantStatusEnum.INVITING.getStatus(),  
204 - new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()));  
205 -  
206 - // 断言:仅 r1 的 2 条 INVITING 被改,其它保持  
207 - assertEquals(2, updated);  
208 - assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(), mapper.selectById(inv1.getId()).getStatus());  
209 - assertEquals(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus(), mapper.selectById(inv2.getId()).getStatus());  
210 - assertEquals(ImRtcParticipantStatusEnum.JOINED.getStatus(), mapper.selectById(joined.getId()).getStatus());  
211 - assertEquals(ImRtcParticipantStatusEnum.INVITING.getStatus(), mapper.selectById(otherRoom.getId()).getStatus());  
212 - }  
213 -  
214 - @Test  
215 - public void testUpdateByRoomAndStatus_noMatch_returnsZero() {  
216 - // 准备:r1 只有 JOINED 状态  
217 - insert("r1", 100L, ImRtcParticipantStatusEnum.JOINED);  
218 -  
219 - // 调用:找 INVITING 应无命中  
220 - int updated = mapper.updateByRoomAndStatus("r1",  
221 - ImRtcParticipantStatusEnum.INVITING.getStatus(),  
222 - new ImRtcParticipantDO().setStatus(ImRtcParticipantStatusEnum.NO_ANSWER.getStatus()));  
223 -  
224 - // 断言:0 行受影响  
225 - assertEquals(0, updated);  
226 - }  
227 -  
228 - private ImRtcParticipantDO insert(String room, Long userId, ImRtcParticipantStatusEnum status) {  
229 - return insert(room, userId, status, LocalDateTime.now());  
230 - }  
231 -  
232 - private ImRtcParticipantDO insert(String room, Long userId, ImRtcParticipantStatusEnum status, LocalDateTime inviteTime) {  
233 - ImRtcParticipantDO p = new ImRtcParticipantDO()  
234 - .setRoom(room)  
235 - .setUserId(userId)  
236 - .setRole(ImRtcParticipantRoleEnum.INVITEE.getRole())  
237 - .setStatus(status.getStatus())  
238 - .setInviteTime(inviteTime);  
239 - mapper.insert(p);  
240 - return p;  
241 - }  
242 -  
243 -}  
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.statistics;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
  8 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
  9 +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper;
  10 +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper;
  11 +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
  12 +import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
  13 +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
  14 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  15 +import jakarta.annotation.Resource;
  16 +import org.junit.jupiter.api.BeforeEach;
  17 +import org.junit.jupiter.api.Test;
  18 +import org.springframework.jdbc.core.JdbcTemplate;
  19 +
  20 +import javax.sql.DataSource;
  21 +import java.time.LocalDateTime;
  22 +import java.util.List;
  23 +import java.util.Map;
  24 +
  25 +import static org.junit.jupiter.api.Assertions.*;
  26 +
  27 +/**
  28 + * IM 数据看板 Mapper 单元测试
  29 + *
  30 + * @author 芋道源码
  31 + */
  32 +public class ImStatisticsManagerMapperTest extends BaseDbUnitTest {
  33 +
  34 + private static final LocalDateTime WINDOW_BEGIN = LocalDateTime.of(2026, 1, 1, 0, 0);
  35 + private static final LocalDateTime WINDOW_END = LocalDateTime.of(2026, 2, 1, 0, 0);
  36 +
  37 + @Resource
  38 + private ImStatisticsManagerMapper mapper;
  39 +
  40 + @Resource
  41 + private ImPrivateMessageMapper privateMessageMapper;
  42 + @Resource
  43 + private ImGroupMessageMapper groupMessageMapper;
  44 + @Resource
  45 + private ImGroupMapper groupMapper;
  46 + @Resource
  47 + private ImGroupMemberMapper groupMemberMapper;
  48 + @Resource
  49 + private DataSource dataSource;
  50 +
  51 + private JdbcTemplate jdbcTemplate;
  52 +
  53 + @BeforeEach
  54 + public void initJdbcTemplate() {
  55 + jdbcTemplate = new JdbcTemplate(dataSource);
  56 + }
  57 +
  58 + // ========== 用户 ==========
  59 +
  60 + @Test
  61 + public void testSelectTotalUserCount() {
  62 + // 准备:3 个未删除用户
  63 + insertUser(1L, LocalDateTime.now());
  64 + insertUser(2L, LocalDateTime.now());
  65 + insertUser(3L, LocalDateTime.now());
  66 +
  67 + // 调用 + 断言
  68 + assertEquals(3L, mapper.selectTotalUserCount());
  69 + }
  70 +
  71 + @Test
  72 + public void testSelectNewUserCount_windowFilter() {
  73 + // 准备:1 个在窗口内 + 2 个在窗口外
  74 + insertUser(1L, WINDOW_BEGIN.plusDays(3));
  75 + insertUser(2L, WINDOW_BEGIN.minusDays(1));
  76 + insertUser(3L, WINDOW_END.plusDays(1));
  77 +
  78 + // 调用 + 断言:只命中窗口内
  79 + assertEquals(1L, mapper.selectNewUserCount(WINDOW_BEGIN, WINDOW_END));
  80 + }
  81 +
  82 + @Test
  83 + public void testSelectActiveUserCount_distinctAcrossPrivateAndGroup() {
  84 + // 准备:1 私聊;2 群聊;3 在窗口外;1 既发私聊又发群聊(去重)
  85 + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1)));
  86 + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(2)));
  87 + groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(3)));
  88 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(4))); // 用户 1 已计一次
  89 + privateMessageMapper.insert(buildPrivate(3L, 4L, WINDOW_BEGIN.minusDays(1))); // 窗口外
  90 +
  91 + // 调用 + 断言:去重后 = 2
  92 + assertEquals(2L, mapper.selectActiveUserCount(WINDOW_BEGIN, WINDOW_END));
  93 + }
  94 +
  95 + @Test
  96 + public void testSelectNewUserDailyCount_groupByDay() {
  97 + // 准备:第 1 天 2 个;第 2 天 1 个;窗口外 1 个
  98 + insertUser(1L, WINDOW_BEGIN.plusDays(1));
  99 + insertUser(2L, WINDOW_BEGIN.plusDays(1));
  100 + insertUser(3L, WINDOW_BEGIN.plusDays(2));
  101 + insertUser(4L, WINDOW_BEGIN.minusDays(1));
  102 +
  103 + // 调用
  104 + List<Map<String, Object>> list = mapper.selectNewUserDailyCount(WINDOW_BEGIN, WINDOW_END);
  105 +
  106 + // 断言:返回两个分组、总数 = 3
  107 + assertEquals(2, list.size());
  108 + long sum = list.stream().mapToLong(m -> ((Number) m.get("count")).longValue()).sum();
  109 + assertEquals(3L, sum);
  110 + }
  111 +
  112 + @Test
  113 + public void testSelectActiveUserDailyCount_distinctPerDay() {
  114 + // 准备:同一天里用户 1 在私聊与群聊都发了;第二天用户 2 发了
  115 + privateMessageMapper.insert(buildPrivate(1L, 9L, WINDOW_BEGIN.plusDays(1)));
  116 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)));
  117 + groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(2)));
  118 +
  119 + // 调用
  120 + List<Map<String, Object>> list = mapper.selectActiveUserDailyCount(WINDOW_BEGIN, WINDOW_END);
  121 +
  122 + // 断言:两天合计 2 名活跃用户(同一天 user 1 去重 + 第二天 user 2)
  123 + assertEquals(2, list.size());
  124 + long sum = list.stream().mapToLong(m -> ((Number) m.get("count")).longValue()).sum();
  125 + assertEquals(2L, sum);
  126 + }
  127 +
  128 + // ========== 群 ==========
  129 +
  130 + @Test
  131 + public void testSelectTotalGroupCount_onlyEnabled() {
  132 + // 准备:2 个正常 + 1 个已解散
  133 + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE));
  134 + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE));
  135 + groupMapper.insert(buildGroup(CommonStatusEnum.DISABLE));
  136 +
  137 + // 调用 + 断言
  138 + assertEquals(2L, mapper.selectTotalGroupCount());
  139 + }
  140 +
  141 + @Test
  142 + public void testSelectNewGroupCount_windowFilter() {
  143 + // 准备:1 个窗口内 + 1 个窗口外
  144 + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE));
  145 + groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE));
  146 + // 窗口窄到只覆盖 1 秒
  147 + LocalDateTime narrowBegin = LocalDateTime.now().plusYears(10);
  148 + LocalDateTime narrowEnd = narrowBegin.plusSeconds(1);
  149 +
  150 + // 调用 + 断言:极窄窗口不命中任何群
  151 + assertEquals(0L, mapper.selectNewGroupCount(narrowBegin, narrowEnd));
  152 + }
  153 +
  154 + @Test
  155 + public void testSelectGroupSizeDistribution_bucketing() {
  156 + // 准备:一个 5 人群、一个 15 人群
  157 + Long groupSmall = insertGroupAndMembers(5);
  158 + Long groupMid = insertGroupAndMembers(15);
  159 + assertNotNull(groupSmall);
  160 + assertNotNull(groupMid);
  161 +
  162 + // 调用
  163 + List<Map<String, Object>> dist = mapper.selectGroupSizeDistribution();
  164 +
  165 + // 断言:分桶包含「1-9 人」和「10-49 人」
  166 + Map<Object, Long> byRange = dist.stream().collect(java.util.stream.Collectors.toMap(
  167 + m -> m.get("range"), m -> ((Number) m.get("count")).longValue()));
  168 + assertEquals(1L, byRange.get("1-9 人"));
  169 + assertEquals(1L, byRange.get("10-49 人"));
  170 + }
  171 +
  172 + // ========== 消息 ==========
  173 +
  174 + @Test
  175 + public void testSelectPrivateMessageCount_windowFilter() {
  176 + // 准备:1 在窗口内 + 1 在窗口外
  177 + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1)));
  178 + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.minusDays(1)));
  179 +
  180 + // 调用 + 断言
  181 + assertEquals(1L, mapper.selectPrivateMessageCount(WINDOW_BEGIN, WINDOW_END));
  182 + }
  183 +
  184 + @Test
  185 + public void testSelectGroupMessageCount_windowFilter() {
  186 + // 准备:1 在窗口内 + 1 在窗口外
  187 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)));
  188 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.minusDays(1)));
  189 +
  190 + // 调用 + 断言
  191 + assertEquals(1L, mapper.selectGroupMessageCount(WINDOW_BEGIN, WINDOW_END));
  192 + }
  193 +
  194 + @Test
  195 + public void testSelectMessageTypeDistribution_mergePrivateAndGroup() {
  196 + // 准备:私聊 type=0 ×1,群聊 type=0 ×2、type=1 ×1
  197 + privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.TEXT.getType()));
  198 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.TEXT.getType()));
  199 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.TEXT.getType()));
  200 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImMessageTypeEnum.IMAGE.getType()));
  201 +
  202 + // 调用
  203 + List<Map<String, Object>> dist = mapper.selectMessageTypeDistribution(WINDOW_BEGIN, WINDOW_END);
  204 +
  205 + // 断言:type=TEXT(0) → 3;type=IMAGE(1) → 1
  206 + Map<Object, Long> byType = dist.stream().collect(java.util.stream.Collectors.toMap(
  207 + m -> ((Number) m.get("type")).intValue(), m -> ((Number) m.get("count")).longValue()));
  208 + assertEquals(3L, byType.get(ImMessageTypeEnum.TEXT.getType()));
  209 + assertEquals(1L, byType.get(ImMessageTypeEnum.IMAGE.getType()));
  210 + }
  211 +
  212 + @Test
  213 + public void testSelectTopSenders_orderByCountDescAndLimit() {
  214 + // 准备:user 1 共 3 条;user 2 共 2 条;user 3 共 1 条
  215 + privateMessageMapper.insert(buildPrivate(1L, 9L, WINDOW_BEGIN.plusDays(1)));
  216 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)));
  217 + groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)));
  218 + privateMessageMapper.insert(buildPrivate(2L, 9L, WINDOW_BEGIN.plusDays(1)));
  219 + groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(1)));
  220 + privateMessageMapper.insert(buildPrivate(3L, 9L, WINDOW_BEGIN.plusDays(1)));
  221 +
  222 + // 调用:取 TOP 2
  223 + List<Map<String, Object>> tops = mapper.selectTopSenders(WINDOW_BEGIN, WINDOW_END, 2);
  224 +
  225 + // 断言:返回 2 条,按消息数倒序,user1 > user2
  226 + assertEquals(2, tops.size());
  227 + assertEquals(1L, ((Number) tops.get(0).get("userId")).longValue());
  228 + assertEquals(3L, ((Number) tops.get(0).get("messageCount")).longValue());
  229 + assertEquals(2L, ((Number) tops.get(1).get("userId")).longValue());
  230 + assertEquals(2L, ((Number) tops.get(1).get("messageCount")).longValue());
  231 + }
  232 +
  233 + // ========== 工具方法 ==========
  234 +
  235 + private void insertUser(Long id, LocalDateTime createTime) {
  236 + jdbcTemplate.update(
  237 + "INSERT INTO system_users (id, username, password, nickname, status, create_time, update_time, deleted, tenant_id) " +
  238 + "VALUES (?, ?, '', ?, 0, ?, ?, FALSE, 0)",
  239 + id, "u" + id, "n" + id, createTime, createTime);
  240 + }
  241 +
  242 + private static ImPrivateMessageDO buildPrivate(Long senderId, Long receiverId, LocalDateTime sendTime) {
  243 + return ImPrivateMessageDO.builder()
  244 + .clientMessageId("uuid-" + System.nanoTime())
  245 + .senderId(senderId).receiverId(receiverId)
  246 + .type(ImMessageTypeEnum.TEXT.getType())
  247 + .content("{}")
  248 + .status(ImMessageStatusEnum.UNREAD.getStatus())
  249 + .sendTime(sendTime).build();
  250 + }
  251 +
  252 + private static ImGroupMessageDO buildGroupMessage(Long senderId, Long groupId, LocalDateTime sendTime) {
  253 + return new ImGroupMessageDO()
  254 + .setClientMessageId("uuid-" + System.nanoTime())
  255 + .setSenderId(senderId).setGroupId(groupId)
  256 + .setType(ImMessageTypeEnum.TEXT.getType())
  257 + .setContent("{}")
  258 + .setStatus(ImMessageStatusEnum.UNREAD.getStatus())
  259 + .setSendTime(sendTime);
  260 + }
  261 +
  262 + private static ImGroupDO buildGroup(CommonStatusEnum status) {
  263 + return ImGroupDO.builder().name("g" + System.nanoTime()).ownerUserId(1L)
  264 + .status(status.getStatus()).build();
  265 + }
  266 +
  267 + private Long insertGroupAndMembers(int memberCount) {
  268 + ImGroupDO group = buildGroup(CommonStatusEnum.ENABLE);
  269 + groupMapper.insert(group);
  270 + for (int i = 0; i < memberCount; i++) {
  271 + groupMemberMapper.insert(new ImGroupMemberDO()
  272 + .setGroupId(group.getId()).setUserId((long) (1000 + i))
  273 + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setRole(3));
  274 + }
  275 + return group.getId();
  276 + }
  277 +
  278 +}
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.face;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemSaveReqVO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO;
  8 +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackItemDO;
  9 +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFacePackItemMapper;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.mockito.ArgumentCaptor;
  12 +import org.mockito.InjectMocks;
  13 +import org.mockito.Mock;
  14 +
  15 +import java.util.Arrays;
  16 +import java.util.Collections;
  17 +import java.util.List;
  18 +
  19 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_ITEM_NOT_EXISTS;
  20 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_NOT_EXISTS;
  21 +import static org.junit.jupiter.api.Assertions.*;
  22 +import static org.mockito.ArgumentMatchers.any;
  23 +import static org.mockito.ArgumentMatchers.anyLong;
  24 +import static org.mockito.Mockito.never;
  25 +import static org.mockito.Mockito.verify;
  26 +import static org.mockito.Mockito.when;
  27 +
  28 +/**
  29 + * IM 表情包项 Service 单元测试
  30 + *
  31 + * @author 芋道源码
  32 + */
  33 +public class ImFacePackItemServiceImplTest extends BaseMockitoUnitTest {
  34 +
  35 + @InjectMocks
  36 + private ImFacePackItemServiceImpl service;
  37 +
  38 + @Mock
  39 + private ImFacePackItemMapper facePackItemMapper;
  40 + @Mock
  41 + private ImFacePackService facePackService;
  42 +
  43 + // ========== getEnabledItemListByPackIds ==========
  44 +
  45 + @Test
  46 + public void testGetEnabledItemListByPackIds_emptyShortReturn() {
  47 + // 调用 + 断言:空入参直接返回空列表,不查 DB
  48 + assertTrue(service.getEnabledItemListByPackIds(Collections.emptyList()).isEmpty());
  49 + verify(facePackItemMapper, never()).selectListByPackIdsAndStatus(any(), any());
  50 + }
  51 +
  52 + @Test
  53 + public void testGetEnabledItemListByPackIds_passesEnableStatus() {
  54 + // 准备
  55 + List<Long> packIds = Arrays.asList(1L, 2L);
  56 + ImFacePackItemDO item = new ImFacePackItemDO();
  57 + when(facePackItemMapper.selectListByPackIdsAndStatus(packIds, CommonStatusEnum.ENABLE.getStatus()))
  58 + .thenReturn(Collections.singletonList(item));
  59 +
  60 + // 调用 + 断言
  61 + assertEquals(1, service.getEnabledItemListByPackIds(packIds).size());
  62 + }
  63 +
  64 + // ========== createFacePackItem ==========
  65 +
  66 + @Test
  67 + public void testCreateFacePackItem_success() {
  68 + // 准备
  69 + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO();
  70 + reqVO.setPackId(10L).setUrl("a.png").setName("dog").setWidth(100).setHeight(100)
  71 + .setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus());
  72 + when(facePackService.validateFacePackExists(10L)).thenReturn(new ImFacePackDO());
  73 +
  74 + // 调用
  75 + service.createFacePackItem(reqVO);
  76 +
  77 + // 断言
  78 + ArgumentCaptor<ImFacePackItemDO> captor = ArgumentCaptor.forClass(ImFacePackItemDO.class);
  79 + verify(facePackItemMapper).insert(captor.capture());
  80 + assertEquals(10L, captor.getValue().getPackId());
  81 + assertEquals("a.png", captor.getValue().getUrl());
  82 + }
  83 +
  84 + @Test
  85 + public void testCreateFacePackItem_packNotExists() {
  86 + // 准备:所属表情包不存在
  87 + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO();
  88 + reqVO.setPackId(99L).setUrl("a.png").setWidth(100).setHeight(100).setSort(0)
  89 + .setStatus(CommonStatusEnum.ENABLE.getStatus());
  90 + when(facePackService.validateFacePackExists(99L))
  91 + .thenThrow(new ServiceException(FACE_PACK_NOT_EXISTS.getCode(), FACE_PACK_NOT_EXISTS.getMsg()));
  92 +
  93 + // 调用 + 断言:不落库
  94 + assertThrows(ServiceException.class, () -> service.createFacePackItem(reqVO));
  95 + verify(facePackItemMapper, never()).insert(any(ImFacePackItemDO.class));
  96 + }
  97 +
  98 + // ========== updateFacePackItem ==========
  99 +
  100 + @Test
  101 + public void testUpdateFacePackItem_success() {
  102 + // 准备
  103 + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO();
  104 + reqVO.setId(1L).setPackId(10L).setUrl("a.png").setWidth(100).setHeight(100)
  105 + .setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus());
  106 + when(facePackItemMapper.selectById(1L)).thenReturn(new ImFacePackItemDO());
  107 + when(facePackService.validateFacePackExists(10L)).thenReturn(new ImFacePackDO());
  108 +
  109 + // 调用
  110 + service.updateFacePackItem(reqVO);
  111 +
  112 + // 断言
  113 + verify(facePackItemMapper).updateById(any(ImFacePackItemDO.class));
  114 + }
  115 +
  116 + @Test
  117 + public void testUpdateFacePackItem_itemNotExists() {
  118 + ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO();
  119 + reqVO.setId(99L).setPackId(10L).setUrl("a.png").setWidth(100).setHeight(100).setSort(0)
  120 + .setStatus(CommonStatusEnum.ENABLE.getStatus());
  121 + when(facePackItemMapper.selectById(99L)).thenReturn(null);
  122 +
  123 + ServiceException exception = assertThrows(ServiceException.class,
  124 + () -> service.updateFacePackItem(reqVO));
  125 + assertEquals(FACE_PACK_ITEM_NOT_EXISTS.getCode(), exception.getCode());
  126 + verify(facePackItemMapper, never()).updateById(any(ImFacePackItemDO.class));
  127 + }
  128 +
  129 + // ========== deleteFacePackItem ==========
  130 +
  131 + @Test
  132 + public void testDeleteFacePackItem_success() {
  133 + when(facePackItemMapper.selectById(1L)).thenReturn(new ImFacePackItemDO());
  134 +
  135 + service.deleteFacePackItem(1L);
  136 +
  137 + verify(facePackItemMapper).deleteById(1L);
  138 + }
  139 +
  140 + @Test
  141 + public void testDeleteFacePackItem_notExists() {
  142 + when(facePackItemMapper.selectById(99L)).thenReturn(null);
  143 +
  144 + ServiceException exception = assertThrows(ServiceException.class,
  145 + () -> service.deleteFacePackItem(99L));
  146 + assertEquals(FACE_PACK_ITEM_NOT_EXISTS.getCode(), exception.getCode());
  147 + verify(facePackItemMapper, never()).deleteById(anyLong());
  148 + }
  149 +
  150 + @Test
  151 + public void testDeleteFacePackItemList_emptySkip() {
  152 + service.deleteFacePackItemList(Collections.emptyList());
  153 +
  154 + verify(facePackItemMapper, never()).deleteByIds(any());
  155 + }
  156 +
  157 + @Test
  158 + public void testDeleteFacePackItemList_success() {
  159 + service.deleteFacePackItemList(Arrays.asList(1L, 2L));
  160 +
  161 + verify(facePackItemMapper).deleteByIds(Arrays.asList(1L, 2L));
  162 + }
  163 +
  164 +}
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.face;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackSaveReqVO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO;
  8 +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFacePackMapper;
  9 +import org.junit.jupiter.api.Test;
  10 +import org.mockito.ArgumentCaptor;
  11 +import org.mockito.InjectMocks;
  12 +import org.mockito.Mock;
  13 +
  14 +import java.util.Arrays;
  15 +import java.util.Collections;
  16 +
  17 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_HAS_ITEMS;
  18 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_NOT_EXISTS;
  19 +import static org.junit.jupiter.api.Assertions.assertEquals;
  20 +import static org.junit.jupiter.api.Assertions.assertThrows;
  21 +import static org.mockito.ArgumentMatchers.any;
  22 +import static org.mockito.ArgumentMatchers.anyLong;
  23 +import static org.mockito.Mockito.never;
  24 +import static org.mockito.Mockito.verify;
  25 +import static org.mockito.Mockito.when;
  26 +
  27 +/**
  28 + * IM 表情包 Service 单元测试
  29 + *
  30 + * @author 芋道源码
  31 + */
  32 +public class ImFacePackServiceImplTest extends BaseMockitoUnitTest {
  33 +
  34 + @InjectMocks
  35 + private ImFacePackServiceImpl service;
  36 +
  37 + @Mock
  38 + private ImFacePackMapper facePackMapper;
  39 + @Mock
  40 + private ImFacePackItemService facePackItemService;
  41 +
  42 + // ========== validateFacePackExists ==========
  43 +
  44 + @Test
  45 + public void testValidateFacePackExists_notFound() {
  46 + when(facePackMapper.selectById(1L)).thenReturn(null);
  47 +
  48 + ServiceException exception = assertThrows(ServiceException.class,
  49 + () -> service.validateFacePackExists(1L));
  50 + assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode());
  51 + }
  52 +
  53 + @Test
  54 + public void testValidateFacePackExists_returnsDo() {
  55 + ImFacePackDO pack = ImFacePackDO.builder().id(1L).name("猫主子").build();
  56 + when(facePackMapper.selectById(1L)).thenReturn(pack);
  57 +
  58 + assertEquals(pack, service.validateFacePackExists(1L));
  59 + }
  60 +
  61 + // ========== createFacePack ==========
  62 +
  63 + @Test
  64 + public void testCreateFacePack_insert() {
  65 + // 准备
  66 + ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO();
  67 + reqVO.setName("猫主子").setIcon("icon.png").setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus());
  68 +
  69 + // 调用
  70 + service.createFacePack(reqVO);
  71 +
  72 + // 断言:mapper.insert 被调用一次
  73 + ArgumentCaptor<ImFacePackDO> captor = ArgumentCaptor.forClass(ImFacePackDO.class);
  74 + verify(facePackMapper).insert(captor.capture());
  75 + assertEquals("猫主子", captor.getValue().getName());
  76 + assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus());
  77 + }
  78 +
  79 + // ========== updateFacePack ==========
  80 +
  81 + @Test
  82 + public void testUpdateFacePack_success() {
  83 + // 准备:存在
  84 + ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO();
  85 + reqVO.setId(1L).setName("新名").setSort(1).setStatus(CommonStatusEnum.ENABLE.getStatus());
  86 + when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build());
  87 +
  88 + // 调用
  89 + service.updateFacePack(reqVO);
  90 +
  91 + // 断言
  92 + ArgumentCaptor<ImFacePackDO> captor = ArgumentCaptor.forClass(ImFacePackDO.class);
  93 + verify(facePackMapper).updateById(captor.capture());
  94 + assertEquals(1L, captor.getValue().getId());
  95 + assertEquals("新名", captor.getValue().getName());
  96 + }
  97 +
  98 + @Test
  99 + public void testUpdateFacePack_notExists() {
  100 + // 准备
  101 + ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO();
  102 + reqVO.setId(99L).setName("x").setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus());
  103 + when(facePackMapper.selectById(99L)).thenReturn(null);
  104 +
  105 + // 调用 + 断言
  106 + ServiceException exception = assertThrows(ServiceException.class,
  107 + () -> service.updateFacePack(reqVO));
  108 + assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode());
  109 + verify(facePackMapper, never()).updateById(any(ImFacePackDO.class));
  110 + }
  111 +
  112 + // ========== deleteFacePack ==========
  113 +
  114 + @Test
  115 + public void testDeleteFacePack_success() {
  116 + when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build());
  117 + when(facePackItemService.getFacePackItemCount(1L)).thenReturn(0L);
  118 +
  119 + service.deleteFacePack(1L);
  120 +
  121 + verify(facePackMapper).deleteById(1L);
  122 + }
  123 +
  124 + @Test
  125 + public void testDeleteFacePack_hasItems() {
  126 + // 准备:包下仍有表情
  127 + when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build());
  128 + when(facePackItemService.getFacePackItemCount(1L)).thenReturn(3L);
  129 +
  130 + ServiceException exception = assertThrows(ServiceException.class, () -> service.deleteFacePack(1L));
  131 + assertEquals(FACE_PACK_HAS_ITEMS.getCode(), exception.getCode());
  132 + verify(facePackMapper, never()).deleteById(anyLong());
  133 + }
  134 +
  135 + @Test
  136 + public void testDeleteFacePack_notExists() {
  137 + when(facePackMapper.selectById(99L)).thenReturn(null);
  138 +
  139 + ServiceException exception = assertThrows(ServiceException.class, () -> service.deleteFacePack(99L));
  140 + assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode());
  141 + }
  142 +
  143 + // ========== deleteFacePackList ==========
  144 +
  145 + @Test
  146 + public void testDeleteFacePackList_emptySkip() {
  147 + // 调用:空 ids 列表直接返回,不查询、不删除
  148 + service.deleteFacePackList(Collections.emptyList());
  149 +
  150 + verify(facePackItemService, never()).getFacePackItemCount(any(java.util.Collection.class));
  151 + verify(facePackMapper, never()).deleteByIds(any());
  152 + }
  153 +
  154 + @Test
  155 + public void testDeleteFacePackList_anyHasItemsRejectAll() {
  156 + // 准备:批量中存在表情
  157 + when(facePackItemService.getFacePackItemCount(Arrays.asList(1L, 2L))).thenReturn(1L);
  158 +
  159 + ServiceException exception = assertThrows(ServiceException.class,
  160 + () -> service.deleteFacePackList(Arrays.asList(1L, 2L)));
  161 + assertEquals(FACE_PACK_HAS_ITEMS.getCode(), exception.getCode());
  162 + verify(facePackMapper, never()).deleteByIds(any());
  163 + }
  164 +
  165 + @Test
  166 + public void testDeleteFacePackList_success() {
  167 + // 准备
  168 + when(facePackItemService.getFacePackItemCount(Arrays.asList(1L, 2L))).thenReturn(0L);
  169 +
  170 + // 调用
  171 + service.deleteFacePackList(Arrays.asList(1L, 2L));
  172 +
  173 + // 断言
  174 + verify(facePackMapper).deleteByIds(Arrays.asList(1L, 2L));
  175 + }
  176 +
  177 +}
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.face;
  2 +
  3 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  4 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  5 +import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemSaveReqVO;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO;
  7 +import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper;
  8 +import org.junit.jupiter.api.Test;
  9 +import org.mockito.ArgumentCaptor;
  10 +import org.mockito.InjectMocks;
  11 +import org.mockito.Mock;
  12 +
  13 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED;
  14 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS;
  15 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN;
  16 +import static org.junit.jupiter.api.Assertions.assertEquals;
  17 +import static org.junit.jupiter.api.Assertions.assertThrows;
  18 +import static org.mockito.ArgumentMatchers.any;
  19 +import static org.mockito.ArgumentMatchers.anyLong;
  20 +import static org.mockito.Mockito.never;
  21 +import static org.mockito.Mockito.verify;
  22 +import static org.mockito.Mockito.when;
  23 +
  24 +/**
  25 + * IM 用户私有表情 Service 单元测试
  26 + *
  27 + * @author 芋道源码
  28 + */
  29 +public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest {
  30 +
  31 + @InjectMocks
  32 + private ImFaceUserItemServiceImpl service;
  33 +
  34 + @Mock
  35 + private ImFaceUserItemMapper faceUserItemMapper;
  36 +
  37 + // ========== createFaceUserItem ==========
  38 +
  39 + @Test
  40 + public void testCreateFaceUserItem_success() {
  41 + // 准备
  42 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO();
  43 + reqVO.setUrl("a.png").setName("doge").setWidth(100).setHeight(100);
  44 + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null);
  45 +
  46 + // 调用
  47 + service.createFaceUserItem(1L, reqVO);
  48 +
  49 + // 断言:写入用户编号
  50 + ArgumentCaptor<ImFaceUserItemDO> captor = ArgumentCaptor.forClass(ImFaceUserItemDO.class);
  51 + verify(faceUserItemMapper).insert(captor.capture());
  52 + assertEquals(1L, captor.getValue().getUserId());
  53 + assertEquals("a.png", captor.getValue().getUrl());
  54 + }
  55 +
  56 + @Test
  57 + public void testCreateFaceUserItem_duplicateUrl() {
  58 + // 准备:相同 (userId, url) 已存在
  59 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO();
  60 + reqVO.setUrl("a.png").setWidth(100).setHeight(100);
  61 + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png"))
  62 + .thenReturn(new ImFaceUserItemDO().setUserId(1L));
  63 +
  64 + // 调用 + 断言:抛重复异常,不落库
  65 + ServiceException exception = assertThrows(ServiceException.class,
  66 + () -> service.createFaceUserItem(1L, reqVO));
  67 + assertEquals(FACE_USER_ITEM_DUPLICATED.getCode(), exception.getCode());
  68 + verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class));
  69 + }
  70 +
  71 + // ========== deleteFaceUserItem(用户端) ==========
  72 +
  73 + @Test
  74 + public void testDeleteFaceUserItem_userOwn_success() {
  75 + // 准备:归属当前用户
  76 + when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(1L));
  77 +
  78 + // 调用
  79 + service.deleteFaceUserItem(1L, 10L);
  80 +
  81 + // 断言
  82 + verify(faceUserItemMapper).deleteById(10L);
  83 + }
  84 +
  85 + @Test
  86 + public void testDeleteFaceUserItem_notExists() {
  87 + when(faceUserItemMapper.selectById(99L)).thenReturn(null);
  88 +
  89 + ServiceException exception = assertThrows(ServiceException.class,
  90 + () -> service.deleteFaceUserItem(1L, 99L));
  91 + assertEquals(FACE_USER_ITEM_NOT_EXISTS.getCode(), exception.getCode());
  92 + verify(faceUserItemMapper, never()).deleteById(anyLong());
  93 + }
  94 +
  95 + @Test
  96 + public void testDeleteFaceUserItem_notOwn() {
  97 + // 准备:表情归属 user 2,但 user 1 来删
  98 + when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(2L));
  99 +
  100 + ServiceException exception = assertThrows(ServiceException.class,
  101 + () -> service.deleteFaceUserItem(1L, 10L));
  102 + assertEquals(FACE_USER_ITEM_NOT_OWN.getCode(), exception.getCode());
  103 + verify(faceUserItemMapper, never()).deleteById(anyLong());
  104 + }
  105 +
  106 + // ========== deleteFaceUserItem(管理后台 by id) ==========
  107 +
  108 + @Test
  109 + public void testDeleteFaceUserItemAdmin_success() {
  110 + when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(2L));
  111 +
  112 + service.deleteFaceUserItem(10L);
  113 +
  114 + verify(faceUserItemMapper).deleteById(10L);
  115 + }
  116 +
  117 + @Test
  118 + public void testDeleteFaceUserItemAdmin_notExists() {
  119 + when(faceUserItemMapper.selectById(99L)).thenReturn(null);
  120 +
  121 + ServiceException exception = assertThrows(ServiceException.class,
  122 + () -> service.deleteFaceUserItem(99L));
  123 + assertEquals(FACE_USER_ITEM_NOT_EXISTS.getCode(), exception.getCode());
  124 + }
  125 +
  126 +}
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.friend;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestApplyReqVO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
  8 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
  9 +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendRequestMapper;
  10 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum;
  11 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
  12 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  13 +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
  14 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
  15 +import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
  16 +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
  17 +import org.junit.jupiter.api.Test;
  18 +import org.mockito.ArgumentCaptor;
  19 +import org.mockito.InjectMocks;
  20 +import org.mockito.Mock;
  21 +
  22 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
  23 +import static org.junit.jupiter.api.Assertions.*;
  24 +import static org.mockito.ArgumentMatchers.any;
  25 +import static org.mockito.ArgumentMatchers.anyInt;
  26 +import static org.mockito.ArgumentMatchers.anyLong;
  27 +import static org.mockito.ArgumentMatchers.anyString;
  28 +import static org.mockito.ArgumentMatchers.eq;
  29 +import static org.mockito.Mockito.never;
  30 +import static org.mockito.Mockito.verify;
  31 +import static org.mockito.Mockito.when;
  32 +
  33 +/**
  34 + * IM 好友申请 Service 单元测试
  35 + *
  36 + * @author 芋道源码
  37 + */
  38 +public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest {
  39 +
  40 + @InjectMocks
  41 + private ImFriendRequestServiceImpl friendRequestService;
  42 +
  43 + @Mock
  44 + private ImFriendRequestMapper friendRequestMapper;
  45 + @Mock
  46 + private ImFriendService friendService;
  47 + @Mock
  48 + private ImWebSocketService websocketService;
  49 + @Mock
  50 + private ImProperties imProperties;
  51 + @Mock
  52 + private AdminUserApi adminUserApi;
  53 +
  54 + // ========== applyFriend ==========
  55 +
  56 + @Test
  57 + public void testApplyFriend_addSelf() {
  58 + // 准备:发起人 = 接收人
  59 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
  60 + reqVO.setToUserId(1L);
  61 +
  62 + // 调用 + 断言
  63 + ServiceException exception = assertThrows(ServiceException.class,
  64 + () -> friendRequestService.applyFriend(1L, reqVO));
  65 + assertEquals(FRIEND_ADD_SELF.getCode(), exception.getCode());
  66 + }
  67 +
  68 + @Test
  69 + public void testApplyFriend_alreadyFriend() {
  70 + // 准备
  71 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
  72 + reqVO.setToUserId(2L);
  73 + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.FRIEND.getState());
  74 +
  75 + // 调用 + 断言
  76 + ServiceException exception = assertThrows(ServiceException.class,
  77 + () -> friendRequestService.applyFriend(1L, reqVO));
  78 + assertEquals(FRIEND_REQUEST_ALREADY_FRIEND.getCode(), exception.getCode());
  79 + }
  80 +
  81 + @Test
  82 + public void testApplyFriend_blockedByPeer() {
  83 + // 准备
  84 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
  85 + reqVO.setToUserId(2L);
  86 + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.BLOCKED.getState());
  87 +
  88 + // 调用 + 断言
  89 + ServiceException exception = assertThrows(ServiceException.class,
  90 + () -> friendRequestService.applyFriend(1L, reqVO));
  91 + assertEquals(FRIEND_REQUEST_BLOCKED_BY_PEER.getCode(), exception.getCode());
  92 + }
  93 +
  94 + @Test
  95 + public void testApplyFriend_silentReAdd() {
  96 + // 准备:单向好友 — 我已删除,但对方仍把我当好友
  97 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
  98 + reqVO.setToUserId(2L).setDisplayName("老张").setAddSource(1);
  99 + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState());
  100 + ImFriendDO peerFriend = ImFriendDO.builder().userId(2L).friendUserId(1L)
  101 + .status(CommonStatusEnum.ENABLE.getStatus()).build();
  102 + when(friendService.getFriend(2L, 1L)).thenReturn(peerFriend);
  103 +
  104 + // 调用
  105 + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO);
  106 +
  107 + // 断言:走静默重新加好友,不落申请记录
  108 + assertNull(result);
  109 + verify(friendService).silentReAddFriend(eq(1L), eq(2L), eq("老张"), eq(1));
  110 + verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class));
  111 + verify(websocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class));
  112 + }
  113 +
  114 + @Test
  115 + public void testApplyFriend_insertNew() {
  116 + // 准备:双方都无关系,且无历史申请
  117 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
  118 + reqVO.setToUserId(2L).setApplyContent("加个好友").setDisplayName("老张").setAddSource(1);
  119 + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState());
  120 + when(friendService.getFriend(2L, 1L)).thenReturn(null);
  121 + when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(null);
  122 + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setNickname("张三").setAvatar("a.png"));
  123 + when(imProperties.getFriend()).thenReturn(new ImProperties.Friend());
  124 +
  125 + // 调用
  126 + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO);
  127 +
  128 + // 断言:落新申请记录
  129 + ArgumentCaptor<ImFriendRequestDO> captor = ArgumentCaptor.forClass(ImFriendRequestDO.class);
  130 + verify(friendRequestMapper).insert(captor.capture());
  131 + ImFriendRequestDO saved = captor.getValue();
  132 + assertEquals(1L, saved.getFromUserId());
  133 + assertEquals(2L, saved.getToUserId());
  134 + assertEquals("加个好友", saved.getApplyContent());
  135 + assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), saved.getHandleResult());
  136 + assertSame(saved, result);
  137 + // 断言:推送给接收方
  138 + verify(websocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class));
  139 + }
  140 +
  141 + @Test
  142 + public void testApplyFriend_reuseOldRequest() {
  143 + // 准备:双方都无关系,但存在历史申请 — 走 reset 复用
  144 + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
  145 + reqVO.setToUserId(2L).setApplyContent("再来一次").setDisplayName("老张").setAddSource(2);
  146 + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState());
  147 + when(friendService.getFriend(2L, 1L)).thenReturn(null);
  148 + ImFriendRequestDO old = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
  149 + .setHandleResult(ImFriendRequestHandleResultEnum.REFUSED.getResult())
  150 + .setHandleContent("旧拒绝").setApplyContent("旧内容");
  151 + when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(old);
  152 + when(adminUserApi.getUser(1L)).thenReturn(null);
  153 + when(imProperties.getFriend()).thenReturn(new ImProperties.Friend());
  154 +
  155 + // 调用
  156 + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO);
  157 +
  158 + // 断言:复用旧记录,未触发 insert
  159 + verify(friendRequestMapper).updateByIdReset(eq(100L), eq("再来一次"), eq("老张"), eq(2));
  160 + verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class));
  161 + assertEquals(100L, result.getId());
  162 + assertEquals("再来一次", result.getApplyContent());
  163 + assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult());
  164 + assertNull(result.getHandleContent());
  165 + assertNull(result.getHandleTime());
  166 + }
  167 +
  168 + // ========== agreeFriendRequest ==========
  169 +
  170 + @Test
  171 + public void testAgreeFriendRequest_success() {
  172 + // 准备
  173 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
  174 + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
  175 + when(friendRequestMapper.selectById(100L)).thenReturn(request);
  176 + when(friendRequestMapper.updateByIdAndHandleResult(eq(100L),
  177 + eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), any(ImFriendRequestDO.class))).thenReturn(1);
  178 +
  179 + // 调用
  180 + friendRequestService.agreeFriendRequest(2L, 100L);
  181 +
  182 + // 断言:双向建立好友 + 推 APPROVED 给发起方
  183 + verify(friendService).becomeFriends(request);
  184 + verify(websocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class));
  185 + }
  186 +
  187 + @Test
  188 + public void testAgreeFriendRequest_notExists() {
  189 + // 准备:申请不存在
  190 + when(friendRequestMapper.selectById(100L)).thenReturn(null);
  191 +
  192 + // 调用 + 断言
  193 + ServiceException exception = assertThrows(ServiceException.class,
  194 + () -> friendRequestService.agreeFriendRequest(2L, 100L));
  195 + assertEquals(FRIEND_REQUEST_NOT_EXISTS.getCode(), exception.getCode());
  196 + }
  197 +
  198 + @Test
  199 + public void testAgreeFriendRequest_notToMe() {
  200 + // 准备:操作人不是接收方
  201 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
  202 + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
  203 + when(friendRequestMapper.selectById(100L)).thenReturn(request);
  204 +
  205 + // 调用 + 断言:3L 不是接收方 2L
  206 + ServiceException exception = assertThrows(ServiceException.class,
  207 + () -> friendRequestService.agreeFriendRequest(3L, 100L));
  208 + assertEquals(FRIEND_REQUEST_NOT_TO_ME.getCode(), exception.getCode());
  209 + }
  210 +
  211 + @Test
  212 + public void testAgreeFriendRequest_alreadyHandled() {
  213 + // 准备:申请已被处理
  214 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
  215 + .setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult());
  216 + when(friendRequestMapper.selectById(100L)).thenReturn(request);
  217 +
  218 + // 调用 + 断言
  219 + ServiceException exception = assertThrows(ServiceException.class,
  220 + () -> friendRequestService.agreeFriendRequest(2L, 100L));
  221 + assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode());
  222 + }
  223 +
  224 + @Test
  225 + public void testAgreeFriendRequest_concurrentCasFail() {
  226 + // 准备:fail-fast 校验通过,但乐观锁 CAS 被并发抢先
  227 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
  228 + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
  229 + when(friendRequestMapper.selectById(100L)).thenReturn(request);
  230 + when(friendRequestMapper.updateByIdAndHandleResult(anyLong(), anyInt(), any(ImFriendRequestDO.class))).thenReturn(0);
  231 +
  232 + // 调用 + 断言:返回 FRIEND_REQUEST_HANDLED
  233 + ServiceException exception = assertThrows(ServiceException.class,
  234 + () -> friendRequestService.agreeFriendRequest(2L, 100L));
  235 + assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode());
  236 + verify(friendService, never()).becomeFriends(any(ImFriendRequestDO.class));
  237 + }
  238 +
  239 + // ========== refuseFriendRequest ==========
  240 +
  241 + @Test
  242 + public void testRefuseFriendRequest_success() {
  243 + // 准备
  244 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
  245 + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
  246 + when(friendRequestMapper.selectById(100L)).thenReturn(request);
  247 + when(friendRequestMapper.updateByIdAndHandleResult(eq(100L),
  248 + eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), any(ImFriendRequestDO.class))).thenReturn(1);
  249 +
  250 + // 调用
  251 + friendRequestService.refuseFriendRequest(2L, 100L, "不认识");
  252 +
  253 + // 断言:handleResult / handleContent 写入;推 REJECTED 给发起方
  254 + ArgumentCaptor<ImFriendRequestDO> captor = ArgumentCaptor.forClass(ImFriendRequestDO.class);
  255 + verify(friendRequestMapper).updateByIdAndHandleResult(eq(100L),
  256 + eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), captor.capture());
  257 + assertEquals(ImFriendRequestHandleResultEnum.REFUSED.getResult(), captor.getValue().getHandleResult());
  258 + assertEquals("不认识", captor.getValue().getHandleContent());
  259 + verify(websocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class));
  260 + }
  261 +
  262 + @Test
  263 + public void testRefuseFriendRequest_concurrentCasFail() {
  264 + // 准备:CAS 失败
  265 + ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
  266 + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
  267 + when(friendRequestMapper.selectById(100L)).thenReturn(request);
  268 + when(friendRequestMapper.updateByIdAndHandleResult(anyLong(), anyInt(), any(ImFriendRequestDO.class))).thenReturn(0);
  269 +
  270 + // 调用 + 断言:抛 HANDLED,且不推 REJECTED
  271 + ServiceException exception = assertThrows(ServiceException.class,
  272 + () -> friendRequestService.refuseFriendRequest(2L, 100L, "x"));
  273 + assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode());
  274 + verify(websocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class));
  275 + }
  276 +
  277 + // ========== getMyFriendRequestList ==========
  278 +
  279 + @Test
  280 + public void testGetMyFriendRequestList_delegate() {
  281 + // 准备:mapper 返回 mock 数据
  282 + ImFriendRequestDO one = new ImFriendRequestDO().setId(1L);
  283 + when(friendRequestMapper.selectMyList(1L, 99L, 20)).thenReturn(java.util.Collections.singletonList(one));
  284 +
  285 + // 调用 + 断言:参数透传、结果原样返回
  286 + assertEquals(1, friendRequestService.getMyFriendRequestList(1L, 99L, 20).size());
  287 + verify(friendRequestMapper).selectMyList(1L, 99L, 20);
  288 + }
  289 +
  290 +}
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java
@@ -22,6 +22,7 @@ import java.util.List; @@ -22,6 +22,7 @@ import java.util.List;
22 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; 22 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND;
23 import static org.junit.jupiter.api.Assertions.*; 23 import static org.junit.jupiter.api.Assertions.*;
24 import static org.mockito.ArgumentMatchers.any; 24 import static org.mockito.ArgumentMatchers.any;
  25 +import static org.mockito.ArgumentMatchers.anyLong;
25 import static org.mockito.ArgumentMatchers.eq; 26 import static org.mockito.ArgumentMatchers.eq;
26 import static org.mockito.Mockito.never; 27 import static org.mockito.Mockito.never;
27 import static org.mockito.Mockito.verify; 28 import static org.mockito.Mockito.verify;
@@ -109,17 +110,15 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest { @@ -109,17 +110,15 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
109 110
110 @Test 111 @Test
111 public void testUpdateFriend_emptyRequest() { 112 public void testUpdateFriend_emptyRequest() {
112 - // 准备:silent 和 displayName 都不传 —— 直接返回,不打 SQL 也不发推送 113 + // 准备:silent / displayName / pinned 都不传 —— 进入方法立刻返回,不查 mapper 也不发推送
113 ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); 114 ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO();
114 reqVO.setFriendUserId(2L); 115 reqVO.setFriendUserId(2L);
115 - ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L)  
116 - .silent(false).status(CommonStatusEnum.ENABLE.getStatus()).build();  
117 - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend);  
118 116
119 // 调用 117 // 调用
120 friendService.updateFriend(1L, reqVO); 118 friendService.updateFriend(1L, reqVO);
121 119
122 - // 断言:没触发 SQL 更新 / 没发 WebSocket 推送 120 + // 断言:没查记录、没触发 SQL 更新 / 没发 WebSocket 推送
  121 + verify(imFriendMapper, never()).selectByUserIdAndFriendUserId(anyLong(), anyLong());
123 verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); 122 verify(imFriendMapper, never()).updateById(any(ImFriendDO.class));
124 verify(imWebSocketService, never()).sendPrivateMessageAsync(any(Long.class), any(ImPrivateMessageDTO.class)); 123 verify(imWebSocketService, never()).sendPrivateMessageAsync(any(Long.class), any(ImPrivateMessageDTO.class));
125 } 124 }
@@ -127,18 +126,25 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest { @@ -127,18 +126,25 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
127 // ========== addFriend0(内部方法,被 becomeFriends / silentReAddFriend 调用) ========== 126 // ========== addFriend0(内部方法,被 becomeFriends / silentReAddFriend 调用) ==========
128 127
129 @Test 128 @Test
130 - public void testAddFriend0_existingEnabledSkipsUpdate() {  
131 - // 准备:已存在且启用,第二步不应触发更新 129 + public void testAddFriend0_existingEnabledResetsFields() {
  130 + // 准备:已存在且启用 —— 走复用旧记录路径,重置 silent / pinned / blocked
132 ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) 131 ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L)
133 - .status(CommonStatusEnum.ENABLE.getStatus()).build(); 132 + .status(CommonStatusEnum.ENABLE.getStatus()).silent(true).pinned(true).blocked(true).build();
134 when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); 133 when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists);
135 134
136 // 调用 135 // 调用
137 friendService.addFriend0(1L, 2L, null, null); 136 friendService.addFriend0(1L, 2L, null, null);
138 137
139 - // 断言:不插入,也不更新 138 + // 断言:不插入;走 update 重置 silent / pinned / blocked 为 false,并刷新 addTime
140 verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); 139 verify(imFriendMapper, never()).insert(any(ImFriendDO.class));
141 - verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); 140 + ArgumentCaptor<ImFriendDO> captor = ArgumentCaptor.forClass(ImFriendDO.class);
  141 + verify(imFriendMapper).updateById(captor.capture());
  142 + assertEquals(10L, captor.getValue().getId());
  143 + assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus());
  144 + assertFalse(captor.getValue().getSilent());
  145 + assertFalse(captor.getValue().getPinned());
  146 + assertFalse(captor.getValue().getBlocked());
  147 + assertNotNull(captor.getValue().getAddTime());
142 } 148 }
143 149
144 @Test 150 @Test
@@ -160,15 +166,14 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest { @@ -160,15 +166,14 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
160 } 166 }
161 167
162 @Test 168 @Test
163 - public void testAddFriend0_duplicateKeySwallowed() {  
164 - // 准备:mapper 抛并发冲突 169 + public void testAddFriend0_duplicateKeyPropagates() {
  170 + // 准备:mapper 抛并发冲突;极端并发下让异常向外抛,由外层事务回滚
165 when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); 171 when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null);
166 when(imFriendMapper.insert(any(ImFriendDO.class))) 172 when(imFriendMapper.insert(any(ImFriendDO.class)))
167 .thenThrow(new DuplicateKeyException("concurrent insert")); 173 .thenThrow(new DuplicateKeyException("concurrent insert"));
168 174
169 - // 调用:应被吞掉,不抛异常  
170 - friendService.addFriend0(1L, 2L, null, null);  
171 - 175 + // 调用 + 断言:异常向外抛
  176 + assertThrows(DuplicateKeyException.class, () -> friendService.addFriend0(1L, 2L, null, null));
172 verify(imFriendMapper).insert(any(ImFriendDO.class)); 177 verify(imFriendMapper).insert(any(ImFriendDO.class));
173 } 178 }
174 179
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java
@@ -36,6 +36,8 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { @@ -36,6 +36,8 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
36 private ImGroupMemberMapper groupMemberMapper; 36 private ImGroupMemberMapper groupMemberMapper;
37 @Mock 37 @Mock
38 private ImWebSocketService webSocketService; 38 private ImWebSocketService webSocketService;
  39 + @Mock
  40 + private cn.iocoder.yudao.module.im.service.message.ImGroupMessageService groupMessageService;
39 41
40 // ========== addGroupMember ========== 42 // ========== addGroupMember ==========
41 43
@@ -241,7 +243,11 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { @@ -241,7 +243,11 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
241 verify(groupMemberMapper).updateById(captor.capture()); 243 verify(groupMemberMapper).updateById(captor.capture());
242 assertEquals(50L, captor.getValue().getId()); 244 assertEquals(50L, captor.getValue().getId());
243 assertTrue(captor.getValue().getSilent()); 245 assertTrue(captor.getValue().getSilent());
244 - verify(webSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); 246 + // 公开字段昵称变化 → 全员广播 GROUP_MEMBER_NICKNAME_UPDATE
  247 + verify(groupMessageService).sendGroupMessage(eq(1L), any(cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO.class));
  248 + // 个人字段 silent 变化 → 仅自己多端同步 GROUP_MEMBER_SETTING_UPDATE
  249 + verify(groupMessageService).sendGroupMessage(eq(1L), eq(java.util.List.of(1L)),
  250 + any(cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO.class));
245 } 251 }
246 252
247 // ========== removeGroupMember ========== 253 // ========== removeGroupMember ==========
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; @@ -18,6 +18,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper;
18 import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum; 18 import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum;
19 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; 19 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
20 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 20 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  21 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
21 import cn.iocoder.yudao.module.im.service.friend.ImFriendService; 22 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
22 import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; 23 import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService;
23 import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; 24 import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
@@ -30,6 +31,7 @@ import org.mockito.ArgumentCaptor; @@ -30,6 +31,7 @@ import org.mockito.ArgumentCaptor;
30 import org.mockito.InjectMocks; 31 import org.mockito.InjectMocks;
31 import org.mockito.Mock; 32 import org.mockito.Mock;
32 import org.mockito.MockedStatic; 33 import org.mockito.MockedStatic;
  34 +import org.mockito.Spy;
33 35
34 import java.time.LocalDateTime; 36 import java.time.LocalDateTime;
35 import java.util.ArrayList; 37 import java.util.ArrayList;
@@ -66,6 +68,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -66,6 +68,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
66 private ImGroupRequestService groupRequestService; 68 private ImGroupRequestService groupRequestService;
67 @Mock 69 @Mock
68 private AdminUserApi adminUserApi; 70 private AdminUserApi adminUserApi;
  71 + @Spy
  72 + private ImProperties imProperties = new ImProperties();
69 73
70 // ========== createGroup ========== 74 // ========== createGroup ==========
71 75
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
@@ -68,7 +68,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -68,7 +68,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
68 ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO(); 68 ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO();
69 reqVO.setClientMessageId("test-uuid-group-001"); 69 reqVO.setClientMessageId("test-uuid-group-001");
70 reqVO.setGroupId(10L); 70 reqVO.setGroupId(10L);
71 - reqVO.setType(0); // TEXT 71 + reqVO.setType(ImMessageTypeEnum.TEXT.getType());
72 reqVO.setContent("{\"content\":\"群聊你好\"}"); 72 reqVO.setContent("{\"content\":\"群聊你好\"}");
73 return reqVO; 73 return reqVO;
74 } 74 }
@@ -1062,9 +1062,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -1062,9 +1062,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1062 1062
1063 @Test 1063 @Test
1064 public void testSendGroupMessage_dto_nonPersistentTypeNotInserted() { 1064 public void testSendGroupMessage_dto_nonPersistentTypeNotInserted() {
1065 - // 准备:persistent=false 类型(GROUP_CREATE)→ 不入库,仅推送 1065 + // 准备:persistent=false 类型(RECEIPT 回执)→ 不入库,仅推送
1066 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() 1066 ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO()
1067 - .setGroupId(10L).setType(ImMessageTypeEnum.GROUP_CREATE.getType()); 1067 + .setGroupId(10L).setType(ImMessageTypeEnum.RECEIPT.getType());
1068 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L)); 1068 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L));
1069 1069
1070 groupMessageService.sendGroupMessage(1L, dto); 1070 groupMessageService.sendGroupMessage(1L, dto);
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
@@ -56,7 +56,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @@ -56,7 +56,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
56 ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); 56 ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO();
57 reqVO.setClientMessageId("test-uuid-001"); 57 reqVO.setClientMessageId("test-uuid-001");
58 reqVO.setReceiverId(2L); 58 reqVO.setReceiverId(2L);
59 - reqVO.setType(0); // TEXT 59 + reqVO.setType(ImMessageTypeEnum.TEXT.getType());
60 reqVO.setContent("{\"content\":\"你好\"}"); 60 reqVO.setContent("{\"content\":\"你好\"}");
61 return reqVO; 61 return reqVO;
62 } 62 }
@@ -82,7 +82,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @@ -82,7 +82,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
82 assertNotNull(result); 82 assertNotNull(result);
83 assertEquals(1L, result.getSenderId()); 83 assertEquals(1L, result.getSenderId());
84 assertEquals(2L, result.getReceiverId()); 84 assertEquals(2L, result.getReceiverId());
85 - assertEquals(0, result.getType()); 85 + assertEquals(ImMessageTypeEnum.TEXT.getType(), result.getType());
86 assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); 86 assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus());
87 assertNotNull(result.getSendTime()); 87 assertNotNull(result.getSendTime());
88 88
@@ -395,15 +395,15 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @@ -395,15 +395,15 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
395 395
396 @Test 396 @Test
397 public void testSendPrivateMessage_dto_nonPersistentTypeNotInserted() { 397 public void testSendPrivateMessage_dto_nonPersistentTypeNotInserted() {
398 - // 准备:persistent=false 类型(FRIEND_ADD)→ 不入库,仅推送 398 + // 准备:persistent=false 类型(FRIEND_DELETE 通知)→ 不入库;仅推 sender 多端,receiver 不感知
399 ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO() 399 ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO()
400 - .setReceiverId(2L).setType(ImMessageTypeEnum.FRIEND_ADD.getType()); 400 + .setReceiverId(2L).setType(ImMessageTypeEnum.FRIEND_DELETE.getType());
401 401
402 privateMessageService.sendPrivateMessage(1L, dto); 402 privateMessageService.sendPrivateMessage(1L, dto);
403 403
404 verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); 404 verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class));
405 verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); 405 verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class));
406 - verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); 406 + verify(imWebSocketService, never()).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class));
407 } 407 }
408 408
409 // ========== getPrivateMessageList ========== 409 // ========== getPrivateMessageList ==========
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/statistics/ImStatisticsManagerServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.statistics;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.im.dal.mysql.statistics.ImStatisticsManagerMapper;
  5 +import org.junit.jupiter.api.Test;
  6 +import org.mockito.InjectMocks;
  7 +import org.mockito.Mock;
  8 +
  9 +import java.time.LocalDate;
  10 +import java.time.LocalDateTime;
  11 +import java.util.Arrays;
  12 +import java.util.HashMap;
  13 +import java.util.Map;
  14 +
  15 +import static org.junit.jupiter.api.Assertions.assertEquals;
  16 +import static org.mockito.ArgumentMatchers.any;
  17 +import static org.mockito.ArgumentMatchers.eq;
  18 +import static org.mockito.Mockito.when;
  19 +
  20 +/**
  21 + * IM 数据看板 Service 单元测试
  22 + *
  23 + * @author 芋道源码
  24 + */
  25 +public class ImStatisticsManagerServiceImplTest extends BaseMockitoUnitTest {
  26 +
  27 + private static final LocalDateTime BEGIN = LocalDateTime.of(2026, 1, 1, 0, 0);
  28 + private static final LocalDateTime END = LocalDateTime.of(2026, 2, 1, 0, 0);
  29 +
  30 + @InjectMocks
  31 + private ImStatisticsManagerServiceImpl service;
  32 +
  33 + @Mock
  34 + private ImStatisticsManagerMapper statisticsMapper;
  35 +
  36 + // ========== 单值委托 ==========
  37 +
  38 + @Test
  39 + public void testGetTotalUserCount_delegate() {
  40 + when(statisticsMapper.selectTotalUserCount()).thenReturn(99L);
  41 + assertEquals(99L, service.getTotalUserCount());
  42 + }
  43 +
  44 + @Test
  45 + public void testGetNewUserCount_delegate() {
  46 + when(statisticsMapper.selectNewUserCount(BEGIN, END)).thenReturn(10L);
  47 + assertEquals(10L, service.getNewUserCount(BEGIN, END));
  48 + }
  49 +
  50 + @Test
  51 + public void testGetActiveUserCount_delegate() {
  52 + when(statisticsMapper.selectActiveUserCount(BEGIN, END)).thenReturn(5L);
  53 + assertEquals(5L, service.getActiveUserCount(BEGIN, END));
  54 + }
  55 +
  56 + @Test
  57 + public void testGetTotalGroupCount_delegate() {
  58 + when(statisticsMapper.selectTotalGroupCount()).thenReturn(33L);
  59 + assertEquals(33L, service.getTotalGroupCount());
  60 + }
  61 +
  62 + @Test
  63 + public void testGetPrivateMessageCount_delegate() {
  64 + when(statisticsMapper.selectPrivateMessageCount(BEGIN, END)).thenReturn(123L);
  65 + assertEquals(123L, service.getPrivateMessageCount(BEGIN, END));
  66 + }
  67 +
  68 + @Test
  69 + public void testGetGroupMessageCount_delegate() {
  70 + when(statisticsMapper.selectGroupMessageCount(BEGIN, END)).thenReturn(456L);
  71 + assertEquals(456L, service.getGroupMessageCount(BEGIN, END));
  72 + }
  73 +
  74 + // ========== 每日序列 toDailyCountMap ==========
  75 +
  76 + @Test
  77 + public void testGetNewUserDailyCountMap_dateConvert() {
  78 + // 准备:date 字段以 SQL Date / String 混入,count 为 Long
  79 + when(statisticsMapper.selectNewUserDailyCount(eq(BEGIN), eq(END))).thenReturn(Arrays.asList(
  80 + row("date", java.sql.Date.valueOf("2026-01-10"), "count", 5L),
  81 + row("date", "2026-01-11", "count", 8L)));
  82 +
  83 + // 调用
  84 + Map<LocalDateTime, Long> result = service.getNewUserDailyCountMap(BEGIN, END);
  85 +
  86 + // 断言:date → LocalDateTime 起始零点
  87 + assertEquals(2, result.size());
  88 + assertEquals(5L, result.get(LocalDate.of(2026, 1, 10).atStartOfDay()));
  89 + assertEquals(8L, result.get(LocalDate.of(2026, 1, 11).atStartOfDay()));
  90 + }
  91 +
  92 + @Test
  93 + public void testGetActiveUserDailyCountMap_dateConvert() {
  94 + when(statisticsMapper.selectActiveUserDailyCount(any(), any())).thenReturn(Arrays.asList(
  95 + row("date", "2026-01-10", "count", 3L)));
  96 +
  97 + Map<LocalDateTime, Long> result = service.getActiveUserDailyCountMap(BEGIN, END);
  98 + assertEquals(3L, result.get(LocalDate.of(2026, 1, 10).atStartOfDay()));
  99 + }
  100 +
  101 + @Test
  102 + public void testGetPrivateMessageDailyCountMap_dateConvert() {
  103 + when(statisticsMapper.selectPrivateMessageDailyCount(any(), any())).thenReturn(Arrays.asList(
  104 + row("date", "2026-01-10", "count", 7L)));
  105 + assertEquals(7L, service.getPrivateMessageDailyCountMap(BEGIN, END)
  106 + .get(LocalDate.of(2026, 1, 10).atStartOfDay()));
  107 + }
  108 +
  109 + @Test
  110 + public void testGetGroupMessageDailyCountMap_dateConvert() {
  111 + when(statisticsMapper.selectGroupMessageDailyCount(any(), any())).thenReturn(Arrays.asList(
  112 + row("date", "2026-01-10", "count", 9L)));
  113 + assertEquals(9L, service.getGroupMessageDailyCountMap(BEGIN, END)
  114 + .get(LocalDate.of(2026, 1, 10).atStartOfDay()));
  115 + }
  116 +
  117 + // ========== 分桶 / 分布 ==========
  118 +
  119 + @Test
  120 + public void testGetGroupSizeCountMap() {
  121 + when(statisticsMapper.selectGroupSizeDistribution()).thenReturn(Arrays.asList(
  122 + row("range", "1-9 人", "count", 3L),
  123 + row("range", "10-49 人", "count", 2L)));
  124 +
  125 + Map<String, Long> result = service.getGroupSizeCountMap();
  126 + assertEquals(3L, result.get("1-9 人"));
  127 + assertEquals(2L, result.get("10-49 人"));
  128 + }
  129 +
  130 + @Test
  131 + public void testGetMessageTypeCountMap() {
  132 + when(statisticsMapper.selectMessageTypeDistribution(BEGIN, END)).thenReturn(Arrays.asList(
  133 + row("type", 101, "count", 8L),
  134 + row("type", 102, "count", 1L)));
  135 +
  136 + Map<Integer, Long> result = service.getMessageTypeCountMap(BEGIN, END);
  137 + assertEquals(8L, result.get(101));
  138 + assertEquals(1L, result.get(102));
  139 + }
  140 +
  141 + @Test
  142 + public void testGetTopSenderCountMap_passesLimit() {
  143 + when(statisticsMapper.selectTopSenders(BEGIN, END, 3)).thenReturn(Arrays.asList(
  144 + row("userId", 1L, "messageCount", 10L),
  145 + row("userId", 2L, "messageCount", 5L)));
  146 +
  147 + Map<Long, Long> result = service.getTopSenderCountMap(BEGIN, END, 3);
  148 + assertEquals(10L, result.get(1L));
  149 + assertEquals(5L, result.get(2L));
  150 + }
  151 +
  152 + // ========== 工具 ==========
  153 +
  154 + private static Map<String, Object> row(String k1, Object v1, String k2, Object v2) {
  155 + Map<String, Object> map = new HashMap<>();
  156 + map.put(k1, v1);
  157 + map.put(k2, v2);
  158 + return map;
  159 + }
  160 +
  161 +}
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/util/ImMessageUtilsTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.util;
  2 +
  3 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  4 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage;
  5 +import org.junit.jupiter.api.Test;
  6 +
  7 +import java.util.Map;
  8 +
  9 +import static org.junit.jupiter.api.Assertions.assertEquals;
  10 +import static org.junit.jupiter.api.Assertions.assertFalse;
  11 +import static org.junit.jupiter.api.Assertions.assertNull;
  12 +import static org.junit.jupiter.api.Assertions.assertTrue;
  13 +
  14 +/**
  15 + * IM 消息内容工具类单元测试
  16 + *
  17 + * @author 芋道源码
  18 + */
  19 +public class ImMessageUtilsTest {
  20 +
  21 + // ========== parseQuoteMessageId ==========
  22 +
  23 + @Test
  24 + public void testParseQuoteMessageId_valid() {
  25 + // 准备:content 含合法 quote 字段
  26 + String content = "{\"text\":\"你好\",\"quote\":{\"messageId\":1001,\"senderId\":2,\"type\":1}}";
  27 +
  28 + // 调用 + 断言
  29 + assertEquals(1001L, ImMessageUtils.parseQuoteMessageId(content));
  30 + }
  31 +
  32 + @Test
  33 + public void testParseQuoteMessageId_invalidJson() {
  34 + // 准备:content 非合法 JSON
  35 + // 调用 + 断言:解析失败返回 null
  36 + assertNull(ImMessageUtils.parseQuoteMessageId("not a json"));
  37 + }
  38 +
  39 + @Test
  40 + public void testParseQuoteMessageId_noQuoteField() {
  41 + // 准备:content 无 quote 字段
  42 + String content = "{\"text\":\"你好\"}";
  43 +
  44 + // 调用 + 断言
  45 + assertNull(ImMessageUtils.parseQuoteMessageId(content));
  46 + }
  47 +
  48 + @Test
  49 + public void testParseQuoteMessageId_quoteIsNotMap() {
  50 + // 准备:quote 字段不是对象
  51 + String content = "{\"text\":\"你好\",\"quote\":\"oops\"}";
  52 +
  53 + // 调用 + 断言
  54 + assertNull(ImMessageUtils.parseQuoteMessageId(content));
  55 + }
  56 +
  57 + @Test
  58 + public void testParseQuoteMessageId_messageIdMissing() {
  59 + // 准备:quote 对象内无 messageId
  60 + String content = "{\"quote\":{\"senderId\":2}}";
  61 +
  62 + // 调用 + 断言
  63 + assertNull(ImMessageUtils.parseQuoteMessageId(content));
  64 + }
  65 +
  66 + // ========== appendQuote ==========
  67 +
  68 + @Test
  69 + public void testAppendQuote_existingContent() {
  70 + // 准备:已有 text 字段的 content
  71 + String content = "{\"text\":\"你好\"}";
  72 + QuoteMessage quote = new QuoteMessage().setMessageId(1001L).setSenderId(2L).setType(1).setContent("{}");
  73 +
  74 + // 调用
  75 + String result = ImMessageUtils.appendQuote(content, quote);
  76 +
  77 + // 断言:保留原字段,注入 quote
  78 + Map<String, Object> map = JsonUtils.parseMap(result);
  79 + assertEquals("你好", map.get("text"));
  80 + assertTrue(map.get("quote") instanceof Map);
  81 + assertEquals(1001, ((Map<?, ?>) map.get("quote")).get("messageId"));
  82 + }
  83 +
  84 + @Test
  85 + public void testAppendQuote_blankContent() {
  86 + // 准备:content 为空字符串,无法解析为 map
  87 + QuoteMessage quote = new QuoteMessage().setMessageId(1001L);
  88 +
  89 + // 调用
  90 + String result = ImMessageUtils.appendQuote("", quote);
  91 +
  92 + // 断言:内部新建 map,仅含 quote
  93 + Map<String, Object> map = JsonUtils.parseMap(result);
  94 + assertEquals(1, map.size());
  95 + assertTrue(map.containsKey("quote"));
  96 + }
  97 +
  98 + @Test
  99 + public void testAppendQuote_overwriteExistingQuote() {
  100 + // 准备:content 已经有一个旧 quote,期望被新 quote 覆盖
  101 + String content = "{\"text\":\"hi\",\"quote\":{\"messageId\":1}}";
  102 + QuoteMessage quote = new QuoteMessage().setMessageId(9999L).setSenderId(2L).setType(1);
  103 +
  104 + // 调用
  105 + String result = ImMessageUtils.appendQuote(content, quote);
  106 +
  107 + // 断言:quote.messageId 被覆盖为新值
  108 + Map<String, Object> map = JsonUtils.parseMap(result);
  109 + assertEquals(9999, ((Map<?, ?>) map.get("quote")).get("messageId"));
  110 + }
  111 +
  112 + // ========== removeQuote ==========
  113 +
  114 + @Test
  115 + public void testRemoveQuote_blankContent() {
  116 + // 调用 + 断言:空内容原样返回
  117 + assertEquals("", ImMessageUtils.removeQuote(""));
  118 + assertNull(ImMessageUtils.removeQuote(null));
  119 + }
  120 +
  121 + @Test
  122 + public void testRemoveQuote_noQuoteField() {
  123 + // 准备:content 字符串里没有 quote 关键字,提前返回原值
  124 + String content = "{\"text\":\"你好\"}";
  125 +
  126 + // 调用 + 断言:同一引用直接返回
  127 + assertEquals(content, ImMessageUtils.removeQuote(content));
  128 + }
  129 +
  130 + @Test
  131 + public void testRemoveQuote_withQuote() {
  132 + // 准备:含 quote 的 content
  133 + String content = "{\"text\":\"你好\",\"quote\":{\"messageId\":1}}";
  134 +
  135 + // 调用
  136 + String result = ImMessageUtils.removeQuote(content);
  137 +
  138 + // 断言:quote 被移除,保留其它字段
  139 + Map<String, Object> map = JsonUtils.parseMap(result);
  140 + assertFalse(map.containsKey("quote"));
  141 + assertEquals("你好", map.get("text"));
  142 + }
  143 +
  144 + @Test
  145 + public void testRemoveQuote_invalidJsonContainingQuoteToken() {
  146 + // 准备:字符串包含 quote 字面量但解析失败
  147 + String content = "not-a-json-but-has-\"quote\"-token";
  148 +
  149 + // 调用 + 断言:解析失败返回原值
  150 + assertEquals(content, ImMessageUtils.removeQuote(content));
  151 + }
  152 +
  153 + // ========== buildQuote ==========
  154 +
  155 + @Test
  156 + public void testBuildQuote_stripsNestedQuote() {
  157 + // 准备:被引用消息本身也含有 quote 字段;防止嵌套
  158 + String originalContent = "{\"text\":\"被引用\",\"quote\":{\"messageId\":777}}";
  159 +
  160 + // 调用
  161 + QuoteMessage quote = ImMessageUtils.buildQuote(1001L, 2L, 1, originalContent);
  162 +
  163 + // 断言:基础字段透传,content 已被剥离 quote
  164 + assertEquals(1001L, quote.getMessageId());
  165 + assertEquals(2L, quote.getSenderId());
  166 + assertEquals(1, quote.getType());
  167 + Map<String, Object> contentMap = JsonUtils.parseMap(quote.getContent());
  168 + assertFalse(contentMap.containsKey("quote"));
  169 + assertEquals("被引用", contentMap.get("text"));
  170 + }
  171 +
  172 + @Test
  173 + public void testBuildQuote_originalWithoutQuote() {
  174 + // 准备:被引用消息原本就没 quote 字段
  175 + String originalContent = "{\"text\":\"hi\"}";
  176 +
  177 + // 调用
  178 + QuoteMessage quote = ImMessageUtils.buildQuote(1001L, 2L, 1, originalContent);
  179 +
  180 + // 断言:content 原样保留
  181 + assertEquals(originalContent, quote.getContent());
  182 + }
  183 +
  184 +}
yudao-module-im/src/test/resources/application-unit-test.yaml
@@ -9,7 +9,7 @@ spring: @@ -9,7 +9,7 @@ spring:
9 # 数据源配置项 9 # 数据源配置项
10 datasource: 10 datasource:
11 name: ruoyi-vue-pro 11 name: ruoyi-vue-pro
12 - url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 12 + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value,day; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写
13 driver-class-name: org.h2.Driver 13 driver-class-name: org.h2.Driver
14 username: sa 14 username: sa
15 password: 15 password:
yudao-module-im/src/test/resources/sql/clean.sql
@@ -10,3 +10,4 @@ DELETE FROM &quot;im_face_pack_item&quot;; @@ -10,3 +10,4 @@ DELETE FROM &quot;im_face_pack_item&quot;;
10 DELETE FROM "im_face_user_item"; 10 DELETE FROM "im_face_user_item";
11 DELETE FROM "im_rtc_call"; 11 DELETE FROM "im_rtc_call";
12 DELETE FROM "im_rtc_participant"; 12 DELETE FROM "im_rtc_participant";
  13 +DELETE FROM "system_users";
yudao-module-im/src/test/resources/sql/create_tables.sql
@@ -235,3 +235,27 @@ CREATE TABLE IF NOT EXISTS &quot;im_face_user_item&quot; ( @@ -235,3 +235,27 @@ CREATE TABLE IF NOT EXISTS &quot;im_face_user_item&quot; (
235 "tenant_id" bigint NOT NULL DEFAULT 0, 235 "tenant_id" bigint NOT NULL DEFAULT 0,
236 PRIMARY KEY ("id") 236 PRIMARY KEY ("id")
237 ) COMMENT 'IM 用户私有表情表'; 237 ) COMMENT 'IM 用户私有表情表';
  238 +
  239 +CREATE TABLE IF NOT EXISTS "system_users" (
  240 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  241 + "username" varchar(30) NOT NULL DEFAULT '',
  242 + "password" varchar(100) NOT NULL DEFAULT '',
  243 + "nickname" varchar(30) NOT NULL DEFAULT '',
  244 + "remark" varchar(500) DEFAULT NULL,
  245 + "dept_id" bigint DEFAULT NULL,
  246 + "post_ids" varchar(255) DEFAULT NULL,
  247 + "email" varchar(50) DEFAULT '',
  248 + "mobile" varchar(11) DEFAULT '',
  249 + "sex" tinyint DEFAULT 0,
  250 + "avatar" varchar(100) DEFAULT '',
  251 + "status" tinyint NOT NULL DEFAULT 0,
  252 + "login_ip" varchar(50) DEFAULT '',
  253 + "login_date" timestamp DEFAULT NULL,
  254 + "creator" varchar(64) DEFAULT '',
  255 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  256 + "updater" varchar(64) DEFAULT '',
  257 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  258 + "deleted" bit NOT NULL DEFAULT FALSE,
  259 + "tenant_id" bigint NOT NULL DEFAULT 0,
  260 + PRIMARY KEY ("id")
  261 +) COMMENT '用户信息表';