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()); } }