🎉 Initial commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 又作为 to;3 是无关用户
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) → 3;type=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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 但没有 silent(MyBatis-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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 返回 null,insert 抛 DuplicateKey,再次 select 返回已插入记录
|
||||
ImGroupMemberDO inserted = ImGroupMemberDO.builder().id(80L).groupId(10L).userId(1L)
|
||||
.status(CommonStatusEnum.ENABLE.getStatus()).build();
|
||||
when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null).thenReturn(inserted);
|
||||
when(groupMemberMapper.insert(any(ImGroupMemberDO.class)))
|
||||
.thenThrow(new DuplicateKeyException("concurrent insert"));
|
||||
|
||||
// 调用:冲突后降级 select
|
||||
ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(80L, result.getId());
|
||||
}
|
||||
|
||||
// ========== addGroupMembers ==========
|
||||
|
||||
@Test
|
||||
public void testAddGroupMembers_mixedInsertAndUpdate() {
|
||||
// 准备:用户 2 不存在(新增),用户 3 已存在且 DISABLE(恢复),用户 4 已存在且 ENABLE(跳过)
|
||||
ImGroupMemberDO exist3 = ImGroupMemberDO.builder().id(30L).groupId(10L).userId(3L)
|
||||
.status(CommonStatusEnum.DISABLE.getStatus()).build();
|
||||
ImGroupMemberDO exist4 = ImGroupMemberDO.builder().id(40L).groupId(10L).userId(4L)
|
||||
.status(CommonStatusEnum.ENABLE.getStatus()).build();
|
||||
when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection()))
|
||||
.thenReturn(ListUtil.of(exist3, exist4));
|
||||
|
||||
// 调用
|
||||
groupMemberService.addGroupMembers(10L, ListUtil.of(2L, 3L, 4L));
|
||||
|
||||
// 断言:updates 只有用户 3;inserts 只有用户 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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=5;mapper 返回更新行数 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)消息标记为已完成(DONE);status 不再写 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
// 断言:第二次复用 cache,mapper 仍只被调用 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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
15
yudao-module-im/src/test/resources/sql/clean.sql
Normal file
15
yudao-module-im/src/test/resources/sql/clean.sql
Normal 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";
|
||||
349
yudao-module-im/src/test/resources/sql/create_tables.sql
Normal file
349
yudao-module-im/src/test/resources/sql/create_tables.sql
Normal 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 '用户信息表';
|
||||
Reference in New Issue
Block a user