🎉 Initial commit

This commit is contained in:
2026-07-27 14:26:14 +08:00
commit fd54f07889
6935 changed files with 617410 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
package cn.iocoder.yudao.module.im.dal.mysql.conversation;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link ImConversationReadMapper} 的单元测试
*
* @author 芋道源码
*/
public class ImConversationReadMapperTest extends BaseDbUnitTest {
@Resource
private ImConversationReadMapper mapper;
@Test
public void testUpdateReadMessageIdToLarger_onlyAdvancesWhenLarger() {
// 准备:用户 1 在群 10 的读位置 = 50
ImConversationReadDO read = buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 50L);
mapper.insert(read);
// 1. 新位置更大 → 前进
int advanced = mapper.updateReadMessageIdToLarger(read.getId(), 100L, LocalDateTime.now());
assertEquals(1, advanced);
assertEquals(100L, mapper.selectById(read.getId()).getMessageId());
// 2. 新位置相等 → 不更新
int equalUpdate = mapper.updateReadMessageIdToLarger(read.getId(), 100L, LocalDateTime.now());
assertEquals(0, equalUpdate);
// 3. 新位置更小(乱序上报)→ 不回退
int smaller = mapper.updateReadMessageIdToLarger(read.getId(), 80L, LocalDateTime.now());
assertEquals(0, smaller);
assertEquals(100L, mapper.selectById(read.getId()).getMessageId());
}
@Test
public void testSelectByUserIdAndConversation() {
ImConversationReadDO read = buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 50L);
mapper.insert(read);
// 同用户不同会话类型,不应命中
mapper.insert(buildRead(1L, ImConversationTypeEnum.PRIVATE.getType(), 10L, 99L));
ImConversationReadDO result = mapper.selectByUserIdAndConversation(
1L, ImConversationTypeEnum.GROUP.getType(), 10L);
assertNotNull(result);
assertEquals(50L, result.getMessageId());
}
@Test
public void testSelectListByConversation_aggregatesAllUsers() {
// 群 10 内三个用户的读位置,用于群回执人数聚合
mapper.insert(buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 10L, 100L));
mapper.insert(buildRead(2L, ImConversationTypeEnum.GROUP.getType(), 10L, 80L));
mapper.insert(buildRead(3L, ImConversationTypeEnum.GROUP.getType(), 10L, 50L));
// 别的群,不应混入
mapper.insert(buildRead(1L, ImConversationTypeEnum.GROUP.getType(), 20L, 999L));
List<ImConversationReadDO> list = mapper.selectListByConversation(
ImConversationTypeEnum.GROUP.getType(), 10L);
Map<Long, Long> positions = convertMap(list,
ImConversationReadDO::getUserId, ImConversationReadDO::getMessageId);
assertEquals(3, positions.size());
assertEquals(100L, positions.get(1L));
assertEquals(80L, positions.get(2L));
assertEquals(50L, positions.get(3L));
}
@Test
public void testSelectListByUserIdAndConversations_batch() {
// 用户 1 在频道 10 / 20 / 30 的读位置
mapper.insert(buildRead(1L, ImConversationTypeEnum.CHANNEL.getType(), 10L, 5L));
mapper.insert(buildRead(1L, ImConversationTypeEnum.CHANNEL.getType(), 20L, 8L));
mapper.insert(buildRead(1L, ImConversationTypeEnum.CHANNEL.getType(), 30L, 3L));
List<ImConversationReadDO> list = mapper.selectListByUserIdAndConversations(
1L, ImConversationTypeEnum.CHANNEL.getType(), List.of(10L, 20L));
assertEquals(2, list.size());
}
private ImConversationReadDO buildRead(Long userId, Integer conversationType, Long conversationId,
Long readMessageId) {
return ImConversationReadDO.builder()
.userId(userId)
.conversationType(conversationType)
.targetId(conversationId)
.messageId(readMessageId)
.readTime(LocalDateTime.now())
.build();
}
}

View File

@@ -0,0 +1,80 @@
package cn.iocoder.yudao.module.im.dal.mysql.friend;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link ImFriendRequestMapper} 的单元测试
*
* @author 芋道源码
*/
public class ImFriendRequestMapperTest extends BaseDbUnitTest {
@Resource
private ImFriendRequestMapper mapper;
// ========== selectMyList ==========
@Test
public void testSelectMyList_bidirectional() {
// 准备1 既作为 from 又作为 to3 是无关用户
mapper.insert(buildRequest(1L, 2L));
mapper.insert(buildRequest(2L, 1L));
mapper.insert(buildRequest(3L, 4L));
// 调用cursor 为空,拉首页
List<ImFriendRequestDO> list = mapper.selectMyList(1L, null, null, 10);
// 断言:双向 OR 命中两条,无关用户被排除
assertEquals(2, list.size());
list.forEach(r -> assertTrue(r.getFromUserId().equals(1L) || r.getToUserId().equals(1L)));
}
@Test
public void testSelectMyList_cursorPaging() {
// 准备:三条 1 相关的申请
ImFriendRequestDO r1 = buildRequest(1L, 2L);
ImFriendRequestDO r2 = buildRequest(1L, 3L);
ImFriendRequestDO r3 = buildRequest(1L, 4L);
mapper.insert(r1);
mapper.insert(r2);
mapper.insert(r3);
// 调用cursor = r3拉比 r3 更早的下一页
ImFriendRequestDO cursor = mapper.selectById(r3.getId());
List<ImFriendRequestDO> next = mapper.selectMyList(1L, cursor.getUpdateTime(), cursor.getId(), 10);
// 断言:仅含 r1 / r2按 id 倒序
assertEquals(2, next.size());
assertEquals(r2.getId(), next.get(0).getId());
assertEquals(r1.getId(), next.get(1).getId());
}
@Test
public void testSelectMyList_limit() {
// 准备:插入 3 条
for (int i = 0; i < 3; i++) {
mapper.insert(buildRequest(1L, (long) (10 + i)));
}
// 调用limit = 2
List<ImFriendRequestDO> list = mapper.selectMyList(1L, null, null, 2);
// 断言:手写 LIMIT 真生效
assertEquals(2, list.size());
}
private static ImFriendRequestDO buildRequest(Long fromUserId, Long toUserId) {
return new ImFriendRequestDO()
.setFromUserId(fromUserId).setToUserId(toUserId)
.setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
}
}

View File

@@ -0,0 +1,195 @@
package cn.iocoder.yudao.module.im.dal.mysql.message;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link ImGroupMessageMapper} 的单元测试
*
* @author 芋道源码
*/
public class ImGroupMessageMapperTest extends BaseDbUnitTest {
@Resource
private ImGroupMessageMapper mapper;
private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0);
// ========== selectListByMinId ==========
@Test
public void testSelectListByMinId_filterByReceiverSnapshot() {
// 准备:群 10 三条消息,快照分别命中 / 不命中用户 1
ImGroupMessageDO visible = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
mapper.insert(visible);
ImGroupMessageDO invisible = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(2L, 3L));
mapper.insert(invisible);
ImGroupMessageDO selfSend = buildMessage(10L, 1L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
mapper.insert(selfSend);
// 调用:用户 1 拉取群 10
List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100);
// 断言:仅返回快照包含用户 1 的消息
assertEquals(2, result.size());
assertEquals(visible.getId(), result.get(0).getId());
assertEquals(selfSend.getId(), result.get(1).getId());
}
@Test
public void testSelectListByMinId_excludeNullAndEmptySnapshot() {
// 准备:快照为 null / 空字符串的老数据,不应被任何用户拉到
ImGroupMessageDO nullSnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, null);
mapper.insert(nullSnapshot);
ImGroupMessageDO emptySnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of());
mapper.insert(emptySnapshot);
List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100);
assertTrue(result.isEmpty());
}
@Test
public void testSelectListByMinId_onlyReturnsGroupsInIdList() {
// 准备:群 10 + 群 20 各一条,用户 1 都在快照内
ImGroupMessageDO msg10 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
mapper.insert(msg10);
ImGroupMessageDO msg20 = buildMessage(20L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
mapper.insert(msg20);
// 调用:候选群只含群 10
List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100);
assertEquals(1, result.size());
assertEquals(msg10.getId(), result.get(0).getId());
}
@Test
public void testSelectListByMinId_includesRecall() {
// 准备:撤回消息也要返回,由客户端按 status 渲染「此消息已撤回」占位
ImGroupMessageDO normal = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
mapper.insert(normal);
ImGroupMessageDO recalled = buildMessage(10L, 2L, ImMessageStatusEnum.RECALL, List.of(1L));
mapper.insert(recalled);
List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100);
assertEquals(2, result.size());
}
@Test
public void testSelectListByMinId_sendTimeWindow() {
// 准备:一条在窗口内,一条在窗口外
ImGroupMessageDO inWindow = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
inWindow.setSendTime(LocalDateTime.now().minusDays(1));
mapper.insert(inWindow);
ImGroupMessageDO outWindow = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
outWindow.setSendTime(LocalDateTime.now().minusDays(40));
mapper.insert(outWindow);
List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L,
LocalDateTime.now().minusDays(30), 100);
assertEquals(1, result.size());
assertEquals(inWindow.getId(), result.get(0).getId());
}
@Test
public void testSelectListByMinId_sortAscLimit() {
// 准备:插入 3 条可见消息
ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
mapper.insert(m1);
ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
mapper.insert(m2);
ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
mapper.insert(m3);
// 调用size=2返回 id 最小的 2 条
List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 2);
assertEquals(2, result.size());
assertTrue(result.get(0).getId() < result.get(1).getId());
assertEquals(m1.getId(), result.get(0).getId());
assertEquals(m2.getId(), result.get(1).getId());
}
@Test
public void testSelectListByMinId_minIdExclusive() {
// 准备:游标之前的消息不返回
ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
mapper.insert(m1);
ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
mapper.insert(m2);
List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), m1.getId(), FAR_PAST, 100);
assertEquals(1, result.size());
assertEquals(m2.getId(), result.get(0).getId());
}
// ========== selectHistoryListByUser ==========
@Test
public void testSelectHistoryListByUser_filterByReceiverSnapshot() {
// 准备:群 10 三条,用户 1 在前两条快照内、第三条定向给别人
ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
mapper.insert(m1);
ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
mapper.insert(m2);
ImGroupMessageDO directedOther = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(2L, 3L));
mapper.insert(directedOther);
// 别的群
ImGroupMessageDO other = buildMessage(20L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
mapper.insert(other);
List<ImGroupMessageDO> result = mapper.selectHistoryListByUser(1L, 10L, null, 100);
// 断言:只返回群 10 中用户 1 可见的,按 id 倒序
assertEquals(2, result.size());
assertEquals(m2.getId(), result.get(0).getId());
assertEquals(m1.getId(), result.get(1).getId());
}
@Test
public void testSelectHistoryListByUser_maxIdCursor() {
ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
mapper.insert(m1);
ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
mapper.insert(m2);
ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
mapper.insert(m3);
List<ImGroupMessageDO> result = mapper.selectHistoryListByUser(1L, 10L, m3.getId(), 100);
// 断言:只返回 id < m3.id 的 m1、m2
assertEquals(2, result.size());
assertTrue(result.stream().allMatch(m -> m.getId() < m3.getId()));
}
// ========== 工具方法 ==========
private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status,
List<Long> receiverUserIds) {
return ImGroupMessageDO.builder()
.clientMessageId("uuid-" + System.nanoTime())
.senderId(senderId)
.groupId(groupId)
.type(0)
.content("{\"content\":\"test\"}")
.status(status.getStatus())
.sendTime(LocalDateTime.now())
.receiverUserIds(receiverUserIds)
.receiptStatus(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus())
.build();
}
}

View File

@@ -0,0 +1,196 @@
package cn.iocoder.yudao.module.im.dal.mysql.message;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link ImPrivateMessageMapper} 的单元测试
*
* @author 芋道源码
*/
public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
@Resource
private ImPrivateMessageMapper mapper;
private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0);
// ========== selectListByMinId ==========
@Test
public void testSelectListByMinId() {
// 准备:用户 1 发给用户 2
ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg1);
// 用户 2 发给用户 1
ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg2);
// 用户 3 发给用户 4不相关
ImPrivateMessageDO msg3 = buildMessage(3L, 4L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg3);
// 调用:用户 1 从 id=0 拉取
List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 100);
// 断言:只包含与用户 1 相关的 2 条
assertEquals(2, result.size());
assertTrue(result.stream().allMatch(m ->
m.getSenderId().equals(1L) || m.getReceiverId().equals(1L)));
}
@Test
public void testSelectListByMinId_withMinId() {
// 准备
ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg1);
ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg2);
// 调用:从 msg1.id 之后拉取
List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, msg1.getId(), FAR_PAST, 100);
// 断言:只有 msg2
assertEquals(1, result.size());
assertEquals(msg2.getId(), result.get(0).getId());
}
@Test
public void testSelectListByMinId_limitSize() {
// 准备:插入 3 条
mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL));
mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL));
mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL));
// 调用limit 2
List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 2);
// 断言
assertEquals(2, result.size());
}
@Test
public void testSelectListByMinId_sendTimeFilter() {
// 准备:一条落在窗口内,一条落在窗口外
ImPrivateMessageDO newMsg = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
newMsg.setSendTime(LocalDateTime.now().minusDays(1));
mapper.insert(newMsg);
ImPrivateMessageDO oldMsg = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
oldMsg.setSendTime(LocalDateTime.now().minusDays(40));
mapper.insert(oldMsg);
// 调用:窗口起点 = 30 天前
List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, 0L,
LocalDateTime.now().minusDays(30), 100);
// 断言:只返回窗口内的消息
assertEquals(1, result.size());
assertEquals(newMsg.getId(), result.get(0).getId());
}
// ========== selectHistoryList ==========
@Test
public void testSelectHistoryList_basic() {
// 准备:用户 1 <-> 用户 2 的消息
ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg1);
ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg2);
// 用户 1 <-> 用户 3 的消息(不相关)
ImPrivateMessageDO msg3 = buildMessage(1L, 3L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg3);
// 调用
List<ImPrivateMessageDO> result = mapper.selectHistoryList(1L, 2L, null, 100);
// 断言:只有与用户 2 对话的 2 条,按 id 倒序
assertEquals(2, result.size());
assertTrue(result.get(0).getId() > result.get(1).getId());
}
@Test
public void testSelectHistoryList_includeRecall() {
// 准备
ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg1);
ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.RECALL);
mapper.insert(msg2);
// 调用
List<ImPrivateMessageDO> result = mapper.selectHistoryList(1L, 2L, null, 100);
// 断言:撤回消息一并返回
assertEquals(2, result.size());
assertEquals(msg2.getId(), result.get(0).getId());
assertEquals(msg1.getId(), result.get(1).getId());
}
@Test
public void testSelectHistoryList_withMaxId() {
// 准备
ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg1);
ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg2);
ImPrivateMessageDO msg3 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
mapper.insert(msg3);
// 调用:从 msg3 往前拉
List<ImPrivateMessageDO> result = mapper.selectHistoryList(1L, 2L, msg3.getId(), 100);
// 断言:只有 msg1 和 msg2
assertEquals(2, result.size());
assertTrue(result.stream().allMatch(m -> m.getId() < msg3.getId()));
}
@Test
public void testSelectHistoryList_limit() {
// 准备
mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL));
mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL));
mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL));
// 调用limit 2
List<ImPrivateMessageDO> result = mapper.selectHistoryList(1L, 2L, null, 2);
// 断言
assertEquals(2, result.size());
}
@Test
public void testSelectHistoryList_bidirectional() {
// 准备:验证双向查询
ImPrivateMessageDO msg1 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL); // 对方发的
mapper.insert(msg1);
// 调用:以用户 1 的视角查
List<ImPrivateMessageDO> result = mapper.selectHistoryList(1L, 2L, null, 100);
// 断言:能查到对方发来的消息
assertEquals(1, result.size());
assertEquals(2L, result.get(0).getSenderId());
}
// ========== 工具方法 ==========
private ImPrivateMessageDO buildMessage(Long senderId, Long receiverId, ImMessageStatusEnum status) {
return ImPrivateMessageDO.builder()
.clientMessageId("uuid-" + System.nanoTime())
.senderId(senderId)
.receiverId(receiverId)
.type(0)
.content("{\"content\":\"test\"}")
.status(status.getStatus())
.sendTime(LocalDateTime.now())
.build();
}
}

View File

@@ -0,0 +1,278 @@
package cn.iocoder.yudao.module.im.dal.mysql.statistics;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper;
import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link ImStatisticsManagerMapper} 的单元测试
*
* @author 芋道源码
*/
public class ImStatisticsManagerMapperTest extends BaseDbUnitTest {
private static final LocalDateTime WINDOW_BEGIN = LocalDateTime.of(2026, 1, 1, 0, 0);
private static final LocalDateTime WINDOW_END = LocalDateTime.of(2026, 2, 1, 0, 0);
@Resource
private ImStatisticsManagerMapper mapper;
@Resource
private ImPrivateMessageMapper privateMessageMapper;
@Resource
private ImGroupMessageMapper groupMessageMapper;
@Resource
private ImGroupMapper groupMapper;
@Resource
private ImGroupMemberMapper groupMemberMapper;
@Resource
private DataSource dataSource;
private JdbcTemplate jdbcTemplate;
@BeforeEach
public void initJdbcTemplate() {
jdbcTemplate = new JdbcTemplate(dataSource);
}
// ========== 用户 ==========
@Test
public void testSelectTotalUserCount() {
// 准备3 个未删除用户
insertUser(1L, LocalDateTime.now());
insertUser(2L, LocalDateTime.now());
insertUser(3L, LocalDateTime.now());
// 调用 + 断言
assertEquals(3L, mapper.selectTotalUserCount());
}
@Test
public void testSelectNewUserCount_windowFilter() {
// 准备1 个在窗口内 + 2 个在窗口外
insertUser(1L, WINDOW_BEGIN.plusDays(3));
insertUser(2L, WINDOW_BEGIN.minusDays(1));
insertUser(3L, WINDOW_END.plusDays(1));
// 调用 + 断言:只命中窗口内
assertEquals(1L, mapper.selectNewUserCount(WINDOW_BEGIN, WINDOW_END));
}
@Test
public void testSelectActiveUserCount_distinctAcrossPrivateAndGroup() {
// 准备1 私聊2 群聊3 在窗口外1 既发私聊又发群聊(去重)
privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1)));
privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(2)));
groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(3)));
groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(4))); // 用户 1 已计一次
privateMessageMapper.insert(buildPrivate(3L, 4L, WINDOW_BEGIN.minusDays(1))); // 窗口外
// 调用 + 断言:去重后 = 2
assertEquals(2L, mapper.selectActiveUserCount(WINDOW_BEGIN, WINDOW_END));
}
@Test
public void testSelectNewUserDailyCount_groupByDay() {
// 准备:第 1 天 2 个;第 2 天 1 个;窗口外 1 个
insertUser(1L, WINDOW_BEGIN.plusDays(1));
insertUser(2L, WINDOW_BEGIN.plusDays(1));
insertUser(3L, WINDOW_BEGIN.plusDays(2));
insertUser(4L, WINDOW_BEGIN.minusDays(1));
// 调用
List<Map<String, Object>> list = mapper.selectNewUserDailyCount(WINDOW_BEGIN, WINDOW_END);
// 断言:返回两个分组、总数 = 3
assertEquals(2, list.size());
long sum = list.stream().mapToLong(m -> ((Number) m.get("count")).longValue()).sum();
assertEquals(3L, sum);
}
@Test
public void testSelectActiveUserDailyCount_distinctPerDay() {
// 准备:同一天里用户 1 在私聊与群聊都发了;第二天用户 2 发了
privateMessageMapper.insert(buildPrivate(1L, 9L, WINDOW_BEGIN.plusDays(1)));
groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)));
groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(2)));
// 调用
List<Map<String, Object>> list = mapper.selectActiveUserDailyCount(WINDOW_BEGIN, WINDOW_END);
// 断言:两天合计 2 名活跃用户(同一天 user 1 去重 + 第二天 user 2
assertEquals(2, list.size());
long sum = list.stream().mapToLong(m -> ((Number) m.get("count")).longValue()).sum();
assertEquals(2L, sum);
}
// ========== 群 ==========
@Test
public void testSelectTotalGroupCount_onlyEnabled() {
// 准备2 个正常 + 1 个已解散
groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE));
groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE));
groupMapper.insert(buildGroup(CommonStatusEnum.DISABLE));
// 调用 + 断言
assertEquals(2L, mapper.selectTotalGroupCount());
}
@Test
public void testSelectNewGroupCount_windowFilter() {
// 准备1 个窗口内 + 1 个窗口外
groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE));
groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE));
// 窗口窄到只覆盖 1 秒
LocalDateTime narrowBegin = LocalDateTime.now().plusYears(10);
LocalDateTime narrowEnd = narrowBegin.plusSeconds(1);
// 调用 + 断言:极窄窗口不命中任何群
assertEquals(0L, mapper.selectNewGroupCount(narrowBegin, narrowEnd));
}
@Test
public void testSelectGroupSizeDistribution_bucketing() {
// 准备:一个 5 人群、一个 15 人群
Long groupSmall = insertGroupAndMembers(5);
Long groupMid = insertGroupAndMembers(15);
assertNotNull(groupSmall);
assertNotNull(groupMid);
// 调用
List<Map<String, Object>> dist = mapper.selectGroupSizeDistribution();
// 断言分桶包含「1-9 人」和「10-49 人」
Map<Object, Long> byRange = dist.stream().collect(java.util.stream.Collectors.toMap(
m -> m.get("range"), m -> ((Number) m.get("count")).longValue()));
assertEquals(1L, byRange.get("1-9 人"));
assertEquals(1L, byRange.get("10-49 人"));
}
// ========== 消息 ==========
@Test
public void testSelectPrivateMessageCount_windowFilter() {
// 准备1 在窗口内 + 1 在窗口外
privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1)));
privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.minusDays(1)));
// 调用 + 断言
assertEquals(1L, mapper.selectPrivateMessageCount(WINDOW_BEGIN, WINDOW_END));
}
@Test
public void testSelectGroupMessageCount_windowFilter() {
// 准备1 在窗口内 + 1 在窗口外
groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)));
groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.minusDays(1)));
// 调用 + 断言
assertEquals(1L, mapper.selectGroupMessageCount(WINDOW_BEGIN, WINDOW_END));
}
@Test
public void testSelectMessageTypeDistribution_mergePrivateAndGroup() {
// 准备:私聊 type=0 ×1群聊 type=0 ×2、type=1 ×1
privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1)).setType(ImContentTypeEnum.TEXT.getType()));
groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImContentTypeEnum.TEXT.getType()));
groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImContentTypeEnum.TEXT.getType()));
groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImContentTypeEnum.IMAGE.getType()));
// 调用
List<Map<String, Object>> dist = mapper.selectMessageTypeDistribution(WINDOW_BEGIN, WINDOW_END);
// 断言type=TEXT(0) → 3type=IMAGE(1) → 1
Map<Object, Long> byType = dist.stream().collect(java.util.stream.Collectors.toMap(
m -> ((Number) m.get("type")).intValue(), m -> ((Number) m.get("count")).longValue()));
assertEquals(3L, byType.get(ImContentTypeEnum.TEXT.getType()));
assertEquals(1L, byType.get(ImContentTypeEnum.IMAGE.getType()));
}
@Test
public void testSelectTopSenders_orderByCountDescAndLimit() {
// 准备user 1 共 3 条user 2 共 2 条user 3 共 1 条
privateMessageMapper.insert(buildPrivate(1L, 9L, WINDOW_BEGIN.plusDays(1)));
groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)));
groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)));
privateMessageMapper.insert(buildPrivate(2L, 9L, WINDOW_BEGIN.plusDays(1)));
groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(1)));
privateMessageMapper.insert(buildPrivate(3L, 9L, WINDOW_BEGIN.plusDays(1)));
// 调用:取 TOP 2
List<Map<String, Object>> tops = mapper.selectTopSenders(WINDOW_BEGIN, WINDOW_END, 2);
// 断言:返回 2 条按消息数倒序user1 > user2
assertEquals(2, tops.size());
assertEquals(1L, ((Number) tops.get(0).get("userId")).longValue());
assertEquals(3L, ((Number) tops.get(0).get("messageCount")).longValue());
assertEquals(2L, ((Number) tops.get(1).get("userId")).longValue());
assertEquals(2L, ((Number) tops.get(1).get("messageCount")).longValue());
}
// ========== 工具方法 ==========
private void insertUser(Long id, LocalDateTime createTime) {
jdbcTemplate.update(
"INSERT INTO system_users (id, username, password, nickname, status, create_time, update_time, deleted, tenant_id) " +
"VALUES (?, ?, '', ?, 0, ?, ?, FALSE, 0)",
id, "u" + id, "n" + id, createTime, createTime);
}
private static ImPrivateMessageDO buildPrivate(Long senderId, Long receiverId, LocalDateTime sendTime) {
return ImPrivateMessageDO.builder()
.clientMessageId("uuid-" + System.nanoTime())
.senderId(senderId).receiverId(receiverId)
.type(ImContentTypeEnum.TEXT.getType())
.content("{}")
.status(ImMessageStatusEnum.NORMAL.getStatus())
.sendTime(sendTime).build();
}
private static ImGroupMessageDO buildGroupMessage(Long senderId, Long groupId, LocalDateTime sendTime) {
return new ImGroupMessageDO()
.setClientMessageId("uuid-" + System.nanoTime())
.setSenderId(senderId).setGroupId(groupId)
.setType(ImContentTypeEnum.TEXT.getType())
.setContent("{}")
.setStatus(ImMessageStatusEnum.NORMAL.getStatus())
.setSendTime(sendTime);
}
private static ImGroupDO buildGroup(CommonStatusEnum status) {
return ImGroupDO.builder().name("g" + System.nanoTime()).ownerUserId(1L)
.status(status.getStatus()).build();
}
private Long insertGroupAndMembers(int memberCount) {
ImGroupDO group = buildGroup(CommonStatusEnum.ENABLE);
groupMapper.insert(group);
for (int i = 0; i < memberCount; i++) {
groupMemberMapper.insert(new ImGroupMemberDO()
.setGroupId(group.getId()).setUserId((long) (1000 + i))
.setStatus(CommonStatusEnum.ENABLE.getStatus()).setRole(3));
}
return group.getId();
}
}

View File

@@ -0,0 +1,177 @@
package cn.iocoder.yudao.module.im.service.face;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.item.ImFacePackItemSaveReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO;
import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackItemDO;
import cn.iocoder.yudao.module.im.dal.mysql.face.ImFacePackItemMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_ITEM_NOT_EXISTS;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_NOT_EXISTS;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ImFacePackItemServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImFacePackItemServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImFacePackItemServiceImpl service;
@Mock
private ImFacePackItemMapper facePackItemMapper;
@Mock
private ImFacePackService facePackService;
// ========== getEnabledItemListByPackIds ==========
@Test
public void testGetEnabledItemListByPackIds_emptyShortReturn() {
// 调用 + 断言:空入参直接返回空列表,不查 DB
assertTrue(service.getEnabledItemListByPackIds(Collections.emptyList()).isEmpty());
verify(facePackItemMapper, never()).selectListByPackIdsAndStatus(any(), any());
}
@Test
public void testGetEnabledItemListByPackIds_passesEnableStatus() {
// 准备
List<Long> packIds = Arrays.asList(1L, 2L);
ImFacePackItemDO item = new ImFacePackItemDO();
when(facePackItemMapper.selectListByPackIdsAndStatus(packIds, CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(Collections.singletonList(item));
// 调用 + 断言
assertEquals(1, service.getEnabledItemListByPackIds(packIds).size());
}
// ========== createFacePackItem ==========
@Test
public void testCreateFacePackItem_success() {
// 准备
ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO();
reqVO.setPackId(10L).setUrl("a.png").setName("dog").setWidth(100).setHeight(100)
.setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus());
when(facePackService.validateFacePackExists(10L)).thenReturn(new ImFacePackDO());
// 调用
service.createFacePackItem(reqVO);
// 断言
ArgumentCaptor<ImFacePackItemDO> captor = ArgumentCaptor.forClass(ImFacePackItemDO.class);
verify(facePackItemMapper).insert(captor.capture());
assertEquals(10L, captor.getValue().getPackId());
assertEquals("a.png", captor.getValue().getUrl());
}
@Test
public void testCreateFacePackItem_packNotExists() {
// 准备:所属表情包不存在
ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO();
reqVO.setPackId(99L).setUrl("a.png").setWidth(100).setHeight(100).setSort(0)
.setStatus(CommonStatusEnum.ENABLE.getStatus());
when(facePackService.validateFacePackExists(99L))
.thenThrow(new ServiceException(FACE_PACK_NOT_EXISTS.getCode(), FACE_PACK_NOT_EXISTS.getMsg()));
// 调用 + 断言:不落库
assertThrows(ServiceException.class, () -> service.createFacePackItem(reqVO));
verify(facePackItemMapper, never()).insert(any(ImFacePackItemDO.class));
}
// ========== updateFacePackItem ==========
@Test
public void testUpdateFacePackItem_success() {
// 准备
ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO();
reqVO.setId(1L).setPackId(10L).setUrl("a.png").setWidth(100).setHeight(100)
.setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus());
when(facePackItemMapper.selectById(1L)).thenReturn(new ImFacePackItemDO());
when(facePackService.validateFacePackExists(10L)).thenReturn(new ImFacePackDO());
// 调用
service.updateFacePackItem(reqVO);
// 断言
verify(facePackItemMapper).updateById(any(ImFacePackItemDO.class));
}
@Test
public void testUpdateFacePackItem_itemNotExists() {
ImFacePackItemSaveReqVO reqVO = new ImFacePackItemSaveReqVO();
reqVO.setId(99L).setPackId(10L).setUrl("a.png").setWidth(100).setHeight(100).setSort(0)
.setStatus(CommonStatusEnum.ENABLE.getStatus());
when(facePackItemMapper.selectById(99L)).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.updateFacePackItem(reqVO));
assertEquals(FACE_PACK_ITEM_NOT_EXISTS.getCode(), exception.getCode());
verify(facePackItemMapper, never()).updateById(any(ImFacePackItemDO.class));
}
// ========== deleteFacePackItem ==========
@Test
public void testDeleteFacePackItem_success() {
when(facePackItemMapper.selectById(1L)).thenReturn(new ImFacePackItemDO());
service.deleteFacePackItem(1L);
verify(facePackItemMapper).deleteById(1L);
}
@Test
public void testDeleteFacePackItem_notExists() {
when(facePackItemMapper.selectById(99L)).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.deleteFacePackItem(99L));
assertEquals(FACE_PACK_ITEM_NOT_EXISTS.getCode(), exception.getCode());
verify(facePackItemMapper, never()).deleteById(anyLong());
}
@Test
public void testDeleteFacePackItemList_emptySkip() {
service.deleteFacePackItemList(Collections.emptyList());
verify(facePackItemMapper, never()).deleteByIds(any());
}
@Test
public void testDeleteFacePackItemList_success() {
List<Long> ids = Arrays.asList(1L, 2L);
when(facePackItemMapper.deleteByIds(ids)).thenReturn(2);
service.deleteFacePackItemList(ids);
verify(facePackItemMapper).deleteByIds(ids);
}
@Test
public void testDeleteFacePackItemList_ignoreMissingIds() {
List<Long> ids = Arrays.asList(1L, 2L);
when(facePackItemMapper.deleteByIds(ids)).thenReturn(1);
service.deleteFacePackItemList(ids);
verify(facePackItemMapper).deleteByIds(ids);
}
}

View File

@@ -0,0 +1,177 @@
package cn.iocoder.yudao.module.im.service.face;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.pack.ImFacePackSaveReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFacePackDO;
import cn.iocoder.yudao.module.im.dal.mysql.face.ImFacePackMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.Arrays;
import java.util.Collections;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_HAS_ITEMS;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_PACK_NOT_EXISTS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ImFacePackServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImFacePackServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImFacePackServiceImpl service;
@Mock
private ImFacePackMapper facePackMapper;
@Mock
private ImFacePackItemService facePackItemService;
// ========== validateFacePackExists ==========
@Test
public void testValidateFacePackExists_notFound() {
when(facePackMapper.selectById(1L)).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.validateFacePackExists(1L));
assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode());
}
@Test
public void testValidateFacePackExists_returnsDo() {
ImFacePackDO pack = ImFacePackDO.builder().id(1L).name("猫主子").build();
when(facePackMapper.selectById(1L)).thenReturn(pack);
assertEquals(pack, service.validateFacePackExists(1L));
}
// ========== createFacePack ==========
@Test
public void testCreateFacePack_insert() {
// 准备
ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO();
reqVO.setName("猫主子").setIcon("icon.png").setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus());
// 调用
service.createFacePack(reqVO);
// 断言mapper.insert 被调用一次
ArgumentCaptor<ImFacePackDO> captor = ArgumentCaptor.forClass(ImFacePackDO.class);
verify(facePackMapper).insert(captor.capture());
assertEquals("猫主子", captor.getValue().getName());
assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus());
}
// ========== updateFacePack ==========
@Test
public void testUpdateFacePack_success() {
// 准备:存在
ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO();
reqVO.setId(1L).setName("新名").setSort(1).setStatus(CommonStatusEnum.ENABLE.getStatus());
when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build());
// 调用
service.updateFacePack(reqVO);
// 断言
ArgumentCaptor<ImFacePackDO> captor = ArgumentCaptor.forClass(ImFacePackDO.class);
verify(facePackMapper).updateById(captor.capture());
assertEquals(1L, captor.getValue().getId());
assertEquals("新名", captor.getValue().getName());
}
@Test
public void testUpdateFacePack_notExists() {
// 准备
ImFacePackSaveReqVO reqVO = new ImFacePackSaveReqVO();
reqVO.setId(99L).setName("x").setSort(0).setStatus(CommonStatusEnum.ENABLE.getStatus());
when(facePackMapper.selectById(99L)).thenReturn(null);
// 调用 + 断言
ServiceException exception = assertThrows(ServiceException.class,
() -> service.updateFacePack(reqVO));
assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode());
verify(facePackMapper, never()).updateById(any(ImFacePackDO.class));
}
// ========== deleteFacePack ==========
@Test
public void testDeleteFacePack_success() {
when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build());
when(facePackItemService.getFacePackItemCount(1L)).thenReturn(0L);
service.deleteFacePack(1L);
verify(facePackMapper).deleteById(1L);
}
@Test
public void testDeleteFacePack_hasItems() {
// 准备:包下仍有表情
when(facePackMapper.selectById(1L)).thenReturn(ImFacePackDO.builder().id(1L).build());
when(facePackItemService.getFacePackItemCount(1L)).thenReturn(3L);
ServiceException exception = assertThrows(ServiceException.class, () -> service.deleteFacePack(1L));
assertEquals(FACE_PACK_HAS_ITEMS.getCode(), exception.getCode());
verify(facePackMapper, never()).deleteById(anyLong());
}
@Test
public void testDeleteFacePack_notExists() {
when(facePackMapper.selectById(99L)).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class, () -> service.deleteFacePack(99L));
assertEquals(FACE_PACK_NOT_EXISTS.getCode(), exception.getCode());
}
// ========== deleteFacePackList ==========
@Test
public void testDeleteFacePackList_emptySkip() {
// 调用:空 ids 列表直接返回,不查询、不删除
service.deleteFacePackList(Collections.emptyList());
verify(facePackItemService, never()).getFacePackItemCount(any(java.util.Collection.class));
verify(facePackMapper, never()).deleteByIds(any());
}
@Test
public void testDeleteFacePackList_anyHasItemsRejectAll() {
// 准备:批量中存在表情
when(facePackItemService.getFacePackItemCount(Arrays.asList(1L, 2L))).thenReturn(1L);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.deleteFacePackList(Arrays.asList(1L, 2L)));
assertEquals(FACE_PACK_HAS_ITEMS.getCode(), exception.getCode());
verify(facePackMapper, never()).deleteByIds(any());
}
@Test
public void testDeleteFacePackList_success() {
// 准备
when(facePackItemService.getFacePackItemCount(Arrays.asList(1L, 2L))).thenReturn(0L);
// 调用
service.deleteFacePackList(Arrays.asList(1L, 2L));
// 断言
verify(facePackMapper).deleteByIds(Arrays.asList(1L, 2L));
}
}

View File

@@ -0,0 +1,164 @@
package cn.iocoder.yudao.module.im.service.face;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemSaveReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO;
import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.springframework.dao.DuplicateKeyException;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_MAX_LIMIT;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ImFaceUserItemServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImFaceUserItemServiceImpl service;
@Mock
private ImFaceUserItemMapper faceUserItemMapper;
@Spy
private ImProperties imProperties = new ImProperties();
// ========== createFaceUserItem ==========
@Test
public void testCreateFaceUserItem_success() {
// 准备
ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO();
reqVO.setUrl("a.png").setName("doge").setWidth(100).setHeight(100);
when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null);
when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(10L);
// 调用
service.createFaceUserItem(1L, reqVO);
// 断言:写入用户编号
ArgumentCaptor<ImFaceUserItemDO> captor = ArgumentCaptor.forClass(ImFaceUserItemDO.class);
verify(faceUserItemMapper).insert(captor.capture());
assertEquals(1L, captor.getValue().getUserId());
assertEquals("a.png", captor.getValue().getUrl());
}
@Test
public void testCreateFaceUserItem_duplicateUrl() {
// 准备:相同 (userId, url) 已存在
ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO();
reqVO.setUrl("a.png").setWidth(100).setHeight(100);
when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png"))
.thenReturn(new ImFaceUserItemDO().setUserId(1L));
// 调用 + 断言:抛重复异常,不落库
ServiceException exception = assertThrows(ServiceException.class,
() -> service.createFaceUserItem(1L, reqVO));
assertEquals(FACE_USER_ITEM_DUPLICATED.getCode(), exception.getCode());
verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class));
}
@Test
public void testCreateFaceUserItem_maxLimit() {
// 准备:个人表情已达上限
ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO();
reqVO.setUrl("a.png").setWidth(100).setHeight(100);
imProperties.getFace().setUserItemMaxCount(20);
when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null);
when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(20L);
// 调用 + 断言:抛上限异常,不落库
ServiceException exception = assertThrows(ServiceException.class,
() -> service.createFaceUserItem(1L, reqVO));
assertEquals(FACE_USER_ITEM_MAX_LIMIT.getCode(), exception.getCode());
verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class));
}
@Test
public void testCreateFaceUserItem_duplicateKey() {
// 准备:并发插入触发唯一约束
ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO();
reqVO.setUrl("a.png").setWidth(100).setHeight(100);
when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null);
when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(10L);
when(faceUserItemMapper.insert(any(ImFaceUserItemDO.class))).thenThrow(new DuplicateKeyException("duplicate"));
// 调用 + 断言:数据库唯一约束冲突转业务重复异常
ServiceException exception = assertThrows(ServiceException.class,
() -> service.createFaceUserItem(1L, reqVO));
assertEquals(FACE_USER_ITEM_DUPLICATED.getCode(), exception.getCode());
}
// ========== deleteFaceUserItem用户端 ==========
@Test
public void testDeleteFaceUserItem_userOwn_success() {
// 准备:归属当前用户
when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(1L));
// 调用
service.deleteFaceUserItem(1L, 10L);
// 断言
verify(faceUserItemMapper).deleteById(10L);
}
@Test
public void testDeleteFaceUserItem_notExists() {
when(faceUserItemMapper.selectById(99L)).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.deleteFaceUserItem(1L, 99L));
assertEquals(FACE_USER_ITEM_NOT_EXISTS.getCode(), exception.getCode());
verify(faceUserItemMapper, never()).deleteById(anyLong());
}
@Test
public void testDeleteFaceUserItem_notOwn() {
// 准备:表情归属 user 2但 user 1 来删
when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(2L));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.deleteFaceUserItem(1L, 10L));
assertEquals(FACE_USER_ITEM_NOT_OWN.getCode(), exception.getCode());
verify(faceUserItemMapper, never()).deleteById(anyLong());
}
// ========== deleteFaceUserItem管理后台 ==========
@Test
public void testDeleteFaceUserItemAdmin_success() {
when(faceUserItemMapper.selectById(10L)).thenReturn(new ImFaceUserItemDO().setUserId(2L));
service.deleteFaceUserItem(10L);
verify(faceUserItemMapper).deleteById(10L);
}
@Test
public void testDeleteFaceUserItemAdmin_notExists() {
when(faceUserItemMapper.selectById(99L)).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.deleteFaceUserItem(99L));
assertEquals(FACE_USER_ITEM_NOT_EXISTS.getCode(), exception.getCode());
}
}

View File

@@ -0,0 +1,355 @@
package cn.iocoder.yudao.module.im.service.friend;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestApplyReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendRequestMapper;
import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum;
import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ImFriendRequestServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImFriendRequestServiceImpl friendRequestService;
@Mock
private ImFriendRequestMapper friendRequestMapper;
@Mock
private ImFriendService friendService;
@Mock
private ImWebSocketService websocketService;
@Mock
private ImProperties imProperties;
@Mock
private AdminUserApi adminUserApi;
// ========== applyFriend ==========
@Test
public void testApplyFriend_addSelf() {
// 准备:发起人 = 接收人
ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
reqVO.setToUserId(1L);
// 调用 + 断言
ServiceException exception = assertThrows(ServiceException.class,
() -> friendRequestService.applyFriend(1L, reqVO));
assertEquals(FRIEND_ADD_SELF.getCode(), exception.getCode());
}
@Test
public void testApplyFriend_alreadyFriend() {
// 准备
ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
reqVO.setToUserId(2L);
when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.FRIEND.getState());
// 调用 + 断言
ServiceException exception = assertThrows(ServiceException.class,
() -> friendRequestService.applyFriend(1L, reqVO));
assertEquals(FRIEND_REQUEST_ALREADY_FRIEND.getCode(), exception.getCode());
}
@Test
public void testApplyFriend_blockedByPeer() {
// 准备
ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
reqVO.setToUserId(2L);
when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.BLOCKED.getState());
// 调用 + 断言
ServiceException exception = assertThrows(ServiceException.class,
() -> friendRequestService.applyFriend(1L, reqVO));
assertEquals(FRIEND_REQUEST_BLOCKED_BY_PEER.getCode(), exception.getCode());
}
@Test
public void testApplyFriend_silentReAdd() {
// 准备:单向好友 — 我已删除,但对方仍把我当好友
ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
reqVO.setToUserId(2L).setDisplayName("老张").setAddSource(1);
when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState());
ImFriendDO peerFriend = ImFriendDO.builder().userId(2L).friendUserId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(friendService.getFriend(2L, 1L)).thenReturn(peerFriend);
// 调用
ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO);
// 断言:走静默重新加好友,不落申请记录
assertNull(result);
verify(friendService).silentReAddFriend(eq(1L), eq(2L), eq("老张"), eq(1));
verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class));
verify(websocketService, never()).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
}
@Test
public void testApplyFriend_blockedByPeerWhenMyselfDeleted() {
// 准备我侧已删除getFriendState=NONE对方仍把我当好友但已拉黑
ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
reqVO.setToUserId(2L).setDisplayName("老张").setAddSource(1);
when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState());
ImFriendDO peerFriend = ImFriendDO.builder().userId(2L).friendUserId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).blocked(true).build();
when(friendService.getFriend(2L, 1L)).thenReturn(peerFriend);
// 调用 + 断言:必须拒掉,不能走 silentReAddFriend 绕过拉黑
ServiceException exception = assertThrows(ServiceException.class,
() -> friendRequestService.applyFriend(1L, reqVO));
assertEquals(FRIEND_REQUEST_BLOCKED_BY_PEER.getCode(), exception.getCode());
verify(friendService, never()).silentReAddFriend(anyLong(), anyLong(), anyString(), anyInt());
verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class));
}
@Test
public void testApplyFriend_insertNew() {
// 准备:双方都无关系,且无历史申请
ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
reqVO.setToUserId(2L).setApplyContent("加个好友").setDisplayName("老张").setAddSource(1);
when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState());
when(friendService.getFriend(2L, 1L)).thenReturn(null);
when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(null);
when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setNickname("张三").setAvatar("a.png"));
when(imProperties.getFriend()).thenReturn(new ImProperties.Friend());
// 调用
ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO);
// 断言:落新申请记录
ArgumentCaptor<ImFriendRequestDO> captor = ArgumentCaptor.forClass(ImFriendRequestDO.class);
verify(friendRequestMapper).insert(captor.capture());
ImFriendRequestDO saved = captor.getValue();
assertEquals(1L, saved.getFromUserId());
assertEquals(2L, saved.getToUserId());
assertEquals("加个好友", saved.getApplyContent());
assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), saved.getHandleResult());
assertSame(saved, result);
// 断言:推送给接收方
verify(websocketService).sendNotificationAsync(eq(2L), anyInt(), anyInt(), any());
}
@Test
public void testApplyFriend_reuseOldRequest() {
// 准备:双方都无关系,但存在历史申请 — 走 reset 复用
ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
reqVO.setToUserId(2L).setApplyContent("再来一次").setDisplayName("老张").setAddSource(2);
when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState());
when(friendService.getFriend(2L, 1L)).thenReturn(null);
ImFriendRequestDO old = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
.setHandleResult(ImFriendRequestHandleResultEnum.REFUSED.getResult())
.setHandleContent("旧拒绝").setApplyContent("旧内容");
when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(old);
when(adminUserApi.getUser(1L)).thenReturn(null);
when(imProperties.getFriend()).thenReturn(new ImProperties.Friend());
// 调用
ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO);
// 断言:复用旧记录,未触发 insert
verify(friendRequestMapper).updateByIdReset(eq(100L), eq("再来一次"), eq("老张"), eq(2),
any(java.time.LocalDateTime.class));
verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class));
assertEquals(100L, result.getId());
assertEquals("再来一次", result.getApplyContent());
assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult());
assertNull(result.getHandleContent());
assertNull(result.getHandleTime());
}
@Test
public void testApplyFriend_insertDuplicateKey_reuseOldRequest() {
// 准备:首次查询不存在,插入时命中唯一键,回查到并发写入的旧申请
ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
reqVO.setToUserId(2L).setApplyContent("并发申请").setDisplayName("老张").setAddSource(2);
when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState());
when(friendService.getFriend(2L, 1L)).thenReturn(null);
ImFriendRequestDO old = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
.setHandleResult(ImFriendRequestHandleResultEnum.REFUSED.getResult());
when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(null, old);
when(friendRequestMapper.insert(any(ImFriendRequestDO.class))).thenThrow(new DuplicateKeyException("dup"));
when(adminUserApi.getUser(1L)).thenReturn(null);
when(imProperties.getFriend()).thenReturn(new ImProperties.Friend());
// 调用
ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO);
// 断言:复用并重置旧申请,不向上抛数据库异常
verify(friendRequestMapper).updateByIdReset(eq(100L), eq("并发申请"), eq("老张"), eq(2),
any(LocalDateTime.class));
assertEquals(100L, result.getId());
assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult());
verify(websocketService).sendNotificationAsync(eq(2L), anyInt(), anyInt(), any());
}
// ========== agreeFriendRequest ==========
@Test
public void testAgreeFriendRequest_success() {
// 准备
ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
.setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
when(friendRequestMapper.selectById(100L)).thenReturn(request);
when(friendRequestMapper.updateByIdAndHandleResult(eq(100L),
eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), any(ImFriendRequestDO.class))).thenReturn(1);
// 调用
friendRequestService.agreeFriendRequest(2L, 100L);
// 断言:双向建立好友 + 推 APPROVED 给发起方
verify(adminUserApi, never()).validateUserList(any());
verify(friendService).becomeFriends(request);
verify(websocketService).sendNotificationAsync(eq(1L), anyInt(), anyInt(), any());
}
@Test
public void testAgreeFriendRequest_notExists() {
// 准备:申请不存在
when(friendRequestMapper.selectById(100L)).thenReturn(null);
// 调用 + 断言
ServiceException exception = assertThrows(ServiceException.class,
() -> friendRequestService.agreeFriendRequest(2L, 100L));
assertEquals(FRIEND_REQUEST_NOT_EXISTS.getCode(), exception.getCode());
}
@Test
public void testAgreeFriendRequest_notToMe() {
// 准备:操作人不是接收方
ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
.setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
when(friendRequestMapper.selectById(100L)).thenReturn(request);
// 调用 + 断言3L 不是接收方 2L
ServiceException exception = assertThrows(ServiceException.class,
() -> friendRequestService.agreeFriendRequest(3L, 100L));
assertEquals(FRIEND_REQUEST_NOT_TO_ME.getCode(), exception.getCode());
}
@Test
public void testAgreeFriendRequest_alreadyHandled() {
// 准备:申请已被处理
ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
.setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult());
when(friendRequestMapper.selectById(100L)).thenReturn(request);
// 调用 + 断言
ServiceException exception = assertThrows(ServiceException.class,
() -> friendRequestService.agreeFriendRequest(2L, 100L));
assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode());
}
@Test
public void testAgreeFriendRequest_concurrentCasFail() {
// 准备fail-fast 校验通过,但乐观锁 CAS 被并发抢先
ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
.setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
when(friendRequestMapper.selectById(100L)).thenReturn(request);
when(friendRequestMapper.updateByIdAndHandleResult(anyLong(), anyInt(), any(ImFriendRequestDO.class))).thenReturn(0);
// 调用 + 断言:返回 FRIEND_REQUEST_HANDLED
ServiceException exception = assertThrows(ServiceException.class,
() -> friendRequestService.agreeFriendRequest(2L, 100L));
assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode());
verify(friendService, never()).becomeFriends(any(ImFriendRequestDO.class));
}
// ========== refuseFriendRequest ==========
@Test
public void testRefuseFriendRequest_success() {
// 准备
ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
.setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
when(friendRequestMapper.selectById(100L)).thenReturn(request);
when(friendRequestMapper.updateByIdAndHandleResult(eq(100L),
eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), any(ImFriendRequestDO.class))).thenReturn(1);
// 调用
friendRequestService.refuseFriendRequest(2L, 100L, "不认识");
// 断言handleResult / handleContent 写入;推 REJECTED 给发起方
ArgumentCaptor<ImFriendRequestDO> captor = ArgumentCaptor.forClass(ImFriendRequestDO.class);
verify(friendRequestMapper).updateByIdAndHandleResult(eq(100L),
eq(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()), captor.capture());
assertEquals(ImFriendRequestHandleResultEnum.REFUSED.getResult(), captor.getValue().getHandleResult());
assertEquals("不认识", captor.getValue().getHandleContent());
verify(websocketService).sendNotificationAsync(eq(1L), anyInt(), anyInt(), any());
}
@Test
public void testRefuseFriendRequest_concurrentCasFail() {
// 准备CAS 失败
ImFriendRequestDO request = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
.setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
when(friendRequestMapper.selectById(100L)).thenReturn(request);
when(friendRequestMapper.updateByIdAndHandleResult(anyLong(), anyInt(), any(ImFriendRequestDO.class))).thenReturn(0);
// 调用 + 断言:抛 HANDLED且不推 REJECTED
ServiceException exception = assertThrows(ServiceException.class,
() -> friendRequestService.refuseFriendRequest(2L, 100L, "x"));
assertEquals(FRIEND_REQUEST_HANDLED.getCode(), exception.getCode());
verify(websocketService, never()).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
}
// ========== getMyFriendRequestList ==========
@Test
public void testGetMyFriendRequestList_delegate() {
// 准备mapper 返回 mock 数据
LocalDateTime updateTime = LocalDateTime.now();
ImFriendRequestDO cursor = new ImFriendRequestDO();
cursor.setId(99L);
cursor.setUpdateTime(updateTime);
ImFriendRequestDO one = new ImFriendRequestDO();
one.setId(1L);
when(friendRequestMapper.selectById(99L)).thenReturn(cursor);
when(friendRequestMapper.selectMyList(1L, updateTime, 99L, 20))
.thenReturn(java.util.Collections.singletonList(one));
// 调用 + 断言cursor 由 Service 查询后传给 Mapper
assertEquals(1, friendRequestService.getMyFriendRequestList(1L, 99L, 20).size());
verify(friendRequestMapper).selectMyList(1L, updateTime, 99L, 20);
}
@Test
public void testGetMyFriendRequestList_cursorNotExists() {
// 准备cursor 不存在
when(friendRequestMapper.selectById(99L)).thenReturn(null);
// 调用 + 断言:返回空列表
assertTrue(friendRequestService.getMyFriendRequestList(1L, 99L, 20).isEmpty());
verify(friendRequestMapper, never()).selectMyList(anyLong(), any(), anyLong(), anyInt());
}
}

View File

@@ -0,0 +1,291 @@
package cn.iocoder.yudao.module.im.service.friend;
import cn.hutool.core.collection.ListUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper;
import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService;
import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ImFriendServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImFriendServiceImpl friendService;
@Mock
private ImFriendMapper imFriendMapper;
@Mock
private AdminUserApi adminUserApi;
@Mock
private ImWebSocketService imWebSocketService;
@Mock
private ImPrivateMessageService privateMessageService;
// ========== updateFriend ==========
@Test
public void testUpdateFriend_success() {
// 准备
ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO();
reqVO.setFriendUserId(2L);
reqVO.setSilent(true);
ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L)
.silent(false).status(CommonStatusEnum.ENABLE.getStatus()).build();
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend);
// 调用
friendService.updateFriend(1L, reqVO);
// 断言:更新了 silent 字段
ArgumentCaptor<ImFriendDO> captor = ArgumentCaptor.forClass(ImFriendDO.class);
verify(imFriendMapper).updateById(captor.capture());
assertEquals(100L, captor.getValue().getId());
assertTrue(captor.getValue().getSilent());
// 断言:推送了好友更新通知
verify(imWebSocketService).sendNotificationAsync(eq(1L), anyInt(), anyInt(), any());
}
@Test
public void testUpdateFriend_notFriend() {
// 准备
ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO();
reqVO.setFriendUserId(2L);
reqVO.setSilent(true);
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null);
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> friendService.updateFriend(1L, reqVO));
assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode());
}
@Test
public void testUpdateFriend_disabledFriend() {
// 准备
ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO();
reqVO.setFriendUserId(2L);
reqVO.setSilent(true);
ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L)
.status(CommonStatusEnum.DISABLE.getStatus()).build();
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend);
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> friendService.updateFriend(1L, reqVO));
assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode());
verify(imFriendMapper, never()).updateById(any(ImFriendDO.class));
verify(imWebSocketService, never()).sendNotificationAsync(any(Long.class), anyInt(), anyInt(), any());
}
@Test
public void testUpdateFriend_displayNameOnly() {
// 准备:只传备注、不传 silent —— 走差量更新
ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO();
reqVO.setFriendUserId(2L);
reqVO.setDisplayName("老张");
ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L)
.silent(false).status(CommonStatusEnum.ENABLE.getStatus()).build();
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend);
// 调用
friendService.updateFriend(1L, reqVO);
// 断言updateById 收到 displayName 但没有 silentMyBatis-Plus 靠 NOT_NULL 跳过)
ArgumentCaptor<ImFriendDO> captor = ArgumentCaptor.forClass(ImFriendDO.class);
verify(imFriendMapper).updateById(captor.capture());
assertEquals(100L, captor.getValue().getId());
assertEquals("老张", captor.getValue().getDisplayName());
assertNull(captor.getValue().getSilent());
// 断言:推送好友更新通知
verify(imWebSocketService).sendNotificationAsync(eq(1L), anyInt(), anyInt(), any());
}
@Test
public void testUpdateFriend_emptyRequest() {
// 准备silent / displayName / pinned 都不传 —— 进入方法立刻返回,不查 mapper 也不发推送
ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO();
reqVO.setFriendUserId(2L);
// 调用
friendService.updateFriend(1L, reqVO);
// 断言:没查记录、没触发 SQL 更新 / 没发 WebSocket 推送
verify(imFriendMapper, never()).selectByUserIdAndFriendUserId(anyLong(), anyLong());
verify(imFriendMapper, never()).updateById(any(ImFriendDO.class));
verify(imWebSocketService, never()).sendNotificationAsync(any(Long.class), anyInt(), anyInt(), any());
}
// ========== 建立好友 ==========
@Test
public void testAddFriend0_existingEnabledSkip() {
// 准备:已存在且启用
ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L)
.status(CommonStatusEnum.ENABLE.getStatus()).silent(true).pinned(true).blocked(true).build();
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists);
// 调用
friendService.addFriend0(1L, 2L, null, null);
// 断言:不插入也不更新
verify(imFriendMapper, never()).insert(any(ImFriendDO.class));
verify(imFriendMapper, never()).updateById(any(ImFriendDO.class));
verify(imFriendMapper, never()).updateReAddFields(anyLong(), anyInt(), any(LocalDateTime.class),
any(LocalDateTime.class), anyBoolean(), anyBoolean(), anyBoolean(), any(), any());
}
@Test
public void testAddFriend0_existingDisabledRecovers() {
// 准备:已存在且 DISABLE应当恢复状态
ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L)
.status(CommonStatusEnum.DISABLE.getStatus()).build();
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists);
// 调用
friendService.addFriend0(1L, 2L, null, null);
// 断言:恢复 ENABLE并清空 deleteTime
verify(imFriendMapper).updateReAddFields(eq(10L), eq(CommonStatusEnum.ENABLE.getStatus()),
any(LocalDateTime.class), any(LocalDateTime.class), eq(false), eq(false), eq(false), isNull(), isNull());
verify(imFriendMapper, never()).insert(any(ImFriendDO.class));
}
@Test
public void testAddFriend0_duplicateKeyPropagates() {
// 准备mapper 抛并发冲突;极端并发下让异常向外抛,由外层事务回滚
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null);
when(imFriendMapper.insert(any(ImFriendDO.class)))
.thenThrow(new DuplicateKeyException("concurrent insert"));
// 调用 + 断言:异常向外抛
assertThrows(DuplicateKeyException.class, () -> friendService.addFriend0(1L, 2L, null, null));
verify(imFriendMapper).insert(any(ImFriendDO.class));
}
// ========== deleteFriend ==========
@Test
public void testDeleteFriend0_alreadyDisabled() {
// 准备:已经是 DISABLE不再更新
ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L)
.status(CommonStatusEnum.DISABLE.getStatus()).build();
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists);
boolean result = friendService.deleteFriend0(1L, 2L);
assertFalse(result);
verify(imFriendMapper, never()).updateById(any(ImFriendDO.class));
}
@Test
public void testDeleteFriend0_enabledGetsDisabled() {
ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists);
boolean result = friendService.deleteFriend0(1L, 2L);
assertTrue(result);
ArgumentCaptor<ImFriendDO> captor = ArgumentCaptor.forClass(ImFriendDO.class);
verify(imFriendMapper).updateById(captor.capture());
assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus());
assertNotNull(captor.getValue().getDeleteTime());
}
@Test
public void testDeleteFriend_alreadyDisabledSkipNotification() {
// 准备:已经是 DISABLE不再推本端删除通知
ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L)
.status(CommonStatusEnum.DISABLE.getStatus()).build();
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists);
friendService.deleteFriend(1L, 2L, true);
verify(imFriendMapper, never()).updateById(any(ImFriendDO.class));
verify(privateMessageService, never()).sendPrivateMessage(anyLong(), any(ImPrivateMessageSendDTO.class));
}
@Test
public void testDeleteFriend_enabledSendNotification() {
// 准备
ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists);
friendService.deleteFriend(1L, 2L, true);
verify(imFriendMapper).updateById(any(ImFriendDO.class));
verify(privateMessageService).sendPrivateMessage(eq(1L), any(ImPrivateMessageSendDTO.class));
}
// ========== 其它读方法 ==========
@Test
public void testGetFriendList() {
List<ImFriendDO> list = ListUtil.of(
ImFriendDO.builder().id(1L).userId(1L).friendUserId(2L)
.status(CommonStatusEnum.ENABLE.getStatus()).build(),
ImFriendDO.builder().id(2L).userId(1L).friendUserId(3L)
.status(CommonStatusEnum.DISABLE.getStatus())
.deleteTime(LocalDateTime.now()).build()
);
when(imFriendMapper.selectListByUserId(1L)).thenReturn(list);
List<ImFriendDO> result = friendService.getFriendList(1L);
assertEquals(2, result.size());
}
@Test
public void testGetActiveFriendList_emptySkip() {
List<ImFriendDO> result = friendService.getActiveFriendList(1L, Collections.emptyList());
assertTrue(result.isEmpty());
verify(imFriendMapper, never()).selectListByUserIdAndFriendUserIdsAndStatus(anyLong(), anyCollection(), anyInt());
}
@Test
public void testGetMutualEnableFriendList_filterSingleSideDeleted() {
ImFriendDO friend2 = ImFriendDO.builder().id(1L).userId(1L).friendUserId(2L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
ImFriendDO friend3 = ImFriendDO.builder().id(2L).userId(1L).friendUserId(3L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(imFriendMapper.selectListByUserIdAndStatus(1L, CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(friend2, friend3));
when(imFriendMapper.selectListByUserIdsAndFriendUserIdAndStatus(anyCollection(), eq(1L),
eq(CommonStatusEnum.ENABLE.getStatus()))).thenReturn(ListUtil.of(
ImFriendDO.builder().userId(2L).friendUserId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).build()));
List<ImFriendDO> result = friendService.getMutualEnableFriendList(1L);
assertEquals(1, result.size());
assertEquals(2L, result.get(0).getFriendUserId());
}
}

View File

@@ -0,0 +1,349 @@
package cn.iocoder.yudao.module.im.service.group;
import cn.hutool.core.collection.ListUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper;
import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImGroupMessageNotification;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import java.util.List;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* {@link ImGroupMemberServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImGroupMemberServiceImpl groupMemberService;
@Mock
private ImGroupMemberMapper groupMemberMapper;
@Mock
private ImWebSocketService webSocketService;
@Mock
private cn.iocoder.yudao.module.im.service.message.ImGroupMessageService groupMessageService;
// ========== addGroupMember ==========
@Test
public void testAddGroupMember_newInsert() {
// 准备:成员记录不存在
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null);
// 调用
ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L);
// 断言:执行了 insert返回记录的 status 为 ENABLE
assertNotNull(result);
assertEquals(10L, result.getGroupId());
assertEquals(1L, result.getUserId());
assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus());
assertNotNull(result.getJoinTime());
verify(groupMemberMapper).insert(any(ImGroupMemberDO.class));
}
@Test
public void testAddGroupMember_existingEnabledReturns() {
// 准备:已存在且 ENABLE只返回已有记录不做其它操作
ImGroupMemberDO exists = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(exists);
ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L);
assertEquals(50L, result.getId());
verify(groupMemberMapper, never()).insert(any(ImGroupMemberDO.class));
verify(groupMemberMapper, never()).updateById(any(ImGroupMemberDO.class));
}
@Test
public void testAddGroupMember_existingDisabledRecovers() {
// 准备:已存在且 DISABLE应重置为 ENABLE 并重置 role 为 MEMBER
ImGroupMemberDO exists = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L)
.status(CommonStatusEnum.DISABLE.getStatus())
.role(ImGroupMemberRoleEnum.ADMIN.getRole()).build();
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(exists);
ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L);
verify(groupMemberMapper).updateRejoinFields(eq(50L), eq(CommonStatusEnum.ENABLE.getStatus()),
any(), eq(ImGroupMemberRoleEnum.NORMAL.getRole()), isNull(), isNull());
assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus());
assertEquals(ImGroupMemberRoleEnum.NORMAL.getRole(), result.getRole());
assertNull(result.getQuitTime());
assertNull(result.getMuteEndTime());
}
@Test
public void testAddGroupMember_duplicateKeyFallsBackToSelect() {
// 准备:第一次 select 返回 nullinsert 抛 DuplicateKey再次 select 返回已插入记录
ImGroupMemberDO inserted = ImGroupMemberDO.builder().id(80L).groupId(10L).userId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null).thenReturn(inserted);
when(groupMemberMapper.insert(any(ImGroupMemberDO.class)))
.thenThrow(new DuplicateKeyException("concurrent insert"));
// 调用:冲突后降级 select
ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L);
assertNotNull(result);
assertEquals(80L, result.getId());
}
// ========== addGroupMembers ==========
@Test
public void testAddGroupMembers_mixedInsertAndUpdate() {
// 准备:用户 2 不存在(新增),用户 3 已存在且 DISABLE恢复用户 4 已存在且 ENABLE跳过
ImGroupMemberDO exist3 = ImGroupMemberDO.builder().id(30L).groupId(10L).userId(3L)
.status(CommonStatusEnum.DISABLE.getStatus()).build();
ImGroupMemberDO exist4 = ImGroupMemberDO.builder().id(40L).groupId(10L).userId(4L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection()))
.thenReturn(ListUtil.of(exist3, exist4));
// 调用
groupMemberService.addGroupMembers(10L, ListUtil.of(2L, 3L, 4L));
// 断言updates 只有用户 3inserts 只有用户 2
verify(groupMemberMapper).updateRejoinFields(eq(30L), eq(CommonStatusEnum.ENABLE.getStatus()),
any(), eq(ImGroupMemberRoleEnum.NORMAL.getRole()), isNull(), isNull());
verify(groupMemberMapper).insertBatch(argThat((List<ImGroupMemberDO> list) ->
list.size() == 1 && list.get(0).getUserId().equals(2L)));
}
@Test
public void testAddGroupMembers_allExisting_onlyUpdates() {
// 准备:传入的 3 个用户都已有记录(全部 DISABLE → 只做 update不做 insert
List<ImGroupMemberDO> existing = ListUtil.of(
ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L)
.status(CommonStatusEnum.DISABLE.getStatus()).build(),
ImGroupMemberDO.builder().id(2L).groupId(10L).userId(3L)
.status(CommonStatusEnum.DISABLE.getStatus()).build()
);
when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection()))
.thenReturn(existing);
groupMemberService.addGroupMembers(10L, ListUtil.of(2L, 3L));
verify(groupMemberMapper, times(2)).updateRejoinFields(anyLong(), eq(CommonStatusEnum.ENABLE.getStatus()),
any(), eq(ImGroupMemberRoleEnum.NORMAL.getRole()), isNull(), isNull());
verify(groupMemberMapper, never()).insertBatch(anyList());
}
@Test
public void testAddGroupMembers_allNew_onlyInserts() {
// 准备:都不存在 → 只做 insert
when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection()))
.thenReturn(ListUtil.of());
groupMemberService.addGroupMembers(10L, ListUtil.of(2L, 3L));
verify(groupMemberMapper, never()).updateRejoinFields(anyLong(), anyInt(), any(), anyInt(), any(), any());
verify(groupMemberMapper).insertBatch(anyList());
}
@Test
public void testAddGroupMembers_allExistingEnabled_nothingHappens() {
// 准备:都已存在且 ENABLE → 既不 update 也不 insert
List<ImGroupMemberDO> existing = ListUtil.of(
ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L)
.status(CommonStatusEnum.ENABLE.getStatus()).build()
);
when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection()))
.thenReturn(existing);
groupMemberService.addGroupMembers(10L, ListUtil.of(2L));
verify(groupMemberMapper, never()).updateRejoinFields(anyLong(), anyInt(), any(), anyInt(), any(), any());
verify(groupMemberMapper, never()).insertBatch(anyList());
}
@Test
public void testAddGroupMembers_batchInsertDuplicateFallback() {
// 准备:两个新增成员,批量插入失败时降级为逐个 addGroupMember
when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection()))
.thenReturn(ListUtil.of());
doThrow(new DuplicateKeyException("concurrent batch insert"))
.when(groupMemberMapper).insertBatch(anyList());
// addGroupMember 单条兜底逻辑
when(groupMemberMapper.selectByGroupIdAndUserId(eq(10L), anyLong())).thenReturn(null);
// 调用
groupMemberService.addGroupMembers(10L, ListUtil.of(2L, 3L));
// 断言:降级为逐条调用 insert
verify(groupMemberMapper, times(2)).insert(any(ImGroupMemberDO.class));
}
// ========== validateMemberInGroup ==========
@Test
public void testValidateMemberInGroup_notInGroup() {
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> groupMemberService.validateMemberInGroup(10L, 1L));
assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
}
@Test
public void testValidateMemberInGroup_disabledEqualsNotInGroup() {
ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L)
.status(CommonStatusEnum.DISABLE.getStatus()).build();
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member);
ServiceException exception = assertThrows(ServiceException.class,
() -> groupMemberService.validateMemberInGroup(10L, 1L));
assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
}
@Test
public void testValidateMemberInGroup_success() {
ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member);
ImGroupMemberDO result = groupMemberService.validateMemberInGroup(10L, 1L);
assertEquals(50L, result.getId());
}
// ========== updateGroupMember ==========
@Test
public void testUpdateGroupMember_success() {
ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member);
ImGroupMemberUpdateReqVO reqVO = new ImGroupMemberUpdateReqVO()
.setGroupId(10L).setSilent(true).setDisplayUserName("昵称");
groupMemberService.updateGroupMember(1L, reqVO);
ArgumentCaptor<ImGroupMemberDO> captor = ArgumentCaptor.forClass(ImGroupMemberDO.class);
verify(groupMemberMapper).updateById(captor.capture());
assertEquals(50L, captor.getValue().getId());
assertTrue(captor.getValue().getSilent());
// 公开字段昵称变化 → 全员在线同步 GROUP_MEMBER_NICKNAME_UPDATE
verify(groupMessageService).sendGroupMessage(eq(1L), any(cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO.class));
// 个人字段 silent 变化 → 仅自己多端同步 GROUP_MEMBER_SETTING_UPDATE
verify(groupMessageService).sendGroupMessage(eq(1L), eq(ListUtil.of(1L)),
any(cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO.class));
}
// ========== removeGroupMember ==========
@Test
public void testRemoveGroupMember_success() {
ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member);
groupMemberService.removeGroupMember(10L, 1L);
ArgumentCaptor<ImGroupMemberDO> captor = ArgumentCaptor.forClass(ImGroupMemberDO.class);
verify(groupMemberMapper).updateById(captor.capture());
assertEquals(50L, captor.getValue().getId());
assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus());
assertNotNull(captor.getValue().getQuitTime());
}
@Test
public void testRemoveGroupMember_notInGroup() {
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> groupMemberService.removeGroupMember(10L, 1L));
assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
}
// ========== removeGroupMembers ==========
@Test
public void testRemoveGroupMembers_batch() {
groupMemberService.removeGroupMembers(10L, ListUtil.of(2L, 3L));
ArgumentCaptor<ImGroupMemberDO> captor = ArgumentCaptor.forClass(ImGroupMemberDO.class);
verify(groupMemberMapper).updateByGroupIdAndUserIdsAndStatus(eq(10L), anyCollection(),
eq(CommonStatusEnum.ENABLE.getStatus()), captor.capture());
assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus());
assertNotNull(captor.getValue().getQuitTime());
}
@Test
public void testRemoveGroupMembersByGroupId() {
groupMemberService.removeGroupMembersByGroupId(10L);
ArgumentCaptor<ImGroupMemberDO> captor = ArgumentCaptor.forClass(ImGroupMemberDO.class);
verify(groupMemberMapper).updateByGroupIdAndStatus(eq(10L),
eq(CommonStatusEnum.ENABLE.getStatus()), captor.capture());
assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus());
assertNotNull(captor.getValue().getQuitTime());
}
// ========== getActiveGroupMemberUserIdsByGroupId ==========
@Test
public void testGetActiveGroupMemberUserIdsByGroupId_extractsUserIds() {
// 准备3 个 ENABLE 成员
List<ImGroupMemberDO> members = ListUtil.of(
ImGroupMemberDO.builder().groupId(10L).userId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).build(),
ImGroupMemberDO.builder().groupId(10L).userId(2L)
.status(CommonStatusEnum.ENABLE.getStatus()).build(),
ImGroupMemberDO.builder().groupId(10L).userId(3L)
.status(CommonStatusEnum.ENABLE.getStatus()).build()
);
when(groupMemberMapper.selectListByGroupIdAndStatus(
10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(members);
// 调用
List<Long> userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L);
// 断言:只返回 userId、顺序保留
assertEquals(ListUtil.of(1L, 2L, 3L), userIds);
}
@Test
public void testGetActiveGroupMemberUserIdsByGroupId_emptyList() {
when(groupMemberMapper.selectListByGroupIdAndStatus(
10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(ListUtil.of());
List<Long> userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L);
assertTrue(userIds.isEmpty());
}
@Test
public void testGetGroupMemberListByOwnerAndAdmin_passesRoles() {
List<Integer> roles = ListUtil.of(ImGroupMemberRoleEnum.OWNER.getRole(), ImGroupMemberRoleEnum.ADMIN.getRole());
List<ImGroupMemberDO> members = ListUtil.of(ImGroupMemberDO.builder().groupId(10L).userId(1L).build());
when(groupMemberMapper.selectListByGroupIdAndStatusAndRoles(10L, CommonStatusEnum.ENABLE.getStatus(), roles))
.thenReturn(members);
List<ImGroupMemberDO> result = groupMemberService.getGroupMemberListByOwnerAndAdmin(10L);
assertEquals(members, result);
verify(groupMemberMapper).selectListByGroupIdAndStatusAndRoles(
10L, CommonStatusEnum.ENABLE.getStatus(), roles);
}
}

View File

@@ -0,0 +1,350 @@
package cn.iocoder.yudao.module.im.service.group;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.map.MapUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.group.vo.request.ImGroupRequestApplyReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupRequestDO;
import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupRequestMapper;
import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum;
import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
import cn.iocoder.yudao.module.im.enums.group.ImGroupRequestHandleResultEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService;
import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import java.util.Collection;
import java.util.List;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* {@link ImGroupRequestServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImGroupRequestServiceImpl groupRequestService;
@Mock
private ImGroupRequestMapper groupRequestMapper;
@Mock
private ImGroupService groupService;
@Mock
private ImGroupMemberService groupMemberService;
@Mock
private ImGroupMessageService groupMessageService;
@Mock
private ImWebSocketService websocketService;
@Mock
private AdminUserApi adminUserApi;
// ==================== applyJoinGroup ====================
@Test
public void testApplyJoinGroup_freeMode_directJoin() {
// 准备:群是 FREE 模式
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
.joinApproval(false)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.validateGroupExists(10L)).thenReturn(group);
ImGroupRequestApplyReqVO reqVO = new ImGroupRequestApplyReqVO();
reqVO.setGroupId(10L);
reqVO.setAddSource(ImGroupAddSourceEnum.SEARCH.getSource());
// 调用
ImGroupRequestDO result = groupRequestService.applyJoinGroup(1L, reqVO);
// 断言FREE 路径直接入群,不落申请记录
assertNull(result);
verify(groupService).validateMemberCountLimit(10L, 1);
verify(groupMemberService).addGroupMember(eq(10L), eq(1L),
eq(ImGroupMemberRoleEnum.NORMAL.getRole()),
eq(ImGroupAddSourceEnum.SEARCH.getSource()), isNull());
verify(groupRequestMapper, never()).insert(any(ImGroupRequestDO.class));
// 推 1510 自由进群
ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class);
verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture());
assertEquals(ImContentTypeEnum.GROUP_MEMBER_ENTER.getType(), dtoCaptor.getValue().getType());
}
@Test
public void testApplyJoinGroup_approvalMode_createsRequest() {
// 准备:群是 APPLY 模式
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
.joinApproval(true)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.validateGroupExists(10L)).thenReturn(group);
// 群里有 owner + 一个 admin作为 1503 推送目标
when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(ListUtil.of(
ImGroupMemberDO.builder().groupId(10L).userId(99L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build(),
ImGroupMemberDO.builder().groupId(10L).userId(98L)
.role(ImGroupMemberRoleEnum.ADMIN.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build()));
when(adminUserApi.getUser(1L)).thenReturn(buildUser(1L, "申请人"));
ImGroupRequestApplyReqVO reqVO = new ImGroupRequestApplyReqVO();
reqVO.setGroupId(10L);
reqVO.setApplyContent("我想进群");
reqVO.setAddSource(ImGroupAddSourceEnum.SEARCH.getSource());
// 调用
ImGroupRequestDO result = groupRequestService.applyJoinGroup(1L, reqVO);
// 断言:申请记录已落库 + 不直进群
assertNotNull(result);
verify(groupMemberService, never()).addGroupMember(anyLong(), anyLong(), anyInt(), anyInt(), anyLong());
verify(groupRequestMapper).insert(any(ImGroupRequestDO.class));
// 1503 推送给 owner(99) + admin(98),去重后两条
verify(websocketService, times(2)).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
}
@Test
public void testApplyJoinGroup_insertDuplicateKey_reuseOldRequest() {
// 准备:群是 APPLY 模式,首次查询不存在,插入时命中唯一键
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
.joinApproval(true)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.validateGroupExists(10L)).thenReturn(group);
when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(ListUtil.of(
ImGroupMemberDO.builder().groupId(10L).userId(99L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build()));
when(adminUserApi.getUser(1L)).thenReturn(buildUser(1L, "申请人"));
ImGroupRequestDO old = new ImGroupRequestDO().setId(50L).setGroupId(10L).setUserId(1L)
.setHandleResult(ImGroupRequestHandleResultEnum.REFUSED.getResult());
when(groupRequestMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null, old);
when(groupRequestMapper.insert(any(ImGroupRequestDO.class))).thenThrow(new DuplicateKeyException("dup"));
ImGroupRequestApplyReqVO reqVO = new ImGroupRequestApplyReqVO();
reqVO.setGroupId(10L);
reqVO.setApplyContent("我想进群");
reqVO.setAddSource(ImGroupAddSourceEnum.SEARCH.getSource());
// 调用
ImGroupRequestDO result = groupRequestService.applyJoinGroup(1L, reqVO);
// 断言:复用并重置旧申请
verify(groupRequestMapper).updateApplyByIdReset(eq(50L), eq("我想进群"),
eq(ImGroupAddSourceEnum.SEARCH.getSource()), any());
assertEquals(50L, result.getId());
assertEquals(ImGroupRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult());
verify(websocketService).sendNotificationAsync(eq(99L), anyInt(), anyInt(), any());
}
@Test
public void testApplyJoinGroup_alreadyMember_throws() {
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
.joinApproval(true)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.validateGroupExists(10L)).thenReturn(group);
when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(
ImGroupMemberDO.builder().groupId(10L).userId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).build());
ImGroupRequestApplyReqVO reqVO = new ImGroupRequestApplyReqVO();
reqVO.setGroupId(10L);
ServiceException exception = assertThrows(ServiceException.class,
() -> groupRequestService.applyJoinGroup(1L, reqVO));
assertEquals(GROUP_REQUEST_ALREADY_MEMBER.getCode(), exception.getCode());
}
// ==================== agreeGroupRequest ====================
@Test
public void testAgreeGroupRequest_success_activeApply() {
// 准备:主动申请未处理,操作人是 admin
ImGroupRequestDO request = new ImGroupRequestDO()
.setGroupId(10L).setUserId(2L).setInviterUserId(null)
.setAddSource(ImGroupAddSourceEnum.SEARCH.getSource())
.setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
request.setId(50L);
when(groupRequestMapper.selectById(50L)).thenReturn(request);
when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
ImGroupMemberDO.builder().groupId(10L).userId(1L)
.role(ImGroupMemberRoleEnum.ADMIN.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build());
when(groupRequestMapper.updateByIdAndHandleResult(eq(50L),
eq(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()), any())).thenReturn(1);
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.getGroup(10L)).thenReturn(group);
when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(ListUtil.of(
ImGroupMemberDO.builder().groupId(10L).userId(99L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build()));
// 调用
groupRequestService.agreeGroupRequest(1L, 50L);
// 断言:人数校验 + 写群成员 + 推 1505 + 推 1510主动申请
verify(groupService).validateMemberCountLimit(10L, 1);
verify(groupMemberService).addGroupMember(eq(10L), eq(2L),
eq(ImGroupMemberRoleEnum.NORMAL.getRole()),
eq(ImGroupAddSourceEnum.SEARCH.getSource()), isNull());
ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class);
verify(groupMessageService).sendGroupMessage(eq(1L), dtoCaptor.capture());
assertEquals(ImContentTypeEnum.GROUP_MEMBER_ENTER.getType(), dtoCaptor.getValue().getType());
// 1505 推送给申请人 + owner去重后两条
verify(websocketService, times(2)).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
}
@Test
public void testAgreeGroupRequest_concurrent_secondCallFails() {
// 准备:申请存在但乐观锁更新返回 0被并发处理过
ImGroupRequestDO request = new ImGroupRequestDO()
.setGroupId(10L).setUserId(2L)
.setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
request.setId(50L);
when(groupRequestMapper.selectById(50L)).thenReturn(request);
when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
ImGroupMemberDO.builder().groupId(10L).userId(1L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build());
when(groupRequestMapper.updateByIdAndHandleResult(eq(50L),
eq(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()), any())).thenReturn(0);
ServiceException exception = assertThrows(ServiceException.class,
() -> groupRequestService.agreeGroupRequest(1L, 50L));
assertEquals(GROUP_REQUEST_HANDLED.getCode(), exception.getCode());
// 不写群成员
verify(groupMemberService, never()).addGroupMember(anyLong(), anyLong(), anyInt(), any(), any());
}
@Test
public void testAgreeGroupRequest_notOwnerOrAdmin_throws() {
ImGroupRequestDO request = new ImGroupRequestDO()
.setGroupId(10L).setUserId(2L)
.setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
request.setId(50L);
when(groupRequestMapper.selectById(50L)).thenReturn(request);
when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
ImGroupMemberDO.builder().groupId(10L).userId(1L)
.role(ImGroupMemberRoleEnum.NORMAL.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build());
ServiceException exception = assertThrows(ServiceException.class,
() -> groupRequestService.agreeGroupRequest(1L, 50L));
assertEquals(GROUP_REQUEST_NOT_TO_ME.getCode(), exception.getCode());
}
// ==================== refuseGroupRequest ====================
@Test
public void testRefuseGroupRequest_success() {
ImGroupRequestDO request = new ImGroupRequestDO()
.setGroupId(10L).setUserId(2L)
.setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
request.setId(50L);
when(groupRequestMapper.selectById(50L)).thenReturn(request);
when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
ImGroupMemberDO.builder().groupId(10L).userId(1L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build());
when(groupRequestMapper.updateByIdAndHandleResult(eq(50L),
eq(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()), any())).thenReturn(1);
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.getGroup(10L)).thenReturn(group);
when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(ListUtil.of(
ImGroupMemberDO.builder().groupId(10L).userId(1L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build()));
groupRequestService.refuseGroupRequest(1L, 50L, "暂不通过");
// 不写群成员;推 1506 给申请人 + 群主(同一人 1L 时去重为 1 + 申请人 2L = 2 条)
verify(groupMemberService, never()).addGroupMember(anyLong(), anyLong(), anyInt(), any(), any());
verify(websocketService, times(2)).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
}
// ==================== createInviteRequestList ====================
@Test
public void testCreateInviteRequestList_success() {
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
.joinApproval(true)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.validateGroupExists(10L)).thenReturn(group);
when(groupRequestMapper.selectByGroupIdAndUserId(eq(10L), anyLong())).thenReturn(null);
when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(ListUtil.of(
ImGroupMemberDO.builder().groupId(10L).userId(99L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build()));
when(adminUserApi.getUserMap(anyCollection())).thenReturn(MapUtil.<Long, AdminUserRespDTO>builder()
.put(2L, buildUser(2L, "用户A"))
.put(3L, buildUser(3L, "用户B"))
.build());
// 调用:邀请人 1L 邀请 2L、3L都没有旧记录
groupRequestService.createInviteRequestList(10L, 1L, ListUtil.of(2L, 3L));
// 断言:插入 2 条 + 推 1503 给 owner每条 1 帧)共 2 帧
ArgumentCaptor<ImGroupRequestDO> captor = ArgumentCaptor.forClass(ImGroupRequestDO.class);
verify(groupRequestMapper, times(2)).insert(captor.capture());
verify(websocketService, times(2)).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
// 断言:每条记录 inviterUserId=1 + addSource=INVITE避免审批通过后回写群成员留痕的来源为空 / 脏带旧值
Collection<ImGroupRequestDO> inserted = captor.getAllValues();
assertEquals(2, inserted.size());
inserted.forEach(insert -> {
assertEquals(1L, insert.getInviterUserId());
assertEquals(ImGroupAddSourceEnum.INVITE.getSource(), insert.getAddSource());
});
}
@Test
public void testCreateInviteRequestList_insertDuplicateKey_reuseOldRequest() {
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
.joinApproval(true)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.validateGroupExists(10L)).thenReturn(group);
ImGroupRequestDO old = new ImGroupRequestDO().setId(50L).setGroupId(10L).setUserId(2L)
.setHandleResult(ImGroupRequestHandleResultEnum.REFUSED.getResult());
when(groupRequestMapper.selectByGroupIdAndUserId(10L, 2L)).thenReturn(null, old);
when(groupRequestMapper.insert(any(ImGroupRequestDO.class))).thenThrow(new DuplicateKeyException("dup"));
when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(ListUtil.of(
ImGroupMemberDO.builder().groupId(10L).userId(99L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build()));
when(adminUserApi.getUserMap(anyCollection())).thenReturn(MapUtil.of(2L, buildUser(2L, "用户A")));
// 调用
groupRequestService.createInviteRequestList(10L, 1L, ListUtil.of(2L));
// 断言:复用并重置旧邀请申请
verify(groupRequestMapper).updateInviteByIdReset(eq(50L), eq(1L),
eq(ImGroupAddSourceEnum.INVITE.getSource()), any());
verify(websocketService).sendNotificationAsync(eq(99L), anyInt(), anyInt(), any());
}
private AdminUserRespDTO buildUser(Long id, String nickname) {
AdminUserRespDTO user = new AdminUserRespDTO();
user.setId(id);
user.setNickname(nickname);
return user;
}
}

View File

@@ -0,0 +1,94 @@
package cn.iocoder.yudao.module.im.service.message;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper;
import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService;
import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* {@link ImChannelMessageServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImChannelMessageServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImChannelMessageServiceImpl channelMessageService;
@Mock
private ImChannelMessageMapper channelMessageMapper;
@Mock
private ImChannelMaterialService channelMaterialService;
@Mock
private ImWebSocketService webSocketService;
@Mock
private ImConversationReadService conversationReadService;
@Test
public void testReadChannelMessages_messageNotExists() {
// 准备messageId 不存在(伪造 / 未来 id
when(channelMessageMapper.selectById(999L)).thenReturn(null);
// 调用
channelMessageService.readChannelMessages(1L, 10L, 999L);
// 断言:不推进读位置
verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong());
}
@Test
public void testReadChannelMessages_wrongChannel() {
// 准备:消息属于别的频道
ImChannelMessageDO message = ImChannelMessageDO.builder()
.id(100L).channelId(20L).sendTime(LocalDateTime.now()).build();
when(channelMessageMapper.selectById(100L)).thenReturn(message);
// 调用:声称读的是频道 10
channelMessageService.readChannelMessages(1L, 10L, 100L);
// 断言:不推进读位置
verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong());
}
@Test
public void testReadChannelMessages_notVisible() {
// 准备:定向消息,接收人不含当前用户
ImChannelMessageDO message = ImChannelMessageDO.builder()
.id(100L).channelId(10L).receiverUserIds(List.of(2L, 3L)).sendTime(LocalDateTime.now()).build();
when(channelMessageMapper.selectById(100L)).thenReturn(message);
// 调用
channelMessageService.readChannelMessages(1L, 10L, 100L);
// 断言:不推进读位置
verify(conversationReadService, never()).updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong());
}
@Test
public void testReadChannelMessages_notAdvanced() {
// 准备消息真实可见但读位置未前进updateConversationReadPosition 返回 false
ImChannelMessageDO message = ImChannelMessageDO.builder()
.id(100L).channelId(10L).sendTime(LocalDateTime.now()).build();
when(channelMessageMapper.selectById(100L)).thenReturn(message);
when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()))
.thenReturn(false);
// 调用
channelMessageService.readChannelMessages(1L, 10L, 100L);
// 断言:读位置未前进 → 不推 READ 事件
verify(webSocketService, never()).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
}
}

View File

@@ -0,0 +1,445 @@
package cn.iocoder.yudao.module.im.service.message;
import cn.hutool.core.collection.ListUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO;
import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReadNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReceiptNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImPrivateMessageNotification;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* {@link ImPrivateMessageServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImPrivateMessageServiceImpl privateMessageService;
@Mock
private ImPrivateMessageMapper privateMessageMapper;
@Mock
private ImFriendService friendService;
@Mock
private ImSensitiveWordService sensitiveWordService;
@Mock
private ImConversationReadService conversationReadService;
@Mock
private ImWebSocketService imWebSocketService;
@Spy
private ImProperties imProperties = new ImProperties();
private ImPrivateMessageSendReqVO buildSendReqVO() {
ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO();
reqVO.setClientMessageId("test-uuid-001");
reqVO.setReceiverId(2L);
reqVO.setType(ImContentTypeEnum.TEXT.getType());
reqVO.setContent("{\"content\":\"你好\"}");
return reqVO;
}
// ========== 发送测试 ==========
@Test
public void testSendMessage_success() {
// 准备
ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
.thenReturn(null);
when(privateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> {
ImPrivateMessageDO msg = invocation.getArgument(0);
msg.setId(99L);
return 1;
});
// 调用
ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO);
// 断言
assertNotNull(result);
assertEquals(1L, result.getSenderId());
assertEquals(2L, result.getReceiverId());
assertEquals(ImContentTypeEnum.TEXT.getType(), result.getType());
assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.getStatus());
assertEquals(ImMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus(),
"用户私聊消息默认需要回执PENDING");
assertNotNull(result.getSendTime());
// 验证调用
verify(friendService).validateFriend(1L, 2L);
verify(sensitiveWordService).validateText(reqVO.getContent());
verify(privateMessageMapper).insert(any(ImPrivateMessageDO.class));
// 验证推送给接收方和发送方
verify(imWebSocketService).sendNotificationAsync(eq(2L), anyInt(), anyInt(), any());
verify(imWebSocketService).sendNotificationAsync(eq(1L), anyInt(), anyInt(), any());
}
@Test
public void testSendMessage_clientMessageIdIdempotent() {
// 准备:模拟已存在消息
ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
ImPrivateMessageDO existingMessage = ImPrivateMessageDO.builder()
.id(100L).clientMessageId("test-uuid-001").senderId(1L).receiverId(2L)
.type(0).content("{\"content\":\"你好\"}").status(0)
.sendTime(LocalDateTime.now()).build();
when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
.thenReturn(existingMessage);
// 调用
ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO);
// 断言:返回已存在的消息
assertEquals(100L, result.getId());
// 验证不会重复插入
verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class));
}
@Test
public void testSendMessage_notFriend() {
// 准备
ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
.thenReturn(null);
doThrow(new ServiceException(FRIEND_NOT_FRIEND))
.when(friendService).validateFriend(1L, 2L);
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> privateMessageService.sendPrivateMessage(1L, reqVO));
assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode());
}
// ========== pull 测试 ==========
@Test
public void testPullMessages_success() {
// 准备
List<ImPrivateMessageDO> mockMessages = ListUtil.of(
ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(),
ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L).build()
);
when(privateMessageMapper.selectListByMinId(eq(1L), eq(0L), any(LocalDateTime.class), eq(100)))
.thenReturn(mockMessages);
// 调用
List<ImPrivateMessageDO> result = privateMessageService.pullPrivateMessageList(1L, 0L, 100);
// 断言
assertEquals(2, result.size());
}
@Test
public void testPullMessages_sizeExceeded() {
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> privateMessageService.pullPrivateMessageList(1L, 0L, 1001));
assertEquals(MESSAGE_PULL_SIZE_EXCEEDED.getCode(), exception.getCode());
}
// ========== 已读测试 ==========
@Test
public void testReadMessages_success() {
// 准备:前端上报已读到 messageId=5mapper 返回更新行数 2 表示有待回执消息被标记完成
when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(
eq(2L), eq(1L), eq(5L),
eq(ImMessageReceiptStatusEnum.PENDING.getStatus()), any(ImPrivateMessageDO.class)))
.thenReturn(2);
// 读位置前进 → 才下发事件
when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()))
.thenReturn(true);
// 调用
privateMessageService.readPrivateMessages(1L, 2L, 5L);
// 断言:把待回执(PENDING)消息标记为已完成DONEstatus 不再写 READ
verify(privateMessageMapper).updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(
eq(2L), eq(1L), eq(5L),
eq(ImMessageReceiptStatusEnum.PENDING.getStatus()), any(ImPrivateMessageDO.class));
// 断言:发送了 READ + RECEIPT 事件payload 字段正确
ArgumentCaptor<Long> userCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor<Integer> contentTypeCaptor = ArgumentCaptor.forClass(Integer.class);
ArgumentCaptor<Object> payloadCaptor = ArgumentCaptor.forClass(Object.class);
verify(imWebSocketService, times(2)).sendNotificationAsync(
userCaptor.capture(), eq(ImConversationTypeEnum.PRIVATE.getType()),
contentTypeCaptor.capture(), payloadCaptor.capture());
// 第一次:发给自己的 READ 事件
assertEquals(1L, userCaptor.getAllValues().get(0));
assertEquals(ImContentTypeEnum.READ.getType(), contentTypeCaptor.getAllValues().get(0));
ImMessageReadNotification readPayload = (ImMessageReadNotification) payloadCaptor.getAllValues().get(0);
assertEquals(1L, readPayload.getSenderId());
assertEquals(2L, readPayload.getReceiverId());
assertEquals(5L, readPayload.getId(), "READ id 应为前端上报的 messageId");
// 第二次:发给对方的 RECEIPT 事件
assertEquals(2L, userCaptor.getAllValues().get(1));
assertEquals(ImContentTypeEnum.RECEIPT.getType(), contentTypeCaptor.getAllValues().get(1));
ImMessageReceiptNotification receiptPayload =
(ImMessageReceiptNotification) payloadCaptor.getAllValues().get(1);
assertEquals(5L, receiptPayload.getId(), "RECEIPT id 应为前端上报的 messageId");
}
// ========== 撤回测试 ==========
@Test
public void testRecallMessage_success() {
// 准备
ImPrivateMessageDO message = ImPrivateMessageDO.builder()
.id(10L).senderId(1L).receiverId(2L)
.status(ImMessageStatusEnum.NORMAL.getStatus())
.sendTime(LocalDateTime.now()).build(); // 刚发送5 分钟内
when(privateMessageMapper.selectById(10L)).thenReturn(message);
when(privateMessageMapper.updateById(any(ImPrivateMessageDO.class))).thenReturn(1);
when(privateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenReturn(1);
// 调用
ImPrivateMessageDO result = privateMessageService.recallPrivateMessage(1L, 10L);
// 断言:返回撤回消息
assertNotNull(result);
// 验证:更新原消息状态 + 插入 RecallMessage
verify(privateMessageMapper).updateById(any(ImPrivateMessageDO.class));
verify(privateMessageMapper).insert(any(ImPrivateMessageDO.class));
// 验证推送了消息(给接收方和发送方)
verify(imWebSocketService, times(2)).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
}
@Test
public void testRecallMessage_notOwn() {
// 准备
ImPrivateMessageDO message = ImPrivateMessageDO.builder()
.id(10L).senderId(2L).receiverId(1L)
.status(ImMessageStatusEnum.NORMAL.getStatus())
.sendTime(LocalDateTime.now()).build();
when(privateMessageMapper.selectById(10L)).thenReturn(message);
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> privateMessageService.recallPrivateMessage(1L, 10L));
assertEquals(MESSAGE_RECALL_DENIED.getCode(), exception.getCode());
}
@Test
public void testRecallMessage_alreadyRecalled() {
// 准备
ImPrivateMessageDO message = ImPrivateMessageDO.builder()
.id(10L).senderId(1L).receiverId(2L)
.status(ImMessageStatusEnum.RECALL.getStatus())
.sendTime(LocalDateTime.now()).build();
when(privateMessageMapper.selectById(10L)).thenReturn(message);
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> privateMessageService.recallPrivateMessage(1L, 10L));
assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode());
}
@Test
public void testRecallMessage_notExists() {
// 准备
when(privateMessageMapper.selectById(10L)).thenReturn(null);
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> privateMessageService.recallPrivateMessage(1L, 10L));
assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode());
}
@Test
public void testRecallMessage_timeout() {
// 准备:消息发送于 10 分钟前(超过 5 分钟窗口)
ImPrivateMessageDO message = ImPrivateMessageDO.builder()
.id(10L).senderId(1L).receiverId(2L)
.status(ImMessageStatusEnum.NORMAL.getStatus())
.sendTime(LocalDateTime.now().minusMinutes(10)).build();
when(privateMessageMapper.selectById(10L)).thenReturn(message);
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> privateMessageService.recallPrivateMessage(1L, 10L));
assertEquals(MESSAGE_RECALL_TIMEOUT.getCode(), exception.getCode());
// 断言:不推送、不插 tipMessage
verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class));
}
@Test
public void testSendMessage_sensitiveWordBlocked() {
// 准备:文本消息命中敏感词
ImPrivateMessageSendReqVO reqVO = buildSendReqVO();
when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001"))
.thenReturn(null);
doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED))
.when(sensitiveWordService).validateText(reqVO.getContent());
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> privateMessageService.sendPrivateMessage(1L, reqVO));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 断言:不入库、不推送
verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class));
verify(imWebSocketService, never()).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
}
@Test
public void testReadMessages_disabled() {
// 准备:关闭私聊已读
imProperties.getMessage().setPrivateReadEnabled(false);
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> privateMessageService.readPrivateMessages(1L, 2L, 5L));
assertEquals(MESSAGE_PRIVATE_READ_DISABLED.getCode(), exception.getCode());
// 断言:不更新消息状态、不推送
verify(privateMessageMapper, never()).updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(
anyLong(), anyLong(), anyLong(), anyInt(), any(ImPrivateMessageDO.class));
verify(imWebSocketService, never()).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
}
@Test
public void testReadMessages_notAdvanced() {
// 准备:读位置未前进(已读过 / CAS 失败),不下发事件
when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()))
.thenReturn(false);
// 调用
privateMessageService.readPrivateMessages(1L, 2L, 5L);
// 断言:读位置没前进,不推送 READ / RECEIPT
verify(imWebSocketService, never()).sendNotificationAsync(anyLong(), anyInt(), anyInt(), any());
}
// ========== getMaxReadMessageId 测试 ==========
@Test
public void testGetMaxReadMessageId_hit() {
// 准备:对方(2) 在与我(1) 的会话里读位置=10
when(conversationReadService.getConversationReadMessageId(eq(2L), anyInt(), eq(1L)))
.thenReturn(10L);
// 调用
Long result = privateMessageService.getMaxReadMessageId(1L, 2L);
// 断言
assertEquals(10L, result);
}
@Test
public void testGetMaxReadMessageId_miss() {
// 准备:对方一条都没读过(读位置为 null
when(conversationReadService.getConversationReadMessageId(eq(2L), anyInt(), eq(1L)))
.thenReturn(null);
// 调用
Long result = privateMessageService.getMaxReadMessageId(1L, 2L);
// 断言:原样返回 null前端按 falsy 跳过
assertNull(result);
}
@Test
public void testGetMaxReadMessageId_disabled() {
// 准备:关闭私聊已读
imProperties.getMessage().setPrivateReadEnabled(false);
// 调用并断言
ServiceException exception = assertThrows(ServiceException.class,
() -> privateMessageService.getMaxReadMessageId(1L, 2L));
assertEquals(MESSAGE_PRIVATE_READ_DISABLED.getCode(), exception.getCode());
}
// ========== DTO 私聊消息 ==========
@Test
public void testSendPrivateMessage_dto_persistsAndSerializesPojoContent() {
// 准备persistent=true 类型 + POJO content
ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO()
.setReceiverId(2L).setType(ImContentTypeEnum.RECALL.getType())
.setContent(new RecallMessage().setMessageId(50L));
privateMessageService.sendPrivateMessage(1L, dto);
// 断言:入库 + 系统字段兜底 + content 序列化为 JSON
ArgumentCaptor<ImPrivateMessageDO> captor = ArgumentCaptor.forClass(ImPrivateMessageDO.class);
verify(privateMessageMapper).insert(captor.capture());
ImPrivateMessageDO message = captor.getValue();
assertEquals(1L, message.getSenderId());
assertEquals(2L, message.getReceiverId());
assertEquals(ImContentTypeEnum.RECALL.getType(), message.getType());
assertEquals("{\"messageId\":50}", message.getContent());
assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), message.getStatus());
assertNotNull(message.getClientMessageId());
assertNotNull(message.getSendTime());
// 断言sender + receiver 双端推送
verify(imWebSocketService).sendNotificationAsync(eq(1L), anyInt(), anyInt(), any());
verify(imWebSocketService).sendNotificationAsync(eq(2L), anyInt(), anyInt(), any());
}
@Test
public void testSendPrivateMessage_dto_nonPersistentTypeNotInserted() {
// 准备persistent=false 类型FRIEND_DELETE 通知)→ 不入库;仅推 sender 多端receiver 不感知
ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO()
.setReceiverId(2L).setType(ImContentTypeEnum.FRIEND_DELETE.getType());
privateMessageService.sendPrivateMessage(1L, dto);
verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class));
verify(imWebSocketService).sendNotificationAsync(eq(1L), anyInt(), anyInt(), any());
verify(imWebSocketService, never()).sendNotificationAsync(eq(2L), anyInt(), anyInt(), any());
}
// ========== getPrivateMessageList ==========
@Test
public void testGetPrivateMessageList_delegatesToMapper() {
// 准备
ImPrivateMessageListReqVO reqVO = new ImPrivateMessageListReqVO();
reqVO.setReceiverId(2L);
reqVO.setMaxId(100L);
reqVO.setLimit(20);
List<ImPrivateMessageDO> mockList = ListUtil.of(
ImPrivateMessageDO.builder().id(99L).senderId(1L).receiverId(2L).build(),
ImPrivateMessageDO.builder().id(98L).senderId(2L).receiverId(1L).build()
);
when(privateMessageMapper.selectHistoryList(1L, 2L, 100L, 20)).thenReturn(mockList);
// 调用
List<ImPrivateMessageDO> result = privateMessageService.getPrivateMessageList(1L, reqVO);
// 断言:透传到 mapper参数一致
assertEquals(2, result.size());
verify(privateMessageMapper).selectHistoryList(1L, 2L, 100L, 20);
}
}

View File

@@ -0,0 +1,391 @@
package cn.iocoder.yudao.module.im.service.rtc;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.map.MapUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallCreateReqVO;
import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper;
import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper;
import cn.iocoder.yudao.module.im.dal.redis.rtc.ImRtcCallLockRedisDAO;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum;
import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_GROUP_INVITEE_OVER_LIMIT;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_GROUP_INVITEE_REQUIRED;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_SELF_BUSY;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* {@link ImRtcCallServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImRtcCallServiceImpl rtcCallService;
@Mock
private ImRtcParticipantMapper rtcParticipantMapper;
@Mock
private ImRtcCallMapper rtcCallMapper;
@Mock
private ImRtcCallLockRedisDAO rtcCallLockRedisDAO;
@Mock
private AdminUserApi adminUserApi;
@Mock
private ImWebSocketService webSocketService;
@Mock
private ImProperties imProperties;
@Mock
private ImGroupMemberService groupMemberService;
// ========== timeoutInvitingParticipants ==========
@Test
public void testTimeoutInvitingParticipants_emptyCandidates_returnsZeroAndNoDownstream() {
// 准备:无超时候选
when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(
eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any(LocalDateTime.class)))
.thenReturn(Collections.emptyList());
// 调用
int result = rtcCallService.timeoutInvitingParticipants(1);
// 断言:返回 0无候选时不应触发 user 预查 / call 查询 / 推送
assertEquals(0, result);
verifyNoInteractions(adminUserApi, rtcCallMapper, webSocketService);
}
@Test
public void testTimeoutInvitingParticipants_thresholdConvertedToCutoff() {
// 准备:阈值 5 分钟mock 空候选避免触发后续逻辑
when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any(LocalDateTime.class)))
.thenReturn(Collections.emptyList());
// 调用
LocalDateTime before = LocalDateTime.now();
rtcCallService.timeoutInvitingParticipants(5);
// 断言cutoff = now - 5 分钟(允许 5 秒漂移)
ArgumentCaptor<LocalDateTime> cutoffCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(rtcParticipantMapper).selectListByStatusAndInviteTimeBefore(
eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), cutoffCaptor.capture());
LocalDateTime cutoff = cutoffCaptor.getValue();
LocalDateTime expected = before.minusMinutes(5);
assertTrue(Duration.between(cutoff, expected).abs().getSeconds() < 5,
"cutoff 应当约等于 now - 5 min实际" + cutoff);
}
@Test
public void testTimeoutInvitingParticipants_casAllFails_noPushNoEndSession() {
// 准备:候选非空但每个 CAS 都失败(并发已变状态)
ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING);
when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any()))
.thenReturn(ListUtil.of(p));
when(adminUserApi.getUserMap(anySet())).thenReturn(MapUtil.of(100L, buildUser(100L)));
when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any()))
.thenReturn(0);
// 调用
int result = rtcCallService.timeoutInvitingParticipants(1);
// 断言CAS 全失败时返回 0不查 call、不推送
assertEquals(0, result);
verify(rtcCallMapper, never()).selectByRoom(any());
verifyNoInteractions(webSocketService);
}
@Test
public void testTimeoutInvitingParticipants_groupCall_pushesNoAnswerSkipsEndSession() {
// 准备:群通话单候选 CAS 成功shouldCloseGroupRoom 通过 selectListByRoom 多 JOINED 让其返 false跳过 endSession
ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING);
when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any()))
.thenReturn(ListUtil.of(p));
when(adminUserApi.getUserMap(anySet())).thenReturn(MapUtil.of(100L, buildUser(100L)));
when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any()))
.thenReturn(1);
ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(999L)).thenReturn(ListUtil.of(200L, 201L));
// 房内 2 个 JOINED + 1 个 INVITING → shouldCloseGroupRoom 返 false
when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(ListUtil.of(
buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED),
buildParticipant(21L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED),
buildParticipant(22L, "r1", 202L, ImRtcParticipantStatusEnum.INVITING)
));
// 调用
int result = rtcCallService.timeoutInvitingParticipants(1);
// 断言:成功 1 个NO_ANSWER 信令推到主叫;不触发 endSession
assertEquals(1, result);
verify(webSocketService).sendNotificationAsync(eq(200L), anyInt(), anyInt(), any());
verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any());
}
@Test
public void testTimeoutInvitingParticipants_callMissing_silentSkip() {
// 准备CAS 成功后通话主表缺失(异常兜底场景)
ImRtcParticipantDO p = buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING);
when(rtcParticipantMapper.selectListByStatusAndInviteTimeBefore(any(), any()))
.thenReturn(ListUtil.of(p));
when(adminUserApi.getUserMap(anySet())).thenReturn(MapUtil.of(100L, buildUser(100L)));
when(rtcParticipantMapper.updateByIdAndStatus(eq(10L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any()))
.thenReturn(1);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(null);
// 调用
int result = rtcCallService.timeoutInvitingParticipants(1);
// 断言CAS 已成功但 call 缺失视为部分失败返 0不应推送
assertEquals(0, result);
verifyNoInteractions(webSocketService);
}
// ========== noAnswerCallCheck ==========
@Test
public void testNoAnswerCallCheck_authFails_silentNoOp() {
// 准备selectByRoomAndUserId 返 null 覆盖三种鉴权失败场景(非参与者 / 非法 room / null room
when(rtcParticipantMapper.selectByRoomAndUserId(any(), eq(100L))).thenReturn(null);
// 调用
rtcCallService.noAnswerCallCheck(100L, "r1");
rtcCallService.noAnswerCallCheck(100L, "");
rtcCallService.noAnswerCallCheck(100L, null);
// 断言:仅鉴权查询了 3 次;不应进入后续超时扫描 / 推送
verify(rtcParticipantMapper, times(3)).selectByRoomAndUserId(any(), eq(100L));
verify(rtcParticipantMapper, never()).selectListByRoomAndStatusAndInviteTimeBefore(any(), any(), any());
verifyNoInteractions(adminUserApi, webSocketService);
}
@Test
public void testNoAnswerCallCheck_usesBackendThreshold_notFrontend() {
// 准备:鉴权通过 + 后端配置阈值 2 分钟 + 无候选(避免触发推送)
when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L))
.thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING));
ImProperties.Rtc rtcConfig = new ImProperties.Rtc();
rtcConfig.setInviteTimeoutMinutes(2);
when(imProperties.getRtc()).thenReturn(rtcConfig);
when(rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore(
eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any(LocalDateTime.class)))
.thenReturn(Collections.emptyList());
// 调用
LocalDateTime before = LocalDateTime.now();
rtcCallService.noAnswerCallCheck(100L, "r1");
// 断言:扫描时使用 cutoff = now - 2 分钟(后端配置),而非前端 60s
ArgumentCaptor<LocalDateTime> cutoffCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(rtcParticipantMapper).selectListByRoomAndStatusAndInviteTimeBefore(
eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), cutoffCaptor.capture());
LocalDateTime cutoff = cutoffCaptor.getValue();
LocalDateTime expected = before.minusMinutes(2);
assertTrue(Duration.between(cutoff, expected).abs().getSeconds() < 5,
"cutoff 应当约等于 now - 2 min后端配置实际" + cutoff);
}
@Test
public void testNoAnswerCallCheck_groupCall_pushesNoAnswer() {
// 准备:鉴权通过 + 单候选 CAS 成功 + 群通话不关房
when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L))
.thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING));
ImProperties.Rtc rtcConfig = new ImProperties.Rtc();
rtcConfig.setInviteTimeoutMinutes(1);
when(imProperties.getRtc()).thenReturn(rtcConfig);
ImRtcParticipantDO timeoutTarget = buildParticipant(11L, "r1", 101L, ImRtcParticipantStatusEnum.INVITING);
when(rtcParticipantMapper.selectListByRoomAndStatusAndInviteTimeBefore(
eq("r1"), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any()))
.thenReturn(ListUtil.of(timeoutTarget));
when(adminUserApi.getUserMap(anySet())).thenReturn(MapUtil.of(101L, buildUser(101L)));
when(rtcParticipantMapper.updateByIdAndStatus(eq(11L), eq(ImRtcParticipantStatusEnum.INVITING.getStatus()), any()))
.thenReturn(1);
ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(999L)).thenReturn(ListUtil.of(200L, 201L));
// 让 shouldCloseGroupRoom 返 false
when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(ListUtil.of(
buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED),
buildParticipant(21L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED)
));
// 调用
rtcCallService.noAnswerCallCheck(100L, "r1");
// 断言NO_ANSWER 信令推到主叫 200L不触发 endSession
verify(webSocketService).sendNotificationAsync(eq(200L), anyInt(), anyInt(), any());
verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any());
}
// ========== createCall ==========
@Test
public void testCreateCall_groupOnlyInviteSelf_throwInviteeRequired() throws Exception {
when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc());
when(rtcCallLockRedisDAO.lockGroup(eq(10L), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
Callable<ImRtcCallDO> callable = invocation.getArgument(1);
return callable.call();
});
ImRtcCallCreateReqVO reqVO = new ImRtcCallCreateReqVO();
reqVO.setConversationType(ImConversationTypeEnum.GROUP.getType());
reqVO.setGroupId(10L);
reqVO.setInviteeIds(CollUtil.newHashSet(100L));
ServiceException exception = assertThrows(ServiceException.class,
() -> rtcCallService.createCall(100L, reqVO));
assertEquals(RTC_GROUP_INVITEE_REQUIRED.getCode(), exception.getCode());
verify(rtcParticipantMapper, never()).insertBatch(anyList());
}
// ========== acceptCall / joinCall 忙线校验 ==========
@Test
public void testAcceptCall_joinedOtherRoom_throwSelfBusy() {
// 准备:当前通话仍在邀请中,但用户已加入另一个房间
when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc());
ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.PRIVATE, null);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L))
.thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING));
when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1")))
.thenReturn(buildParticipant(11L, "r2", 100L, ImRtcParticipantStatusEnum.JOINED));
// 调用 + 断言:拒绝接听,不覆盖其它房间状态
ServiceException exception = assertThrows(ServiceException.class,
() -> rtcCallService.acceptCall(100L, "r1"));
assertEquals(RTC_SELF_BUSY.getCode(), exception.getCode());
verify(rtcParticipantMapper, never()).updateByIdAndStatus(eq(10L), any(), any());
}
@Test
public void testJoinCall_joinedOtherRoom_throwSelfBusy() {
// 准备:群通话活跃,用户已加入另一个房间
when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc());
ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
when(groupMemberService.validateMemberInGroup(999L, 100L)).thenReturn(new ImGroupMemberDO());
when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1")))
.thenReturn(buildParticipant(11L, "r2", 100L, ImRtcParticipantStatusEnum.JOINED));
// 调用 + 断言:拒绝加入,不写参与者状态
ServiceException exception = assertThrows(ServiceException.class,
() -> rtcCallService.joinCall(100L, "r1"));
assertEquals(RTC_SELF_BUSY.getCode(), exception.getCode());
verify(rtcParticipantMapper, never()).insert(any(ImRtcParticipantDO.class));
verify(rtcParticipantMapper, never()).updateById(any(ImRtcParticipantDO.class));
}
@Test
public void testJoinCall_insertDuplicateKey_reuseExistingParticipant() {
// 准备:群通话活跃,首次查询无参与者,插入时命中唯一键
when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc());
ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
when(groupMemberService.validateMemberInGroup(999L, 100L)).thenReturn(new ImGroupMemberDO());
when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1")))
.thenReturn(null);
when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L))
.thenReturn(null, buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.JOINED));
when(rtcParticipantMapper.insert(any(ImRtcParticipantDO.class))).thenThrow(new DuplicateKeyException("dup"));
// 调用
ImRtcCallDO result = rtcCallService.joinCall(100L, "r1");
// 断言:不向上抛数据库异常
assertSame(call, result);
verify(rtcParticipantMapper, never()).updateById(any(ImRtcParticipantDO.class));
}
@Test
public void testInviteCall_overLimit_throws() throws Exception {
ImProperties.Rtc rtcConfig = new ImProperties.Rtc();
rtcConfig.setGroupMaxParticipants(3);
when(imProperties.getRtc()).thenReturn(rtcConfig);
ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
when(rtcParticipantMapper.selectByRoomAndUserId("r1", 200L))
.thenReturn(buildParticipant(10L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED));
when(rtcCallLockRedisDAO.lockGroup(eq(999L), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
Callable<Void> callable = invocation.getArgument(1);
return callable.call();
});
when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(ListUtil.of(
buildParticipant(10L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED),
buildParticipant(11L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED),
buildParticipant(12L, "r1", 202L, ImRtcParticipantStatusEnum.INVITING)
));
ImRtcCallInviteReqVO reqVO = new ImRtcCallInviteReqVO();
reqVO.setRoom("r1");
reqVO.setInviteeIds(CollUtil.newHashSet(203L));
ServiceException exception = assertThrows(ServiceException.class,
() -> rtcCallService.inviteCall(200L, reqVO));
assertEquals(RTC_GROUP_INVITEE_OVER_LIMIT.getCode(), exception.getCode());
verify(rtcParticipantMapper, never()).insertBatch(anyList());
}
// ========== 测试数据构造 ==========
private ImRtcParticipantDO buildParticipant(Long id, String room, Long userId, ImRtcParticipantStatusEnum status) {
return new ImRtcParticipantDO()
.setId(id)
.setRoom(room)
.setUserId(userId)
.setRole(ImRtcParticipantRoleEnum.INVITEE.getRole())
.setStatus(status.getStatus())
.setInviteTime(LocalDateTime.now());
}
private ImRtcCallDO buildCall(String room, Long inviterUserId, ImConversationTypeEnum conversationType, Long groupId) {
return new ImRtcCallDO()
.setRoom(room)
.setConversationType(conversationType.getType())
.setMediaType(1)
.setInviterUserId(inviterUserId)
.setGroupId(groupId)
.setStatus(ImRtcCallStatusEnum.RUNNING.getStatus())
.setStartTime(LocalDateTime.now());
}
private AdminUserRespDTO buildUser(Long id) {
AdminUserRespDTO user = new AdminUserRespDTO();
user.setId(id);
user.setNickname("user-" + id);
return user;
}
}

View File

@@ -0,0 +1,278 @@
package cn.iocoder.yudao.module.im.service.sensitiveword;
import cn.hutool.core.collection.ListUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordSaveReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO;
import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_DUPLICATED;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_NOT_EXISTS;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* {@link ImSensitiveWordServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest {
private static final Long TENANT_ID = 1L;
@InjectMocks
private ImSensitiveWordServiceImpl sensitiveWordService;
@Mock
private ImSensitiveWordMapper imSensitiveWordMapper;
@BeforeEach
public void setUp() {
// 设置租户上下文validateText 与 loadFresh 都依赖 TenantContextHolder
TenantContextHolder.setTenantId(TENANT_ID);
// mock 启用敏感词列表LoadingCache 在首次 validateText 时懒加载,无需主动 init
// 用 lenient 避免 null / empty 等不触发 cache load 的用例报 UnnecessaryStubbing
lenient().when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(1L).word("badword")
.status(CommonStatusEnum.ENABLE.getStatus()).build(),
ImSensitiveWordDO.builder().id(2L).word("违禁词")
.status(CommonStatusEnum.ENABLE.getStatus()).build()
));
}
@AfterEach
public void tearDown() {
// 清理租户上下文,避免污染其它测试
TenantContextHolder.clear();
}
@Test
public void testValidateText_null() {
// null 直接返回,不抛异常
assertDoesNotThrow(() -> sensitiveWordService.validateText(null));
}
@Test
public void testValidateText_empty() {
assertDoesNotThrow(() -> sensitiveWordService.validateText(""));
}
@Test
public void testValidateText_clean() {
// 正常文本不应命中
assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world"));
}
@Test
public void testValidateText_hitEnglish() {
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("this contains badword here"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
}
@Test
public void testValidateText_hitChinese() {
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
}
@Test
public void testValidateText_lazyLoadsCacheOnFirstCall() {
// 调用:首次 validateText 应触发 cache load
sensitiveWordService.validateText("hello world");
// 断言mapper 各调用 1 次loadFresh 里先取 maxUpdateTime 再读词库)
verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID);
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testValidateText_reusesCachedBsAcrossCalls() {
// 调用:连续两次 validateText
sensitiveWordService.validateText("hello world");
sensitiveWordService.validateText("another text");
// 断言:第二次复用 cachemapper 仍只被调用 1 次
verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID);
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testCreateSensitiveWord_invalidatesCacheAndReloadsOnNextValidate() {
// 准备:首次 validateText 触发 cache load旧词库
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备mapper 返回新词库(额外多一个 newbad并让 createSensitiveWord 走通
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(1L).word("badword")
.status(CommonStatusEnum.ENABLE.getStatus()).build(),
ImSensitiveWordDO.builder().id(2L).word("违禁词")
.status(CommonStatusEnum.ENABLE.getStatus()).build(),
ImSensitiveWordDO.builder().id(3L).word("newbad")
.status(CommonStatusEnum.ENABLE.getStatus()).build()
));
when(imSensitiveWordMapper.selectByWord("newbad")).thenReturn(null);
// 调用:新增敏感词,触发 invalidate
ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
reqVO.setWord("newbad");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
sensitiveWordService.createSensitiveWord(reqVO);
// 调用:再次 validateText应触发重新 load
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("contains newbad here"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 旧词依然命中
assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("contains badword here"));
// 断言selectListByStatus 共被调用 2 次(首次 load + invalidate 后 reload
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testCreateSensitiveWord_duplicateWord_throws() {
// 准备mock 已存在同名敏感词
when(imSensitiveWordMapper.selectByWord("dup")).thenReturn(
ImSensitiveWordDO.builder().id(99L).word("dup")
.status(CommonStatusEnum.ENABLE.getStatus()).build());
// 调用 + 断言:重复敏感词抛 SENSITIVE_WORD_DUPLICATED
ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
reqVO.setWord("dup");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.createSensitiveWord(reqVO));
assertEquals(SENSITIVE_WORD_DUPLICATED.getCode(), exception.getCode());
// 断言:未走到 insert
verify(imSensitiveWordMapper, never()).insert(any(ImSensitiveWordDO.class));
}
@Test
public void testUpdateSensitiveWord_invalidatesCache() {
// 准备:首次 validateText 触发 cache load
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备:让 update 校验通过
when(imSensitiveWordMapper.selectById(1L)).thenReturn(
ImSensitiveWordDO.builder().id(1L).word("badword")
.status(CommonStatusEnum.ENABLE.getStatus()).build());
when(imSensitiveWordMapper.selectByWord("updatedbad")).thenReturn(null);
// 准备reload 时返回新词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(1L).word("updatedbad")
.status(CommonStatusEnum.ENABLE.getStatus()).build()
));
// 调用:更新敏感词,触发 invalidate
ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
reqVO.setId(1L);
reqVO.setWord("updatedbad");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
sensitiveWordService.updateSensitiveWord(reqVO);
// 调用 + 断言:再次 validateText 应使用新词库
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("contains updatedbad here"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 断言selectListByStatus 共被调用 2 次
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWord_invalidatesCache() {
// 准备:首次 validateText 触发 cache load
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备:让 delete 校验通过
when(imSensitiveWordMapper.selectById(1L)).thenReturn(
ImSensitiveWordDO.builder().id(1L).word("badword")
.status(CommonStatusEnum.ENABLE.getStatus()).build());
// 准备reload 时返回剩余词库(只剩中文那条)
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(2L).word("违禁词")
.status(CommonStatusEnum.ENABLE.getStatus()).build()
));
// 调用:删除 badword触发 invalidate
sensitiveWordService.deleteSensitiveWord(1L);
// 调用 + 断言badword 已被删,不再命中
assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here"));
// 中文词依然在
assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
// 断言selectListByStatus 共被调用 2 次
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWord_notExists_throws() {
// 准备selectById 返回 null
when(imSensitiveWordMapper.selectById(999L)).thenReturn(null);
// 调用 + 断言:抛 SENSITIVE_WORD_NOT_EXISTS
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.deleteSensitiveWord(999L));
assertEquals(SENSITIVE_WORD_NOT_EXISTS.getCode(), exception.getCode());
// 断言:未走到 deleteById
verify(imSensitiveWordMapper, never()).deleteById(anyLong());
}
@Test
public void testDeleteSensitiveWordList_invalidatesCache() {
// 准备:首次 validateText 触发 cache load
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备reload 时返回空词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of());
// 调用:批量删除,触发 invalidate
sensitiveWordService.deleteSensitiveWordList(ListUtil.of(1L, 2L));
// 调用 + 断言:所有词都不再命中
assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here"));
assertDoesNotThrow(() -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
// 断言deleteByIds 被调用 1 次selectListByStatus 共 2 次
verify(imSensitiveWordMapper, times(1)).deleteByIds(ListUtil.of(1L, 2L));
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWordList_emptyIds_skip() {
// 调用:空列表直接返回
sensitiveWordService.deleteSensitiveWordList(ListUtil.of());
// 断言mapper 不被调用
verify(imSensitiveWordMapper, never()).deleteByIds(anyList());
}
}

View File

@@ -0,0 +1,162 @@
package cn.iocoder.yudao.module.im.service.statistics;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.dal.mysql.statistics.ImStatisticsManagerMapper;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.math.BigInteger;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
/**
* {@link ImStatisticsManagerServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImStatisticsManagerServiceImplTest extends BaseMockitoUnitTest {
private static final LocalDateTime BEGIN = LocalDateTime.of(2026, 1, 1, 0, 0);
private static final LocalDateTime END = LocalDateTime.of(2026, 2, 1, 0, 0);
@InjectMocks
private ImStatisticsManagerServiceImpl service;
@Mock
private ImStatisticsManagerMapper statisticsMapper;
// ========== 单值委托 ==========
@Test
public void testGetTotalUserCount_delegate() {
when(statisticsMapper.selectTotalUserCount()).thenReturn(99L);
assertEquals(99L, service.getTotalUserCount());
}
@Test
public void testGetNewUserCount_delegate() {
when(statisticsMapper.selectNewUserCount(BEGIN, END)).thenReturn(10L);
assertEquals(10L, service.getNewUserCount(BEGIN, END));
}
@Test
public void testGetActiveUserCount_delegate() {
when(statisticsMapper.selectActiveUserCount(BEGIN, END)).thenReturn(5L);
assertEquals(5L, service.getActiveUserCount(BEGIN, END));
}
@Test
public void testGetTotalGroupCount_delegate() {
when(statisticsMapper.selectTotalGroupCount()).thenReturn(33L);
assertEquals(33L, service.getTotalGroupCount());
}
@Test
public void testGetPrivateMessageCount_delegate() {
when(statisticsMapper.selectPrivateMessageCount(BEGIN, END)).thenReturn(123L);
assertEquals(123L, service.getPrivateMessageCount(BEGIN, END));
}
@Test
public void testGetGroupMessageCount_delegate() {
when(statisticsMapper.selectGroupMessageCount(BEGIN, END)).thenReturn(456L);
assertEquals(456L, service.getGroupMessageCount(BEGIN, END));
}
// ========== 每日序列 ==========
@Test
public void testGetNewUserDailyCountMap_dateConvert() {
// 准备date 字段以 SQL Date / String 混入count 混入多种 Number
when(statisticsMapper.selectNewUserDailyCount(eq(BEGIN), eq(END))).thenReturn(Arrays.asList(
row("date", java.sql.Date.valueOf("2026-01-10"), "count", BigInteger.valueOf(5)),
row("date", "2026-01-11", "count", 8)));
// 调用
Map<LocalDateTime, Long> result = service.getNewUserDailyCountMap(BEGIN, END);
// 断言date → LocalDateTime 起始零点
assertEquals(2, result.size());
assertEquals(5L, result.get(LocalDate.of(2026, 1, 10).atStartOfDay()));
assertEquals(8L, result.get(LocalDate.of(2026, 1, 11).atStartOfDay()));
}
@Test
public void testGetActiveUserDailyCountMap_dateConvert() {
when(statisticsMapper.selectActiveUserDailyCount(any(), any())).thenReturn(Arrays.asList(
row("date", "2026-01-10", "count", 3L)));
Map<LocalDateTime, Long> result = service.getActiveUserDailyCountMap(BEGIN, END);
assertEquals(3L, result.get(LocalDate.of(2026, 1, 10).atStartOfDay()));
}
@Test
public void testGetPrivateMessageDailyCountMap_dateConvert() {
when(statisticsMapper.selectPrivateMessageDailyCount(any(), any())).thenReturn(Arrays.asList(
row("date", "2026-01-10", "count", 7L)));
assertEquals(7L, service.getPrivateMessageDailyCountMap(BEGIN, END)
.get(LocalDate.of(2026, 1, 10).atStartOfDay()));
}
@Test
public void testGetGroupMessageDailyCountMap_dateConvert() {
when(statisticsMapper.selectGroupMessageDailyCount(any(), any())).thenReturn(Arrays.asList(
row("date", "2026-01-10", "count", 9L)));
assertEquals(9L, service.getGroupMessageDailyCountMap(BEGIN, END)
.get(LocalDate.of(2026, 1, 10).atStartOfDay()));
}
// ========== 分桶 / 分布 ==========
@Test
public void testGetGroupSizeCountMap() {
when(statisticsMapper.selectGroupSizeDistribution()).thenReturn(Arrays.asList(
row("range", "1-9 人", "count", BigInteger.valueOf(3)),
row("range", "10-49 人", "count", 2)));
Map<String, Long> result = service.getGroupSizeCountMap();
assertEquals(3L, result.get("1-9 人"));
assertEquals(2L, result.get("10-49 人"));
}
@Test
public void testGetMessageTypeCountMap() {
when(statisticsMapper.selectMessageTypeDistribution(BEGIN, END)).thenReturn(Arrays.asList(
row("type", 101L, "count", BigInteger.valueOf(8)),
row("type", 102, "count", 1)));
Map<Integer, Long> result = service.getMessageTypeCountMap(BEGIN, END);
assertEquals(8L, result.get(101));
assertEquals(1L, result.get(102));
}
@Test
public void testGetTopSenderCountMap_passesLimit() {
when(statisticsMapper.selectTopSenders(BEGIN, END, 3)).thenReturn(Arrays.asList(
row("userId", 1, "messageCount", BigInteger.valueOf(10)),
row("userId", 2L, "messageCount", 5)));
Map<Long, Long> result = service.getTopSenderCountMap(BEGIN, END, 3);
assertEquals(10L, result.get(1L));
assertEquals(5L, result.get(2L));
}
// ========== 工具 ==========
private static Map<String, Object> row(String k1, Object v1, String k2, Object v2) {
Map<String, Object> map = new HashMap<>();
map.put(k1, v1);
map.put(k2, v2);
return map;
}
}

View File

@@ -0,0 +1,256 @@
package cn.iocoder.yudao.module.im.service.websocket;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.service.websocket.notification.ImNotificationWebSocketDTO;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImGroupMessageNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImPrivateMessageNotification;
import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.*;
/**
* {@link ImWebSocketServiceImpl} 的单元测试
*
* @author 芋道源码
*/
public class ImWebSocketServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ImWebSocketServiceImpl imWebSocketService;
@Mock
private WebSocketSenderApi webSocketSenderApi;
@AfterEach
public void tearDown() {
// 清理事务同步上下文,避免串扰其它用例
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
// ========== 私聊推送 ==========
@Test
public void testSendNotificationAsync_private_noTransactionSendsImmediately() {
try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class)))
.thenReturn(imWebSocketService);
// 准备
ImPrivateMessageNotification dto = new ImPrivateMessageNotification().setSenderId(1L).setReceiverId(2L)
.setType(ImContentTypeEnum.TEXT.getType());
// 调用:无事务,应立即发送
imWebSocketService.sendNotificationAsync(2L, ImConversationTypeEnum.PRIVATE.getType(),
ImContentTypeEnum.TEXT.getType(), dto);
// 断言
verify(webSocketSenderApi).sendObject(
eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImNotificationWebSocketDTO.TYPE),
argThat(actual -> isNotification(actual, ImConversationTypeEnum.PRIVATE,
ImContentTypeEnum.TEXT, dto)));
}
}
@Test
public void testSendNotificationAsync_private_inTransactionDeferredUntilCommit() {
try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class)))
.thenReturn(imWebSocketService);
// 准备:开启事务同步
TransactionSynchronizationManager.initSynchronization();
try {
ImPrivateMessageNotification dto = new ImPrivateMessageNotification().setSenderId(1L).setReceiverId(2L)
.setType(ImContentTypeEnum.TEXT.getType());
// 调用
imWebSocketService.sendNotificationAsync(2L, ImConversationTypeEnum.PRIVATE.getType(),
ImContentTypeEnum.TEXT.getType(), dto);
// 断言:事务未提交,未推送
verify(webSocketSenderApi, never()).sendObject(anyInt(), anyLong(), anyString(), any());
// 模拟事务提交
List<TransactionSynchronization> syncs =
TransactionSynchronizationManager.getSynchronizations();
assertEquals(1, syncs.size());
syncs.forEach(TransactionSynchronization::afterCommit);
// 断言:提交后推送
verify(webSocketSenderApi).sendObject(
eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImNotificationWebSocketDTO.TYPE),
argThat(actual -> isNotification(actual, ImConversationTypeEnum.PRIVATE,
ImContentTypeEnum.TEXT, dto)));
} finally {
TransactionSynchronizationManager.clear();
}
}
}
// ========== 群聊推送 ==========
@Test
public void testSendNotificationAsync_group_fanOutToAllUsers() {
try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class)))
.thenReturn(imWebSocketService);
ImGroupMessageNotification dto = new ImGroupMessageNotification();
dto.setGroupId(10L);
dto.setSenderId(1L);
dto.setType(ImContentTypeEnum.TEXT.getType());
imWebSocketService.sendNotificationAsync(ListUtil.of(1L, 2L, 3L),
ImConversationTypeEnum.GROUP.getType(), ImContentTypeEnum.TEXT.getType(), dto);
verify(webSocketSenderApi).sendObject(
eq(UserTypeEnum.ADMIN.getValue()), eq(1L), eq(ImNotificationWebSocketDTO.TYPE),
argThat(actual -> isNotification(actual, ImConversationTypeEnum.GROUP,
ImContentTypeEnum.TEXT, dto)));
verify(webSocketSenderApi).sendObject(
eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImNotificationWebSocketDTO.TYPE),
argThat(actual -> isNotification(actual, ImConversationTypeEnum.GROUP,
ImContentTypeEnum.TEXT, dto)));
verify(webSocketSenderApi).sendObject(
eq(UserTypeEnum.ADMIN.getValue()), eq(3L), eq(ImNotificationWebSocketDTO.TYPE),
argThat(actual -> isNotification(actual, ImConversationTypeEnum.GROUP,
ImContentTypeEnum.TEXT, dto)));
}
}
@Test
public void testSendNotificationAsync_group_senderExceptionDoesNotBreakOthers() {
try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class)))
.thenReturn(imWebSocketService);
ImGroupMessageNotification dto = new ImGroupMessageNotification();
dto.setGroupId(10L);
// 给 1 号用户推送时抛异常,不能影响 2/3 号
doThrow(new RuntimeException("user offline"))
.when(webSocketSenderApi).sendObject(anyInt(), eq(1L), anyString(), any());
imWebSocketService.sendNotificationAsync(ListUtil.of(1L, 2L, 3L),
ImConversationTypeEnum.GROUP.getType(), ImContentTypeEnum.TEXT.getType(), dto);
// 2L 和 3L 也都被推送
verify(webSocketSenderApi).sendObject(anyInt(), eq(2L), anyString(), any());
verify(webSocketSenderApi).sendObject(anyInt(), eq(3L), anyString(), any());
}
}
@Test
public void testDoSendNotification_emptyUserIds_noSend() {
ImGroupMessageNotification dto = new ImGroupMessageNotification();
dto.setGroupId(10L);
dto.setType(ImContentTypeEnum.TEXT.getType());
ImNotificationWebSocketDTO notification = buildNotification(ImConversationTypeEnum.GROUP,
ImContentTypeEnum.TEXT, dto);
imWebSocketService.doSendNotification(Collections.emptyList(), notification);
imWebSocketService.doSendNotification(null, notification);
verifyNoInteractions(webSocketSenderApi);
}
@Test
public void testDoSendNotification_distinctUserIds() {
ImGroupMessageNotification dto = new ImGroupMessageNotification();
dto.setGroupId(10L);
dto.setType(ImContentTypeEnum.TEXT.getType());
ImNotificationWebSocketDTO notification = buildNotification(ImConversationTypeEnum.GROUP,
ImContentTypeEnum.TEXT, dto);
imWebSocketService.doSendNotification(Arrays.asList(1L, 2L, 1L, null), notification);
verify(webSocketSenderApi).sendObject(
eq(UserTypeEnum.ADMIN.getValue()), eq(1L), eq(ImNotificationWebSocketDTO.TYPE),
argThat(actual -> isNotification(actual, ImConversationTypeEnum.GROUP,
ImContentTypeEnum.TEXT, dto)));
verify(webSocketSenderApi).sendObject(
eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImNotificationWebSocketDTO.TYPE),
argThat(actual -> isNotification(actual, ImConversationTypeEnum.GROUP,
ImContentTypeEnum.TEXT, dto)));
verifyNoMoreInteractions(webSocketSenderApi);
}
@Test
public void testSendNotificationAsync_private_exceptionSwallowed() {
try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class)))
.thenReturn(imWebSocketService);
// 准备sender 抛异常
ImPrivateMessageNotification dto = new ImPrivateMessageNotification().setSenderId(1L).setReceiverId(2L)
.setType(ImContentTypeEnum.TEXT.getType());
doThrow(new RuntimeException("user offline"))
.when(webSocketSenderApi).sendObject(anyInt(), anyLong(), anyString(), any());
// 调用:异常应被吞掉,不向上抛
imWebSocketService.sendNotificationAsync(2L, ImConversationTypeEnum.PRIVATE.getType(),
ImContentTypeEnum.TEXT.getType(), dto);
verify(webSocketSenderApi).sendObject(anyInt(), eq(2L), anyString(), any());
}
}
@Test
public void testSendNotificationAsync_group_singleUserDefaultOverload() {
try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class)))
.thenReturn(imWebSocketService);
ImGroupMessageNotification dto = new ImGroupMessageNotification();
dto.setGroupId(10L);
dto.setType(ImContentTypeEnum.TEXT.getType());
imWebSocketService.sendNotificationAsync(42L, ImConversationTypeEnum.GROUP.getType(),
ImContentTypeEnum.TEXT.getType(), dto);
verify(webSocketSenderApi).sendObject(
eq(UserTypeEnum.ADMIN.getValue()), eq(42L), eq(ImNotificationWebSocketDTO.TYPE),
argThat(actual -> isNotification(actual, ImConversationTypeEnum.GROUP,
ImContentTypeEnum.TEXT, dto)));
}
}
private static boolean isNotification(Object object, ImConversationTypeEnum conversationType,
ImContentTypeEnum contentType, Object payload) {
if (!(object instanceof ImNotificationWebSocketDTO notification)) {
return false;
}
return conversationType.getType().equals(notification.getConversationType())
&& contentType.getType().equals(notification.getContentType())
&& payload == notification.getPayload();
}
private static ImNotificationWebSocketDTO buildNotification(ImConversationTypeEnum conversationType,
ImContentTypeEnum contentType, Object payload) {
return new ImNotificationWebSocketDTO()
.setConversationType(conversationType.getType())
.setContentType(contentType.getType())
.setPayload(payload);
}
}

View File

@@ -0,0 +1,184 @@
package cn.iocoder.yudao.module.im.util;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.QuoteMessage;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@link ImMessageUtils} 的单元测试
*
* @author 芋道源码
*/
public class ImMessageUtilsTest {
// ========== parseQuoteMessageId ==========
@Test
public void testParseQuoteMessageId_valid() {
// 准备content 含合法 quote 字段
String content = "{\"text\":\"你好\",\"quote\":{\"messageId\":1001,\"senderId\":2,\"type\":1}}";
// 调用 + 断言
assertEquals(1001L, ImMessageUtils.parseQuoteMessageId(content));
}
@Test
public void testParseQuoteMessageId_invalidJson() {
// 准备content 非合法 JSON
// 调用 + 断言:解析失败返回 null
assertNull(ImMessageUtils.parseQuoteMessageId("not a json"));
}
@Test
public void testParseQuoteMessageId_noQuoteField() {
// 准备content 无 quote 字段
String content = "{\"text\":\"你好\"}";
// 调用 + 断言
assertNull(ImMessageUtils.parseQuoteMessageId(content));
}
@Test
public void testParseQuoteMessageId_quoteIsNotMap() {
// 准备quote 字段不是对象
String content = "{\"text\":\"你好\",\"quote\":\"oops\"}";
// 调用 + 断言
assertNull(ImMessageUtils.parseQuoteMessageId(content));
}
@Test
public void testParseQuoteMessageId_messageIdMissing() {
// 准备quote 对象内无 messageId
String content = "{\"quote\":{\"senderId\":2}}";
// 调用 + 断言
assertNull(ImMessageUtils.parseQuoteMessageId(content));
}
// ========== appendQuote ==========
@Test
public void testAppendQuote_existingContent() {
// 准备:已有 text 字段的 content
String content = "{\"text\":\"你好\"}";
QuoteMessage quote = new QuoteMessage().setMessageId(1001L).setSenderId(2L).setType(1).setContent("{}");
// 调用
String result = ImMessageUtils.appendQuote(content, quote);
// 断言:保留原字段,注入 quote
Map<String, Object> map = JsonUtils.parseMap(result);
assertEquals("你好", map.get("text"));
assertTrue(map.get("quote") instanceof Map);
assertEquals(1001, ((Map<?, ?>) map.get("quote")).get("messageId"));
}
@Test
public void testAppendQuote_blankContent() {
// 准备content 为空字符串,无法解析为 map
QuoteMessage quote = new QuoteMessage().setMessageId(1001L);
// 调用
String result = ImMessageUtils.appendQuote("", quote);
// 断言:内部新建 map仅含 quote
Map<String, Object> map = JsonUtils.parseMap(result);
assertEquals(1, map.size());
assertTrue(map.containsKey("quote"));
}
@Test
public void testAppendQuote_overwriteExistingQuote() {
// 准备content 已经有一个旧 quote期望被新 quote 覆盖
String content = "{\"text\":\"hi\",\"quote\":{\"messageId\":1}}";
QuoteMessage quote = new QuoteMessage().setMessageId(9999L).setSenderId(2L).setType(1);
// 调用
String result = ImMessageUtils.appendQuote(content, quote);
// 断言quote.messageId 被覆盖为新值
Map<String, Object> map = JsonUtils.parseMap(result);
assertEquals(9999, ((Map<?, ?>) map.get("quote")).get("messageId"));
}
// ========== removeQuote ==========
@Test
public void testRemoveQuote_blankContent() {
// 调用 + 断言:空内容原样返回
assertEquals("", ImMessageUtils.removeQuote(""));
assertNull(ImMessageUtils.removeQuote(null));
}
@Test
public void testRemoveQuote_noQuoteField() {
// 准备content 字符串里没有 quote 关键字,提前返回原值
String content = "{\"text\":\"你好\"}";
// 调用 + 断言:同一引用直接返回
assertEquals(content, ImMessageUtils.removeQuote(content));
}
@Test
public void testRemoveQuote_withQuote() {
// 准备:含 quote 的 content
String content = "{\"text\":\"你好\",\"quote\":{\"messageId\":1}}";
// 调用
String result = ImMessageUtils.removeQuote(content);
// 断言quote 被移除,保留其它字段
Map<String, Object> map = JsonUtils.parseMap(result);
assertFalse(map.containsKey("quote"));
assertEquals("你好", map.get("text"));
}
@Test
public void testRemoveQuote_invalidJsonContainingQuoteToken() {
// 准备:字符串包含 quote 字面量但解析失败
String content = "not-a-json-but-has-\"quote\"-token";
// 调用 + 断言:解析失败返回原值
assertEquals(content, ImMessageUtils.removeQuote(content));
}
// ========== buildQuote ==========
@Test
public void testBuildQuote_stripsNestedQuote() {
// 准备:被引用消息本身也含有 quote 字段;防止嵌套
String originalContent = "{\"text\":\"被引用\",\"quote\":{\"messageId\":777}}";
// 调用
QuoteMessage quote = ImMessageUtils.buildQuote(1001L, 2L, 1, originalContent);
// 断言基础字段透传content 已被剥离 quote
assertEquals(1001L, quote.getMessageId());
assertEquals(2L, quote.getSenderId());
assertEquals(1, quote.getType());
Map<String, Object> contentMap = JsonUtils.parseMap(quote.getContent());
assertFalse(contentMap.containsKey("quote"));
assertEquals("被引用", contentMap.get("text"));
}
@Test
public void testBuildQuote_originalWithoutQuote() {
// 准备:被引用消息原本就没 quote 字段
String originalContent = "{\"text\":\"hi\"}";
// 调用
QuoteMessage quote = ImMessageUtils.buildQuote(1001L, 2L, 1, originalContent);
// 断言content 原样保留
assertEquals(originalContent, quote.getContent());
}
}

View File

@@ -0,0 +1,49 @@
spring:
main:
lazy-initialization: true # 开启懒加载,加快速度
banner-mode: off # 单元测试,禁用 Banner
--- #################### 数据库相关配置 ####################
spring:
# 数据源配置项
datasource:
name: ruoyi-vue-pro
url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value,day; # MODE 使用 MySQL 模式DATABASE_TO_UPPER 配置表和字段使用小写
driver-class-name: org.h2.Driver
username: sa
password:
druid:
async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度
initial-size: 1 # 单元测试,配置为 1提升启动速度
sql:
init:
schema-locations: classpath:/sql/create_tables.sql
encoding: UTF-8
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
data:
redis:
host: 127.0.0.1 # 地址
port: 16379 # 端口(单元测试,使用 16379 端口)
database: 0 # 数据库索引
mybatis:
lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试
--- #################### 定时任务相关配置 ####################
--- #################### 配置中心相关配置 ####################
--- #################### 服务保障相关配置 ####################
# Lock4j 配置项(单元测试,禁用 Lock4j
--- #################### 监控相关配置 ####################
--- #################### 芋道相关配置 ####################
# 芋道配置项,设置当前项目所有自定义的配置
yudao:
info:
base-package: cn.iocoder.yudao.module

View File

@@ -0,0 +1,15 @@
DELETE FROM "im_private_message";
DELETE FROM "im_group_message";
DELETE FROM "im_conversation_read";
DELETE FROM "im_group";
DELETE FROM "im_group_member";
DELETE FROM "im_friend";
DELETE FROM "im_friend_request";
DELETE FROM "im_group_request";
DELETE FROM "im_face_pack";
DELETE FROM "im_face_pack_item";
DELETE FROM "im_face_user_item";
DELETE FROM "im_rtc_call";
DELETE FROM "im_rtc_participant";
DELETE FROM "im_sensitive_word";
DELETE FROM "system_users";

View File

@@ -0,0 +1,349 @@
CREATE TABLE IF NOT EXISTS "im_private_message" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号',
"sender_id" bigint NOT NULL COMMENT '发送人编号',
"receiver_id" bigint NOT NULL COMMENT '接收人编号',
"type" smallint NOT NULL COMMENT '消息类型',
"content" varchar(8192) DEFAULT NULL COMMENT '消息内容',
"status" tinyint NOT NULL COMMENT '消息状态',
"receipt_status" tinyint NOT NULL DEFAULT 0 COMMENT '回执状态',
"send_time" timestamp NOT NULL COMMENT '发送时间',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id"),
CONSTRAINT "uk_im_private_message_sender_client" UNIQUE ("sender_id", "client_message_id", "tenant_id")
) COMMENT 'IM 私聊消息表';
CREATE TABLE IF NOT EXISTS "im_group_message" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号',
"sender_id" bigint NOT NULL COMMENT '发送人编号',
"group_id" bigint NOT NULL COMMENT '群编号',
"type" smallint NOT NULL COMMENT '消息类型',
"content" varchar(8192) DEFAULT NULL COMMENT '消息内容',
"status" tinyint NOT NULL COMMENT '消息状态',
"send_time" timestamp NOT NULL COMMENT '发送时间',
"receiver_user_ids" text DEFAULT NULL COMMENT '定向接收用户编号列表',
"at_user_ids" varchar(1024) DEFAULT NULL COMMENT '@ 目标用户编号列表',
"receipt_status" tinyint NOT NULL DEFAULT 0 COMMENT '回执状态',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id"),
CONSTRAINT "uk_im_group_message_sender_client" UNIQUE ("sender_id", "client_message_id", "tenant_id")
) COMMENT 'IM 群聊消息表';
CREATE TABLE IF NOT EXISTS "im_group" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"name" varchar(64) NOT NULL COMMENT '群名称',
"owner_user_id" bigint NOT NULL COMMENT '群主用户编号',
"avatar" varchar(512) DEFAULT NULL COMMENT '群头像',
"notice" varchar(2048) DEFAULT NULL COMMENT '群公告',
"banned" bit DEFAULT FALSE COMMENT '是否封禁',
"banned_reason" varchar(512) DEFAULT NULL COMMENT '封禁原因',
"banned_time" timestamp DEFAULT NULL COMMENT '封禁时间',
"status" tinyint NOT NULL COMMENT '群状态',
"dissolved_time" timestamp DEFAULT NULL COMMENT '解散时间',
"muted_all" bit DEFAULT FALSE COMMENT '是否全群禁言',
"join_approval" bit NOT NULL DEFAULT FALSE COMMENT '进群是否需群主 / 管理员审批false 自由进群true 需审批',
"pinned_message_ids" varchar(128) DEFAULT NULL COMMENT '群置顶消息编号列表,逗号分隔',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
) COMMENT 'IM 群信息表';
CREATE TABLE IF NOT EXISTS "im_group_member" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"group_id" bigint NOT NULL COMMENT '群编号',
"user_id" bigint NOT NULL COMMENT '用户编号',
"display_user_name" varchar(64) DEFAULT NULL COMMENT '组内显示名',
"group_remark" varchar(64) DEFAULT NULL COMMENT '群备注',
"silent" bit DEFAULT FALSE COMMENT '是否免打扰',
"status" tinyint NOT NULL COMMENT '成员状态',
"role" tinyint NOT NULL DEFAULT 3 COMMENT '成员角色1=群主 2=管理员 3=普通成员',
"join_time" timestamp DEFAULT NULL COMMENT '入群时间',
"add_source" tinyint DEFAULT NULL COMMENT '加入来源',
"inviter_user_id" bigint DEFAULT NULL COMMENT '邀请人用户编号;用户主动申请进群时为 NULL',
"quit_time" timestamp DEFAULT NULL COMMENT '退群时间',
"mute_end_time" timestamp DEFAULT NULL COMMENT '禁言到期时间',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id"),
CONSTRAINT "uk_im_group_member" UNIQUE ("group_id", "user_id", "tenant_id")
) COMMENT 'IM 群成员表';
CREATE TABLE IF NOT EXISTS "im_friend" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"user_id" bigint NOT NULL COMMENT '用户编号',
"friend_user_id" bigint NOT NULL COMMENT '好友用户编号',
"silent" bit DEFAULT FALSE COMMENT '是否免打扰',
"display_name" varchar(64) NOT NULL DEFAULT '' COMMENT '好友展示备注(仅自己可见)',
"add_source" tinyint DEFAULT NULL COMMENT '添加来源',
"pinned" bit DEFAULT FALSE COMMENT '是否置顶联系人',
"blocked" bit DEFAULT FALSE COMMENT '是否拉黑',
"status" tinyint NOT NULL COMMENT '好友状态',
"add_time" timestamp DEFAULT NULL COMMENT '添加好友时间',
"delete_time" timestamp DEFAULT NULL COMMENT '删除好友时间',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id"),
CONSTRAINT "uk_user_friend" UNIQUE ("user_id", "friend_user_id", "tenant_id")
) COMMENT 'IM 好友关系表';
CREATE TABLE IF NOT EXISTS "im_friend_request" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"from_user_id" bigint NOT NULL COMMENT '发起方用户编号',
"to_user_id" bigint NOT NULL COMMENT '接收方用户编号',
"handle_result" tinyint NOT NULL DEFAULT 0 COMMENT '处理结果0未处理1同意2拒绝',
"apply_content" varchar(255) DEFAULT NULL COMMENT '申请理由',
"handle_content" varchar(255) DEFAULT NULL COMMENT '处理理由',
"display_name" varchar(64) DEFAULT NULL COMMENT '发起方对接收方的备注',
"add_source" tinyint DEFAULT NULL COMMENT '添加来源',
"handle_time" timestamp DEFAULT NULL COMMENT '处理时间',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id"),
CONSTRAINT "uk_im_friend_request" UNIQUE ("from_user_id", "to_user_id", "tenant_id")
) COMMENT 'IM 好友申请记录表';
CREATE TABLE IF NOT EXISTS "im_group_request" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"group_id" bigint NOT NULL COMMENT '群编号',
"user_id" bigint NOT NULL COMMENT '申请人 / 被邀请人用户编号',
"inviter_user_id" bigint DEFAULT NULL COMMENT '邀请人用户编号NULL=主动申请非NULL=被邀请待审批',
"apply_content" varchar(255) DEFAULT NULL COMMENT '申请理由',
"add_source" tinyint DEFAULT NULL COMMENT '加入来源',
"handle_result" tinyint NOT NULL DEFAULT 0 COMMENT '处理结果0未处理1同意2拒绝',
"handle_user_id" bigint DEFAULT NULL COMMENT '处理人用户编号',
"handle_content" varchar(255) DEFAULT NULL COMMENT '处理理由',
"handle_time" timestamp DEFAULT NULL COMMENT '处理时间',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id"),
CONSTRAINT "uk_im_group_request" UNIQUE ("group_id", "user_id", "tenant_id")
) COMMENT 'IM 加群申请记录表';
CREATE TABLE IF NOT EXISTS "im_face_pack" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"name" varchar(64) NOT NULL COMMENT '表情包名称',
"icon" varchar(512) DEFAULT NULL COMMENT '表情包图标(面板底部 tab 显示)',
"sort" int NOT NULL DEFAULT 0 COMMENT '排序',
"status" tinyint NOT NULL COMMENT '状态',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
) COMMENT 'IM 表情包表';
CREATE TABLE IF NOT EXISTS "im_face_pack_item" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"pack_id" bigint NOT NULL COMMENT '所属表情包编号',
"url" varchar(512) NOT NULL COMMENT '表情图 URL',
"name" varchar(64) DEFAULT NULL COMMENT '表情名(可选;如「狗头」「捂脸」)',
"width" int NOT NULL DEFAULT 0 COMMENT '渲染宽度(像素)',
"height" int NOT NULL DEFAULT 0 COMMENT '渲染高度(像素)',
"sort" int NOT NULL DEFAULT 0 COMMENT '排序',
"status" tinyint NOT NULL COMMENT '状态',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
) COMMENT 'IM 表情包项表';
CREATE TABLE IF NOT EXISTS "im_rtc_call" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"room" varchar(64) NOT NULL COMMENT '业务通话编号',
"conversation_type" tinyint NOT NULL COMMENT '会话类型',
"media_type" tinyint NOT NULL COMMENT '媒体类型',
"inviter_user_id" bigint NOT NULL COMMENT '发起人用户编号',
"group_id" bigint DEFAULT NULL COMMENT '群编号',
"status" tinyint NOT NULL COMMENT '通话状态',
"end_reason" tinyint DEFAULT NULL COMMENT '结束原因',
"start_time" timestamp NOT NULL COMMENT '发起时间',
"accept_time" timestamp DEFAULT NULL COMMENT '接通时间',
"end_time" timestamp DEFAULT NULL COMMENT '结束时间',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
) COMMENT 'IM 通话记录表';
CREATE TABLE IF NOT EXISTS "im_rtc_participant" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"call_id" bigint NOT NULL COMMENT '通话编号',
"room" varchar(64) NOT NULL COMMENT '业务通话编号',
"user_id" bigint NOT NULL COMMENT '参与者用户编号',
"role" tinyint NOT NULL COMMENT '参与角色',
"status" tinyint NOT NULL COMMENT '参与状态',
"invite_time" timestamp NOT NULL COMMENT '被邀请时间',
"accept_time" timestamp DEFAULT NULL COMMENT '接听时间',
"leave_time" timestamp DEFAULT NULL COMMENT '离开时间',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id"),
CONSTRAINT "uk_im_rtc_participant_room_user" UNIQUE ("room", "user_id", "tenant_id")
) COMMENT 'IM 通话参与者表';
CREATE TABLE IF NOT EXISTS "im_face_user_item" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"user_id" bigint NOT NULL COMMENT '所属用户编号',
"url" varchar(512) NOT NULL COMMENT '表情图 URL',
"name" varchar(64) DEFAULT NULL COMMENT '表情名(可选)',
"width" int NOT NULL DEFAULT 0 COMMENT '渲染宽度(像素)',
"height" int NOT NULL DEFAULT 0 COMMENT '渲染高度(像素)',
"sort" int NOT NULL DEFAULT 0 COMMENT '排序',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id"),
CONSTRAINT "uk_im_face_user_item_user_url_deleted" UNIQUE ("user_id", "url", "deleted")
) COMMENT 'IM 用户私有表情表';
CREATE TABLE IF NOT EXISTS "im_channel" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"code" varchar(64) NOT NULL COMMENT '频道业务码;唯一',
"name" varchar(64) NOT NULL COMMENT '频道名称',
"avatar" varchar(512) DEFAULT NULL COMMENT '频道头像',
"sort" int NOT NULL DEFAULT 0 COMMENT '排序',
"status" tinyint NOT NULL DEFAULT 0 COMMENT '状态0 启用 1 停用',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
) COMMENT 'IM 频道表';
CREATE TABLE IF NOT EXISTS "im_channel_material" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"channel_id" bigint NOT NULL COMMENT '频道编号',
"type" tinyint NOT NULL COMMENT '内容类型1 站内富文本 2 外链',
"title" varchar(128) NOT NULL COMMENT '标题',
"cover_url" varchar(512) DEFAULT NULL COMMENT '封面图',
"summary" varchar(255) DEFAULT NULL COMMENT '摘要',
"content" clob DEFAULT NULL COMMENT '正文;富文本 HTML',
"url" varchar(512) DEFAULT NULL COMMENT '跳转链接;为空时点击在客户端内置详情页拉 content非空则跳 url',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
) COMMENT 'IM 频道素材表';
CREATE TABLE IF NOT EXISTS "im_channel_message" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"channel_id" bigint NOT NULL COMMENT '频道编号;冗余 im_channel_material.channel_id 便于检索',
"material_id" bigint NOT NULL COMMENT '关联素材编号',
"type" smallint NOT NULL COMMENT '消息类型',
"content" varchar(8192) DEFAULT NULL COMMENT '消息内容;推送时 payload JSON 快照;不含富文本正文',
"receiver_user_ids" text DEFAULT NULL COMMENT '接收人编号列表;逗号分隔;为空表示全员',
"send_time" timestamp NOT NULL COMMENT '发送时间',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
) COMMENT 'IM 频道消息表';
CREATE TABLE IF NOT EXISTS "im_conversation_read" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"user_id" bigint NOT NULL COMMENT '用户编号',
"conversation_type" tinyint NOT NULL COMMENT '会话类型',
"target_id" bigint NOT NULL COMMENT '目标编号',
"message_id" bigint NOT NULL COMMENT '最大已读消息编号',
"read_time" timestamp NOT NULL COMMENT '最近已读时间',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id"),
CONSTRAINT "uk_im_conversation_read_user_target" UNIQUE ("user_id", "conversation_type", "target_id", "tenant_id")
) COMMENT 'IM 会话读位置表';
CREATE TABLE IF NOT EXISTS "im_sensitive_word" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
"word" varchar(128) NOT NULL COMMENT '敏感词',
"status" tinyint NOT NULL DEFAULT 0 COMMENT '状态0 启用 1 停用',
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id"),
CONSTRAINT "uk_im_sensitive_word" UNIQUE ("word", "tenant_id")
) COMMENT 'IM 敏感词表';
CREATE TABLE IF NOT EXISTS "system_users" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"username" varchar(30) NOT NULL DEFAULT '',
"password" varchar(100) NOT NULL DEFAULT '',
"nickname" varchar(30) NOT NULL DEFAULT '',
"remark" varchar(500) DEFAULT NULL,
"dept_id" bigint DEFAULT NULL,
"post_ids" varchar(255) DEFAULT NULL,
"email" varchar(50) DEFAULT '',
"mobile" varchar(11) DEFAULT '',
"sex" tinyint DEFAULT 0,
"avatar" varchar(100) DEFAULT '',
"status" tinyint NOT NULL DEFAULT 0,
"login_ip" varchar(50) DEFAULT '',
"login_date" timestamp DEFAULT NULL,
"creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '',
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
) COMMENT '用户信息表';