diff --git a/pom.xml b/pom.xml index d596e7177..6e5f65b3e 100644 --- a/pom.xml +++ b/pom.xml @@ -74,7 +74,7 @@ 1.7.3 - true + false @@ -113,6 +113,15 @@ + + + + org.springframework.boot + spring-boot-starter-test + test + + + @@ -508,8 +517,11 @@ ${maven-surefire-plugin.version} -Dfile.encoding=UTF-8 - - ${profiles.active} + exclude diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 8f2d51cdf..fcaf93b78 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -110,13 +110,6 @@ spring-boot-admin-starter-client - - - org.springframework.boot - spring-boot-starter-test - test - - diff --git a/ruoyi-admin/src/test/java/org/dromara/test/AssertUnitTest.java b/ruoyi-admin/src/test/java/org/dromara/test/AssertUnitTest.java deleted file mode 100644 index cfa4b7fed..000000000 --- a/ruoyi-admin/src/test/java/org/dromara/test/AssertUnitTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.dromara.test; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -/** - * 断言单元测试案例 - * - * @author Lion Li - */ -@DisplayName("断言单元测试案例") -public class AssertUnitTest { - - /** - * 验证相等与不相等断言,确保值比较语义清晰。 - */ - @DisplayName("测试 assertEquals 方法") - @Test - public void testAssertEquals() { - Assertions.assertEquals("666", new String("666")); - Assertions.assertNotEquals("666", "777"); - } - - /** - * 验证同一对象引用与不同对象引用的断言。 - */ - @DisplayName("测试 assertSame 方法") - @Test - public void testAssertSame() { - Object obj = new Object(); - Object obj1 = obj; - Object obj2 = new Object(); - Assertions.assertSame(obj, obj1); - Assertions.assertNotSame(obj, obj2); - } - - /** - * 验证布尔条件断言,覆盖 true 与 false 两类结果。 - */ - @DisplayName("测试 assertTrue 方法") - @Test - public void testAssertTrue() { - Assertions.assertTrue(true); - Assertions.assertFalse(false); - } - - /** - * 验证空值与非空值断言,避免空指针场景被误判。 - */ - @DisplayName("测试 assertNull 方法") - @Test - public void testAssertNull() { - Assertions.assertNull(null); - Assertions.assertNotNull("not null"); - } - -} diff --git a/ruoyi-admin/src/test/java/org/dromara/test/DemoUnitTest.java b/ruoyi-admin/src/test/java/org/dromara/test/DemoUnitTest.java deleted file mode 100644 index 283c31fab..000000000 --- a/ruoyi-admin/src/test/java/org/dromara/test/DemoUnitTest.java +++ /dev/null @@ -1,102 +0,0 @@ -package org.dromara.test; - -import org.dromara.common.web.config.properties.CaptchaProperties; -import org.junit.jupiter.api.*; - -import java.util.concurrent.TimeUnit; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * 单元测试基础案例。 - * - * @author Lion Li - */ -@DisplayName("单元测试案例") -public class DemoUnitTest { - - /** - * 所有测试执行前的初始化示例。 - */ - @BeforeAll - public static void testBeforeAll() { - System.out.println("@BeforeAll =================="); - } - - /** - * 所有测试执行后的清理示例。 - */ - @AfterAll - public static void testAfterAll() { - System.out.println("@AfterAll =================="); - } - - /** - * 验证普通 {@link Test} 与 {@link DisplayName} 注解的使用方式。 - */ - @DisplayName("测试 @Test @DisplayName 注解") - @Test - public void testTest() { - CaptchaProperties captchaProperties = new CaptchaProperties(); - captchaProperties.setEnable(Boolean.TRUE); - captchaProperties.setType("math"); - captchaProperties.setNumberLength(1); - captchaProperties.setCharLength(4); - - assertAll("验证码配置属性", - () -> assertTrue(captchaProperties.getEnable()), - () -> assertEquals("math", captchaProperties.getType()), - () -> assertEquals(1, captchaProperties.getNumberLength()), - () -> assertEquals(4, captchaProperties.getCharLength()) - ); - } - - /** - * 演示 {@link Disabled} 注解,保留一个不会被执行的测试占位。 - */ - @Disabled - @DisplayName("测试 @Disabled 注解") - @Test - public void testDisabled() { - fail("禁用测试不应被执行"); - } - - /** - * 验证 {@link Timeout} 注解在指定时间内可以正常通过。 - * - * @throws InterruptedException 线程等待被中断时抛出 - */ - @Timeout(value = 2L, unit = TimeUnit.SECONDS) - @DisplayName("测试 @Timeout 注解") - @Test - public void testTimeout() throws InterruptedException { - Thread.sleep(100); - assertTrue(true); - } - - /** - * 验证 {@link RepeatedTest} 注解会按指定次数重复执行。 - */ - @DisplayName("测试 @RepeatedTest 注解") - @RepeatedTest(3) - public void testRepeatedTest() { - assertDoesNotThrow(() -> Integer.parseInt("666")); - } - - /** - * 每个测试执行前的初始化示例。 - */ - @BeforeEach - public void testBeforeEach() { - System.out.println("@BeforeEach =================="); - } - - /** - * 每个测试执行后的清理示例。 - */ - @AfterEach - public void testAfterEach() { - System.out.println("@AfterEach =================="); - } - -} diff --git a/ruoyi-admin/src/test/java/org/dromara/test/ParamUnitTest.java b/ruoyi-admin/src/test/java/org/dromara/test/ParamUnitTest.java deleted file mode 100644 index 375fac712..000000000 --- a/ruoyi-admin/src/test/java/org/dromara/test/ParamUnitTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package org.dromara.test; - -import org.dromara.common.core.enums.UserType; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.NullSource; -import org.junit.jupiter.params.provider.ValueSource; - -import java.util.List; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * 带参数单元测试案例 - * - * @author Lion Li - */ -@DisplayName("带参数单元测试案例") -public class ParamUnitTest { - - /** - * 参数化测试共用的字符串样例。 - */ - private static final List TEST_VALUES = List.of("t1", "t2", "t3"); - - /** - * 提供 {@link MethodSource} 参数化测试数据。 - * - * @return 测试参数流 - */ - public static Stream getParam() { - return TEST_VALUES.stream(); - } - - /** - * 验证 {@link ValueSource} 能按固定字符串集合逐个传参。 - * - * @param str 当前参数值 - */ - @DisplayName("测试 @ValueSource 注解") - @ParameterizedTest - @ValueSource(strings = {"t1", "t2", "t3"}) - public void testValueSource(String str) { - assertTrue(TEST_VALUES.contains(str)); - } - - /** - * 验证 {@link NullSource} 能传入空值参数。 - * - * @param str 当前参数值 - */ - @DisplayName("测试 @NullSource 注解") - @ParameterizedTest - @NullSource - public void testNullSource(String str) { - assertNull(str); - } - - /** - * 验证 {@link EnumSource} 能遍历用户类型枚举。 - * - * @param type 当前用户类型 - */ - @DisplayName("测试 @EnumSource 注解") - @ParameterizedTest - @EnumSource(UserType.class) - public void testEnumSource(UserType type) { - assertNotNull(type); - assertFalse(type.getUserType().isBlank()); - } - - /** - * 验证 {@link MethodSource} 能读取方法提供的参数流。 - * - * @param str 当前参数值 - */ - @DisplayName("测试 @MethodSource 注解") - @ParameterizedTest - @MethodSource("getParam") - public void testMethodSource(String str) { - assertTrue(TEST_VALUES.contains(str)); - } - - /** - * 每个参数化测试执行前的初始化示例。 - */ - @BeforeEach - public void testBeforeEach() { - System.out.println("@BeforeEach =================="); - } - - /** - * 每个参数化测试执行后的清理示例。 - */ - @AfterEach - public void testAfterEach() { - System.out.println("@AfterEach =================="); - } - - -} diff --git a/ruoyi-admin/src/test/java/org/dromara/test/TagUnitTest.java b/ruoyi-admin/src/test/java/org/dromara/test/TagUnitTest.java deleted file mode 100644 index ec5670d59..000000000 --- a/ruoyi-admin/src/test/java/org/dromara/test/TagUnitTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.dromara.test; - -import org.junit.jupiter.api.*; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -/** - * 标签单元测试案例 - * - * @author Lion Li - */ -@DisplayName("标签单元测试案例") -public class TagUnitTest { - - /** - * 验证 dev 标签测试可以独立筛选执行。 - */ - @Tag("dev") - @DisplayName("测试 @Tag dev") - @Test - public void testTagDev() { - assertEquals("dev", "dev"); - } - - /** - * 验证 prod 标签测试可以独立筛选执行。 - */ - @Tag("prod") - @DisplayName("测试 @Tag prod") - @Test - public void testTagProd() { - assertEquals("prod", "prod"); - } - - /** - * 验证 local 标签测试可以独立筛选执行。 - */ - @Tag("local") - @DisplayName("测试 @Tag local") - @Test - public void testTagLocal() { - assertEquals("local", "local"); - } - - /** - * 验证 exclude 标签测试可以配合构建配置排除。 - */ - @Tag("exclude") - @DisplayName("测试 @Tag exclude") - @Test - public void testTagExclude() { - assertEquals("exclude", "exclude"); - } - - /** - * 每个标签测试执行前的初始化示例。 - */ - @BeforeEach - public void testBeforeEach() { - System.out.println("@BeforeEach =================="); - } - - /** - * 每个标签测试执行后的清理示例。 - */ - @AfterEach - public void testAfterEach() { - System.out.println("@AfterEach =================="); - } - - -} diff --git a/ruoyi-common/ruoyi-common-ai/src/test/java/org/dromara/common/ai/SnailAiConfigTest.java b/ruoyi-common/ruoyi-common-ai/src/test/java/org/dromara/common/ai/SnailAiConfigTest.java new file mode 100644 index 000000000..eefbb8722 --- /dev/null +++ b/ruoyi-common/ruoyi-common-ai/src/test/java/org/dromara/common/ai/SnailAiConfigTest.java @@ -0,0 +1,28 @@ +package org.dromara.common.ai; + +import org.dromara.common.ai.config.SnailAiConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@DisplayName("common-ai 功能单元测试") +class SnailAiConfigTest { + + /** + * 验证 AI 自动配置只在显式开启 snail-ai.enabled 时生效,避免默认启动外部 AI 客户端。 + */ + @Test + @DisplayName("声明 Snail AI 启用条件") + void shouldDeclareSnailAiEnablementCondition() { + ConditionalOnProperty condition = SnailAiConfig.class.getAnnotation(ConditionalOnProperty.class); + + assertNotNull(condition); + assertEquals("snail-ai", condition.prefix()); + assertArrayEquals(new String[]{"enabled"}, condition.name()); + assertEquals("true", condition.havingValue()); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java index 186cb6ee6..0f4f0752b 100644 --- a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java @@ -140,7 +140,7 @@ public class DateUtils extends DateUtil { // 未来时间或非今年 if (date.after(now) || year(date) != year(now)) { - return formatDateTime(now); + return formatDateTime(date); } // 今天 diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/sql/SqlUtil.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/sql/SqlUtil.java index c0a5a6f79..8b2d0b952 100644 --- a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/sql/SqlUtil.java +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/sql/SqlUtil.java @@ -5,6 +5,8 @@ import lombok.AccessLevel; import lombok.NoArgsConstructor; import org.dromara.common.core.utils.StringUtils; +import java.util.Locale; + /** * sql操作工具类 * @@ -55,7 +57,7 @@ public class SqlUtil { } // ==================== 原有逻辑不变 ==================== - String normalizedValue = value.replaceAll("\\p{Z}|\\s", ""); + String normalizedValue = value.replaceAll("[\\p{Z}\\s]+", " ").toLowerCase(Locale.ROOT); String[] sqlKeywords = StringUtils.split(SQL_REGEX, "\\|"); for (String sqlKeyword : sqlKeywords) { if (StringUtils.indexOf(normalizedValue, sqlKeyword) > -1) { diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/domain/ResponseModelTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/domain/ResponseModelTest.java new file mode 100644 index 000000000..c37f2f242 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/domain/ResponseModelTest.java @@ -0,0 +1,72 @@ +package org.dromara.common.core.domain; + +import org.dromara.common.core.constant.HttpStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("公共响应模型单元测试") +class ResponseModelTest { + + /** + * 验证响应工厂方法设置正确的状态码、消息和业务数据。 + */ + @Test + @DisplayName("构建成功、失败和警告响应") + void responseFactoriesShouldSetExpectedFields() { + R success = R.ok("done", "payload"); + R failure = R.fail("failed", "payload"); + R warning = R.warn("warning", "payload"); + + assertAll( + () -> assertEquals(HttpStatus.SUCCESS, success.getCode()), + () -> assertEquals("done", success.getMsg()), + () -> assertEquals("payload", success.getData()), + () -> assertEquals(HttpStatus.ERROR, failure.getCode()), + () -> assertEquals(HttpStatus.WARN, warning.getCode()) + ); + } + + /** + * 验证成功状态判断能够处理失败响应和空响应。 + */ + @Test + @DisplayName("正确识别响应成功状态") + void responseStatusChecksShouldHandleNullAndError() { + assertTrue(R.isSuccess(R.ok())); + assertFalse(R.isSuccess(R.fail())); + assertFalse(R.isSuccess(null)); + assertTrue(R.isError(null)); + } + + /** + * 验证仅传集合时分页总数使用集合实际大小。 + */ + @Test + @DisplayName("分页结果按集合大小计算总数") + void pageResultShouldUseCollectionSizeAsTotal() { + PageResult result = PageResult.build(List.of("a", "b")); + + assertEquals(2L, result.getTotal()); + assertEquals(List.of("a", "b"), result.getRows()); + } + + /** + * 验证空行集合会被标准化为空列表,避免调用方判空。 + */ + @Test + @DisplayName("分页结果将空集合参数转换为空列表") + void pageResultShouldNormalizeNullRows() { + PageResult result = PageResult.build(null, 10L); + PageResult constructed = new PageResult<>(null, 5L); + + assertNotNull(result.getRows()); + assertTrue(result.getRows().isEmpty()); + assertTrue(constructed.getRows().isEmpty()); + assertEquals(10L, result.getTotal()); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/enums/BusinessStatusEnumTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/enums/BusinessStatusEnumTest.java new file mode 100644 index 000000000..b1b3ea8c7 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/enums/BusinessStatusEnumTest.java @@ -0,0 +1,77 @@ +package org.dromara.common.core.enums; + +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("BusinessStatusEnum 单元测试") +class BusinessStatusEnumTest { + + /** + * 验证状态码查询、描述查询以及运行中和已结束状态集合。 + */ + @Test + @DisplayName("查询业务状态") + void shouldResolveBusinessStatuses() { + assertEquals(BusinessStatusEnum.DRAFT, BusinessStatusEnum.getByStatus("draft")); + assertNull(BusinessStatusEnum.getByStatus("unknown")); + assertEquals("已完成", BusinessStatusEnum.findByStatus("finish")); + assertEquals("", BusinessStatusEnum.findByStatus(" ")); + assertEquals(List.of("draft", "waiting", "back", "cancel"), BusinessStatusEnum.runningStatus()); + assertEquals(List.of("finish", "invalid", "termination"), BusinessStatusEnum.finishStatus()); + } + + /** + * 验证流程状态分类能够正确识别可重新发起状态和终止类状态。 + */ + @Test + @DisplayName("分类业务状态") + void shouldClassifyBusinessStatuses() { + assertTrue(BusinessStatusEnum.isDraftOrCancelOrBack("draft")); + assertTrue(BusinessStatusEnum.isDraftOrCancelOrBack("cancel")); + assertTrue(BusinessStatusEnum.isDraftOrCancelOrBack("back")); + assertFalse(BusinessStatusEnum.isDraftOrCancelOrBack("waiting")); + assertTrue(BusinessStatusEnum.initialState("invalid")); + assertTrue(BusinessStatusEnum.initialState("termination")); + assertFalse(BusinessStatusEnum.initialState("waiting")); + } + + /** + * 验证启动流程仅允许草稿、撤销和退回等可发起状态,并为禁止状态返回业务异常。 + */ + @Test + @DisplayName("校验流程启动状态") + void shouldValidateStartStatus() { + assertDoesNotThrow(() -> BusinessStatusEnum.checkStartStatus("draft")); + assertEquals("该单据已提交过申请,正在审批中!", + assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkStartStatus("waiting")).getMessage()); + assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkStartStatus("finish")); + assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkStartStatus("invalid")); + assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkStartStatus("termination")); + assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkStartStatus(" ")); + } + + /** + * 验证撤销、退回及作废校验分别拒绝所有已结束或重复操作状态。 + */ + @Test + @DisplayName("校验流程变更状态") + void shouldValidateCancelBackAndInvalidStatuses() { + assertDoesNotThrow(() -> BusinessStatusEnum.checkCancelStatus("waiting")); + for (String status : List.of("cancel", "finish", "invalid", "termination", "back", " ")) { + assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkCancelStatus(status)); + } + assertDoesNotThrow(() -> BusinessStatusEnum.checkBackStatus("waiting")); + for (String status : List.of("back", "finish", "invalid", "termination", "cancel", " ")) { + assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkBackStatus(status)); + } + assertDoesNotThrow(() -> BusinessStatusEnum.checkInvalidStatus("waiting")); + for (String status : List.of("finish", "invalid", "termination", " ")) { + assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkInvalidStatus(status)); + } + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/factory/YmlPropertySourceFactoryTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/factory/YmlPropertySourceFactoryTest.java new file mode 100644 index 000000000..924fbb805 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/factory/YmlPropertySourceFactoryTest.java @@ -0,0 +1,67 @@ +package org.dromara.common.core.factory; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.support.EncodedResource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DisplayName("YmlPropertySourceFactory 单元测试") +class YmlPropertySourceFactoryTest { + + /** + * 验证公共配置源工厂可以将 YAML 层级结构展开为 Spring 属性键。 + * + * @throws IOException 配置资源读取失败 + */ + @Test + @DisplayName("解析 YAML 配置资源") + void shouldLoadYamlAsProperties() throws IOException { + ByteArrayResource resource = namedResource("common-test.yml", "feature:\n enabled: true\n timeout: 30\n"); + + PropertySource source = new YmlPropertySourceFactory() + .createPropertySource(null, new EncodedResource(resource, StandardCharsets.UTF_8)); + + assertEquals("common-test.yml", source.getName()); + assertEquals(true, source.getProperty("feature.enabled")); + assertEquals(30, source.getProperty("feature.timeout")); + } + + /** + * 验证非 YAML 资源仍委托 Spring 默认逻辑解析,避免公共工厂破坏 properties 配置。 + * + * @throws IOException 配置资源读取失败 + */ + @Test + @DisplayName("回退解析 properties 配置资源") + void shouldDelegatePropertiesResourcesToSpring() throws IOException { + ByteArrayResource resource = namedResource("common-test.properties", "feature.mode=strict\n"); + + PropertySource source = new YmlPropertySourceFactory() + .createPropertySource("fallback", new EncodedResource(resource, StandardCharsets.UTF_8)); + + assertEquals("fallback", source.getName()); + assertEquals("strict", source.getProperty("feature.mode")); + } + + /** + * 创建具有稳定文件名的内存资源,以触发配置源工厂对应的扩展名分支。 + * + * @param filename 资源文件名 + * @param content 资源内容 + * @return 命名内存资源 + */ + private static ByteArrayResource namedResource(String filename, String content) { + return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) { + @Override + public String getFilename() { + return filename; + } + }; + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/CoreFrameworkUtilityContractTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/CoreFrameworkUtilityContractTest.java new file mode 100644 index 000000000..b64e7d5ab --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/CoreFrameworkUtilityContractTest.java @@ -0,0 +1,251 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.lang.Dict; +import cn.hutool.extra.spring.SpringUtil; +import io.github.linpeilie.Converter; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validator; +import org.dromara.common.core.utils.reflect.AnnotationUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.context.support.StaticMessageSource; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@DisplayName("common-core 框架工具契约单元测试") +class CoreFrameworkUtilityContractTest { + + private static Converter converter; + private static Validator validator; + private static StaticMessageSource messageSource; + + /** + * 初始化框架工具静态依赖的最小 Spring 容器,确保测试不需要启动完整应用。 + */ + @BeforeAll + static void initializeFrameworkUtilities() { + converter = mock(Converter.class); + validator = mock(Validator.class); + StaticApplicationContext context = new StaticApplicationContext(); + context.getBeanFactory().registerSingleton("converter", converter); + context.getBeanFactory().registerSingleton("validator", validator); + messageSource = context.getStaticMessageSource(); + messageSource.addMessage("welcome", Locale.SIMPLIFIED_CHINESE, "欢迎 {0}"); + context.refresh(); + new SpringUtil().setApplicationContext(context); + } + + /** + * 清理线程语言环境和 mock 调用记录,避免测试之间共享状态。 + */ + @AfterEach + void resetFrameworkState() { + LocaleContextHolder.resetLocaleContext(); + reset(converter, validator); + } + + /** + * 验证 Mapstruct Plus 的对象转换和目标对象填充均委托给框架 Converter。 + */ + @Test + @DisplayName("委托对象转换和目标填充") + void shouldDelegateObjectConversions() { + Source source = new Source("alice"); + Destination converted = new Destination("converted"); + Destination target = new Destination("existing"); + when(converter.convert(source, Destination.class)).thenReturn(converted); + when(converter.convert(source, target)).thenReturn(target); + + assertSame(converted, MapstructUtils.convert(source, Destination.class)); + assertSame(target, MapstructUtils.convert(source, target)); + verify(converter).convert(source, Destination.class); + verify(converter).convert(source, target); + } + + /** + * 验证对象转换在来源或目标为空时直接返回空值,不误调用底层 Converter。 + */ + @Test + @DisplayName("短路空对象转换") + void shouldShortCircuitNullObjectConversions() { + Source source = new Source("alice"); + + assertNull(MapstructUtils.convert((Source) null, Destination.class)); + assertNull(MapstructUtils.convert(source, (Class) null)); + assertNull(MapstructUtils.convert(source, (Destination) null)); + assertNull(MapstructUtils.convert((Source) null, new Destination("existing"))); + verifyNoInteractions(converter); + } + + /** + * 验证列表转换保留 null、空列表和普通列表各自约定的返回语义。 + */ + @Test + @DisplayName("转换列表并处理空输入") + void shouldConvertListsAndHandleEmptyInputs() { + List sources = List.of(new Source("alice")); + List targets = List.of(new Destination("converted")); + when(converter.convert(sources, Destination.class)).thenReturn(targets); + + assertNull(MapstructUtils.convert((List) null, Destination.class)); + assertEquals(List.of(), MapstructUtils.convert(List.of(), Destination.class)); + assertSame(targets, MapstructUtils.convert(sources, Destination.class)); + verify(converter).convert(sources, Destination.class); + } + + /** + * 验证 Map 转 Bean 仅在数据和目标类型有效时调用 Mapstruct Plus。 + */ + @Test + @DisplayName("转换 Map 并处理无效输入") + void shouldConvertMapsAndHandleInvalidInputs() { + Map source = Map.of("name", "alice"); + Destination target = new Destination("converted"); + when(converter.convert(source, Destination.class)).thenReturn(target); + + assertNull(MapstructUtils.convert((Map) null, Destination.class)); + assertNull(MapstructUtils.convert(Map.of(), Destination.class)); + assertNull(MapstructUtils.convert(source, null)); + assertSame(target, MapstructUtils.convert(source, Destination.class)); + verify(converter).convert(source, Destination.class); + } + + /** + * 验证安全 Getter 只在对象和函数均有效时求值,并在无法求值时返回约定默认值。 + */ + @Test + @DisplayName("安全读取对象属性") + void shouldReadObjectPropertiesSafely() { + Source source = new Source("alice"); + + assertEquals("alice", ObjectUtils.notNullGetter(source, Source::name)); + assertNull(ObjectUtils.notNullGetter(null, Source::name)); + assertNull(ObjectUtils.notNullGetter(source, null)); + assertEquals("fallback", ObjectUtils.notNullGetter(null, Source::name, "fallback")); + assertEquals("fallback", ObjectUtils.notNullGetter(source, null, "fallback")); + assertEquals("alice", ObjectUtils.notNullGetter(source, Source::name, "fallback")); + assertEquals("alice", ObjectUtils.notNull("alice", "fallback")); + assertEquals("fallback", ObjectUtils.notNull(null, "fallback")); + } + + /** + * 验证 Bean Validation 在无约束违规时正常返回并传递指定校验组。 + */ + @Test + @DisplayName("通过 Bean Validation 校验有效对象") + void shouldValidateObjectWithRequestedGroups() { + Source source = new Source("alice"); + when(validator.validate(source, ValidationGroup.class)).thenReturn(Set.of()); + + assertDoesNotThrow(() -> ValidatorUtils.validate(source, ValidationGroup.class)); + verify(validator).validate(source, ValidationGroup.class); + } + + /** + * 验证 Bean Validation 对空对象和约束违规分别抛出框架约定的异常。 + */ + @Test + @DisplayName("报告空对象和约束违规") + void shouldReportNullObjectAndConstraintViolations() { + RuntimeException nullException = assertThrows(RuntimeException.class, + () -> ValidatorUtils.validate(null)); + @SuppressWarnings("unchecked") + ConstraintViolation violation = mock(ConstraintViolation.class); + Source source = new Source(""); + when(validator.validate(source)).thenReturn(Set.of(violation)); + + ConstraintViolationException validationException = assertThrows(ConstraintViolationException.class, + () -> ValidatorUtils.validate(source)); + + assertEquals("请求参数不能为空", nullException.getMessage()); + assertEquals("参数校验异常", validationException.getMessage()); + assertEquals(Set.of(violation), validationException.getConstraintViolations()); + } + + /** + * 验证国际化工具使用线程当前语言环境和消息参数查询 MessageSource。 + */ + @Test + @DisplayName("按当前语言环境解析国际化消息") + void shouldResolveMessageUsingCurrentLocale() { + Locale locale = Locale.SIMPLIFIED_CHINESE; + LocaleContextHolder.setLocale(locale); + assertEquals("欢迎 alice", MessageUtils.message("welcome", "alice")); + } + + /** + * 验证缺少国际化资源时返回消息键,保持调用方可读的降级结果。 + */ + @Test + @DisplayName("缺少国际化资源时返回消息键") + void shouldFallbackToCodeWhenMessageIsMissing() { + assertEquals("missing.code", MessageUtils.message("missing.code")); + } + + /** + * 验证按注解全类名读取注解实例及属性字典,防止 Hutool 升级改变反射结果。 + */ + @Test + @DisplayName("按全类名读取注解和属性") + void shouldReadAnnotationAndValuesByClassName() { + String annotationName = ContractMarker.class.getName(); + + ContractMarker annotation = assertInstanceOf(ContractMarker.class, + AnnotationUtils.getAnnotation(AnnotatedType.class, annotationName)); + Dict values = AnnotationUtils.getAnnotationValueMap(AnnotatedType.class, annotationName); + + assertEquals("core", annotation.value()); + assertEquals(3, annotation.level()); + assertNotNull(values); + assertEquals("core", values.getStr("value")); + assertEquals(3, values.getInt("level")); + } + + /** + * 验证注解类不存在或目标元素未标注时返回空值,不把反射细节泄漏给调用方。 + */ + @Test + @DisplayName("处理缺失注解类型和未标注元素") + void shouldHandleMissingAnnotationTypesAndValues() { + assertNull(AnnotationUtils.getAnnotation(String.class, ContractMarker.class.getName())); + assertNull(AnnotationUtils.getAnnotation(AnnotatedType.class, "missing.Annotation")); + assertNull(AnnotationUtils.getAnnotationValueMap(AnnotatedType.class, "missing.Annotation")); + } + + private interface ValidationGroup { + } + + private record Source(String name) { + } + + private record Destination(String name) { + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + private @interface ContractMarker { + + String value(); + + int level(); + } + + @ContractMarker(value = "core", level = 3) + private static final class AnnotatedType { + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/CoreInfrastructureContractTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/CoreInfrastructureContractTest.java new file mode 100644 index 000000000..61fcc5c54 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/CoreInfrastructureContractTest.java @@ -0,0 +1,133 @@ +package org.dromara.common.core.utils; + +import jakarta.validation.ConstraintValidatorContext; +import cn.hutool.extra.spring.SpringUtil; +import org.dromara.common.core.service.DictService; +import org.dromara.common.core.validate.dicts.DictPattern; +import org.dromara.common.core.validate.dicts.DictPatternValidator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +@DisplayName("common-core 基础设施契约单元测试") +class CoreInfrastructureContractTest { + + /** + * 清理线程绑定的请求上下文,避免测试之间共享 Servlet 状态。 + */ + @AfterEach + void resetRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + + /** + * 验证 Servlet 工具从 Spring 请求上下文读取参数、响应和会话,并保持类型转换约定。 + */ + @Test + @DisplayName("读取线程绑定的 Servlet 上下文") + void shouldReadRequestContextAndConvertParameters() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.setParameter("name", "alice"); + request.setParameter("age", "18"); + request.setParameter("enabled", "true"); + request.setParameter("roles", "admin", "user"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + + assertSame(request, ServletUtils.getRequest()); + assertSame(response, ServletUtils.getResponse()); + assertEquals("alice", ServletUtils.getParameter("name")); + assertEquals("fallback", ServletUtils.getParameter("missing", "fallback")); + assertEquals(18, ServletUtils.getParameterToInt("age")); + assertTrue(ServletUtils.getParameterToBool("enabled")); + assertEquals("admin,user", ServletUtils.getParamMap(request).get("roles")); + assertSame(request.getSession(), ServletUtils.getSession()); + } + + /** + * 验证请求头解码、Ajax 识别、代理 IP 解析和 JSON 响应渲染保持稳定。 + */ + @Test + @DisplayName("处理常用 HTTP 协议细节") + void shouldHandleHeadersAjaxClientIpAndJsonRendering() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/report.json"); + request.addHeader("X-Name", ServletUtils.urlEncode("中文 value")); + request.addHeader("X-Forwarded-For", "[2001:db8::1]"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertEquals("中文 value", ServletUtils.getHeader(request, "X-Name")); + assertEquals("", ServletUtils.getHeader(request, "missing")); + assertEquals(ServletUtils.getHeaders(request).get("x-name"), request.getHeader("X-Name")); + assertTrue(ServletUtils.isAjaxRequest(request)); + assertEquals("2001:db8::1", ServletUtils.getClientIP(request)); + + ServletUtils.renderString(response, "{\"ok\":true}"); + + assertEquals(200, response.getStatus()); + assertTrue(response.getContentType().startsWith("application/json")); + assertEquals("{\"ok\":true}", response.getContentAsString()); + } + + /** + * 验证虚拟线程批量执行保持提交顺序,并把任务异常的真实原因传递给调用方。 + */ + @Test + @DisplayName("批量执行虚拟线程任务") + void shouldPreserveVirtualTaskOrderAndFailureCause() { + List results = ThreadUtils.virtualSubmitAll( + () -> 1, + () -> 2, + () -> 3); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> ThreadUtils.virtualInvokeAll(() -> { + throw new IllegalStateException("task-failed"); + })); + + assertEquals(List.of(1, 2, 3), results); + assertInstanceOf(IllegalStateException.class, exception.getCause()); + assertTrue(exception.getMessage().contains("task-failed")); + } + + /** + * 验证字典校验器按注解分隔符调用字典服务,并正确处理空值、缺失类型和未知字典值。 + */ + @Test + @DisplayName("通过字典服务校验字段值") + void shouldValidateDictionaryValuesThroughConfiguredService() { + DictPattern annotation = mock(DictPattern.class); + when(annotation.dictType()).thenReturn("sys_status"); + when(annotation.separator()).thenReturn("|"); + DictService dictService = mock(DictService.class); + when(dictService.getDictLabel("sys_status", "0|1", "|")).thenReturn("正常|停用"); + when(dictService.getDictLabel("sys_status", "9", "|")).thenReturn(""); + DictPatternValidator validator = new DictPatternValidator(); + validator.initialize(annotation); + + try (MockedStatic spring = mockStatic(SpringUtil.class)) { + spring.when(() -> SpringUtil.getBean(DictService.class)).thenReturn(dictService); + + assertTrue(validator.isValid(null, mock(ConstraintValidatorContext.class))); + assertTrue(validator.isValid("0|1", null)); + assertFalse(validator.isValid("9", null)); + } + + DictPattern invalidAnnotation = mock(DictPattern.class); + when(invalidAnnotation.dictType()).thenReturn(" "); + DictPatternValidator invalidValidator = new DictPatternValidator(); + invalidValidator.initialize(invalidAnnotation); + assertFalse(invalidValidator.isValid("0", null)); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/CoreUtilityBoundaryTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/CoreUtilityBoundaryTest.java new file mode 100644 index 000000000..b3260d2ea --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/CoreUtilityBoundaryTest.java @@ -0,0 +1,156 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.exceptions.ValidateException; +import jakarta.servlet.http.HttpServletResponse; +import org.dromara.common.core.utils.file.FileUtils; +import org.dromara.common.core.utils.file.MimeTypeUtils; +import org.dromara.common.core.utils.reflect.ReflectUtils; +import org.dromara.common.core.utils.regex.RegexUtils; +import org.dromara.common.core.utils.regex.RegexValidator; +import org.dromara.common.core.xss.XssValidator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@DisplayName("common-core 公共工具边界单元测试") +class CoreUtilityBoundaryTest { + + /** + * 验证下载文件名对中文、空格和加号执行 URL 编码,并同步写入浏览器可读取的响应头。 + */ + @Test + @DisplayName("编码并设置下载文件名响应头") + void shouldEncodeAndExposeAttachmentFileName() { + HttpServletResponse response = mock(HttpServletResponse.class); + String encoded = "%E6%B5%8B%E8%AF%95%20report%2B1.xlsx"; + + assertEquals(encoded, FileUtils.percentEncode("测试 report+1.xlsx")); + + FileUtils.setAttachmentResponseHeader(response, "测试 report+1.xlsx"); + + verify(response).addHeader("Access-Control-Expose-Headers", "Content-Disposition,download-filename"); + verify(response).setHeader("Content-disposition", + "attachment; filename=" + encoded + ";filename*=utf-8''" + encoded); + verify(response).setHeader("download-filename", encoded); + } + + /** + * 验证上传文件类型判断忽略扩展名大小写,同时拒绝未列入白名单的可执行文件。 + */ + @Test + @DisplayName("识别允许的文件扩展名") + void shouldRecognizeAllowedFileExtensionsCaseInsensitively() { + assertTrue(MimeTypeUtils.isImage("JpEg")); + assertTrue(MimeTypeUtils.isVideo("MP4")); + assertTrue(MimeTypeUtils.isMedia("Mp3")); + assertTrue(MimeTypeUtils.isDefaultAllowed("PDF")); + assertFalse(MimeTypeUtils.isDefaultAllowed("exe")); + assertFalse(MimeTypeUtils.isImage(null)); + } + + /** + * 验证正则提取失败时稳定回退默认值,并校验账号与状态的有效边界。 + */ + @Test + @DisplayName("处理正则提取与业务格式边界") + void shouldHandleRegexExtractionAndValidationBoundaries() { + assertEquals("42", RegexUtils.extractFromString("order-42", "order-(\\d+)", "none")); + assertEquals("none", RegexUtils.extractFromString("missing", "order-(\\d+)", "none")); + assertEquals("none", RegexUtils.extractFromString("order-42", "([", "none")); + + assertTrue(RegexValidator.isAccount("user_1")); + assertFalse(RegexValidator.isAccount("1user")); + assertFalse(RegexValidator.isAccount("user")); + assertTrue(RegexValidator.isStatus("0")); + assertTrue(RegexValidator.isStatus("1")); + assertFalse(RegexValidator.isStatus("2")); + ValidateException exception = assertThrows(ValidateException.class, + () -> RegexValidator.validateAccount("bad", "账号格式错误")); + assertEquals("账号格式错误", exception.getMessage()); + } + + /** + * 验证反射工具可以沿 JavaBean 属性路径读取和修改嵌套对象,保障 Excel 等调用方的字段访问。 + */ + @Test + @DisplayName("读写嵌套 JavaBean 属性") + void shouldReadAndWriteNestedBeanProperties() { + TestRoot root = new TestRoot(new TestChild("before")); + + assertEquals("before", ReflectUtils.invokeGetter(root, "child.name")); + + ReflectUtils.invokeSetter(root, "child.name", "after"); + + assertEquals("after", root.getChild().getName()); + } + + /** + * 验证 XSS 校验允许空值和普通文本,并拒绝任意 HTML 标签输入。 + */ + @Test + @DisplayName("拒绝包含 HTML 标签的文本") + void shouldRejectHtmlMarkup() { + XssValidator validator = new XssValidator(); + + assertTrue(validator.isValid(null, null)); + assertTrue(validator.isValid("plain text", null)); + assertFalse(validator.isValid("", null)); + assertFalse(validator.isValid("hello world", null)); + } + + private static class TestRoot { + private final TestChild child; + + /** + * 创建带子对象的测试根对象。 + * + * @param child 子对象 + */ + private TestRoot(TestChild child) { + this.child = child; + } + + /** + * 返回子对象,供嵌套反射路径访问。 + * + * @return 子对象 + */ + public TestChild getChild() { + return child; + } + } + + private static class TestChild { + private String name; + + /** + * 创建具有初始名称的测试子对象。 + * + * @param name 初始名称 + */ + private TestChild(String name) { + this.name = name; + } + + /** + * 返回名称,供嵌套反射路径读取。 + * + * @return 名称 + */ + public String getName() { + return name; + } + + /** + * 修改名称,供嵌套反射路径写入。 + * + * @param name 新名称 + */ + public void setName(String name) { + this.name = name; + } + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/DateNetDesensitizedUtilsTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/DateNetDesensitizedUtilsTest.java new file mode 100644 index 000000000..ada464539 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/DateNetDesensitizedUtilsTest.java @@ -0,0 +1,114 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.date.DateUtil; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("日期、网络与脱敏工具单元测试") +class DateNetDesensitizedUtilsTest { + + /** + * 验证时间差可以忽略参数顺序并按秒精度格式化。 + */ + @Test + @DisplayName("格式化时间差") + void shouldFormatTimeDifferenceBySecond() { + Date start = new Date(0); + Date end = new Date(3_661_000); + + assertEquals("1小时1分1秒", DateUtils.formatBetweenBySecond(start, end)); + assertEquals("1小时1分1秒", DateUtils.formatBetweenBySecond(end, start)); + } + + /** + * 验证日期范围接受边界值,并拒绝倒序、超限和不支持的单位。 + */ + @Test + @DisplayName("校验日期范围") + void shouldValidateDateRangeAndUnits() { + Date start = DateUtil.parse("2026-01-01 00:00:00"); + Date end = DateUtil.parse("2026-01-03 00:00:00"); + + assertDoesNotThrow(() -> DateUtils.validateDateRange(start, end, 2, TimeUnit.DAYS)); + assertThrows(ServiceException.class, () -> DateUtils.validateDateRange(end, start, 2, TimeUnit.DAYS)); + assertThrows(ServiceException.class, () -> DateUtils.validateDateRange(start, end, 1, TimeUnit.DAYS)); + assertThrows(IllegalArgumentException.class, + () -> DateUtils.validateDateRange(start, end, 1, TimeUnit.SECONDS)); + } + + /** + * 验证一天内各小时会映射到凌晨、上午、中午、下午和晚上。 + */ + @Test + @DisplayName("识别当天时间段") + void shouldResolveTodayPeriod() { + assertEquals("凌晨", DateUtils.getTodayHour(DateUtil.parse("2026-01-01 06:00:00"))); + assertEquals("上午", DateUtils.getTodayHour(DateUtil.parse("2026-01-01 09:00:00"))); + assertEquals("中午", DateUtils.getTodayHour(DateUtil.parse("2026-01-01 12:00:00"))); + assertEquals("下午", DateUtils.getTodayHour(DateUtil.parse("2026-01-01 15:00:00"))); + assertEquals("晚上", DateUtils.getTodayHour(DateUtil.parse("2026-01-01 20:00:00"))); + } + + /** + * 验证友好时间处理空值、刚刚、分钟前以及未来时间,并确保未来时间使用目标日期。 + */ + @Test + @DisplayName("格式化友好时间") + void shouldFormatFriendlyTimeUsingTargetDate() { + Date now = new Date(); + Date future = DateUtil.offsetDay(now, 2); + + assertEquals("", DateUtils.formatFriendlyTime(null)); + assertEquals("刚刚", DateUtils.formatFriendlyTime(DateUtil.offsetSecond(now, -10))); + assertTrue(DateUtils.formatFriendlyTime(DateUtil.offsetMinute(now, -5)).endsWith("分钟前")); + assertEquals(DateUtils.formatDateTime(future), DateUtils.formatFriendlyTime(future)); + } + + /** + * 验证 IPv4、IPv6、精确地址、通配符和 CIDR 规则的匹配结果。 + */ + @Test + @DisplayName("匹配 IP 地址规则") + void shouldMatchIpAddressRules() { + assertTrue(NetUtils.isIPv4("192.168.1.1")); + assertFalse(NetUtils.isIPv4("999.1.1.1")); + assertTrue(NetUtils.isIPv6("::1")); + assertTrue(NetUtils.isInnerIPv6("::1")); + assertTrue(NetUtils.isMatchIpRule("192.168.1.10", "192.168.1.10")); + assertTrue(NetUtils.isMatchIpRule("192.168.*.?", "192.168.1.8")); + assertTrue(NetUtils.isMatchIpRule("10.0.0.0/8", "10.20.30.40")); + assertFalse(NetUtils.isMatchIpRule("10.0.0.0/8", "11.20.30.40")); + assertFalse(NetUtils.isMatchCidr("10.0.0.0/99", "10.0.0.1")); + assertFalse(NetUtils.isMatchIpRule(" ", "10.0.0.1")); + } + + /** + * 验证普通脱敏在短值、临界值和标准长度下应用固定掩码规则。 + */ + @Test + @DisplayName("应用固定长度脱敏") + void shouldMaskValuesWithFixedLength() { + assertNull(DesensitizedUtils.mask(null, 2, 2, 4)); + assertEquals("***", DesensitizedUtils.mask("abc", 2, 2, 4)); + assertEquals("ab****", DesensitizedUtils.mask("abcdef", 2, 2, 4)); + assertEquals("ab****g", DesensitizedUtils.mask("abcdefg", 2, 2, 4)); + assertEquals("ab****ij", DesensitizedUtils.mask("abcdefghij", 2, 2, 4)); + } + + /** + * 验证高安全脱敏对短 Token 全掩码,并在长 Token 中仅保留指定首尾字符。 + */ + @Test + @DisplayName("应用高安全脱敏") + void shouldMaskHighSecurityValues() { + assertEquals("***", DesensitizedUtils.maskHighSecurity("abc", 3, 2)); + assertEquals("ab***", DesensitizedUtils.maskHighSecurity("abcde", 3, 2)); + assertEquals("ab******ij", DesensitizedUtils.maskHighSecurity("abcdefghij", 2, 2)); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/SqlUtilTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/SqlUtilTest.java new file mode 100644 index 000000000..714cfe244 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/SqlUtilTest.java @@ -0,0 +1,54 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.exceptions.UtilException; +import org.dromara.common.core.utils.sql.SqlUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("SqlUtil 单元测试") +class SqlUtilTest { + + /** + * 验证合法字段、逗号和表字段表达式可以通过排序校验。 + */ + @Test + @DisplayName("接受合法的多字段排序表达式") + void escapeOrderBySqlShouldAcceptSafeColumns() { + assertEquals("user_name,create_time", SqlUtil.escapeOrderBySql("user_name,create_time")); + assertTrue(SqlUtil.isValidOrderBySql("table_name.column_name")); + } + + /** + * 验证包含 SQL 分隔符等非法字符的排序参数会被拒绝。 + */ + @Test + @DisplayName("拒绝包含非法字符的排序表达式") + void escapeOrderBySqlShouldRejectUnsafeCharacters() { + assertThrows(IllegalArgumentException.class, + () -> SqlUtil.escapeOrderBySql("create_time desc;drop table sys_user")); + } + + /** + * 验证单引号、关键字及大小写空白变体无法绕过过滤。 + */ + @Test + @DisplayName("拒绝单引号和 SQL 敏感关键词") + void filterKeywordShouldRejectRiskyInput() { + assertThrows(UtilException.class, () -> SqlUtil.filterKeyword("name='admin'")); + assertThrows(UtilException.class, () -> SqlUtil.filterKeyword("union select password")); + assertThrows(UtilException.class, () -> SqlUtil.filterKeyword("UNION\tSELECT password")); + } + + /** + * 验证普通业务文本和空值不会被误判为 SQL 注入。 + */ + @Test + @DisplayName("允许普通业务查询文本") + void filterKeywordShouldAcceptRegularText() { + assertDoesNotThrow(() -> SqlUtil.filterKeyword("normal-business-value")); + assertDoesNotThrow(() -> SqlUtil.filterKeyword(null)); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/StreamUtilsTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/StreamUtilsTest.java new file mode 100644 index 000000000..bbfe4dd46 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/StreamUtilsTest.java @@ -0,0 +1,102 @@ +package org.dromara.common.core.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("StreamUtils 单元测试") +class StreamUtilsTest { + + /** + * 验证过滤结果保持可修改,并对空输入返回空集合。 + */ + @Test + @DisplayName("过滤结果可修改且空集合返回空列表") + void filterShouldReturnMutableListAndHandleEmptyInput() { + List result = StreamUtils.filter(List.of(1, 2, 3), value -> value % 2 == 1); + + assertEquals(List.of(1, 3), result); + assertDoesNotThrow(() -> result.add(5)); + assertTrue(StreamUtils.filter(null, value -> true).isEmpty()); + } + + /** + * 验证集合拼接会跳过映射函数产生的空值。 + */ + @Test + @DisplayName("拼接时忽略映射结果中的空值") + void joinShouldIgnoreNullMappedValues() { + String result = StreamUtils.join(List.of("first", "skip", "last"), + value -> "skip".equals(value) ? null : value, "|"); + + assertEquals("first|last", result); + assertEquals(StringUtils.EMPTY, StreamUtils.join(List.of(), value -> value)); + } + + /** + * 验证集合转 Map 时的空元素过滤和重复键处理规则。 + */ + @Test + @DisplayName("转 Map 时重复键保留第一个值并忽略空元素") + void toMapShouldKeepFirstDuplicateValue() { + List items = Arrays.asList( + new TestItem(1L, "first"), + new TestItem(1L, "second"), + null, + new TestItem(2L, "third") + ); + + Map result = StreamUtils.toMap(items, TestItem::id, TestItem::value); + + assertEquals(Map.of(1L, "first", 2L, "third"), result); + } + + /** + * 验证分组结果按照键在输入中的首次出现顺序排列。 + */ + @Test + @DisplayName("分组结果保持输入键的出现顺序") + void groupByKeyShouldPreserveKeyOrder() { + List items = List.of( + new TestItem(2L, "a"), + new TestItem(1L, "b"), + new TestItem(2L, "c") + ); + + Map> result = StreamUtils.groupByKey(items, TestItem::id); + + assertEquals(List.of(2L, 1L), result.keySet().stream().toList()); + assertEquals(List.of("a", "c"), result.get(2L).stream().map(TestItem::value).toList()); + } + + /** + * 验证两个 Map 合并时同时处理独有键和共有键。 + */ + @Test + @DisplayName("合并 Map 时覆盖两侧独有键和共有键") + void mergeShouldCoverUnionOfKeys() { + Map left = new LinkedHashMap<>(); + left.put(1L, "L1"); + left.put(2L, "L2"); + Map right = new LinkedHashMap<>(); + right.put(2L, "R2"); + right.put(3L, "R3"); + + Map result = StreamUtils.merge(left, right, + (leftValue, rightValue) -> String.valueOf(leftValue) + ":" + String.valueOf(rightValue)); + + assertEquals("L1:null", result.get(1L)); + assertEquals("L2:R2", result.get(2L)); + assertEquals("null:R3", result.get(3L)); + } + + private record TestItem(Long id, String value) { + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/StringUtilsTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/StringUtilsTest.java new file mode 100644 index 000000000..4521a396d --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/StringUtilsTest.java @@ -0,0 +1,100 @@ +package org.dromara.common.core.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("StringUtils 补充单元测试") +class StringUtilsTest { + + /** + * 验证空白默认值、裁剪、截取、格式化和命名转换等基础字符串操作。 + */ + @Test + @DisplayName("处理基础字符串转换") + void shouldHandleBasicStringTransformations() { + assertEquals("fallback", StringUtils.blankToDefault(" ", "fallback")); + assertTrue(StringUtils.isEmpty("")); + assertTrue(StringUtils.isNotEmpty("value")); + assertEquals("value", StringUtils.trim(" value ")); + assertEquals("bc", StringUtils.substring("abcd", 1, 3)); + assertEquals("id=12", StringUtils.format("id={}", 12)); + assertEquals("user_name", StringUtils.toUnderScoreCase("userName")); + assertEquals("HelloWorld", StringUtils.convertToCamelCase("HELLO_WORLD")); + assertEquals("userName", StringUtils.toCamelCase("user_name")); + } + + /** + * 验证分隔字符串时的空白过滤、裁剪、去重和自定义类型转换行为。 + */ + @Test + @DisplayName("拆分字符串集合") + void shouldSplitStringsIntoCollections() { + assertEquals(List.of("a", "b"), StringUtils.str2List(" a, ,b ", ",", true, true)); + assertEquals(Set.of("a", "b"), StringUtils.str2Set("a,b,a", ",")); + assertEquals(List.of("a", "b"), StringUtils.splitList("a,b")); + assertEquals(List.of(1, 2), StringUtils.splitTo("1|2", "|", value -> Integer.valueOf(value.toString()))); + assertTrue(StringUtils.splitList(" ").isEmpty()); + } + + /** + * 验证 Ant 风格路径规则可以区分单层通配符、跨层通配符和空输入。 + */ + @Test + @DisplayName("匹配路径规则") + void shouldMatchAntStylePaths() { + assertTrue(StringUtils.isMatch("/system/**", "/system/user/list")); + assertFalse(StringUtils.isMatch("/system/*", "/system/user/list")); + assertTrue(StringUtils.matches("/system/user/list", List.of("/login", "/system/**"))); + assertFalse(StringUtils.matches("", List.of("/**"))); + assertFalse(StringUtils.matches("/system", List.of())); + } + + /** + * 验证定长左补齐会补零、截取尾部,并正确处理空值。 + */ + @Test + @DisplayName("定长左补齐字符串") + void shouldPadOrTruncateFromLeft() { + assertEquals("0012", StringUtils.padl(12, 4)); + assertEquals("cdef", StringUtils.padl("abcdef", 4, '0')); + assertEquals("***a", StringUtils.padl("a", 4, '*')); + assertEquals("000", StringUtils.padl(null, 3, '0')); + } + + /** + * 验证大小写敏感和忽略大小写的查找、前后缀与替换方法保持不同语义。 + */ + @Test + @DisplayName("比较和替换字符串") + void shouldCompareAndReplaceStrings() { + assertTrue(StringUtils.containsAnyIgnoreCase("Hello", "WORLD", "he")); + assertTrue(StringUtils.inStringIgnoreCase("ADMIN", "user", "admin")); + assertTrue(StringUtils.startWithAnyIgnoreCase("Bearer token", "basic", "bearer")); + assertTrue(StringUtils.equalsAny("a", "b", "a")); + assertTrue(StringUtils.equalsAnyIgnoreCase("A", "b", "a")); + assertTrue(StringUtils.containsIgnoreCase("Hello", "ELL")); + assertTrue(StringUtils.endsWithIgnoreCase("report.XLSX", ".xlsx")); + assertEquals(2, StringUtils.indexOf("abcabc", "ca")); + assertEquals("path", StringUtils.removeStart("/path", "/")); + assertEquals("a-b_c", StringUtils.replaceOnce("a_b_c", "_", "-")); + } + + /** + * 验证字符集转换、URL 判断与逗号拼接的公共便捷方法。 + */ + @Test + @DisplayName("处理编码和拼接") + void shouldHandleEncodingUrlAndJoining() { + assertEquals("中文", StringUtils.convert("中文", StandardCharsets.UTF_8, StandardCharsets.UTF_8)); + assertEquals("", StringUtils.convert("", StandardCharsets.UTF_8, StandardCharsets.UTF_16)); + assertTrue(StringUtils.ishttp("https://example.com/path")); + assertEquals("a,b", StringUtils.joinComma(List.of("a", "b"))); + assertEquals("1,2", StringUtils.joinComma(new Integer[]{1, 2})); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/TreeBuildUtilsTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/TreeBuildUtilsTest.java new file mode 100644 index 000000000..ba65ba69b --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/utils/TreeBuildUtilsTest.java @@ -0,0 +1,98 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.lang.tree.Tree; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("TreeBuildUtils 单元测试") +class TreeBuildUtilsTest { + + static { + // TreeBuildUtils 会将 Hutool 默认名称键调整为前端使用的 label。 + TreeBuildUtils.DEFAULT_CONFIG.getNameKey(); + } + + /** + * 验证递归遍历只返回没有子节点的叶子节点。 + */ + @Test + @DisplayName("获取树中所有叶子节点") + void getLeafNodesShouldReturnOnlyLeaves() { + Tree root = tree(1L, "root"); + Tree leaf = tree(2L, "leaf"); + Tree branch = tree(3L, "branch"); + Tree nestedLeaf = tree(4L, "nested"); + branch.setChildren(List.of(nestedLeaf)); + root.setChildren(List.of(leaf, branch)); + + List> result = TreeBuildUtils.getLeafNodes(List.of(root)); + + assertEquals(List.of(2L, 4L), result.stream().map(Tree::getId).toList()); + assertTrue(TreeBuildUtils.getLeafNodes(null).isEmpty()); + } + + /** + * 验证树节点按照深度优先顺序生成完整路径键。 + */ + @Test + @DisplayName("按深度优先顺序构建节点路径映射") + void buildTreeNodeMapShouldCreateFullPaths() { + Tree root = tree(1L, "root"); + Tree child = tree(2L, "child"); + Tree leaf = tree(3L, "leaf"); + child.setChildren(List.of(leaf)); + root.setChildren(List.of(child)); + + Map> result = TreeBuildUtils.buildTreeNodeMap(List.of(root), "/", Tree::getName); + + assertEquals(List.of("root", "root/child", "root/child/leaf"), result.keySet().stream().toList()); + assertEquals(3L, result.get("root/child/leaf").getId()); + } + + /** + * 验证不同父级来源的顶级节点可以合并为多根树。 + */ + @Test + @DisplayName("构建包含多个顶级节点的树") + void buildMultiRootShouldKeepAllRoots() { + List nodes = List.of( + new TestNode(1L, 0L, "root-a"), + new TestNode(2L, 1L, "child-a"), + new TestNode(10L, 9L, "root-b") + ); + + List> result = TreeBuildUtils.buildMultiRoot(nodes, TestNode::id, TestNode::parentId, + (node, treeNode) -> treeNode.setId(node.id()).setParentId(node.parentId()).setName(node.name())); + + Set rootIds = result.stream().map(Tree::getId).collect(Collectors.toSet()); + assertEquals(Set.of(1L, 10L), rootIds); + Tree firstRoot = result.stream().filter(tree -> tree.getId().equals(1L)).findFirst().orElseThrow(); + assertEquals(List.of(2L), firstRoot.getChildren().stream().map(Tree::getId).toList()); + } + + /** + * 创建测试使用的最小树节点。 + * + * @param id 节点 ID + * @param name 节点名称 + * @return 树节点 + */ + private static Tree tree(Long id, String name) { + Tree tree = new Tree<>(); + tree.setId(id); + tree.setName(name); + return tree; + } + + private record TestNode(Long id, Long parentId, String name) { + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/validate/enums/EnumPatternValidatorTest.java b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/validate/enums/EnumPatternValidatorTest.java new file mode 100644 index 000000000..887d52280 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/test/java/org/dromara/common/core/validate/enums/EnumPatternValidatorTest.java @@ -0,0 +1,85 @@ +package org.dromara.common.core.validate.enums; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("EnumPatternValidator 单元测试") +class EnumPatternValidatorTest { + + /** + * 验证字符串形式的数字能够按枚举字段实际类型转换并完成合法值校验。 + */ + @Test + @DisplayName("校验枚举字段值") + void shouldValidateConvertedEnumFieldValues() throws NoSuchFieldException { + EnumPatternValidator validator = validatorFor("status"); + + assertTrue(validator.isValid(null, null)); + assertTrue(validator.isValid(1, null)); + assertTrue(validator.isValid("2", null)); + assertFalse(validator.isValid("invalid", null)); + assertFalse(validator.isValid(9, null)); + } + + /** + * 验证未配置枚举字段名时不会错误接受非空输入。 + */ + @Test + @DisplayName("拒绝缺少枚举字段配置的输入") + void shouldRejectValueWhenEnumFieldIsNotConfigured() throws NoSuchFieldException { + EnumPatternValidator validator = validatorFor("unconfigured"); + + assertTrue(validator.isValid(null, null)); + assertFalse(validator.isValid(1, null)); + } + + /** + * 根据测试字段上的真实注解创建并初始化枚举校验器。 + * + * @param fieldName 测试字段名 + * @return 已初始化的校验器 + */ + private static EnumPatternValidator validatorFor(String fieldName) throws NoSuchFieldException { + Field field = ValidationTarget.class.getDeclaredField(fieldName); + EnumPatternValidator validator = new EnumPatternValidator(); + validator.initialize(field.getAnnotation(EnumPattern.class)); + return validator; + } + + private enum Status { + ENABLED(1), DISABLED(2); + + private final Integer code; + + /** + * 创建带校验码的测试状态。 + * + * @param code 枚举校验码 + */ + Status(Integer code) { + this.code = code; + } + + /** + * 返回供枚举校验器读取的测试状态码。 + * + * @return 状态码 + */ + public Integer getCode() { + return code; + } + } + + private static class ValidationTarget { + @EnumPattern(type = Status.class, fieldName = "code") + private Integer status; + + @EnumPattern(type = Status.class, fieldName = "") + private Integer unconfigured; + } +} diff --git a/ruoyi-common/ruoyi-common-doc/src/test/java/org/dromara/common/doc/SaTokenSecurityMetadataTest.java b/ruoyi-common/ruoyi-common-doc/src/test/java/org/dromara/common/doc/SaTokenSecurityMetadataTest.java new file mode 100644 index 000000000..2bfb41ee2 --- /dev/null +++ b/ruoyi-common/ruoyi-common-doc/src/test/java/org/dromara/common/doc/SaTokenSecurityMetadataTest.java @@ -0,0 +1,45 @@ +package org.dromara.common.doc; + +import org.dromara.common.doc.core.model.SaTokenSecurityMetadata; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("common-doc 功能单元测试") +class SaTokenSecurityMetadataTest { + + /** + * 验证未声明权限和忽略权限时分别输出登录要求与忽略检查说明。 + */ + @Test + @DisplayName("生成基础权限文档") + void shouldDescribeLoginAndIgnoredSecurity() { + SaTokenSecurityMetadata metadata = new SaTokenSecurityMetadata(); + assertTrue(metadata.toMarkdownString().contains("需要登录")); + + metadata.setIgnore(true); + assertTrue(metadata.toMarkdownString().contains("忽略权限检查")); + } + + /** + * 验证权限、或角色和角色校验能够按 AND/OR 模式生成可读的 Markdown。 + */ + @Test + @DisplayName("生成权限和角色文档") + void shouldDescribePermissionsAndRoles() { + SaTokenSecurityMetadata metadata = new SaTokenSecurityMetadata(); + metadata.addPermission(new String[]{"system:user:list", "system:user:query"}, "AND", "permission", + new String[]{"admin", "auditor"}); + metadata.addRole(new String[]{"manager", "operator"}, "OR", "role"); + + String markdown = metadata.toMarkdownString(); + + assertTrue(markdown.contains("`system:user:list` & `system:user:query`")); + assertTrue(markdown.contains("或角色:`admin` & `auditor`")); + assertTrue(markdown.contains("`manager` | `operator`")); + assertEquals(1, metadata.getPermissions().size()); + assertEquals(1, metadata.getRoles().size()); + } +} diff --git a/ruoyi-common/ruoyi-common-elasticsearch/src/test/java/org/dromara/common/elasticsearch/ActuatorEnvironmentPostProcessorTest.java b/ruoyi-common/ruoyi-common-elasticsearch/src/test/java/org/dromara/common/elasticsearch/ActuatorEnvironmentPostProcessorTest.java new file mode 100644 index 000000000..b46a9bd63 --- /dev/null +++ b/ruoyi-common/ruoyi-common-elasticsearch/src/test/java/org/dromara/common/elasticsearch/ActuatorEnvironmentPostProcessorTest.java @@ -0,0 +1,48 @@ +package org.dromara.common.elasticsearch; + +import org.dromara.common.elasticsearch.config.ActuatorEnvironmentPostProcessor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.Ordered; +import org.springframework.mock.env.MockEnvironment; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DisplayName("common-elasticsearch 功能单元测试") +class ActuatorEnvironmentPostProcessorTest { + + /** + * 清理测试写入的 JVM 系统属性,避免影响同一测试进程中的其他用例。 + */ + @AfterEach + void clearHealthProperty() { + System.clearProperty("management.health.elasticsearch.enabled"); + } + + /** + * 验证 Easy-ES 开关会同步到 Elasticsearch 健康检查,并以最高优先级执行。 + */ + @Test + @DisplayName("同步 Elasticsearch 健康检查开关") + void shouldSynchronizeElasticsearchHealthFlag() { + ActuatorEnvironmentPostProcessor processor = new ActuatorEnvironmentPostProcessor(); + MockEnvironment environment = new MockEnvironment().withProperty("easy-es.enable", "true"); + + processor.postProcessEnvironment(environment, null); + + assertEquals("true", System.getProperty("management.health.elasticsearch.enabled")); + assertEquals(Ordered.HIGHEST_PRECEDENCE, processor.getOrder()); + } + + /** + * 验证缺少 Easy-ES 配置时健康检查默认关闭,避免未配置连接时触发探测。 + */ + @Test + @DisplayName("默认关闭 Elasticsearch 健康检查") + void shouldDisableElasticsearchHealthByDefault() { + new ActuatorEnvironmentPostProcessor().postProcessEnvironment(new MockEnvironment(), null); + + assertEquals("false", System.getProperty("management.health.elasticsearch.enabled")); + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/test/java/org/dromara/common/encrypt/EncryptFunctionTest.java b/ruoyi-common/ruoyi-common-encrypt/src/test/java/org/dromara/common/encrypt/EncryptFunctionTest.java new file mode 100644 index 000000000..ce329c3e9 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/test/java/org/dromara/common/encrypt/EncryptFunctionTest.java @@ -0,0 +1,111 @@ +package org.dromara.common.encrypt; + +import org.dromara.common.core.constant.Constants; +import org.dromara.common.encrypt.annotation.EncryptField; +import org.dromara.common.encrypt.core.EncryptContext; +import org.dromara.common.encrypt.core.EncryptContextFactory; +import org.dromara.common.encrypt.core.EncryptorManager; +import org.dromara.common.encrypt.enums.AlgorithmType; +import org.dromara.common.encrypt.enums.EncodeType; +import org.dromara.common.encrypt.properties.EncryptorProperties; +import org.dromara.common.encrypt.utils.EncryptUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("common-encrypt 功能单元测试") +class EncryptFunctionTest { + + /** + * 验证 Base64、AES 和 SM4 的常用加解密能够无损往返。 + */ + @Test + @DisplayName("常用对称算法加解密往返") + void shouldRoundTripCommonSymmetricAlgorithms() { + String text = "RuoYi-Vue-Plus"; + String aesKey = "1234567890abcdef"; + String sm4Key = "abcdef1234567890"; + + assertEquals(text, EncryptUtils.decryptByBase64(EncryptUtils.encryptByBase64(text))); + assertEquals(text, EncryptUtils.decryptByAes(EncryptUtils.encryptByAes(text, aesKey), aesKey)); + assertEquals(text, EncryptUtils.decryptBySm4(EncryptUtils.encryptBySm4(text, sm4Key), sm4Key)); + } + + /** + * 验证非法对称密钥长度在执行加密前被拒绝。 + */ + @Test + @DisplayName("校验对称算法密钥长度") + void shouldRejectInvalidSymmetricKeys() { + assertThrows(IllegalArgumentException.class, () -> EncryptUtils.encryptByAes("data", "short")); + assertThrows(IllegalArgumentException.class, () -> EncryptUtils.encryptBySm4("data", "short")); + } + + /** + * 验证生成的 RSA 密钥满足校验要求并可以完成公钥加密、私钥解密。 + */ + @Test + @DisplayName("RSA 密钥生成和加解密往返") + void shouldGenerateAndUseRsaKeys() { + Map keys = EncryptUtils.generateRsaKey(); + + assertDoesNotThrow(() -> EncryptUtils.validateRsaPublicKey(keys.get(EncryptUtils.PUBLIC_KEY))); + assertDoesNotThrow(() -> EncryptUtils.validateRsaPrivateKey(keys.get(EncryptUtils.PRIVATE_KEY))); + String encrypted = EncryptUtils.encryptByRsa("secure-data", keys.get(EncryptUtils.PUBLIC_KEY)); + assertEquals("secure-data", EncryptUtils.decryptByRsa(encrypted, keys.get(EncryptUtils.PRIVATE_KEY))); + } + + /** + * 验证字段注解优先于默认配置构建加密上下文。 + * + * @throws Exception 读取测试字段失败 + */ + @Test + @DisplayName("合并字段注解和默认加密配置") + void shouldCreateEncryptContextFromAnnotationAndDefaults() throws Exception { + EncryptorProperties properties = new EncryptorProperties(); + properties.setAlgorithm(AlgorithmType.AES); + properties.setEncode(EncodeType.BASE64); + properties.setPassword("default-password"); + properties.setPublicKey("default-public"); + properties.setPrivateKey("default-private"); + Field field = TestEntity.class.getDeclaredField("secret"); + + EncryptContext context = new EncryptContextFactory(properties).create(field); + + assertEquals(AlgorithmType.SM4, context.getAlgorithm()); + assertEquals(EncodeType.HEX, context.getEncode()); + assertEquals("field-password", context.getPassword()); + assertEquals("default-public", context.getPublicKey()); + assertEquals("default-private", context.getPrivateKey()); + } + + /** + * 验证加密管理器添加统一密文头,并避免对已有密文重复加密。 + */ + @Test + @DisplayName("管理带标识头的加密值") + void shouldManageEncryptedValueHeader() { + EncryptContext context = new EncryptContext(); + context.setAlgorithm(AlgorithmType.BASE64); + context.setEncode(EncodeType.BASE64); + EncryptorManager manager = new EncryptorManager(""); + + String encrypted = manager.encrypt("plain-text", context); + + assertTrue(encrypted.startsWith(Constants.ENCRYPT_HEADER)); + assertEquals(encrypted, manager.encrypt(encrypted, context)); + assertEquals("plain-text", manager.decrypt(encrypted, context)); + assertEquals("plain-text", manager.decrypt("plain-text", context)); + } + + private static class TestEntity { + + @EncryptField(algorithm = AlgorithmType.SM4, encode = EncodeType.HEX, password = "field-password") + private String secret; + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/test/java/org/dromara/common/encrypt/EncryptInfrastructureTest.java b/ruoyi-common/ruoyi-common-encrypt/src/test/java/org/dromara/common/encrypt/EncryptInfrastructureTest.java new file mode 100644 index 000000000..d0f162d79 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/test/java/org/dromara/common/encrypt/EncryptInfrastructureTest.java @@ -0,0 +1,156 @@ +package org.dromara.common.encrypt; + +import org.dromara.common.core.constant.Constants; +import org.dromara.common.encrypt.annotation.EncryptField; +import org.dromara.common.encrypt.core.EncryptContext; +import org.dromara.common.encrypt.core.EncryptContextFactory; +import org.dromara.common.encrypt.core.EncryptedFieldProcessor; +import org.dromara.common.encrypt.core.EncryptorManager; +import org.dromara.common.encrypt.core.IEncryptor; +import org.dromara.common.encrypt.enums.AlgorithmType; +import org.dromara.common.encrypt.enums.EncodeType; +import org.dromara.common.encrypt.filter.DecryptRequestBodyWrapper; +import org.dromara.common.encrypt.filter.EncryptResponseBodyWrapper; +import org.dromara.common.encrypt.properties.EncryptorProperties; +import org.dromara.common.encrypt.utils.EncryptUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("common-encrypt 基础设施单元测试") +class EncryptInfrastructureTest { + + /** + * 验证集合和 Map 中的加密字段会被处理,且字段快照能够恢复持久化前的原始值。 + */ + @Test + @DisplayName("加密并恢复容器中的字段") + void shouldEncryptAndRestoreFieldsInsideContainers() { + EncryptorProperties properties = new EncryptorProperties(); + properties.setAlgorithm(AlgorithmType.BASE64); + properties.setEncode(EncodeType.BASE64); + EncryptorManager manager = new EncryptorManager(""); + EncryptedFieldProcessor processor = new EncryptedFieldProcessor( + manager, new EncryptContextFactory(properties)); + TestSecretEntity first = new TestSecretEntity("first"); + TestSecretEntity second = new TestSecretEntity("second"); + List source = new ArrayList<>(); + source.add(first); + source.add(Map.of("entity", second)); + source.add(source); + + List snapshots = processor.encrypt(source); + + assertEquals(2, snapshots.size()); + assertTrue(first.secret.startsWith(Constants.ENCRYPT_HEADER)); + assertTrue(second.secret.startsWith(Constants.ENCRYPT_HEADER)); + snapshots.forEach(EncryptedFieldProcessor.FieldSnapshot::restore); + assertEquals("first", first.secret); + assertEquals("second", second.secret); + + processor.encrypt(source); + processor.decrypt(source); + assertEquals("first", first.secret); + assertEquals("second", second.secret); + } + + /** + * 验证相同上下文复用加密器缓存,显式移除后会重新创建实例。 + */ + @Test + @DisplayName("复用和移除加密器缓存") + void shouldReuseAndRemoveEncryptorCache() { + EncryptorManager manager = new EncryptorManager(""); + EncryptContext context = new EncryptContext(); + context.setAlgorithm(AlgorithmType.BASE64); + context.setEncode(EncodeType.BASE64); + + IEncryptor first = manager.registAndGetEncryptor(context); + IEncryptor cached = manager.registAndGetEncryptor(context); + manager.removeEncryptor(context); + IEncryptor recreated = manager.registAndGetEncryptor(context); + + assertSame(first, cached); + assertNotSame(first, recreated); + assertTrue(manager.getFieldCache(String.class).isEmpty()); + assertTrue(manager.getFieldCache(null).isEmpty()); + } + + /** + * 验证加密请求包装器能解开请求头中的 AES 密钥并提供可重复读取的 JSON 明文。 + */ + @Test + @DisplayName("解密 API 请求体") + void shouldDecryptApiRequestBody() throws Exception { + Map rsaKeys = EncryptUtils.generateRsaKey(); + String aesPassword = "1234567890abcdef"; + String headerName = "encrypt-key"; + String encryptedHeader = EncryptUtils.encryptByRsa( + EncryptUtils.encryptByBase64(aesPassword), rsaKeys.get(EncryptUtils.PUBLIC_KEY)); + String json = "{\"name\":\"测试\"}"; + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(headerName, encryptedHeader); + request.setContent(EncryptUtils.encryptByAes(json, aesPassword).getBytes(StandardCharsets.UTF_8)); + + DecryptRequestBodyWrapper wrapper = new DecryptRequestBodyWrapper( + request, rsaKeys.get(EncryptUtils.PRIVATE_KEY), headerName); + + assertEquals(json, wrapper.getReader().readLine()); + assertEquals(json, new String(wrapper.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + assertEquals("application/json", wrapper.getContentType()); + assertEquals(json.getBytes(StandardCharsets.UTF_8).length, wrapper.getContentLength()); + assertEquals(wrapper.getContentLength(), wrapper.getContentLengthLong()); + } + + /** + * 验证加密响应包装器输出可由响应头携带的密钥完整解密,并正确设置响应元数据。 + */ + @Test + @DisplayName("加密 API 响应体") + void shouldEncryptApiResponseBody() throws Exception { + Map rsaKeys = EncryptUtils.generateRsaKey(); + String headerName = "encrypt-key"; + String body = "{\"message\":\"成功\"}"; + MockHttpServletResponse response = new MockHttpServletResponse(); + EncryptResponseBodyWrapper wrapper = new EncryptResponseBodyWrapper(response); + wrapper.getWriter().write(body); + + String encryptedBody = wrapper.getEncryptContent( + response, rsaKeys.get(EncryptUtils.PUBLIC_KEY), headerName); + String encodedAes = EncryptUtils.decryptByRsa( + response.getHeader(headerName), rsaKeys.get(EncryptUtils.PRIVATE_KEY)); + String aesPassword = EncryptUtils.decryptByBase64(encodedAes); + + assertEquals(body, EncryptUtils.decryptByAes(encryptedBody, aesPassword)); + assertEquals(StandardCharsets.UTF_8.name(), response.getCharacterEncoding()); + assertEquals(encryptedBody.getBytes(StandardCharsets.UTF_8).length, response.getContentLength()); + assertEquals(headerName, response.getHeader("Access-Control-Expose-Headers")); + } + +} + +class TestSecretEntity { + + @EncryptField(algorithm = AlgorithmType.BASE64) + String secret; + + /** + * 创建带待加密字段的顶层测试实体,以符合生产扫描器对实体类的约束。 + * + * @param secret 原始明文 + */ + TestSecretEntity(String secret) { + this.secret = secret; + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelBuilderTest.java b/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelBuilderTest.java new file mode 100644 index 000000000..ea4a941cc --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelBuilderTest.java @@ -0,0 +1,326 @@ +package org.dromara.common.excel; + +import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated; +import org.apache.fesod.sheet.annotation.ExcelProperty; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.dromara.common.excel.annotation.CellMerge; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.annotation.ExcelEnumFormat; +import org.dromara.common.excel.annotation.ExcelNotation; +import org.dromara.common.excel.annotation.ExcelRequired; +import org.dromara.common.excel.core.CellMergeHandler; +import org.dromara.common.excel.core.DropDownOptions; +import org.dromara.common.excel.utils.ExcelBuilder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("ExcelBuilder 单元测试") +class ExcelBuilderTest { + + /** + * 验证内存导出同时应用表头样式、批注、字典和枚举下拉、外部下拉及单元格合并。 + */ + @Test + @DisplayName("构建带内部增强的 Excel 工作簿") + void shouldBuildWorkbookWithInternalEnhancements() throws Exception { + byte[] bytes = buildWorkbook(); + + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) { + assertEquals("report", workbook.getSheetAt(0).getSheetName()); + assertFalse(workbook.getSheetAt(0).getDataValidations().isEmpty()); + assertFalse(workbook.getSheetAt(0).getMergedRegions().isEmpty()); + assertEquals("填写业务分类", workbook.getSheetAt(0).getRow(0).getCell(0).getCellComment().getString().getString()); + assertTrue(workbook.getNumberOfSheets() >= 3); + assertTrue(workbook.isSheetHidden(workbook.getSheetIndex("options_0"))); + assertTrue(workbook.isSheetHidden(workbook.getSheetIndex("linkedOptions_0"))); + } + } + + /** + * 验证通过构造器写出的工作簿可按工作表编号、名称和读取选项重新解析为对象。 + */ + @Test + @DisplayName("写入并读取 Excel 数据") + void shouldRoundTripWorkbookThroughReadBuilder() { + byte[] bytes = buildWorkbook(); + + List rows = ExcelBuilder.read(new ByteArrayInputStream(bytes), ExportRow.class) + .validate(false) + .failFast(false) + .sheetNo(0) + .sheetName("report") + .headRowNumber(1) + .ignoreEmptyRow(true) + .autoTrim(true) + .autoStrip(true) + .numRows(20) + .doReadSync(); + + assertEquals(3, rows.size()); + assertEquals("A", rows.getFirst().getCategory()); + assertEquals("1", rows.getFirst().getStatus()); + assertEquals(1, rows.getFirst().getLevel()); + } + + /** + * 验证构造器拒绝非法尺寸、ZIP 分页和空模板数据,避免生成不可用文件。 + */ + @Test + @DisplayName("校验 Excel 构造参数") + void shouldValidateBuilderArguments() { + ExcelBuilder builder = ExcelBuilder.of(rows(), ExportRow.class); + + assertThrows(IllegalArgumentException.class, () -> builder.columnWidth(0)); + assertThrows(IllegalArgumentException.class, () -> builder.rowHeight((short) 0, (short) 10)); + assertThrows(IllegalArgumentException.class, () -> builder.zip(0)); + assertThrows(UnsupportedOperationException.class, + () -> builder.zip(2).toStream(new ByteArrayOutputStream())); + assertThrows(IllegalArgumentException.class, + () -> ExcelBuilder.template("missing.xlsx").data(List.of()).toStream(new ByteArrayOutputStream())); + assertThrows(IllegalArgumentException.class, + () -> ExcelBuilder.template("missing.xlsx").multiList(Map.of()).toStream(new ByteArrayOutputStream())); + assertThrows(IllegalArgumentException.class, + () -> ExcelBuilder.template("missing.xlsx").multiSheet(List.of()).toStream(new ByteArrayOutputStream())); + assertThrows(IllegalArgumentException.class, + () -> ExcelBuilder.read(new ByteArrayInputStream(new byte[0]), ExportRow.class).headRowNumber(-1)); + assertThrows(IllegalArgumentException.class, + () -> ExcelBuilder.read(new ByteArrayInputStream(new byte[0]), ExportRow.class).numRows(0)); + } + + /** + * 验证合并处理器按依赖字段切断重复段,空值中断合并,并正确计算多级表头偏移。 + */ + @Test + @DisplayName("计算依赖字段单元格合并区域") + void shouldCalculateConditionalMergeRanges() { + List rows = List.of( + new MergeRow("A", "east"), + new MergeRow("A", "east"), + new MergeRow("A", "west"), + new MergeRow("", "west"), + new MergeRow("B", "west"), + new MergeRow("B", "west")); + + List ranges = CellMergeHandler.of(true).handle(rows).stream() + .map(range -> range.formatAsString()) + .sorted() + .toList(); + + assertEquals(List.of("A3:A4", "A7:A8"), ranges); + assertEquals("A1:A2", CellMergeHandler.of(false, 9).handle(rows.subList(0, 2)).getFirst().formatAsString()); + assertTrue(CellMergeHandler.of().handle(List.of()).isEmpty()); + assertTrue(CellMergeHandler.of().handle(List.of(new Object())).isEmpty()); + } + + /** + * 创建覆盖简单、额外 Sheet 和级联下拉分支的内存工作簿。 + * + * @return XLSX 字节数组 + */ + private static byte[] buildWorkbook() { + List manyOptions = IntStream.rangeClosed(1, 11).mapToObj(i -> "选项" + i).toList(); + List options = List.of( + new DropDownOptions(3, List.of("是", "否")), + new DropDownOptions(4, manyOptions), + new DropDownOptions(5, 6, List.of("父级_1"), Map.of("父级_1", List.of("子级_1", "子级_2")))); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + ExcelBuilder.of(rows(), ExportRow.class) + .sheetName("report") + .sheetNo(0) + .merge() + .options(options) + .needHead(true) + .automaticMergeHead(true) + .columnWidth(18) + .rowHeight((short) 22, (short) 18) + .toStream(outputStream); + return outputStream.toByteArray(); + } + + /** + * 创建用于导出和读取往返的测试数据。 + * + * @return 测试数据行 + */ + private static List rows() { + return List.of( + new ExportRow("A", "1", 1), + new ExportRow("A", "0", 2), + new ExportRow("B", "1", 1)); + } + + @ExcelIgnoreUnannotated + public static class ExportRow { + + @ExcelProperty("分类") + @CellMerge + @ExcelRequired + @ExcelNotation("填写业务分类") + private String category; + + @ExcelProperty("状态") + @ExcelDictFormat(readConverterExp = "0=停用,1=启用") + private String status; + + @ExcelProperty("级别") + @ExcelEnumFormat(enumClass = Level.class) + private Integer level; + + /** + * 创建供 Excel 反射实例化的空对象。 + */ + public ExportRow() { + } + + /** + * 创建包含导出字段的测试数据行。 + * + * @param category 分类 + * @param status 状态编码 + * @param level 级别编码 + */ + public ExportRow(String category, String status, Integer level) { + this.category = category; + this.status = status; + this.level = level; + } + + /** + * 返回业务分类。 + * + * @return 业务分类 + */ + public String getCategory() { + return category; + } + + /** + * 设置业务分类供 Excel 导入使用。 + * + * @param category 业务分类 + */ + public void setCategory(String category) { + this.category = category; + } + + /** + * 返回状态编码。 + * + * @return 状态编码 + */ + public String getStatus() { + return status; + } + + /** + * 设置状态编码供 Excel 导入使用。 + * + * @param status 状态编码 + */ + public void setStatus(String status) { + this.status = status; + } + + /** + * 返回级别编码。 + * + * @return 级别编码 + */ + public Integer getLevel() { + return level; + } + + /** + * 设置级别编码供 Excel 导入使用。 + * + * @param level 级别编码 + */ + public void setLevel(Integer level) { + this.level = level; + } + } + + private enum Level { + NORMAL(1, "普通"), + HIGH(2, "高级"); + + private final int code; + private final String text; + + Level(int code, String text) { + this.code = code; + this.text = text; + } + + /** + * 返回级别编码供下拉处理器读取。 + * + * @return 级别编码 + */ + public int getCode() { + return code; + } + + /** + * 返回级别文本供下拉处理器读取。 + * + * @return 级别文本 + */ + public String getText() { + return text; + } + } + + @ExcelIgnoreUnannotated + private static class MergeRow { + + @ExcelProperty({"业务", "分类"}) + @CellMerge(mergeBy = "region") + private final String category; + + @ExcelProperty({"业务", "区域"}) + private final String region; + + /** + * 创建用于验证依赖字段合并规则的数据行。 + * + * @param category 分类 + * @param region 区域 + */ + private MergeRow(String category, String region) { + this.category = category; + this.region = region; + } + + /** + * 返回待合并分类。 + * + * @return 分类 + */ + public String getCategory() { + return category; + } + + /** + * 返回控制分类是否允许合并的区域。 + * + * @return 区域 + */ + public String getRegion() { + return region; + } + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelConvertTest.java b/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelConvertTest.java new file mode 100644 index 000000000..25a5262f9 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelConvertTest.java @@ -0,0 +1,146 @@ +package org.dromara.common.excel; + +import org.apache.fesod.sheet.enums.CellDataTypeEnum; +import org.apache.fesod.sheet.metadata.data.ReadCellData; +import org.apache.fesod.sheet.metadata.data.WriteCellData; +import org.apache.fesod.sheet.metadata.property.ExcelContentProperty; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.annotation.ExcelEnumFormat; +import org.dromara.common.excel.convert.ExcelBigNumberConvert; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.common.excel.convert.ExcelEnumConvert; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("Excel 转换器单元测试") +class ExcelConvertTest { + + /** + * 验证普通 Long 按数字写出,而超过 Excel 精度上限的 Long 按字符串写出。 + */ + @Test + @DisplayName("按精度范围写出 Long") + void shouldWriteLongAccordingToExcelPrecisionLimit() { + ExcelBigNumberConvert converter = new ExcelBigNumberConvert(); + + WriteCellData normal = converter.convertToExcelData(123456789012345L, null, null); + WriteCellData large = converter.convertToExcelData(1234567890123456L, null, null); + WriteCellData empty = converter.convertToExcelData(null, null, null); + + assertEquals(CellDataTypeEnum.NUMBER, normal.getType()); + assertEquals(new BigDecimal("123456789012345"), normal.getNumberValue()); + assertEquals("1234567890123456", large.getStringValue()); + assertEquals("", empty.getStringValue()); + assertEquals(42L, converter.convertToJavaData(new ReadCellData<>("42"), null, null)); + } + + /** + * 验证字典表达式支持单值和多值的双向转换,并保留目标字段类型。 + */ + @Test + @DisplayName("双向转换字典表达式") + void shouldConvertDictionaryExpressionInBothDirections() throws Exception { + ExcelDictConvert converter = new ExcelDictConvert(); + ExcelContentProperty singleProperty = property("status"); + ExcelContentProperty multiProperty = property("roles"); + + assertEquals("启用", converter.convertToExcelData(1, singleProperty, null).getStringValue()); + assertEquals(0, converter.convertToJavaData(new ReadCellData<>("停用"), singleProperty, null)); + assertEquals("管理员|访客", converter.convertToExcelData("A|G", multiProperty, null).getStringValue()); + assertEquals("A|G", converter.convertToJavaData(new ReadCellData<>("访客|管理员"), multiProperty, null)); + assertEquals("", converter.convertToExcelData(null, singleProperty, null).getStringValue()); + } + + /** + * 验证格式错误的字典表达式会被明确拒绝,避免静默生成错误导入导出值。 + */ + @Test + @DisplayName("拒绝格式错误的字典表达式") + void shouldRejectMalformedDictionaryExpression() throws Exception { + ExcelDictConvert converter = new ExcelDictConvert(); + + assertThrows(IllegalArgumentException.class, + () -> converter.convertToExcelData("1", property("malformed"), null)); + } + + /** + * 验证枚举编码和显示文本可以双向转换,未知显示文本会返回可诊断异常。 + */ + @Test + @DisplayName("双向转换枚举编码与文本") + void shouldConvertEnumCodeAndTextInBothDirections() throws Exception { + ExcelEnumConvert converter = new ExcelEnumConvert(); + ExcelContentProperty property = property("level"); + + assertEquals("高级", converter.convertToExcelData(2, property, null).getStringValue()); + assertEquals(1, converter.convertToJavaData(new ReadCellData<>("普通"), property, null)); + assertEquals("", converter.convertToExcelData(null, property, null).getStringValue()); + assertThrows(IllegalArgumentException.class, + () -> converter.convertToJavaData(new ReadCellData<>("未知级别"), property, null)); + } + + /** + * 创建绑定指定测试字段的 Excel 内容属性。 + * + * @param fieldName 测试字段名 + * @return Excel 内容属性 + */ + private static ExcelContentProperty property(String fieldName) throws NoSuchFieldException { + Field field = ExcelRow.class.getDeclaredField(fieldName); + ExcelContentProperty property = new ExcelContentProperty(); + property.setField(field); + return property; + } + + private static class ExcelRow { + + @ExcelDictFormat(readConverterExp = "0=停用,1=启用") + private Integer status; + + @ExcelDictFormat(readConverterExp = "A=管理员,G=访客", separator = "|") + private String roles; + + @ExcelDictFormat(readConverterExp = "0=正常,错误项") + private String malformed; + + @ExcelEnumFormat(enumClass = Level.class) + private Integer level; + } + + private enum Level { + NORMAL(1, "普通"), + HIGH(2, "高级"); + + private final int code; + private final String text; + + Level(int code, String text) { + this.code = code; + this.text = text; + } + + /** + * 返回枚举编码供转换器反射读取。 + * + * @return 枚举编码 + */ + public int getCode() { + return code; + } + + /** + * 返回枚举显示文本供转换器反射读取。 + * + * @return 显示文本 + */ + public String getText() { + return text; + } + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelFunctionTest.java b/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelFunctionTest.java new file mode 100644 index 000000000..1ebfa6dc3 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelFunctionTest.java @@ -0,0 +1,82 @@ +package org.dromara.common.excel; + +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.excel.core.DefaultExcelResult; +import org.dromara.common.excel.core.DropDownOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("common-excel 功能单元测试") +class ExcelFunctionTest { + + /** + * 验证级联下拉选项值能够安全拼接和还原,确保生成的名称符合 Excel 名称规则。 + */ + @Test + @DisplayName("创建并解析 Excel 下拉选项") + void shouldCreateAndAnalyzeDropDownOptionValue() { + String option = DropDownOptions.createOptionValue("华东", 1001); + + assertEquals("华东_1001", option); + assertEquals(List.of("华东", "1001"), DropDownOptions.analyzeOptionValue(option)); + } + + /** + * 验证数字开头、单元格引用和特殊字符不会进入 Excel 名称管理器。 + */ + @Test + @DisplayName("拒绝非法 Excel 下拉选项") + void shouldRejectInvalidDropDownOptionValue() { + assertThrows(ServiceException.class, () -> DropDownOptions.createOptionValue(1001, "华东")); + assertThrows(ServiceException.class, () -> DropDownOptions.validateOptionValue("A1")); + assertThrows(ServiceException.class, () -> DropDownOptions.createOptionValue("华东-一区")); + } + + /** + * 验证 Excel 导入结果对全失败、全成功和部分成功场景生成准确统计文案。 + */ + @Test + @DisplayName("汇总 Excel 导入结果") + void shouldSummarizeExcelImportResult() { + assertEquals("读取失败,未解析到数据", new DefaultExcelResult<>(List.of(), List.of("格式错误")).getAnalysis()); + assertEquals("恭喜您,全部读取成功!共2条", + new DefaultExcelResult<>(List.of("a", "b"), List.of()).getAnalysis()); + assertEquals("共3条,成功导入2条,错误1条", + new DefaultExcelResult<>(List.of("a", "b"), List.of("格式错误")).getAnalysis()); + } + + /** + * 验证父子数据按父 ID 构建级联下拉,且没有有效父项的子数据不会进入结果。 + */ + @Test + @DisplayName("构建父子级联下拉选项") + void shouldBuildLinkedDropDownOptions() { + List parents = List.of( + new OptionNode(1L, null, "华东_1"), + new OptionNode(2L, null, "华北_2")); + List children = List.of( + new OptionNode(11L, 1L, "上海_11"), + new OptionNode(12L, 1L, "杭州_12"), + new OptionNode(21L, 2L, "北京_21"), + new OptionNode(99L, 9L, "孤立_99")); + + DropDownOptions result = DropDownOptions.buildLinkedOptions( + parents, 0, children, 1, OptionNode::id, OptionNode::parentId, OptionNode::label); + + assertEquals(0, result.getIndex()); + assertEquals(1, result.getNextIndex()); + assertEquals(List.of("华东_1", "华北_2"), result.getOptions()); + assertEquals(Map.of( + "华东_1", List.of("上海_11", "杭州_12"), + "华北_2", List.of("北京_21")), result.getNextOptions()); + } + + private record OptionNode(Long id, Long parentId, String label) { + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelWriterWrapperTest.java b/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelWriterWrapperTest.java new file mode 100644 index 000000000..4a353dd7e --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/test/java/org/dromara/common/excel/ExcelWriterWrapperTest.java @@ -0,0 +1,94 @@ +package org.dromara.common.excel; + +import org.apache.fesod.sheet.ExcelWriter; +import org.apache.fesod.sheet.context.WriteContext; +import org.apache.fesod.sheet.write.metadata.WriteSheet; +import org.apache.fesod.sheet.write.metadata.WriteTable; +import org.apache.fesod.sheet.write.metadata.fill.FillConfig; +import org.dromara.common.excel.utils.ExcelWriterWrapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@DisplayName("ExcelWriterWrapper 契约单元测试") +class ExcelWriterWrapperTest { + + /** + * 验证集合写出和 Supplier 写出都完整委托给 ExcelWriter,并在 Supplier 重载中只求值一次。 + */ + @Test + @DisplayName("委托四种集合写出") + void shouldDelegateCollectionWrites() { + ExcelWriter writer = mock(ExcelWriter.class); + ExcelWriterWrapper wrapper = ExcelWriterWrapper.of(writer); + WriteSheet sheet = ExcelWriterWrapper.buildSheet(2, "users"); + WriteTable table = ExcelWriterWrapper.buildTable(3); + List data = List.of("a", "b"); + AtomicBoolean supplied = new AtomicBoolean(); + Supplier> supplier = () -> { + supplied.set(true); + return data; + }; + + wrapper.write(data, sheet); + wrapper.write(supplier, sheet); + wrapper.write(data, sheet, table); + wrapper.write(supplier, sheet, table); + + assertTrue(supplied.get()); + verify(writer, times(2)).write(data, sheet); + verify(writer, times(2)).write(data, sheet, table); + } + + /** + * 验证普通对象、填充配置和 Supplier 三类填充方法保持底层参数及 Supplier 实例不变。 + */ + @Test + @DisplayName("委托四种模板填充") + void shouldDelegateFillOperations() { + ExcelWriter writer = mock(ExcelWriter.class); + ExcelWriterWrapper wrapper = ExcelWriterWrapper.of(writer); + WriteSheet sheet = ExcelWriterWrapper.buildSheet("report"); + FillConfig config = FillConfig.builder().forceNewRow(true).build(); + Supplier supplier = () -> "value"; + + wrapper.fill("value", sheet); + wrapper.fill("value", config, sheet); + wrapper.fill(supplier, sheet); + wrapper.fill(supplier, config, sheet); + + verify(writer).fill("value", sheet); + verify(writer).fill("value", config, sheet); + verify(writer).fill(supplier, sheet); + verify(writer).fill(supplier, config, sheet); + } + + /** + * 验证写出上下文和所有静态构造器均返回底层对象或包含请求元数据的 Fesod 对象。 + */ + @Test + @DisplayName("获取上下文并构造工作表和表格") + void shouldExposeContextAndBuildMetadata() { + ExcelWriter writer = mock(ExcelWriter.class); + WriteContext writeContext = mock(WriteContext.class); + when(writer.writeContext()).thenReturn(writeContext); + ExcelWriterWrapper wrapper = ExcelWriterWrapper.of(writer); + + assertSame(writer, wrapper.excelWriter()); + assertSame(writeContext, wrapper.writeContext()); + assertEquals(4, ExcelWriterWrapper.buildSheet(4, "named").getSheetNo()); + assertEquals("named", ExcelWriterWrapper.buildSheet(4, "named").getSheetName()); + assertEquals(5, ExcelWriterWrapper.buildSheet(5).getSheetNo()); + assertEquals("report", ExcelWriterWrapper.buildSheet("report").getSheetName()); + assertNotNull(ExcelWriterWrapper.buildSheet()); + assertEquals(6, ExcelWriterWrapper.buildTable(6).getTableNo()); + assertNotNull(ExcelWriterWrapper.buildTable()); + } +} diff --git a/ruoyi-common/ruoyi-common-job/src/test/java/org/dromara/common/job/SnailJobConfigTest.java b/ruoyi-common/ruoyi-common-job/src/test/java/org/dromara/common/job/SnailJobConfigTest.java new file mode 100644 index 000000000..2c51a65f7 --- /dev/null +++ b/ruoyi-common/ruoyi-common-job/src/test/java/org/dromara/common/job/SnailJobConfigTest.java @@ -0,0 +1,30 @@ +package org.dromara.common.job; + +import org.dromara.common.job.config.SnailJobConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.EnableScheduling; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@DisplayName("common-job 功能单元测试") +class SnailJobConfigTest { + + /** + * 验证定时任务配置仅在显式启用 Snail Job 时加载,并同时开启 Spring 调度能力。 + */ + @Test + @DisplayName("声明 Snail Job 启用条件") + void shouldDeclareSnailJobEnablementCondition() { + ConditionalOnProperty condition = SnailJobConfig.class.getAnnotation(ConditionalOnProperty.class); + + assertNotNull(condition); + assertEquals("snail-job", condition.prefix()); + assertArrayEquals(new String[]{"enabled"}, condition.name()); + assertEquals("true", condition.havingValue()); + assertNotNull(SnailJobConfig.class.getAnnotation(EnableScheduling.class)); + } +} diff --git a/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/utils/JsonUtils.java b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/utils/JsonUtils.java index 5b9f83219..196632264 100644 --- a/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/utils/JsonUtils.java +++ b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/utils/JsonUtils.java @@ -191,7 +191,8 @@ public class JsonUtils { * @return true = 合法 JSON,false = 非法或空 */ public static boolean isJson(String str) { - return readTreeQuietly(str) != null; + JsonNode node = readTreeQuietly(str); + return node != null && (node.isObject() || node.isArray()); } /** diff --git a/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/JsonTestContext.java b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/JsonTestContext.java new file mode 100644 index 000000000..ebe96ab37 --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/JsonTestContext.java @@ -0,0 +1,37 @@ +package org.dromara.common.json; + +import org.dromara.common.core.utils.SpringUtils; +import org.springframework.context.support.GenericApplicationContext; +import tools.jackson.databind.json.JsonMapper; + +/** + * 为依赖全局 JsonMapper 的测试初始化最小 Spring 容器。 + */ +public final class JsonTestContext { + + private static final GenericApplicationContext CONTEXT = createContext(); + + private JsonTestContext() { + } + + /** + * 触发最小 Spring 容器初始化,供 JsonUtils 获取 JsonMapper。 + */ + public static void initialize() { + CONTEXT.isActive(); + } + + /** + * 创建仅注册 JsonMapper 的测试容器。 + * + * @return 已启动的测试容器 + */ + private static GenericApplicationContext createContext() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean(JsonMapper.class, () -> JsonMapper.builder().build()); + context.refresh(); + new SpringUtils().setApplicationContext(context); + return context; + } + +} diff --git a/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/config/JacksonConfigTest.java b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/config/JacksonConfigTest.java new file mode 100644 index 000000000..663596abe --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/config/JacksonConfigTest.java @@ -0,0 +1,56 @@ +package org.dromara.common.json.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("JacksonConfig 单元测试") +class JacksonConfigTest { + + /** + * 验证安全范围内整数保持数字类型,超出 JavaScript 安全范围的整数和 BigDecimal 输出字符串。 + */ + @Test + @DisplayName("安全序列化大数字") + void shouldSerializeNumbersWithoutJavaScriptPrecisionLoss() { + JsonMapper mapper = configuredMapper(); + + assertEquals("9007199254740991", mapper.writeValueAsString(9_007_199_254_740_991L)); + assertEquals("\"9007199254740992\"", mapper.writeValueAsString(9_007_199_254_740_992L)); + assertEquals("\"1234567890.123456789\"", + mapper.writeValueAsString(new BigDecimal("1234567890.123456789"))); + } + + /** + * 验证 LocalDateTime 使用统一格式序列化,并支持带空白的日期和时间字符串反序列化。 + */ + @Test + @DisplayName("序列化和反序列化日期时间") + void shouldSerializeAndDeserializeTemporalValues() { + JsonMapper mapper = configuredMapper(); + LocalDateTime value = LocalDateTime.of(2026, 9, 15, 10, 20, 30); + + assertEquals("\"2026-09-15 10:20:30\"", mapper.writeValueAsString(value)); + assertEquals(value, mapper.readValue("\" 2026-09-15 10:20:30 \"", LocalDateTime.class)); + Date date = mapper.readValue("\"2026-09-15 10:20:30\"", Date.class); + assertTrue(date.getTime() > 0); + } + + /** + * 创建注册项目 Jackson 模块的独立 JsonMapper,避免依赖完整 Spring 容器。 + * + * @return 配置完成的 JsonMapper + */ + private static JsonMapper configuredMapper() { + return JsonMapper.builder() + .addModule(new JacksonConfig().registerJavaTimeModule()) + .build(); + } +} diff --git a/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/enhance/JsonEnhancementContextTest.java b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/enhance/JsonEnhancementContextTest.java new file mode 100644 index 000000000..f57b6cfcf --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/enhance/JsonEnhancementContextTest.java @@ -0,0 +1,46 @@ +package org.dromara.common.json.enhance; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("JsonEnhancementContext 单元测试") +class JsonEnhancementContextTest { + + /** + * 验证上下文属性按需创建,并可读取、复用和移除。 + */ + @Test + @DisplayName("上下文属性支持创建、读取和移除") + void shouldManageAttributes() { + JsonEnhancementContext context = new JsonEnhancementContext(null); + List values = context.getOrCreateAttribute("values", ArrayList::new); + values.add("first"); + + List sameValues = context.getOrCreateAttribute("values", ArrayList::new); + + assertSame(values, sameValues); + assertEquals(List.of("first"), context.>getAttribute("values")); + assertTrue(context.containsAttribute("values")); + context.removeAttribute("values"); + assertFalse(context.containsAttribute("values")); + } + + /** + * 验证响应增强处理标记能够被正确设置。 + */ + @Test + @DisplayName("可以标记响应需要增强处理") + void shouldMarkProcessingRequired() { + JsonEnhancementContext context = new JsonEnhancementContext(null); + + context.markProcessingRequired(); + + assertTrue(context.isProcessingRequired()); + } + +} diff --git a/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/enhance/JsonValueEnhancerTest.java b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/enhance/JsonValueEnhancerTest.java new file mode 100644 index 000000000..083800430 --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/enhance/JsonValueEnhancerTest.java @@ -0,0 +1,174 @@ +package org.dromara.common.json.enhance; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.util.LinkedHashMap; +import java.util.List; +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("JsonValueEnhancer 单元测试") +class JsonValueEnhancerTest { + + /** + * 验证没有处理器或没有字段命中时直接返回原对象,避免无意义的 JSON 树转换。 + */ + @Test + @DisplayName("无处理需求时保留原对象") + void shouldKeepOriginalBodyWhenProcessingIsNotRequired() { + Payload body = new Payload("secret", List.of(), new Object[0], null); + JsonValueEnhancer emptyEnhancer = new JsonValueEnhancer(JsonMapper.builder().build(), List.of()); + JsonValueEnhancer unmatchedEnhancer = new JsonValueEnhancer( + JsonMapper.builder().build(), List.of(new RecordingProcessor(false))); + + assertSame(body, emptyEnhancer.enhance(body)); + assertSame(body, unmatchedEnhancer.enhance(body)); + assertSame(null, emptyEnhancer.enhance(null)); + } + + /** + * 验证增强器递归处理 Map、集合、数组和 POJO,并且 prepare 在所有字段收集完成后只执行一次。 + */ + @Test + @DisplayName("递归增强混合对象结构") + void shouldEnhanceMapIterableArrayAndPojoValues() { + RecordingProcessor processor = new RecordingProcessor(true); + JsonValueEnhancer enhancer = new JsonValueEnhancer(JsonMapper.builder().build(), List.of(processor)); + Map body = new LinkedHashMap<>(); + body.put("payload", new Payload("root", List.of(new Child("list")), + new Object[]{new Child("array"), 3}, null)); + body.put("plain", "value"); + + JsonNode result = (JsonNode) enhancer.enhance(body); + + assertEquals("ROOT", result.get("payload").get("secret").stringValue()); + assertEquals("LIST", result.get("payload").get("children").get(0).get("secret").stringValue()); + assertEquals("ARRAY", result.get("payload").get("values").get(0).get("secret").stringValue()); + assertEquals(3, result.get("payload").get("values").get(1).intValue()); + assertEquals("value", result.get("plain").stringValue()); + assertEquals(3, processor.collectedValues.size()); + assertEquals(1, processor.prepareCalls); + assertEquals(4, processor.processCalls); + } + + /** + * 验证字段被替换为复杂对象后会执行二次增强,使新对象中的目标字段也得到处理。 + */ + @Test + @DisplayName("二次增强处理器生成的复杂对象") + void shouldEnhanceComplexValueProducedByProcessor() { + RecordingProcessor processor = new RecordingProcessor(true); + JsonValueEnhancer enhancer = new JsonValueEnhancer(JsonMapper.builder().build(), List.of(processor)); + + JsonNode result = (JsonNode) enhancer.enhance( + new Payload("root", List.of(), new Object[0], "translated")); + + assertEquals("ROOT", result.get("secret").stringValue()); + assertEquals("TRANSLATED", result.get("replacement").get("secret").stringValue()); + assertEquals(2, processor.prepareCalls); + } + + /** + * 验证已有 JsonNode 不重复处理,并正确过滤字符串和字节数组消息转换器。 + */ + @Test + @DisplayName("判断响应转换器支持范围") + void shouldFilterUnsupportedMessageConverters() { + JsonMapper mapper = JsonMapper.builder().build(); + JsonValueEnhancer enhancer = new JsonValueEnhancer(mapper, List.of(new RecordingProcessor(true))); + JsonNode tree = mapper.createObjectNode().put("secret", "value"); + + assertSame(tree, enhancer.enhance(tree)); + assertTrue(enhancer.supports(JacksonJsonHttpMessageConverter.class)); + assertFalse(enhancer.supports(StringHttpMessageConverter.class)); + assertFalse(enhancer.supports(ByteArrayHttpMessageConverter.class)); + assertFalse(new JsonValueEnhancer(mapper, List.of()).supports(JacksonJsonHttpMessageConverter.class)); + } + + private record Payload(String secret, List children, Object[] values, String replacement) { + } + + private record Child(String secret) { + } + + private static class RecordingProcessor implements JsonFieldProcessor { + + private final boolean enabled; + private final List collectedValues = new java.util.ArrayList<>(); + private int prepareCalls; + private int processCalls; + + /** + * 创建可控制是否命中字段的记录型处理器。 + * + * @param enabled 是否处理目标字段 + */ + private RecordingProcessor(boolean enabled) { + this.enabled = enabled; + } + + /** + * 仅匹配 secret 和 replacement 字段,控制测试覆盖的增强范围。 + * + * @param fieldContext 字段上下文 + * @return 是否处理当前字段 + */ + @Override + public boolean supports(JsonFieldContext fieldContext) { + return enabled && ("secret".equals(fieldContext.propertyName()) + || "replacement".equals(fieldContext.propertyName())); + } + + /** + * 记录非空字段值,验证递归收集覆盖了所有目标对象。 + * + * @param fieldContext 字段上下文 + * @param context 增强上下文 + */ + @Override + public void collect(JsonFieldContext fieldContext, JsonEnhancementContext context) { + if (fieldContext.value() != null) { + collectedValues.add(fieldContext.value()); + } + } + + /** + * 记录预处理次数并写入跨阶段属性。 + * + * @param context 增强上下文 + */ + @Override + public void prepare(JsonEnhancementContext context) { + prepareCalls++; + context.setAttribute("prepared", Boolean.TRUE); + } + + /** + * 将 secret 转为大写,并把 replacement 文本转换为待二次增强的对象。 + * + * @param fieldContext 字段上下文 + * @param value 当前字段值 + * @param context 增强上下文 + * @return 增强后的字段值 + */ + @Override + public Object process(JsonFieldContext fieldContext, Object value, JsonEnhancementContext context) { + processCalls++; + assertTrue(context.containsAttribute("prepared")); + if ("replacement".equals(fieldContext.propertyName())) { + return value == null ? null : new Child(String.valueOf(value)); + } + return value == null ? null : String.valueOf(value).toUpperCase(); + } + } +} diff --git a/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/utils/JsonUtilsTest.java b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/utils/JsonUtilsTest.java new file mode 100644 index 000000000..4cbe9f501 --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/utils/JsonUtilsTest.java @@ -0,0 +1,108 @@ +package org.dromara.common.json.utils; + +import cn.hutool.core.lang.Dict; +import org.dromara.common.json.JsonTestContext; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("JsonUtils 单元测试") +class JsonUtilsTest { + + /** + * 在 JsonUtils 类初始化前注册其依赖的全局 JsonMapper。 + */ + @BeforeAll + static void initializeJsonMapper() { + JsonTestContext.initialize(); + } + + /** + * 验证普通对象能够完成 JSON 往返转换及空输入处理。 + */ + @Test + @DisplayName("对象可以完成 JSON 序列化和反序列化") + void shouldSerializeAndDeserializeObject() { + TestUser user = new TestUser(1L, "admin"); + + String json = JsonUtils.toJsonString(user); + TestUser result = JsonUtils.parseObject(json, TestUser.class); + + assertEquals(user, result); + assertNull(JsonUtils.toJsonString(null)); + assertNull(JsonUtils.parseObject("", TestUser.class)); + } + + /** + * 验证对象 Map、Map 列表和指定类型列表的解析结果。 + */ + @Test + @DisplayName("解析对象 Map 和对象列表") + void shouldParseMapsAndArrays() { + Dict map = JsonUtils.parseMap("{\"name\":\"admin\",\"enabled\":true}"); + List maps = JsonUtils.parseArrayMap("[{\"id\":1},{\"id\":2}]"); + List users = JsonUtils.parseArray("[{\"id\":1,\"name\":\"a\"}]", TestUser.class); + + assertEquals("admin", map.getStr("name")); + assertEquals(2, maps.size()); + assertEquals(new TestUser(1L, "a"), users.getFirst()); + assertTrue(JsonUtils.parseArray("", TestUser.class).isEmpty()); + } + + /** + * 验证指定敏感字段会从嵌套对象和数组元素中递归移除。 + */ + @Test + @DisplayName("递归移除对象和数组中的指定字段") + void shouldRemoveFieldsRecursively() { + Map value = Map.of( + "password", "root-secret", + "profile", Map.of("name", "admin", "password", "profile-secret"), + "items", List.of(Map.of("password", "item-secret", "value", 1)) + ); + + String json = JsonUtils.toJsonStringExcludeFields(value, "password"); + JsonNode node = JsonUtils.getJsonMapper().readTree(json); + + assertFalse(node.has("password")); + assertFalse(node.get("profile").has("password")); + assertFalse(node.get("items").get(0).has("password")); + assertEquals("admin", node.get("profile").get("name").asString()); + } + + /** + * 验证业务 JSON 仅接受对象和数组,不接受 JSON 标量。 + */ + @Test + @DisplayName("仅将 JSON 对象或数组识别为业务 JSON") + void shouldRecognizeOnlyObjectOrArrayJson() { + assertTrue(JsonUtils.isJson("{\"id\":1}")); + assertTrue(JsonUtils.isJson("[1,2]")); + assertFalse(JsonUtils.isJson("1")); + assertFalse(JsonUtils.isJson("\"text\"")); + assertFalse(JsonUtils.isJson("invalid")); + assertFalse(JsonUtils.isJson(" ")); + } + + /** + * 验证对象和数组类型判断不会相互混淆。 + */ + @Test + @DisplayName("区分 JSON 对象和数组") + void shouldDistinguishObjectAndArray() { + assertTrue(JsonUtils.isJsonObject("{}")); + assertFalse(JsonUtils.isJsonObject("[]")); + assertTrue(JsonUtils.isJsonArray("[]")); + assertFalse(JsonUtils.isJsonArray("{}")); + } + + private record TestUser(Long id, String name) { + } + +} diff --git a/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/validate/JsonPatternValidatorTest.java b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/validate/JsonPatternValidatorTest.java new file mode 100644 index 000000000..245f300ff --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/test/java/org/dromara/common/json/validate/JsonPatternValidatorTest.java @@ -0,0 +1,63 @@ +package org.dromara.common.json.validate; + +import org.dromara.common.json.JsonTestContext; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("JsonPatternValidator 单元测试") +class JsonPatternValidatorTest { + + /** + * 在校验器调用 JsonUtils 前初始化全局 JsonMapper。 + */ + @BeforeAll + static void initializeJsonMapper() { + JsonTestContext.initialize(); + } + + /** + * 验证空值由 NotNull 或 NotBlank 等其他注解负责约束。 + */ + @Test + @DisplayName("空值交由其他校验注解处理") + void blankValueShouldBeValid() { + JsonPatternValidator validator = validator(JsonType.ANY); + + assertTrue(validator.isValid(null, null)); + assertTrue(validator.isValid(" ", null)); + } + + /** + * 验证 OBJECT、ARRAY 和 ANY 三种类型约束的分支行为。 + */ + @Test + @DisplayName("按注解配置校验 JSON 类型") + void shouldValidateConfiguredJsonType() { + assertTrue(validator(JsonType.OBJECT).isValid("{\"id\":1}", null)); + assertFalse(validator(JsonType.OBJECT).isValid("[1]", null)); + assertTrue(validator(JsonType.ARRAY).isValid("[1]", null)); + assertFalse(validator(JsonType.ARRAY).isValid("{\"id\":1}", null)); + assertFalse(validator(JsonType.ANY).isValid("1", null)); + } + + /** + * 根据指定 JSON 类型创建已初始化的校验器。 + * + * @param type JSON 类型 + * @return 校验器 + */ + private static JsonPatternValidator validator(JsonType type) { + JsonPattern annotation = mock(JsonPattern.class); + when(annotation.type()).thenReturn(type); + JsonPatternValidator validator = new JsonPatternValidator(); + validator.initialize(annotation); + return validator; + } + +} diff --git a/ruoyi-common/ruoyi-common-liteflow/src/test/java/org/dromara/common/liteflow/LiteFlowComponentTest.java b/ruoyi-common/ruoyi-common-liteflow/src/test/java/org/dromara/common/liteflow/LiteFlowComponentTest.java new file mode 100644 index 000000000..bbb101bd1 --- /dev/null +++ b/ruoyi-common/ruoyi-common-liteflow/src/test/java/org/dromara/common/liteflow/LiteFlowComponentTest.java @@ -0,0 +1,73 @@ +package org.dromara.common.liteflow; + +import org.dromara.common.liteflow.component.AlwaysFalseComponent; +import org.dromara.common.liteflow.component.AlwaysTrueComponent; +import org.dromara.common.liteflow.component.NoopComponent; +import org.dromara.common.liteflow.component.FailComponent; +import org.dromara.common.liteflow.component.ContextRequiredComponent; +import org.dromara.common.liteflow.core.FailMessageProvider; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +@DisplayName("common-liteflow 功能单元测试") +class LiteFlowComponentTest { + + /** + * 验证内置 LiteFlow 条件节点始终返回其声明的固定布尔值。 + */ + @Test + @DisplayName("执行固定布尔条件节点") + void shouldReturnFixedBooleanValues() { + assertTrue(new AlwaysTrueComponent().processBoolean()); + assertFalse(new AlwaysFalseComponent().processBoolean()); + } + + /** + * 验证空操作节点可被安全执行,作为无需业务动作的显式流程分支。 + */ + @Test + @DisplayName("执行空操作节点") + void shouldExecuteNoopComponentSafely() { + assertDoesNotThrow(() -> new NoopComponent().process()); + } + + /** + * 验证失败节点优先采用业务上下文消息,并在缺少上下文时使用统一默认消息。 + */ + @Test + @DisplayName("从流程上下文解析失败消息") + void shouldResolveFailureMessageFromContextOrDefault() { + FailComponent contextual = spy(new FailComponent()); + FailMessageProvider provider = () -> "库存不足"; + doReturn(provider).when(contextual).getFirstContextBean(); + FailComponent fallback = spy(new FailComponent()); + doReturn(null).when(fallback).getFirstContextBean(); + + assertEquals("库存不足", assertThrows(ServiceException.class, contextual::process).getMessage()); + assertEquals("LiteFlow 链路执行失败", assertThrows(ServiceException.class, fallback::process).getMessage()); + } + + /** + * 验证上下文必填节点拒绝空上下文,并允许有效流程上下文继续执行。 + */ + @Test + @DisplayName("校验流程上下文是否存在") + void shouldRequireLiteFlowContext() { + ContextRequiredComponent missing = spy(new ContextRequiredComponent()); + doReturn(null).when(missing).getFirstContextBean(); + ContextRequiredComponent present = spy(new ContextRequiredComponent()); + doReturn(new Object()).when(present).getFirstContextBean(); + + assertEquals("LiteFlow 上下文不能为空", assertThrows(ServiceException.class, missing::process).getMessage()); + assertDoesNotThrow(present::process); + } +} diff --git a/ruoyi-common/ruoyi-common-liteflow/src/test/java/org/dromara/common/liteflow/LiteFlowUtilsTest.java b/ruoyi-common/ruoyi-common-liteflow/src/test/java/org/dromara/common/liteflow/LiteFlowUtilsTest.java new file mode 100644 index 000000000..5ead45258 --- /dev/null +++ b/ruoyi-common/ruoyi-common-liteflow/src/test/java/org/dromara/common/liteflow/LiteFlowUtilsTest.java @@ -0,0 +1,127 @@ +package org.dromara.common.liteflow; + +import cn.hutool.extra.spring.SpringUtil; +import com.yomahub.liteflow.core.FlowExecutor; +import com.yomahub.liteflow.flow.LiteflowResponse; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.liteflow.utils.LiteFlowUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.support.StaticApplicationContext; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@DisplayName("LiteFlow 工具契约单元测试") +class LiteFlowUtilsTest { + + private static FlowExecutor flowExecutor; + + /** + * 注册 LiteFlow 工具所需的 mock 执行器,验证工具逻辑时不启动真实流程引擎。 + */ + @BeforeAll + static void initializeFlowExecutor() { + flowExecutor = mock(FlowExecutor.class); + StaticApplicationContext context = new StaticApplicationContext(); + context.getBeanFactory().registerSingleton("flowExecutor", flowExecutor); + context.refresh(); + new SpringUtil().setApplicationContext(context); + } + + /** + * 清理执行器调用记录,确保每个用例只验证自身的链路交互。 + */ + @BeforeEach + void resetExecutorInteractions() { + reset(flowExecutor); + } + + /** + * 验证空上下文在调用引擎前被拒绝,并返回统一业务异常。 + */ + @Test + @DisplayName("拒绝空流程上下文") + void shouldRejectNullContextBeforeCallingExecutor() { + ServiceException exception = assertThrows(ServiceException.class, + () -> LiteFlowUtils.execute("demo-chain", null)); + + assertEquals("LiteFlow 上下文不能为空", exception.getMessage()); + verifyNoInteractions(flowExecutor); + } + + /** + * 验证成功响应只透传链路标识、上下文和空配置,不额外抛出异常。 + */ + @Test + @DisplayName("执行成功链路") + void shouldExecuteSuccessfulChain() { + Object context = new Object(); + LiteflowResponse response = new LiteflowResponse(); + response.setSuccess(true); + when(flowExecutor.execute2Resp(eq("demo-chain"), isNull(), any(Object.class))).thenReturn(response); + + assertDoesNotThrow(() -> LiteFlowUtils.execute("demo-chain", context)); + verify(flowExecutor).execute2Resp("demo-chain", null, context); + } + + /** + * 验证 LiteFlow 返回运行时失败原因时原样抛出,保留业务异常类型和堆栈。 + */ + @Test + @DisplayName("原样传播运行时失败") + void shouldRethrowRuntimeFailureCause() { + IllegalStateException cause = new IllegalStateException("chain failed"); + LiteflowResponse response = failedResponse(cause, "failed"); + Object context = new Object(); + when(flowExecutor.execute2Resp(eq("runtime-chain"), isNull(), any(Object.class))).thenReturn(response); + + assertSame(cause, assertThrows(IllegalStateException.class, + () -> LiteFlowUtils.execute("runtime-chain", context))); + } + + /** + * 验证受检异常和无原因失败会转换为 ServiceException,并分别使用原因或响应消息。 + */ + @Test + @DisplayName("转换受检异常和无原因失败") + void shouldWrapCheckedOrMissingFailureCause() { + Exception checked = new Exception("checked failure"); + Object context = new Object(); + LiteflowResponse checkedResponse = failedResponse(checked, "ignored"); + LiteflowResponse emptyResponse = failedResponse(null, "response failure"); + when(flowExecutor.execute2Resp(eq("checked-chain"), isNull(), any(Object.class))) + .thenReturn(checkedResponse); + when(flowExecutor.execute2Resp(eq("empty-chain"), isNull(), any(Object.class))) + .thenReturn(emptyResponse); + + ServiceException checkedException = assertThrows(ServiceException.class, + () -> LiteFlowUtils.execute("checked-chain", context)); + ServiceException emptyException = assertThrows(ServiceException.class, + () -> LiteFlowUtils.execute("empty-chain", context)); + + assertEquals("checked failure", checkedException.getMessage()); + assertEquals("response failure", emptyException.getMessage()); + } + + /** + * 创建失败响应并填充工具日志所需的最小消息字段。 + * + * @param cause 流程失败原因 + * @param message 流程失败消息 + * @return 失败响应 + */ + private static LiteflowResponse failedResponse(Exception cause, String message) { + LiteflowResponse response = mock(LiteflowResponse.class); + when(response.isSuccess()).thenReturn(false); + when(response.getCause()).thenReturn(cause); + when(response.getMessage()).thenReturn(message); + when(response.getRequestId()).thenReturn("request-id"); + when(response.getExecuteStepStrWithTime()).thenReturn("steps"); + return response; + } +} diff --git a/ruoyi-common/ruoyi-common-log/src/test/java/org/dromara/common/log/LogFunctionTest.java b/ruoyi-common/ruoyi-common-log/src/test/java/org/dromara/common/log/LogFunctionTest.java new file mode 100644 index 000000000..ad715b1da --- /dev/null +++ b/ruoyi-common/ruoyi-common-log/src/test/java/org/dromara/common/log/LogFunctionTest.java @@ -0,0 +1,184 @@ +package org.dromara.common.log; + +import cn.hutool.extra.spring.SpringUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.aspect.LogAspect; +import org.dromara.common.log.enums.BusinessStatus; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.log.enums.OperatorType; +import org.dromara.common.log.event.OperLogEvent; +import org.dromara.common.satoken.utils.LoginHelper; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.PayloadApplicationEvent; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import tools.jackson.databind.json.JsonMapper; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; + +@DisplayName("common-log 功能单元测试") +class LogFunctionTest { + + private static final AtomicReference LAST_EVENT = new AtomicReference<>(); + + /** + * 初始化日志切面依赖的 JSON 映射器和事件容器,避免启动完整 Spring 应用。 + */ + @BeforeAll + static void initializeLogInfrastructure() { + StaticApplicationContext context = new StaticApplicationContext(); + context.getBeanFactory().registerSingleton("jsonMapper", JsonMapper.builder().build()); + context.addApplicationListener(event -> { + if (event instanceof PayloadApplicationEvent payload + && payload.getPayload() instanceof OperLogEvent operLog) { + LAST_EVENT.set(operLog); + } + }); + context.refresh(); + new SpringUtil().setApplicationContext(context); + } + + /** + * 为每个日志切面测试绑定独立 HTTP 请求并清空上一次捕获的事件。 + */ + @BeforeEach + void bindRequestContext() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/system/users"); + request.addHeader(LoginHelper.CLIENT_KEY, "web-client"); + request.addHeader("X-Forwarded-For", "10.0.0.8"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + LAST_EVENT.set(null); + } + + /** + * 清理线程请求上下文,避免日志切面测试污染后续用例。 + */ + @AfterEach + void resetRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + + /** + * 验证上传文件、Servlet 请求及包含这些对象的集合会从日志参数中排除。 + */ + @Test + @DisplayName("识别不可记录的请求参数") + void shouldFilterServletAndUploadObjects() { + LogAspect aspect = new LogAspect(); + MockMultipartFile file = new MockMultipartFile("file", "demo.txt", "text/plain", new byte[]{1}); + MockHttpServletRequest request = new MockHttpServletRequest(); + + assertTrue(aspect.isFilterObject(file)); + assertTrue(aspect.isFilterObject(request)); + assertTrue(aspect.isFilterObject(new Object[]{"value", file})); + assertTrue(aspect.isFilterObject(List.of("value", file))); + assertTrue(aspect.isFilterObject(Map.of("file", file))); + assertFalse(aspect.isFilterObject(List.of("value", 1L))); + } + + /** + * 验证操作日志注解保存业务类型、操作人类型及请求响应开关。 + * + * @throws Exception 读取测试方法注解失败 + */ + @Test + @DisplayName("读取操作日志注解配置") + void shouldExposeLogAnnotationConfiguration() throws Exception { + Method method = TestController.class.getDeclaredMethod("update"); + Log log = method.getAnnotation(Log.class); + + assertEquals("用户管理", log.title()); + assertEquals(BusinessType.UPDATE, log.businessType()); + assertEquals(OperatorType.MOBILE, log.operatorType()); + assertFalse(log.isSaveRequestData()); + assertTrue(log.isSaveResponseData()); + assertArrayEquals(new String[]{"password"}, log.excludeParamNames()); + } + + /** + * 验证日志切面执行目标方法后发布完整事件,并从序列化请求参数中排除密码等敏感字段。 + */ + @Test + @DisplayName("记录成功操作日志并排除敏感参数") + void shouldPublishSuccessfulOperationLogWithFilteredParameters() throws Throwable { + Log annotation = TestController.class.getDeclaredMethod("create", CreateRequest.class).getAnnotation(Log.class); + ProceedingJoinPoint joinPoint = joinPoint(new CreateRequest("alice", "secret"), Map.of("id", 1L)); + + Object result; + try (var login = mockStatic(LoginHelper.class)) { + login.when(LoginHelper::getLoginUser).thenReturn(null); + result = new LogAspect().doAround(joinPoint, annotation); + } + + OperLogEvent event = LAST_EVENT.get(); + assertEquals(Map.of("id", 1L), result); + assertNotNull(event); + assertEquals(BusinessStatus.SUCCESS.ordinal(), event.getStatus()); + assertEquals("新增用户", event.getTitle()); + assertEquals("POST", event.getRequestMethod()); + assertEquals("/system/users", event.getOperUrl()); + assertEquals("10.0.0.8", event.getOperIp()); + assertEquals("web-client", event.getClientKey()); + assertTrue(event.getOperParam().contains("alice")); + assertFalse(event.getOperParam().contains("secret")); + assertTrue(event.getJsonResult().contains("\"id\":1")); + } + + /** + * 创建可返回指定结果的切点,并提供日志方法名、目标类和请求参数。 + * + * @param request 请求参数 + * @param result 目标方法返回值 + * @return 模拟切点 + */ + private static ProceedingJoinPoint joinPoint(CreateRequest request, Object result) throws Throwable { + ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + Signature signature = mock(Signature.class); + when(signature.getName()).thenReturn("create"); + when(joinPoint.getSignature()).thenReturn(signature); + when(joinPoint.getTarget()).thenReturn(new TestController()); + when(joinPoint.getArgs()).thenReturn(new Object[]{request}); + when(joinPoint.proceed()).thenReturn(result); + return joinPoint; + } + + private static class TestController { + + /** + * 提供完整日志注解配置供反射测试读取。 + */ + @Log(title = "用户管理", businessType = BusinessType.UPDATE, operatorType = OperatorType.MOBILE, + isSaveRequestData = false, excludeParamNames = "password") + private void update() { + } + + /** + * 提供保存请求和响应数据的日志配置供完整切面测试。 + * + * @param request 新增请求 + */ + @Log(title = "新增用户", businessType = BusinessType.INSERT, excludeParamNames = "password") + private void create(CreateRequest request) { + } + } + + private record CreateRequest(String username, String password) { + } +} diff --git a/ruoyi-common/ruoyi-common-mail/src/test/java/org/dromara/common/mail/MailPropertiesTest.java b/ruoyi-common/ruoyi-common-mail/src/test/java/org/dromara/common/mail/MailPropertiesTest.java new file mode 100644 index 000000000..007633389 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mail/src/test/java/org/dromara/common/mail/MailPropertiesTest.java @@ -0,0 +1,46 @@ +package org.dromara.common.mail; + +import cn.hutool.extra.mail.MailAccount; +import org.dromara.common.mail.config.properties.MailProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("common-mail 功能单元测试") +class MailPropertiesTest { + + /** + * 验证业务邮件属性能够完整转换为 Hutool 邮件账户,避免发送时遗漏安全和超时配置。 + */ + @Test + @DisplayName("转换邮件账户配置") + void shouldConvertPropertiesToMailAccount() { + MailProperties properties = new MailProperties(); + properties.setHost("smtp.example.com"); + properties.setPort(465); + properties.setAuth(true); + properties.setUser("sender@example.com"); + properties.setPass("secret"); + properties.setFrom("Sender "); + properties.setStarttlsEnable(true); + properties.setSslEnable(true); + properties.setTimeout(5000L); + properties.setConnectionTimeout(3000L); + + MailAccount account = properties.toMailAccount(); + + assertEquals("smtp.example.com", account.getHost()); + assertEquals(465, account.getPort()); + assertTrue(account.isAuth()); + assertEquals("sender@example.com", account.getUser()); + assertEquals("secret", account.getPass()); + assertEquals("Sender ", account.getFrom()); + assertTrue(account.isStarttlsEnable()); + assertTrue(account.isSslEnable()); + assertEquals(5000L, ReflectionTestUtils.getField(account, "timeout")); + assertEquals(3000L, ReflectionTestUtils.getField(account, "connectionTimeout")); + } +} diff --git a/ruoyi-common/ruoyi-common-mcp/src/test/java/org/dromara/common/mcp/McpResultTest.java b/ruoyi-common/ruoyi-common-mcp/src/test/java/org/dromara/common/mcp/McpResultTest.java new file mode 100644 index 000000000..33b079f87 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mcp/src/test/java/org/dromara/common/mcp/McpResultTest.java @@ -0,0 +1,67 @@ +package org.dromara.common.mcp; + +import io.modelcontextprotocol.spec.McpSchema; +import org.dromara.common.mcp.core.McpResourceReadResult; +import org.dromara.common.mcp.core.McpToolCallResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +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.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("common-mcp 功能单元测试") +class McpResultTest { + + /** + * 验证 MCP 工具调用结果会保留服务端、内容、结构化数据和错误标记。 + */ + @Test + @DisplayName("转换 MCP 工具调用结果") + void shouldConvertMcpToolCallResult() { + McpSchema.CallToolResult sdkResult = mock(McpSchema.CallToolResult.class); + McpSchema.Content content = mock(McpSchema.Content.class); + when(sdkResult.isError()).thenReturn(true); + when(sdkResult.content()).thenReturn(List.of(content)); + when(sdkResult.structuredContent()).thenReturn(Map.of("id", 1)); + + McpToolCallResult result = McpToolCallResult.of("server-a", sdkResult); + + assertEquals("server-a", result.serverName()); + assertTrue(result.error()); + assertEquals(List.of(content), result.content()); + assertEquals(Map.of("id", 1), result.structuredContent()); + } + + /** + * 验证 MCP SDK 返回空错误标记时按成功处理,兼容未显式设置 isError 的服务端。 + */ + @Test + @DisplayName("兼容空 MCP 错误标记") + void shouldTreatNullMcpErrorFlagAsSuccess() { + McpSchema.CallToolResult sdkResult = mock(McpSchema.CallToolResult.class); + + assertFalse(McpToolCallResult.of("server-a", sdkResult).error()); + } + + /** + * 验证 MCP 资源读取结果会附加来源服务端并保留资源内容列表。 + */ + @Test + @DisplayName("转换 MCP 资源读取结果") + void shouldConvertMcpResourceReadResult() { + McpSchema.ReadResourceResult sdkResult = mock(McpSchema.ReadResourceResult.class); + McpSchema.ResourceContents content = mock(McpSchema.ResourceContents.class); + when(sdkResult.contents()).thenReturn(List.of(content)); + + McpResourceReadResult result = McpResourceReadResult.of("server-b", sdkResult); + + assertEquals("server-b", result.serverName()); + assertEquals(List.of(content), result.contents()); + } +} diff --git a/ruoyi-common/ruoyi-common-mqtt/src/test/java/org/dromara/common/mqtt/MqttAutoConfigurationTest.java b/ruoyi-common/ruoyi-common-mqtt/src/test/java/org/dromara/common/mqtt/MqttAutoConfigurationTest.java new file mode 100644 index 000000000..bed472ddf --- /dev/null +++ b/ruoyi-common/ruoyi-common-mqtt/src/test/java/org/dromara/common/mqtt/MqttAutoConfigurationTest.java @@ -0,0 +1,40 @@ +package org.dromara.common.mqtt; + +import org.dromara.common.mqtt.config.MqttAutoConfiguration; +import org.dromara.common.mqtt.listener.MqttClientConnectListener; +import org.dromara.common.mqtt.listener.MqttClientGlobalMessageListener; +import org.dromara.mica.mqtt.core.client.MqttClientCreator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +@DisplayName("common-mqtt 功能单元测试") +class MqttAutoConfigurationTest { + + /** + * 验证 MQTT 自动配置能够创建连接与全局消息监听器,且不需要建立真实网络连接。 + */ + @Test + @DisplayName("创建 MQTT 监听器") + void shouldCreateMqttListeners() { + MqttAutoConfiguration configuration = new MqttAutoConfiguration(); + + MqttClientConnectListener connectListener = + configuration.mqttClientConnectListener(mock(MqttClientCreator.class)); + MqttClientGlobalMessageListener messageListener = configuration.mqttClientGlobalMessageListener(); + + assertNotNull(connectListener); + assertNotNull(messageListener); + } + + /** + * 验证 MQTT 自定义器 Bean 可以独立创建,避免自动配置方法意外依赖运行时连接状态。 + */ + @Test + @DisplayName("创建 MQTT 客户端自定义器") + void shouldCreateMqttClientCustomizer() { + assertNotNull(new MqttAutoConfiguration().mqttClientCustomizer()); + } +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/query/LambdaJoinQueryBuilder.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/query/LambdaJoinQueryBuilder.java index 2623f40d6..1847c2104 100644 --- a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/query/LambdaJoinQueryBuilder.java +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/query/LambdaJoinQueryBuilder.java @@ -209,7 +209,11 @@ public final class LambdaJoinQueryBuilder { */ public LambdaJoinQueryBuilder selectSub(Class entityClass, Consumer> consumer, String alias) { SubQuery subQuery = buildPlaceholderSubQuery(entityClass, consumer); - wrapper.selectFunc("(" + subQuery.build() + ")", func -> func.values(subQuery.params()), + wrapper.selectFunc("(" + subQuery.build() + ")", func -> { + // MPJ raw select functions require a non-null column argument array even when SQL has no column placeholders. + func.setArgs(new SFunction[0]); + return func.values(subQuery.params()); + }, AggregateSelectUtils.checkAlias(alias)); return this; } diff --git a/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/mapper/LambdaCrudChainWrapperTest.java b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/mapper/LambdaCrudChainWrapperTest.java new file mode 100644 index 000000000..9f828281a --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/mapper/LambdaCrudChainWrapperTest.java @@ -0,0 +1,253 @@ +package org.dromara.common.mybatis.core.mapper; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.HashSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.when; + +@DisplayName("LambdaCrudChainWrapper 单元测试") +class LambdaCrudChainWrapperTest { + + /** + * 初始化测试实体表元数据,确保测试覆盖真实的 MyBatis-Plus Lambda 字段解析。 + */ + @BeforeAll + static void initializeTableMetadata() { + MybatisConfiguration configuration = new MybatisConfiguration(); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, "crudEntity"), CrudEntity.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, "crudRelation"), CrudRelation.class); + } + + /** + * 验证构造器从 Mapper 获取实体类型,并组合查询字段、聚合字段、嵌套条件和共享参数。 + */ + @Test + @DisplayName("构造 Mapper 级查询链") + void shouldBuildMapperBoundQueryState() { + LambdaCrudChainWrapper wrapper = wrapper() + .select(CrudEntity::getId, CrudEntity::getName) + .selectSum(CrudEntity::getScore, "totalScore") + .selectCountAll(CrudVo::getTotal) + .eq(CrudEntity::getName, "alice") + .and(nested -> nested.ge(CrudEntity::getScore, 60) + .or().isNull(CrudEntity::getRemark)); + + assertEquals(CrudEntity.class, wrapper.getEntityClass()); + assertTrue(wrapper.getSqlSelect().contains("id,name")); + assertTrue(wrapper.getSqlSelect().contains("SUM(score) AS totalScore")); + assertTrue(wrapper.getSqlSelect().contains("COUNT(*) AS total")); + assertTrue(wrapper.getSqlSegment().contains("AND (score >=")); + assertEquals(new HashSet<>(List.of("alice", 60)), + new HashSet<>(wrapper.getParamNameValuePairs().values())); + } + + /** + * 验证查询条件和更新 SET 片段可以共存,并保持可选赋值、原生 SQL、自增和自减语义。 + */ + @Test + @DisplayName("组合查询和更新片段") + void shouldComposeQueryAndUpdateFragments() { + LambdaCrudChainWrapper wrapper = wrapper() + .eq(CrudEntity::getId, 1L) + .set(CrudEntity::getName, "updated") + .setIfPresent(CrudEntity::getRemark, null) + .setIfText(CrudEntity::getRemark, " ") + .setIfText(CrudEntity::getRemark, "memo") + .setSql("score = {0}", 80) + .setIncrBy(CrudEntity::getScore, new BigDecimal("1.50")) + .setDecrBy(CrudEntity::getScore, 2); + + String sqlSet = wrapper.getSqlSet(); + + assertTrue(wrapper.getSqlSegment().contains("id =")); + assertTrue(sqlSet.contains("name=")); + assertTrue(sqlSet.contains("remark=")); + assertTrue(sqlSet.contains("score =")); + assertTrue(sqlSet.contains("score=score + 1.50")); + assertTrue(sqlSet.contains("score=score - 2")); + assertFalse(wrapper.getParamNameValuePairs().values().contains(null)); + assertTrue(wrapper.getParamNameValuePairs().values().containsAll(List.of(1L, "updated", "memo", 80))); + } + + /** + * 验证 Mapper 链式包装器中的子查询沿用主 Wrapper 参数序列并生成逻辑删除条件。 + */ + @Test + @DisplayName("构造 Mapper 链式子查询") + void shouldBuildSubQueriesWithSharedParameters() { + LambdaCrudChainWrapper wrapper = wrapper() + .select(CrudEntity::getId) + .selectSub(CrudRelation.class, sub -> sub + .selectCountAll() + .eqColumn(CrudRelation::getOwnerId, CrudEntity::getId) + .eq(CrudRelation::getState, "selected"), CrudVo::getTotal) + .inSub(CrudEntity::getId, CrudRelation.class, sub -> sub + .select(CrudRelation::getOwnerId) + .eq(CrudRelation::getState, "included")) + .existsSub(CrudRelation.class, sub -> sub + .selectCountAll() + .eqColumn(CrudRelation::getOwnerId, CrudEntity::getId) + .eq(CrudRelation::getState, "existing")); + + assertTrue(wrapper.getSqlSelect().contains("SELECT COUNT(*) FROM crud_relation")); + assertTrue(wrapper.getSqlSelect().contains("deleted=0")); + assertTrue(wrapper.getSqlSegment().contains("id IN (SELECT owner_id FROM crud_relation")); + assertTrue(wrapper.getSqlSegment().contains("EXISTS (SELECT COUNT(*) FROM crud_relation")); + assertEquals(new HashSet<>(List.of("selected", "included", "existing")), + new HashSet<>(wrapper.getParamNameValuePairs().values())); + } + + /** + * 验证 clear 清除查询、更新、参数和附加 SQL,防止复用 Wrapper 时残留上一次状态。 + */ + @Test + @DisplayName("清空 Mapper 链式状态") + void shouldClearQueryAndUpdateState() { + LambdaCrudChainWrapper wrapper = wrapper() + .select(CrudEntity::getName) + .selectCountAll("total") + .set(CrudEntity::getName, "updated") + .eq(CrudEntity::getId, 1L) + .first("/*+ INDEX */") + .comment("update-comment") + .last("LIMIT 1"); + + wrapper.clear(); + + assertNull(wrapper.getSqlSelect()); + assertNull(wrapper.getSqlSet()); + assertTrue(wrapper.getSqlSegment().isEmpty()); + assertTrue(wrapper.getCustomSqlSegment().isEmpty()); + assertTrue(wrapper.getParamNameValuePairs().isEmpty()); + } + + /** + * 验证 BaseMapperPlus 默认方法可以从具体 Mapper 泛型解析实体和 VO,并创建项目链式包装器。 + */ + @Test + @DisplayName("通过 BaseMapperPlus 默认入口创建查询链") + void shouldResolveMapperGenericTypesAndCreateLambdaChain() { + CrudMapper mapper = mock(CrudMapper.class, CALLS_REAL_METHODS); + + LambdaCrudChainWrapper wrapper = mapper.lambda(); + + assertEquals(CrudEntity.class, mapper.currentModelClass()); + assertEquals(CrudVo.class, mapper.currentVoClass()); + assertEquals(CrudEntity.class, wrapper.getEntityClass()); + } + + /** + * 创建绑定测试实体类型的 Mapper 链式包装器。 + * + * @return Mapper 链式包装器 + */ + @SuppressWarnings("unchecked") + private static LambdaCrudChainWrapper wrapper() { + BaseMapperPlus mapper = mock(BaseMapperPlus.class); + when(mapper.currentModelClass()).thenReturn(CrudEntity.class); + return new LambdaCrudChainWrapper<>(mapper); + } + + private interface CrudMapper extends BaseMapperPlus { + } + + @TableName("crud_entity") + private static class CrudEntity { + @TableId + private Long id; + private String name; + private Integer score; + private String remark; + + /** + * 返回实体主键,供 Lambda 字段解析。 + * + * @return 实体主键 + */ + public Long getId() { + return id; + } + + /** + * 返回实体名称,供查询和更新字段解析。 + * + * @return 实体名称 + */ + public String getName() { + return name; + } + + /** + * 返回实体分值,供聚合和数值更新解析。 + * + * @return 实体分值 + */ + public Integer getScore() { + return score; + } + + /** + * 返回实体备注,供可选更新和空值查询解析。 + * + * @return 实体备注 + */ + public String getRemark() { + return remark; + } + } + + private static class CrudVo { + private Long total; + + /** + * 返回聚合总数,供 Lambda 推导查询别名。 + * + * @return 聚合总数 + */ + public Long getTotal() { + return total; + } + } + + @TableName("crud_relation") + private static class CrudRelation { + @TableId + private Long id; + private Long ownerId; + private String state; + @TableLogic + private Integer deleted; + + /** + * 返回关联记录所属实体主键,供关联子查询解析。 + * + * @return 所属实体主键 + */ + public Long getOwnerId() { + return ownerId; + } + + /** + * 返回关联记录状态,供子查询参数绑定。 + * + * @return 关联状态 + */ + public String getState() { + return state; + } + } +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/page/PageQueryTest.java b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/page/PageQueryTest.java new file mode 100644 index 000000000..e4e0df758 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/page/PageQueryTest.java @@ -0,0 +1,91 @@ +package org.dromara.common.mybatis.core.page; + +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("PageQuery 单元测试") +class PageQueryTest { + + /** + * 验证缺省分页参数会使用框架定义的页码和页大小。 + */ + @Test + @DisplayName("空分页参数使用默认值") + void shouldUseDefaultPagination() { + Page page = new PageQuery().build(); + + assertEquals(PageQuery.DEFAULT_PAGE_NUM, page.getCurrent()); + assertEquals(PageQuery.DEFAULT_PAGE_SIZE, page.getSize()); + assertTrue(page.orders().isEmpty()); + } + + /** + * 验证非法页码回退到第一页,并正确计算起始行。 + */ + @Test + @DisplayName("页码小于等于零时回退到第一页") + void shouldNormalizeInvalidPageNumber() { + PageQuery query = new PageQuery(20, 0); + + assertEquals(1L, query.build().getCurrent()); + assertEquals(0, query.getFirstNum()); + } + + /** + * 验证多字段排序支持驼峰转换及每列独立排序方向。 + */ + @Test + @DisplayName("将驼峰排序字段转换为下划线并支持独立方向") + void shouldBuildMultipleOrderItems() { + PageQuery query = new PageQuery(10, 2); + query.setOrderByColumn("userName,createTime"); + query.setIsAsc("ascending,descending"); + + List orders = query.build().orders(); + + assertEquals(2, orders.size()); + assertEquals("user_name", orders.get(0).getColumn()); + assertTrue(orders.get(0).isAsc()); + assertEquals("create_time", orders.get(1).getColumn()); + assertFalse(orders.get(1).isAsc()); + assertEquals(10, query.getFirstNum()); + } + + /** + * 验证排序字段数与方向数不匹配时抛出业务异常。 + */ + @Test + @DisplayName("拒绝排序字段和方向数量不一致") + void shouldRejectMismatchedDirections() { + PageQuery query = new PageQuery(); + query.setOrderByColumn("id,createTime"); + query.setIsAsc("asc,desc,asc"); + + assertThrows(ServiceException.class, query::build); + } + + /** + * 验证非法排序字段和未知排序方向都会被拒绝。 + */ + @Test + @DisplayName("拒绝非法排序字段和排序方向") + void shouldRejectInvalidOrderInput() { + PageQuery unsafeColumn = new PageQuery(); + unsafeColumn.setOrderByColumn("id;drop table sys_user"); + unsafeColumn.setIsAsc("asc"); + assertThrows(IllegalArgumentException.class, unsafeColumn::build); + + PageQuery invalidDirection = new PageQuery(); + invalidDirection.setOrderByColumn("id"); + invalidDirection.setIsAsc("random"); + assertThrows(ServiceException.class, invalidDirection::build); + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/query/AggregateSelectUtilsTest.java b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/query/AggregateSelectUtilsTest.java new file mode 100644 index 000000000..a10a9ebac --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/query/AggregateSelectUtilsTest.java @@ -0,0 +1,65 @@ +package org.dromara.common.mybatis.core.query; + +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("AggregateSelectUtils 单元测试") +class AggregateSelectUtilsTest { + + /** + * 验证聚合字段、子查询字段和已有查询字段的 SQL 拼接格式。 + */ + @Test + @DisplayName("拼接聚合字段和子查询字段") + void shouldBuildSelectFragments() { + assertEquals("COUNT(user_id) AS userCount", + AggregateSelectUtils.aggregateSelect(SqlAggregateFunction.COUNT, "user_id", "userCount")); + assertEquals("(SELECT MAX(id) FROM sys_user) AS maxId", + AggregateSelectUtils.subquerySelect("SELECT MAX(id) FROM sys_user", "maxId")); + assertEquals("id,COUNT(*) AS total", + AggregateSelectUtils.appendSelect("id", "COUNT(*) AS total")); + assertEquals("id", AggregateSelectUtils.appendSelect(null, "id")); + } + + /** + * 验证 Lambda getter 可以解析为对应的 Java 属性名。 + */ + @Test + @DisplayName("从 Lambda getter 提取字段别名") + void shouldResolveAliasFromGetter() { + SFunction getter = TestEntity::getTotalValue; + + assertEquals("totalValue", AggregateSelectUtils.aliasName(getter)); + } + + /** + * 验证非法 SQL 标识符不能作为查询别名。 + */ + @Test + @DisplayName("拒绝非法 SQL 别名") + void shouldRejectInvalidAlias() { + assertThrows(RuntimeException.class, () -> AggregateSelectUtils.checkAlias("1total")); + assertThrows(RuntimeException.class, () -> AggregateSelectUtils.checkAlias("total-value")); + assertThrows(RuntimeException.class, () -> AggregateSelectUtils.checkAlias("total value")); + } + + private static class TestEntity { + + private Long totalValue; + + /** + * 提供 Lambda 属性解析使用的测试 getter。 + * + * @return 测试聚合值 + */ + public Long getTotalValue() { + return totalValue; + } + + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/query/LambdaJoinQueryBuilderTest.java b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/query/LambdaJoinQueryBuilderTest.java new file mode 100644 index 000000000..b57ff7069 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/query/LambdaJoinQueryBuilderTest.java @@ -0,0 +1,312 @@ +package org.dromara.common.mybatis.core.query; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.github.yulichang.wrapper.MPJLambdaWrapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("LambdaJoinQueryBuilder 单元测试") +class LambdaJoinQueryBuilderTest { + + /** + * 初始化联表测试实体的 MyBatis-Plus 元数据,供 MPJ 字段、表名和逻辑删除解析使用。 + */ + @BeforeAll + static void initializeTableMetadata() { + MybatisConfiguration configuration = new MybatisConfiguration(); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, "joinUser"), JoinUser.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, "joinDept"), JoinDept.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, "joinOrder"), JoinOrder.class); + } + + /** + * 验证实际列表查询常用的主表别名、多次左联、字段映射、聚合、筛选和排序可以组成完整 SQL。 + */ + @Test + @DisplayName("构造多表列表查询") + void shouldBuildAliasedMultiTableQuery() { + MPJLambdaWrapper wrapper = QueryBuilder.lambdaJoin("u", JoinUser.class) + .distinct() + .select("u", JoinUser::getId, JoinUser::getName) + .selectAs("d", JoinDept::getName, JoinResult::getDeptName) + .selectSum("o", JoinOrder::getAmount, "totalAmount") + .selectCount("o", JoinOrder::getId, "orderCount") + .leftJoin(JoinDept.class, "d", JoinDept::getId, JoinUser::getDeptId) + .leftJoin(JoinOrder.class, "o", JoinOrder::getUserId, JoinUser::getId) + .eqIfPresent("u", JoinUser::getName, null) + .eqIfText("u", JoinUser::getName, "alice") + .likeIfText("d", JoinDept::getName, "tech") + .betweenParams("o", JoinOrder::getCreatedAt, null, "begin", "end") + .betweenParams("o", JoinOrder::getCreatedAt, Map.of("begin", "2026-01-01"), "begin", "end") + .betweenParams("o", JoinOrder::getCreatedAt, + Map.of("begin", "2026-01-01", "end", "2026-01-31"), "begin", "end") + .inIfNotEmpty("u", JoinUser::getId, List.of()) + .inIfNotEmpty("u", JoinUser::getId, List.of(1L, 2L)) + .notInIfNotEmpty("u", JoinUser::getId, List.of()) + .isNotNull("d", JoinDept::getId) + .groupBy("u", JoinUser::getId, JoinUser::getName) + .orderByAsc("u", JoinUser::getName) + .orderByDesc("o", JoinOrder::getCreatedAt) + .build(); + + String select = wrapper.getSqlSelect(); + String from = wrapper.getFrom(); + String sql = wrapper.getSqlSegment(); + + assertEquals("u", wrapper.getAlias()); + assertTrue(wrapper.getSelectDistinct()); + assertTrue(select.contains("u.id")); + assertTrue(select.contains("u.name")); + assertTrue(select.contains("d.name AS deptName")); + assertTrue(select.contains("SUM(o.amount) AS totalAmount")); + assertTrue(select.contains("COUNT(o.id) AS orderCount")); + assertTrue(from.contains("LEFT JOIN test_dept d ON")); + assertTrue(from.contains("d.id = u.dept_id")); + assertTrue(from.contains("LEFT JOIN test_order o ON")); + assertTrue(from.contains("o.user_id = u.id")); + assertTrue(sql.contains("u.name =")); + assertTrue(sql.contains("d.name LIKE")); + assertEquals(sql.indexOf("o.created_at BETWEEN"), sql.lastIndexOf("o.created_at BETWEEN")); + assertTrue(sql.contains("u.id IN")); + assertTrue(sql.contains("d.id IS NOT NULL")); + assertTrue(sql.contains("GROUP BY u.id,u.name")); + assertTrue(sql.contains("ORDER BY u.name ASC,o.created_at DESC")); + assertEquals(new HashSet<>(List.of("alice", "%tech%", "2026-01-01", "2026-01-31", 1L, 2L)), + new HashSet<>(wrapper.getParamNameValuePairs().values())); + } + + /** + * 验证联表构造器的各类关联子查询使用主表别名、追加逻辑删除条件并完整绑定参数。 + */ + @Test + @DisplayName("构造联表关联子查询") + void shouldBuildCorrelatedSubQueriesForJoinQuery() { + MPJLambdaWrapper wrapper = QueryBuilder.lambdaJoin("u", JoinUser.class) + .select("u", JoinUser::getId) + .selectSub(JoinOrder.class, sub -> sub + .selectCountAll() + .eqColumn(JoinOrder::getUserId, "u", JoinUser::getId) + .eq(JoinOrder::getState, "selected"), "paidCount") + .eqSub("u", JoinUser::getScore, JoinOrder.class, sub -> sub + .selectMax(JoinOrder::getAmount) + .eqColumn(JoinOrder::getUserId, "u", JoinUser::getId) + .eq(JoinOrder::getState, "scored")) + .inSub("u", JoinUser::getId, JoinOrder.class, sub -> sub + .select(JoinOrder::getUserId) + .eq(JoinOrder::getState, "included")) + .notInSub("u", JoinUser::getId, JoinOrder.class, sub -> sub + .select(JoinOrder::getUserId) + .eq(JoinOrder::getState, "excluded")) + .existsSub(JoinOrder.class, sub -> sub + .selectCountAll() + .eqColumn(JoinOrder::getUserId, "u", JoinUser::getId) + .eq(JoinOrder::getState, "existing")) + .notExistsSub(JoinOrder.class, sub -> sub + .selectCountAll() + .eqColumn(JoinOrder::getUserId, "u", JoinUser::getId) + .eq(JoinOrder::getState, "missing")) + .build(); + + String select = wrapper.getSqlSelect(); + String sql = wrapper.getSqlSegment(); + + assertTrue(select.contains("(SELECT COUNT(*) FROM test_order")); + assertTrue(select.contains("deleted=0")); + assertTrue(select.contains("user_id=u.id")); + assertTrue(select.contains("AS paidCount")); + assertTrue(sql.contains("u.score = (SELECT MAX(amount) FROM test_order")); + assertTrue(sql.contains("u.id IN (SELECT user_id FROM test_order")); + assertTrue(sql.contains("u.id NOT IN (SELECT user_id FROM test_order")); + assertTrue(sql.contains("EXISTS (SELECT COUNT(*) FROM test_order")); + assertTrue(sql.contains("NOT EXISTS (SELECT COUNT(*) FROM test_order")); + assertTrue(wrapper.getParamNameValuePairs().values().containsAll( + List.of("selected", "scored", "included", "excluded", "existing", "missing"))); + } + + /** + * 验证 MPJ 默认别名、无显式别名联表、全字段选择和底层 Wrapper 扩展入口保持兼容。 + */ + @Test + @DisplayName("兼容 MPJ 默认联表行为") + void shouldPreserveNativeMpjDefaultsAndEscapeHatch() { + MPJLambdaWrapper wrapper = QueryBuilder.lambdaJoin(JoinUser.class) + .selectAll() + .selectAll(JoinDept.class) + .leftJoin(JoinDept.class, JoinDept::getId, JoinUser::getDeptId) + .neIfText("t", JoinUser::getName, "alice") + .betweenIfPresent("t", JoinUser::getScore, 60, 100) + .notInIfNotEmpty("t", JoinUser::getId, List.of(3L, 4L)) + .apply(nativeWrapper -> nativeWrapper.likeRight(JoinDept::getName, "tech")) + .build(); + + String select = wrapper.getSqlSelect(); + String from = wrapper.getFrom(); + String sql = wrapper.getSqlSegment(); + + assertEquals("t", wrapper.getAlias()); + assertTrue(select.contains("t.id")); + assertTrue(select.contains("t.name")); + assertTrue(select.contains("t1.id")); + assertTrue(select.contains("t1.name")); + assertTrue(from.contains("LEFT JOIN test_dept t1 ON")); + assertTrue(from.contains("t1.id = t.dept_id")); + assertTrue(sql.contains("t.name <>")); + assertTrue(sql.contains("t.score BETWEEN")); + assertTrue(sql.contains("t.id NOT IN")); + assertTrue(sql.contains("t1.name LIKE")); + assertEquals(new HashSet<>(List.of("alice", 60, 100, 3L, 4L, "tech%")), + new HashSet<>(wrapper.getParamNameValuePairs().values())); + } + + @TableName("test_user") + private static class JoinUser { + @TableId + private Long id; + private Long deptId; + private String name; + private Integer score; + + /** + * 返回用户主键,供主表与订单表关联。 + * + * @return 用户主键 + */ + public Long getId() { + return id; + } + + /** + * 返回部门主键,供用户与部门表关联。 + * + * @return 部门主键 + */ + public Long getDeptId() { + return deptId; + } + + /** + * 返回用户名称,供字段选择、筛选、分组和排序解析。 + * + * @return 用户名称 + */ + public String getName() { + return name; + } + + /** + * 返回用户分值,供等值子查询条件解析。 + * + * @return 用户分值 + */ + public Integer getScore() { + return score; + } + } + + @TableName("test_dept") + private static class JoinDept { + @TableId + private Long id; + private String name; + + /** + * 返回部门主键,供联表条件和非空筛选解析。 + * + * @return 部门主键 + */ + public Long getId() { + return id; + } + + /** + * 返回部门名称,供结果字段映射和模糊筛选解析。 + * + * @return 部门名称 + */ + public String getName() { + return name; + } + } + + @TableName("test_order") + private static class JoinOrder { + @TableId + private Long id; + private Long userId; + private Integer amount; + private String state; + private String createdAt; + @TableLogic + private Integer deleted; + + /** + * 返回订单主键,供聚合统计字段解析。 + * + * @return 订单主键 + */ + public Long getId() { + return id; + } + + /** + * 返回订单所属用户,供联表和关联子查询解析。 + * + * @return 用户主键 + */ + public Long getUserId() { + return userId; + } + + /** + * 返回订单金额,供聚合字段和等值子查询解析。 + * + * @return 订单金额 + */ + public Integer getAmount() { + return amount; + } + + /** + * 返回订单状态,供子查询参数条件解析。 + * + * @return 订单状态 + */ + public String getState() { + return state; + } + + /** + * 返回订单创建时间,供区间筛选和排序解析。 + * + * @return 创建时间 + */ + public String getCreatedAt() { + return createdAt; + } + } + + private static class JoinResult { + private String deptName; + + /** + * 返回结果部门名称,供 selectAs 推导字段别名。 + * + * @return 部门名称 + */ + public String getDeptName() { + return deptName; + } + } +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/query/LambdaQueryBuilderTest.java b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/query/LambdaQueryBuilderTest.java new file mode 100644 index 000000000..24e899d87 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/core/query/LambdaQueryBuilderTest.java @@ -0,0 +1,418 @@ +package org.dromara.common.mybatis.core.query; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("LambdaQueryBuilder 单元测试") +class LambdaQueryBuilderTest { + + /** + * 初始化测试实体的 MyBatis-Plus 表元数据,供 Lambda 字段解析和逻辑删除 SQL 使用。 + */ + @BeforeAll + static void initializeTableMetadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), TestEntity.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "testRelation"), TestRelation.class); + } + + /** + * 验证常用比较、集合、空值、分组和排序条件能够通过查询 DSL 生成 SQL 片段。 + */ + @Test + @DisplayName("构造常用 Lambda 查询条件") + void shouldBuildCommonLambdaQueryConditions() { + LambdaQueryWrapper wrapper = QueryBuilder.lambda(TestEntity.class) + .eq(TestEntity::getName, "alice") + .ne(false, TestEntity::getName, "ignored") + .gt(TestEntity::getScore, 60) + .ge(TestEntity::getScore, 61) + .lt(TestEntity::getScore, 100) + .le(TestEntity::getScore, 99) + .like(TestEntity::getName, "ali") + .notLike(TestEntity::getName, "bob") + .between(TestEntity::getScore, 60, 100) + .notBetween(false, TestEntity::getScore, 0, 10) + .isNull(TestEntity::getRemark) + .isNotNull(false, TestEntity::getRemark) + .in(TestEntity::getId, List.of(1L, 2L)) + .notIn(TestEntity::getId, 3L, 4L) + .groupBy(TestEntity::getName) + .having("COUNT(*) > {0}", 1) + .orderByDesc(TestEntity::getScore) + .last("LIMIT 10") + .build(); + + String sql = wrapper.getSqlSegment(); + + assertTrue(sql.contains("name")); + assertTrue(sql.contains("score")); + assertTrue(sql.contains("remark IS NULL")); + assertTrue(sql.contains("GROUP BY name")); + assertTrue(sql.contains("ORDER BY score DESC")); + assertTrue(wrapper.getCustomSqlSegment().contains("LIMIT 10")); + } + + /** + * 验证普通字段、聚合字段和 COUNT(*) 可以组合为稳定的 SELECT 列表。 + */ + @Test + @DisplayName("构造聚合查询字段") + void shouldBuildAggregateSelectColumns() { + LambdaQueryWrapper wrapper = QueryBuilder.lambda(TestEntity.class) + .select(TestEntity::getName) + .selectSum(TestEntity::getScore, "totalScore") + .selectMax(TestEntity::getScore, "maxScore") + .selectMin(TestEntity::getScore, "minScore") + .selectAvg(TestEntity::getScore, "avgScore") + .selectCount(TestEntity::getId, "idCount") + .selectCountDistinct(TestEntity::getName, "nameCount") + .selectCountAll("total") + .build(); + + String select = wrapper.getSqlSelect(); + + assertTrue(select.contains("name")); + assertTrue(select.contains("SUM(score) AS totalScore")); + assertTrue(select.contains("MAX(score) AS maxScore")); + assertTrue(select.contains("MIN(score) AS minScore")); + assertTrue(select.contains("AVG(score) AS avgScore")); + assertTrue(select.contains("COUNT(id) AS idCount")); + assertTrue(select.contains("COUNT(DISTINCT name) AS nameCount")); + assertTrue(select.contains("COUNT(*) AS total")); + } + + /** + * 验证子查询自动加入逻辑删除条件、收集占位参数,并支持显式关闭逻辑删除。 + */ + @Test + @DisplayName("构造带参数的子查询") + void shouldBuildSubQueryWithLogicDeleteAndParameters() { + SubQuery subQuery = SubQuery.ofPlaceholders(TestEntity.class) + .select(TestEntity::getId) + .eq(TestEntity::getName, "alice") + .gt(TestEntity::getScore, 60) + .in(TestEntity::getId, 1L, 2L) + .between(TestEntity::getScore, 60, 100) + .when(false, query -> query.eq(TestEntity::getName, "ignored")); + + String sql = subQuery.build(); + + assertTrue(sql.startsWith("SELECT id FROM test_entity WHERE")); + assertTrue(sql.contains("deleted=0")); + assertTrue(sql.contains("name = {0}")); + assertArrayEquals(new Object[]{"alice", 60, 1L, 2L, 60, 100}, subQuery.params()); + + String withoutLogicDelete = SubQuery.ofPlaceholders(TestEntity.class) + .selectCountAll() + .disableLogicDelete() + .build(); + assertFalse(withoutLogicDelete.contains("deleted")); + } + + /** + * 验证项目查询辅助方法只为有效输入生成条件,避免空筛选值污染业务 SQL。 + */ + @Test + @DisplayName("按输入有效性追加查询条件") + void shouldAddOnlyMeaningfulOptionalConditions() { + LambdaQueryWrapper wrapper = QueryBuilder.lambda(TestEntity.class) + .eqIfPresent(TestEntity::getName, null) + .eqIfText(TestEntity::getName, " ") + .eqIfText(TestEntity::getName, "alice") + .neIfText(TestEntity::getRemark, "") + .gtIfPresent(TestEntity::getScore, null) + .geIfPresent(TestEntity::getScore, 60) + .likeIfText(TestEntity::getRemark, "memo") + .betweenParams(TestEntity::getScore, null, "begin", "end") + .betweenParams(TestEntity::getScore, Map.of("begin", 10), "begin", "end") + .betweenParams(TestEntity::getScore, Map.of("begin", 10, "end", 20), "begin", "end") + .inIfNotEmpty(TestEntity::getId, List.of()) + .inIfNotEmpty(TestEntity::getId, List.of(1L, 2L)) + .notInIfNotEmpty(TestEntity::getId, new Object[0]) + .notInIfNotEmpty(TestEntity::getId, 3L, 4L) + .build(); + + String sql = wrapper.getSqlSegment(); + + assertTrue(sql.contains("name =")); + assertTrue(sql.contains("score >=")); + assertTrue(sql.contains("remark LIKE")); + assertEquals(sql.indexOf("score BETWEEN"), sql.lastIndexOf("score BETWEEN")); + assertTrue(sql.contains("id IN")); + assertTrue(sql.contains("id NOT IN")); + assertEquals(new HashSet<>(List.of("alice", 60, "%memo%", 10, 20, 1L, 2L, 3L, 4L)), + new HashSet<>(wrapper.getParamNameValuePairs().values())); + } + + /** + * 验证查询字段和各类子查询条件能关联外层字段、保留逻辑删除并绑定独立参数。 + */ + @Test + @DisplayName("组合关联子查询") + void shouldComposeCorrelatedSubQueries() { + LambdaQueryWrapper wrapper = QueryBuilder.lambda(TestEntity.class) + .select(TestEntity::getId, TestEntity::getName) + .selectSub(TestRelation.class, sub -> sub + .selectCountAll() + .eqColumn(TestRelation::getOwnerId, TestEntity::getId) + .eq(TestRelation::getState, "selected"), "relationCount") + .eqSub(TestEntity::getScore, TestRelation.class, sub -> sub + .selectMax(TestRelation::getPoints) + .eqColumn(TestRelation::getOwnerId, TestEntity::getId) + .eq(TestRelation::getState, "scored")) + .inSub(TestEntity::getId, TestRelation.class, sub -> sub + .select(TestRelation::getOwnerId) + .eq(TestRelation::getState, "included")) + .notInSub(TestEntity::getId, TestRelation.class, sub -> sub + .select(TestRelation::getOwnerId) + .eq(TestRelation::getState, "excluded")) + .existsSub(TestRelation.class, sub -> sub + .selectCountAll() + .eqColumn(TestRelation::getOwnerId, TestEntity::getId) + .eq(TestRelation::getState, "existing")) + .notExistsSub(TestRelation.class, sub -> sub + .selectCountAll() + .eqColumn(TestRelation::getOwnerId, TestEntity::getId) + .eq(TestRelation::getState, "missing")) + .build(); + + String select = wrapper.getSqlSelect(); + String sql = wrapper.getSqlSegment(); + + assertTrue(select.contains("(SELECT COUNT(*) FROM test_relation")); + assertTrue(select.contains("deleted=0")); + assertTrue(select.contains("owner_id=test_entity.id")); + assertTrue(select.contains("AS relationCount")); + assertTrue(sql.contains("score = (SELECT MAX(points) FROM test_relation")); + assertTrue(sql.contains("id IN (SELECT owner_id FROM test_relation")); + assertTrue(sql.contains("id NOT IN (SELECT owner_id FROM test_relation")); + assertTrue(sql.contains("EXISTS (SELECT COUNT(*) FROM test_relation")); + assertTrue(sql.contains("NOT EXISTS (SELECT COUNT(*) FROM test_relation")); + assertTrue(wrapper.getParamNameValuePairs().values().containsAll( + List.of("selected", "scored", "included", "excluded", "existing", "missing"))); + } + + /** + * 验证空集合不会产生非法 IN 子句,显式外层别名仍能正确生成关联条件。 + */ + @Test + @DisplayName("跳过子查询空集合条件") + void shouldSkipEmptySubQueryCollections() { + SubQuery subQuery = SubQuery.ofPlaceholders(TestRelation.class) + .select(TestRelation::getOwnerId) + .in(TestRelation::getId, List.of()) + .in(TestRelation::getId, (Object[]) null) + .eqColumn(TestRelation::getOwnerId, "u", TestEntity::getId) + .when(true, query -> query.eq(TestRelation::getState, "enabled")); + + String sql = subQuery.build(); + + assertFalse(sql.contains(" IN (")); + assertTrue(sql.contains("owner_id=u.id")); + assertArrayEquals(new Object[]{"enabled"}, subQuery.params()); + } + + /** + * 验证缺少查询字段的子查询会在构造阶段失败,防止生成无法执行的 SQL。 + */ + @Test + @DisplayName("拒绝没有查询字段的子查询") + void shouldRejectSubQueryWithoutSelectColumn() { + assertThrows(MybatisPlusException.class, + () -> SubQuery.ofPlaceholders(TestRelation.class).build()); + } + + /** + * 验证项目构造器透传的 MyBatis-Plus 嵌套逻辑、批量等值和函数式扩展保持原生语义。 + */ + @Test + @DisplayName("兼容 MyBatis-Plus 组合条件") + void shouldPreserveNativeMybatisPlusConditionSemantics() { + Map, Object> values = new LinkedHashMap<>(); + values.put(TestEntity::getName, "alice"); + values.put(TestEntity::getRemark, null); + values.put(TestEntity::getScore, 80); + + LambdaQueryWrapper wrapper = QueryBuilder.lambda(TestEntity.class) + .allEq((column, value) -> !Integer.valueOf(80).equals(value), values, true) + .and(nested -> nested.gt(TestEntity::getScore, 60).lt(TestEntity::getScore, 100)) + .or(nested -> nested.eq(TestEntity::getName, "backup").isNull(TestEntity::getRemark)) + .nested(nested -> nested.likeLeft(TestEntity::getName, "ice") + .or().likeRight(TestEntity::getName, "ali")) + .not(nested -> nested.eq(TestEntity::getScore, 0)) + .func(nested -> nested.ge(TestEntity::getScore, 70)) + .apply(nested -> nested.le(TestEntity::getScore, 90)) + .build(); + + String sql = wrapper.getSqlSegment(); + + assertTrue(sql.contains("name =")); + assertTrue(sql.contains("remark IS NULL")); + assertFalse(wrapper.getParamNameValuePairs().values().contains(80)); + assertTrue(sql.contains("AND (score >")); + assertTrue(sql.contains("OR (name =")); + assertTrue(sql.contains("NOT (score =")); + assertTrue(wrapper.getParamNameValuePairs().values().containsAll( + List.of("alice", 60, 100, "backup", "%ice", "ali%", 0, 70, 90))); + } + + /** + * 验证项目暴露的原生 SQL 入口仍由 MyBatis-Plus 完成占位参数绑定和 SQL 片段拼装。 + */ + @Test + @DisplayName("兼容 MyBatis-Plus 原生 SQL 条件") + void shouldPreserveNativeSqlFragmentsAndParameterBinding() { + LambdaQueryWrapper wrapper = QueryBuilder.lambda(TestEntity.class) + .eqSql(TestEntity::getScore, "SELECT MAX(points) FROM test_relation") + .inSql(TestEntity::getId, "SELECT owner_id FROM test_relation WHERE state = 'enabled'") + .notInSql(TestEntity::getId, "SELECT owner_id FROM test_relation WHERE state = 'disabled'") + .exists("SELECT 1 FROM test_relation r WHERE r.owner_id = test_entity.id AND r.state = {0}", "active") + .notExists("SELECT 1 FROM test_relation r WHERE r.owner_id = test_entity.id AND r.state = {0}", "removed") + .apply("FIND_IN_SET({0}, name)", "alice") + .build(); + + String sql = wrapper.getSqlSegment(); + + assertTrue(sql.contains("score = (SELECT MAX(points) FROM test_relation)")); + assertTrue(sql.contains("id IN (SELECT owner_id FROM test_relation WHERE state = 'enabled')")); + assertTrue(sql.contains("id NOT IN (SELECT owner_id FROM test_relation WHERE state = 'disabled')")); + assertTrue(sql.contains("EXISTS (SELECT 1 FROM test_relation")); + assertTrue(sql.contains("NOT EXISTS (SELECT 1 FROM test_relation")); + assertTrue(sql.contains("FIND_IN_SET(")); + assertEquals(new HashSet<>(List.of("active", "removed", "alice")), + new HashSet<>(wrapper.getParamNameValuePairs().values())); + } + + /** + * 验证清空底层 Wrapper 后查询条件、参数、SELECT、注释和尾部 SQL 都不会泄漏到后续查询。 + */ + @Test + @DisplayName("清空完整查询状态") + void shouldClearAllQueryState() { + LambdaQueryWrapper wrapper = QueryBuilder.lambda(TestEntity.class) + .select(TestEntity::getName) + .selectCountAll("total") + .eq(TestEntity::getName, "alice") + .comment("query-comment") + .last("LIMIT 1") + .build(); + + wrapper.clear(); + + assertNull(wrapper.getSqlSelect()); + assertTrue(wrapper.getSqlSegment().isEmpty()); + assertTrue(wrapper.getCustomSqlSegment().isEmpty()); + assertTrue(wrapper.getParamNameValuePairs().isEmpty()); + } + + @TableName("test_entity") + private static class TestEntity { + @TableId + private Long id; + private String name; + private Integer score; + private String remark; + @TableLogic + private Integer deleted; + + /** + * 返回测试实体主键,供 Lambda 字段解析。 + * + * @return 主键 + */ + public Long getId() { + return id; + } + + /** + * 返回测试实体名称,供 Lambda 字段解析。 + * + * @return 名称 + */ + public String getName() { + return name; + } + + /** + * 返回测试实体分数,供 Lambda 字段解析。 + * + * @return 分数 + */ + public Integer getScore() { + return score; + } + + /** + * 返回测试实体备注,供 Lambda 字段解析。 + * + * @return 备注 + */ + public String getRemark() { + return remark; + } + } + + @TableName("test_relation") + private static class TestRelation { + @TableId + private Long id; + private Long ownerId; + private String state; + private Integer points; + @TableLogic + private Integer deleted; + + /** + * 返回关联记录主键,供 Lambda 字段解析。 + * + * @return 主键 + */ + public Long getId() { + return id; + } + + /** + * 返回关联记录所属主键,供关联子查询字段解析。 + * + * @return 所属主键 + */ + public Long getOwnerId() { + return ownerId; + } + + /** + * 返回关联记录状态,供子查询参数条件解析。 + * + * @return 状态 + */ + public String getState() { + return state; + } + + /** + * 返回关联记录分值,供聚合子查询字段解析。 + * + * @return 分值 + */ + public Integer getPoints() { + return points; + } + } +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/helper/DataPermissionHelperTest.java b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/helper/DataPermissionHelperTest.java new file mode 100644 index 000000000..0cc87f661 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/helper/DataPermissionHelperTest.java @@ -0,0 +1,85 @@ +package org.dromara.common.mybatis.helper; + +import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper; +import org.dromara.common.mybatis.annotation.DataPermission; +import org.dromara.common.mybatis.core.domain.DataPermissionAccess; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +@DisplayName("DataPermissionHelper 单元测试") +class DataPermissionHelperTest { + + /** + * 清理数据权限测试使用的线程变量和 MyBatis-Plus 忽略策略。 + */ + @AfterEach + void clearThreadContext() { + DataPermissionHelper.removePermission(); + InterceptorIgnoreHelper.clearIgnoreStrategy(); + } + + /** + * 验证 Mapper 数据权限注解可以在线程内设置、读取并清理。 + */ + @Test + @DisplayName("管理数据权限注解线程变量") + void shouldManagePermissionThreadLocal() { + DataPermission permission = mock(DataPermission.class); + + DataPermissionHelper.setPermission(permission); + assertSame(permission, DataPermissionHelper.getPermission()); + + DataPermissionHelper.removePermission(); + assertNull(DataPermissionHelper.getPermission()); + } + + /** + * 验证数据权限访问上下文根据接口权限或角色集合判断是否存在约束。 + */ + @Test + @DisplayName("判断数据权限访问约束") + void shouldDetectDataPermissionConstraints() { + assertFalse(DataPermissionAccess.EMPTY.constrained()); + assertTrue(new DataPermissionAccess(Set.of("system:user:list"), Set.of()).constrained()); + assertTrue(new DataPermissionAccess(Set.of(), Set.of("admin")).constrained()); + } + + /** + * 验证嵌套忽略数据权限会在内部保持忽略状态,并在退出后恢复原始线程状态。 + */ + @Test + @DisplayName("嵌套忽略并恢复数据权限") + void shouldNestAndRestoreDataPermissionIgnoreState() { + assertFalse(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select")); + + String result = DataPermissionHelper.ignore(() -> { + assertTrue(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select")); + DataPermissionHelper.ignore(() -> + assertTrue(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select"))); + assertTrue(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select")); + return "done"; + }); + + assertEquals("done", result); + assertFalse(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select")); + } + + /** + * 验证忽略数据权限的业务代码抛出异常时仍会在 finally 中恢复线程状态。 + */ + @Test + @DisplayName("异常后恢复数据权限忽略状态") + void shouldRestoreIgnoreStateAfterException() { + assertThrows(IllegalStateException.class, () -> DataPermissionHelper.ignore(() -> { + throw new IllegalStateException("failed"); + })); + + assertFalse(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select")); + } +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/helper/MybatisUtilityContractTest.java b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/helper/MybatisUtilityContractTest.java new file mode 100644 index 000000000..3958ef108 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/test/java/org/dromara/common/mybatis/helper/MybatisUtilityContractTest.java @@ -0,0 +1,187 @@ +package org.dromara.common.mybatis.helper; + +import cn.hutool.core.exceptions.UtilException; +import cn.hutool.extra.spring.SpringUtil; +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder; +import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; +import org.dromara.common.mybatis.enums.DataBaseType; +import org.dromara.common.mybatis.utils.IdGeneratorUtil; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.context.support.StaticApplicationContext; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("common-mybatis 工具契约单元测试") +class MybatisUtilityContractTest { + + private static DynamicRoutingDataSource dataSource; + private static IdentifierGenerator identifierGenerator; + + /** + * 注册数据库助手和 ID 工具的最小 Spring 依赖,避免连接真实数据库或使用应用数据源。 + */ + @BeforeAll + static void initializeMybatisUtilities() { + dataSource = mock(DynamicRoutingDataSource.class); + identifierGenerator = mock(IdentifierGenerator.class); + StaticApplicationContext context = new StaticApplicationContext(); + context.getBeanFactory().registerSingleton("dynamicRoutingDataSource", dataSource); + context.getBeanFactory().registerSingleton("identifierGenerator", identifierGenerator); + context.refresh(); + new SpringUtil().setApplicationContext(context); + } + + /** + * 每个用例前清理 mock 调用记录,保证数据库类型缓存断言只针对当前数据源名称。 + */ + @BeforeEach + void resetMybatisMocks() { + reset(dataSource, identifierGenerator); + } + + /** + * 验证指定数据源从 JDBC 元数据识别四种常用数据库,并缓存同一数据源的识别结果。 + */ + @Test + @DisplayName("识别并缓存指定数据源类型") + void shouldResolveAndCacheNamedDatabaseTypes() throws Exception { + Map sources = Map.of( + "mysql-contract", dataSource("MySQL"), + "oracle-contract", dataSource("Oracle"), + "postgres-contract", dataSource("PostgreSQL"), + "sqlserver-contract", dataSource("Microsoft SQL Server")); + when(dataSource.getDataSource(any(String.class))).thenAnswer(invocation -> sources.get(invocation.getArgument(0))); + + assertEquals(DataBaseType.MY_SQL, DataBaseHelper.getDataBaseType("mysql-contract")); + assertEquals(DataBaseType.ORACLE, DataBaseHelper.getDataBaseType("oracle-contract")); + assertEquals(DataBaseType.POSTGRE_SQL, DataBaseHelper.getDataBaseType("postgres-contract")); + assertEquals(DataBaseType.SQL_SERVER, DataBaseHelper.getDataBaseType("sqlserver-contract")); + assertEquals(DataBaseType.MY_SQL, DataBaseHelper.getDataBaseType("mysql-contract")); + + verify(dataSource, times(2)).getDataSource("mysql-contract"); + verify(sources.get("mysql-contract"), times(1)).getConnection(); + } + + /** + * 验证当前线程数据源识别、默认 primary 键以及异常包装行为。 + */ + @Test + @DisplayName("识别当前数据源并包装 JDBC 异常") + void shouldResolveCurrentDataSourceAndWrapSqlException() throws Exception { + DataSource current = dataSource("Oracle"); + when(dataSource.determineDataSource()).thenReturn(current); + AtomicReference currentKey = new AtomicReference<>("current-contract"); + when(dataSource.getDataSources()).thenReturn(Map.of("primary", current)); + + try (MockedStatic holder = mockStatic(DynamicDataSourceContextHolder.class)) { + holder.when(DynamicDataSourceContextHolder::peek).thenAnswer(invocation -> currentKey.get()); + assertEquals(DataBaseType.ORACLE, DataBaseHelper.getDataBaseType()); + + currentKey.set(null); + when(current.getConnection()).thenThrow(new SQLException("connection unavailable")); + RuntimeException exception = assertThrows(RuntimeException.class, DataBaseHelper::getDataBaseType); + assertEquals("获取数据库类型失败", exception.getMessage()); + assertInstanceOf(SQLException.class, exception.getCause()); + } + } + + /** + * 验证不同数据库方言生成对应 FIND_IN_SET 片段,并拒绝 SQL 关键字和引号注入。 + */ + @Test + @DisplayName("生成数据库方言 FIND_IN_SET") + void shouldBuildDialectSpecificFindInSetSql() throws Exception { + Map sources = Map.of( + "find-oracle", dataSource("Oracle"), + "find-postgres", dataSource("PostgreSQL"), + "find-sqlserver", dataSource("Microsoft SQL Server"), + "find-mysql", dataSource("MySQL")); + AtomicReference currentKey = new AtomicReference<>(); + when(dataSource.determineDataSource()).thenAnswer(invocation -> sources.get(currentKey.get())); + + try (MockedStatic holder = mockStatic(DynamicDataSourceContextHolder.class)) { + holder.when(DynamicDataSourceContextHolder::peek).thenAnswer(invocation -> currentKey.get()); + currentKey.set("find-oracle"); + assertEquals("instr(','||role_ids||',' , ',100,') <> 0", DataBaseHelper.findInSet(100, "role_ids")); + currentKey.set("find-postgres"); + assertEquals("(select strpos(','||role_ids||',' , ',100,')) <> 0", DataBaseHelper.findInSet(100, "role_ids")); + currentKey.set("find-sqlserver"); + assertEquals("charindex(',100,' , ','+role_ids+',') <> 0", DataBaseHelper.findInSet(100, "role_ids")); + currentKey.set("find-mysql"); + assertEquals("find_in_set('100' , role_ids) <> 0", DataBaseHelper.findInSet(100, "role_ids")); + assertThrows(UtilException.class, () -> DataBaseHelper.findInSet("100'", "role_ids")); + assertThrows(UtilException.class, () -> DataBaseHelper.findInSet(100, "select role_ids")); + } + } + + /** + * 验证数据源名称列表复制动态数据源集合,调用方修改返回列表不会污染路由器状态。 + */ + @Test + @DisplayName("读取数据源名称列表") + void shouldCopyDataSourceNameList() throws Exception { + DataSource primary = dataSource("MySQL"); + DataSource archive = dataSource("Oracle"); + when(dataSource.getDataSources()).thenReturn(Map.of("primary", primary, "archive", archive)); + + var names = DataBaseHelper.getDataSourceNameList(); + + assertEquals(2, names.size()); + assertTrue(names.containsAll(java.util.List.of("primary", "archive"))); + names.clear(); + assertEquals(2, dataSource.getDataSources().size()); + } + + /** + * 验证 ID 工具覆盖生成器的 Number、Long、String、实体、UUID 和前缀 API。 + */ + @Test + @DisplayName("委托主键生成器和 UUID 生成") + void shouldDelegateIdentifierGenerationApis() { + when(identifierGenerator.nextId(any())).thenReturn(123456789L); + when(identifierGenerator.nextUUID(any())).thenReturn("entity-uuid"); + Object entity = new Object(); + + assertEquals("123456789", IdGeneratorUtil.nextId()); + assertEquals(123456789L, IdGeneratorUtil.nextLongId()); + assertEquals(123456789L, IdGeneratorUtil.nextNumberId()); + assertEquals(123456789L, IdGeneratorUtil.nextId(entity)); + assertEquals("123456789", IdGeneratorUtil.nextStringId(entity)); + assertEquals("entity-uuid", IdGeneratorUtil.nextUUID(entity)); + assertEquals("ORD123456789", IdGeneratorUtil.nextIdWithPrefix("ORD")); + assertTrue(IdGeneratorUtil.nextUUIDWithPrefix("ID").startsWith("ID")); + assertEquals(34, IdGeneratorUtil.nextUUIDWithPrefix("ID").length()); + assertEquals(32, IdGeneratorUtil.nextUUID().length()); + verify(identifierGenerator, atLeastOnce()).nextId(any()); + } + + /** + * 创建返回指定数据库产品名的 JDBC mock,集中复用连接和元数据契约。 + * + * @param productName 数据库产品名 + * @return JDBC 数据源 mock + */ + private static DataSource dataSource(String productName) throws SQLException { + DataSource source = mock(DataSource.class); + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(metadata.getDatabaseProductName()).thenReturn(productName); + when(connection.getMetaData()).thenReturn(metadata); + when(source.getConnection()).thenReturn(connection); + return source; + } +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/util/BucketUrlUtil.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/util/BucketUrlUtil.java index a7e7fd91e..87dd39368 100644 --- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/util/BucketUrlUtil.java +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/util/BucketUrlUtil.java @@ -79,9 +79,11 @@ public class BucketUrlUtil { * @return 移除HTTP/HTTPS协议头后的地址 */ public static String removeHttpProtocolHeader(String url) { - if (StringUtils.startsWithIgnoreCase(url, HTTP_PROTOCOL_HEADER) || StringUtils.startsWithIgnoreCase(url, HTTPS_PROTOCOL_HEADER)) { - return url.replace(HTTP_PROTOCOL_HEADER, EMPTY_STRING) - .replace(HTTPS_PROTOCOL_HEADER, EMPTY_STRING); + if (StringUtils.startsWithIgnoreCase(url, HTTPS_PROTOCOL_HEADER)) { + return url.substring(HTTPS_PROTOCOL_HEADER.length()); + } + if (StringUtils.startsWithIgnoreCase(url, HTTP_PROTOCOL_HEADER)) { + return url.substring(HTTP_PROTOCOL_HEADER.length()); } return url; } diff --git a/ruoyi-common/ruoyi-common-oss/src/test/java/org/dromara/common/oss/OssFunctionTest.java b/ruoyi-common/ruoyi-common-oss/src/test/java/org/dromara/common/oss/OssFunctionTest.java new file mode 100644 index 000000000..d67abed60 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/test/java/org/dromara/common/oss/OssFunctionTest.java @@ -0,0 +1,129 @@ +package org.dromara.common.oss; + +import org.dromara.common.oss.enums.AccessPolicy; +import org.dromara.common.oss.exception.S3StorageException; +import org.dromara.common.oss.util.BucketUrlUtil; +import org.dromara.common.oss.config.OssClientConfig; +import org.dromara.common.oss.properties.OssProperties; +import software.amazon.awssdk.regions.Region; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("common-oss 功能单元测试") +class OssFunctionTest { + + /** + * 验证桶地址生成会规范化已有协议头,并正确区分路径风格与站点风格。 + */ + @Test + @DisplayName("生成规范的桶访问地址") + void shouldBuildNormalizedBucketUrls() { + assertEquals("https://s3.example.com/images", + BucketUrlUtil.getPathStyleBucketUrl(true, "http://s3.example.com", "images")); + assertEquals("http://images.s3.example.com", + BucketUrlUtil.getSiteStyleBucketUrl(false, "https://s3.example.com", "images")); + assertEquals("https://s3.example.com", BucketUrlUtil.rebuildUrlHeader(true, "HTTP://s3.example.com")); + } + + /** + * 验证访问策略类型可以映射到枚举,未知类型会抛出明确的存储异常。 + */ + @Test + @DisplayName("解析 OSS 访问策略") + void shouldResolveAccessPolicyOrRejectUnknownType() { + assertEquals(AccessPolicy.PRIVATE, AccessPolicy.formType("0")); + assertEquals(AccessPolicy.PUBLIC_READ_WRITE, AccessPolicy.formType("1")); + assertEquals(AccessPolicy.PUBLIC_READ, AccessPolicy.formType("2")); + assertThrows(S3StorageException.class, () -> AccessPolicy.formType("9")); + } + + /** + * 验证旧版 OSS 属性可转换为客户端配置,并按 endpoint 类型推断路径风格和默认 Region。 + */ + @Test + @DisplayName("从兼容属性构建 OSS 客户端配置") + void shouldBuildClientConfigFromLegacyProperties() { + OssProperties properties = properties("http://minio.example.com", null, "images"); + properties.setRegion(" "); + properties.setIsHttps("Y"); + properties.setAccessPolicy("2"); + + OssClientConfig config = OssClientConfig.formProperties(properties); + + assertTrue(config.useHttps()); + assertTrue(config.usePathStyleAccess()); + assertEquals(Region.US_EAST_1, config.region().orElseThrow()); + assertEquals("https://minio.example.com", config.getEndpointUrl()); + assertEquals("https://minio.example.com/images", config.getBucketUrl()); + assertTrue(config.accessControlPolicyConfig().enabled()); + assertEquals(AccessPolicy.PUBLIC_READ, config.accessControlPolicyConfig().accessPolicy()); + } + + /** + * 验证自定义域名只直接服务默认桶,访问其他桶时仍回退标准 S3 endpoint 地址。 + */ + @Test + @DisplayName("区分默认桶和其他桶的自定义域名") + void shouldUseCustomDomainOnlyForDefaultBucket() { + OssProperties properties = properties("https://oss-cn-hangzhou.aliyuncs.com", "https://cdn.example.com", "images"); + properties.setRegion("ap-southeast-1"); + + OssClientConfig config = OssClientConfig.formProperties(properties); + + assertFalse(config.usePathStyleAccess()); + assertEquals("https://cdn.example.com", config.getBucketUrl()); + assertEquals("https://archive.oss-cn-hangzhou.aliyuncs.com", config.getBucketUrl("archive")); + assertEquals(Region.AP_SOUTHEAST_1, config.region().orElseThrow()); + } + + /** + * 验证必要 endpoint 或 bucket 缺失时明确失败,并且复制配置会深复制嵌套配置对象。 + */ + @Test + @DisplayName("校验 OSS 必要配置并复制客户端配置") + void shouldValidateRequiredConfigAndCopyNestedSettings() { + OssClientConfig missingEndpoint = OssClientConfig.builder().bucket("images").build(); + OssClientConfig missingBucket = OssClientConfig.builder().endpoint("s3.example.com").build(); + OssClientConfig config = OssClientConfig.formProperties(properties("s3.example.com", null, "images")); + + assertThrows(S3StorageException.class, missingEndpoint::getEndpointUrl); + assertThrows(S3StorageException.class, missingBucket::getBucketUrl); + + OssClientConfig copied = config.copy(); + assertNotSame(config, copied); + assertEquals(config.getEndpointUrl(), copied.getEndpointUrl()); + assertEquals(config.getBucketUrl(), copied.getBucketUrl()); + assertEquals(config.region(), copied.region()); + assertEquals(config.prefix(), copied.prefix()); + assertEquals(config.accessControlPolicyConfig(), copied.accessControlPolicyConfig()); + assertEquals(config.asyncExecutorConfig(), copied.asyncExecutorConfig()); + assertNotSame(config.accessControlPolicyConfig(), copied.accessControlPolicyConfig()); + assertNotSame(config.asyncExecutorConfig(), copied.asyncExecutorConfig()); + } + + /** + * 创建覆盖 URL 构造所需字段的 OSS 属性。 + * + * @param endpoint endpoint 地址 + * @param domain 自定义域名 + * @param bucket 默认桶 + * @return OSS 属性 + */ + private static OssProperties properties(String endpoint, String domain, String bucket) { + OssProperties properties = new OssProperties(); + properties.setEndpoint(endpoint); + properties.setDomainUrl(domain); + properties.setBucketName(bucket); + properties.setAccessKey("access-key"); + properties.setSecretKey("secret-key"); + properties.setPrefix("business"); + properties.setIsHttps("Y"); + return properties; + } +} diff --git a/ruoyi-common/ruoyi-common-push/src/test/java/org/dromara/common/push/PushFunctionTest.java b/ruoyi-common/ruoyi-common-push/src/test/java/org/dromara/common/push/PushFunctionTest.java new file mode 100644 index 000000000..da5f04cb2 --- /dev/null +++ b/ruoyi-common/ruoyi-common-push/src/test/java/org/dromara/common/push/PushFunctionTest.java @@ -0,0 +1,55 @@ +package org.dromara.common.push; + +import org.dromara.common.push.annotation.ConditionalOnMessageTransport; +import org.dromara.common.push.condition.MessageTransportCondition; +import org.dromara.common.push.enums.MessageTransportEnum; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.mock.env.MockEnvironment; + +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.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("common-push 功能单元测试") +class PushFunctionTest { + + /** + * 验证消息传输枚举忽略大小写解析,并在未知配置下回退到 SSE。 + */ + @Test + @DisplayName("解析消息传输方式") + void shouldResolveMessageTransportWithSseFallback() { + assertEquals(MessageTransportEnum.WEBSOCKET, MessageTransportEnum.of("WebSocket")); + assertEquals(MessageTransportEnum.SSE, MessageTransportEnum.of("unknown")); + assertEquals(MessageTransportEnum.SSE, MessageTransportEnum.of(null)); + } + + /** + * 验证传输条件同时受启用开关和传输类型约束,防止错误装配推送实现。 + */ + @Test + @DisplayName("匹配消息传输装配条件") + void shouldMatchEnabledMessageTransportOnly() { + MessageTransportCondition condition = new MessageTransportCondition(); + ConditionContext context = mock(ConditionContext.class); + AnnotatedTypeMetadata metadata = mock(AnnotatedTypeMetadata.class); + MockEnvironment environment = new MockEnvironment() + .withProperty("message.enabled", "true") + .withProperty("message.transport", "websocket"); + when(context.getEnvironment()).thenReturn(environment); + when(metadata.getAnnotationAttributes(ConditionalOnMessageTransport.class.getName())) + .thenReturn(Map.of("value", "websocket")); + + assertTrue(condition.matches(context, metadata)); + + environment.setProperty("message.enabled", "false"); + assertFalse(condition.matches(context, metadata)); + } +} diff --git a/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/RedisAspectContractTest.java b/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/RedisAspectContractTest.java new file mode 100644 index 000000000..9d5e0c5c0 --- /dev/null +++ b/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/RedisAspectContractTest.java @@ -0,0 +1,576 @@ +package org.dromara.common.redis; + +import cn.hutool.extra.spring.SpringUtil; +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.JoinPoint; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.redis.annotation.RateLimiter; +import org.dromara.common.redis.annotation.RepeatSubmit; +import org.dromara.common.redis.aspectj.RateLimiterAspect; +import org.dromara.common.redis.aspectj.RepeatSubmitAspect; +import org.dromara.common.redis.utils.CacheUtils; +import org.dromara.common.redis.utils.QueueUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.redis.utils.SequenceUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.redisson.api.RAtomicLong; +import org.redisson.api.RBatch; +import org.redisson.api.RBlockingQueue; +import org.redisson.api.RBucket; +import org.redisson.api.RBucketAsync; +import org.redisson.api.RIdGenerator; +import org.redisson.api.RKeys; +import org.redisson.api.RList; +import org.redisson.api.RMap; +import org.redisson.api.RMapAsync; +import org.redisson.api.ObjectListener; +import org.redisson.api.RPriorityBlockingQueue; +import org.redisson.api.RRateLimiter; +import org.redisson.api.RSet; +import org.redisson.api.RTopic; +import org.redisson.api.RedissonClient; +import org.redisson.api.RateType; +import org.redisson.api.listener.MessageListener; +import org.redisson.api.options.KeysScanOptions; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import tools.jackson.databind.json.JsonMapper; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockMultipartFile; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@DisplayName("Redis 基础设施契约单元测试") +class RedisAspectContractTest { + + private static RedissonClient redissonClient; + private static CacheManager cacheManager; + + /** + * 注入仅包含 mock RedissonClient 的内存 Spring 容器,避免连接真实 Redis 并覆盖工具类初始化契约。 + */ + @BeforeAll + static void initializeRedisInfrastructure() { + redissonClient = mock(RedissonClient.class); + cacheManager = mock(CacheManager.class); + StaticApplicationContext context = new StaticApplicationContext(); + context.getBeanFactory().registerSingleton("redissonClient", redissonClient); + context.getBeanFactory().registerSingleton("cacheManager", cacheManager); + context.getBeanFactory().registerSingleton("jsonMapper", JsonMapper.builder().build()); + context.refresh(); + new SpringUtil().setApplicationContext(context); + } + + /** + * 每个用例前清理 Redisson mock 的行为和配置,避免不同工具契约互相影响。 + */ + @BeforeEach + void resetRedisClient() { + reset(redissonClient); + reset(cacheManager); + } + + /** + * 验证对象缓存的普通写入、带 TTL 写入、条件写入、读取、过期和删除都委托给 RBucket。 + */ + @Test + @DisplayName("委托对象缓存和 TTL 操作") + void shouldDelegateObjectCacheOperations() { + @SuppressWarnings("unchecked") + RBucket bucket = mock(RBucket.class); + Duration duration = Duration.ofMinutes(5); + when(redissonClient.getBucket("object:key")).thenReturn(bucket); + when(bucket.setIfAbsent("created", duration)).thenReturn(true); + when(bucket.setIfExists("updated", duration)).thenReturn(false); + when(bucket.get()).thenReturn("value"); + when(bucket.remainTimeToLive()).thenReturn(12_000L); + when(bucket.expire(duration)).thenReturn(true); + when(bucket.isExists()).thenReturn(true); + when(bucket.delete()).thenReturn(true); + + RedisUtils.setCacheObject("object:key", "plain"); + RedisUtils.setCacheObject("object:key", "timed", duration); + + assertTrue(RedisUtils.setObjectIfAbsent("object:key", "created", duration)); + assertFalse(RedisUtils.setObjectIfExists("object:key", "updated", duration)); + assertEquals("value", RedisUtils.getCacheObject("object:key")); + assertEquals(12_000L, RedisUtils.getTimeToLive("object:key")); + assertTrue(RedisUtils.expire("object:key", duration)); + assertTrue(RedisUtils.isExistsObject("object:key")); + assertTrue(RedisUtils.deleteObject("object:key")); + assertFalse(RedisUtils.deleteObject((String) null)); + + verify(bucket).set("plain"); + verify(bucket).set("timed", duration); + } + + /** + * 验证保留 TTL 优先使用 Redisson 原生命令,命令不可用时按剩余 TTL 降级写入。 + */ + @Test + @DisplayName("保留对象 TTL 并兼容旧 Redis") + void shouldKeepObjectTtlWithCompatibilityFallback() { + @SuppressWarnings("unchecked") + RBucket nativeBucket = mock(RBucket.class); + @SuppressWarnings("unchecked") + RBucket timedFallback = mock(RBucket.class); + @SuppressWarnings("unchecked") + RBucket plainFallback = mock(RBucket.class); + when(redissonClient.getBucket("ttl:native")).thenReturn(nativeBucket); + when(redissonClient.getBucket("ttl:timed")).thenReturn(timedFallback); + when(redissonClient.getBucket("ttl:plain")).thenReturn(plainFallback); + doThrow(new UnsupportedOperationException("keep ttl unsupported")) + .when(timedFallback).setAndKeepTTL("value"); + doThrow(new UnsupportedOperationException("keep ttl unsupported")) + .when(plainFallback).setAndKeepTTL("value"); + when(timedFallback.remainTimeToLive()).thenReturn(1_500L); + when(plainFallback.remainTimeToLive()).thenReturn(-1L); + + RedisUtils.setCacheObject("ttl:native", "value", true); + RedisUtils.setCacheObject("ttl:timed", "value", true); + RedisUtils.setCacheObject("ttl:plain", "value", true); + + verify(nativeBucket).setAndKeepTTL("value"); + verify(timedFallback).set("value", Duration.ofMillis(1_500L)); + verify(plainFallback).set("value"); + } + + /** + * 验证 List、Set 和 Map 工具方法保留 Redisson 的追加、范围读取、去重和 Hash 批量读取语义。 + */ + @Test + @DisplayName("委托 Redis 集合操作") + void shouldDelegateCollectionOperations() { + @SuppressWarnings("unchecked") + RList list = mock(RList.class); + @SuppressWarnings("unchecked") + RSet set = mock(RSet.class); + @SuppressWarnings("unchecked") + RMap map = mock(RMap.class); + when(redissonClient.getList("list:key")).thenReturn(list); + when(redissonClient.getSet("set:key")).thenReturn(set); + when(redissonClient.getMap("map:key")).thenReturn(map); + when(list.addAll(List.of("a", "b"))).thenReturn(true); + when(list.add("c")).thenReturn(true); + when(list.readAll()).thenReturn(List.of("a", "b", "c")); + when(list.range(1, 2)).thenReturn(List.of("b", "c")); + when(set.addAll(Set.of("a", "b"))).thenReturn(true); + when(set.add("c")).thenReturn(true); + when(set.readAll()).thenReturn(Set.of("a", "b", "c")); + when(map.readAllMap()).thenReturn(Map.of("a", "1", "b", "2")); + when(map.keySet()).thenReturn(Set.of("a", "b")); + when(map.get("a")).thenReturn("1"); + when(map.remove("a")).thenReturn("1"); + when(map.getAll(Set.of("a", "b"))).thenReturn(Map.of("a", "1", "b", "2")); + + assertTrue(RedisUtils.setCacheList("list:key", List.of("a", "b"))); + assertTrue(RedisUtils.addCacheList("list:key", "c")); + assertEquals(List.of("a", "b", "c"), RedisUtils.getCacheList("list:key")); + assertEquals(List.of("b", "c"), RedisUtils.getCacheListRange("list:key", 1, 2)); + assertTrue(RedisUtils.setCacheSet("set:key", Set.of("a", "b"))); + assertTrue(RedisUtils.addCacheSet("set:key", "c")); + assertEquals(Set.of("a", "b", "c"), RedisUtils.getCacheSet("set:key")); + + RedisUtils.setCacheMap("map:key", Map.of("a", "1", "b", "2")); + RedisUtils.setCacheMap("map:null", null); + RedisUtils.setCacheMapValue("map:key", "c", "3"); + assertEquals(Map.of("a", "1", "b", "2"), RedisUtils.getCacheMap("map:key")); + assertEquals(Set.of("a", "b"), RedisUtils.getCacheMapKeySet("map:key")); + assertEquals("1", RedisUtils.getCacheMapValue("map:key", "a")); + assertEquals("1", RedisUtils.delCacheMapValue("map:key", "a")); + assertEquals(Map.of("a", "1", "b", "2"), + RedisUtils.getMultiCacheMapValue("map:key", Set.of("a", "b"))); + + verify(map).putAll(Map.of("a", "1", "b", "2")); + verify(map).put("c", "3"); + verify(redissonClient, never()).getMap("map:null"); + } + + /** + * 验证多 Key 和多 Hash 字段删除使用 RBatch 一次执行,空集合不创建批处理。 + */ + @Test + @DisplayName("批量删除 Redis 数据") + void shouldDeleteKeysAndHashFieldsInBatches() { + RBatch keyBatch = mock(RBatch.class); + RBatch mapBatch = mock(RBatch.class); + @SuppressWarnings("unchecked") + RBucketAsync firstBucket = mock(RBucketAsync.class); + @SuppressWarnings("unchecked") + RBucketAsync secondBucket = mock(RBucketAsync.class); + @SuppressWarnings("unchecked") + RMapAsync asyncMap = mock(RMapAsync.class); + when(redissonClient.createBatch()).thenReturn(keyBatch, mapBatch); + when(keyBatch.getBucket("a")).thenReturn(firstBucket); + when(keyBatch.getBucket("b")).thenReturn(secondBucket); + when(mapBatch.getMap("map:key")).thenReturn(asyncMap); + + RedisUtils.deleteObject((java.util.Collection) null); + RedisUtils.deleteObject(List.of()); + RedisUtils.deleteObject(List.of("a", "b")); + RedisUtils.delMultiCacheMapValue("map:key", Set.of("x", "y")); + + verify(firstBucket).deleteAsync(); + verify(secondBucket).deleteAsync(); + verify(keyBatch).execute(); + verify(asyncMap).removeAsync("x"); + verify(asyncMap).removeAsync("y"); + verify(mapBatch).execute(); + verify(redissonClient, times(2)).createBatch(); + } + + /** + * 验证原子计数和 Key 扫描、模式删除、存在性检查使用 Redisson 对应 API。 + */ + @Test + @DisplayName("委托原子值和 Key 管理") + void shouldDelegateAtomicAndKeyOperations() { + RAtomicLong atomic = mock(RAtomicLong.class); + RKeys keys = mock(RKeys.class); + when(redissonClient.getAtomicLong("counter")).thenReturn(atomic); + when(redissonClient.getKeys()).thenReturn(keys); + when(atomic.get()).thenReturn(10L); + when(atomic.incrementAndGet()).thenReturn(11L); + when(atomic.decrementAndGet()).thenReturn(9L); + when(keys.getKeysStream(any(KeysScanOptions.class))).thenReturn(Stream.of("user:1", "user:2")); + when(keys.countExists("user:1")).thenReturn(1L); + + RedisUtils.setAtomicValue("counter", 10L); + + assertEquals(10L, RedisUtils.getAtomicValue("counter")); + assertEquals(11L, RedisUtils.incrAtomicValue("counter")); + assertEquals(9L, RedisUtils.decrAtomicValue("counter")); + assertEquals(List.of("user:1", "user:2"), RedisUtils.keys("user:*")); + assertTrue(RedisUtils.hasKey("user:1")); + RedisUtils.deleteKeys("user:*"); + + verify(atomic).set(10L); + verify(keys).deleteByPattern("user:*"); + } + + /** + * 验证发布、订阅、消息回调和取消订阅的 Redisson Topic 契约。 + */ + @Test + @DisplayName("委托 Redis 发布订阅") + void shouldDelegatePublishAndSubscribeOperations() { + RTopic topic = mock(RTopic.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> listenerCaptor = ArgumentCaptor.forClass(MessageListener.class); + AtomicReference subscribedMessage = new AtomicReference<>(); + AtomicReference publishedMessage = new AtomicReference<>(); + when(redissonClient.getTopic("events")).thenReturn(topic); + when(topic.addListener(eq(String.class), listenerCaptor.capture())).thenReturn(42); + + int listenerId = RedisUtils.subscribeAndGetListenerId("events", String.class, subscribedMessage::set); + listenerCaptor.getValue().onMessage("events", "received"); + RedisUtils.publish("events", "published", publishedMessage::set); + RedisUtils.publish("events", "plain"); + RedisUtils.unsubscribe("events", listenerId); + + assertEquals(42, listenerId); + assertEquals("received", subscribedMessage.get()); + assertEquals("published", publishedMessage.get()); + verify(topic).removeListener(42); + verify(topic).publish("published"); + verify(topic).publish("plain"); + } + + /** + * 验证 RedisUtils 限流入口按秒构建速率与超时配置,并区分获取成功和令牌耗尽。 + */ + @Test + @DisplayName("委托 Redisson 令牌桶") + void shouldDelegateRateLimiterOperations() { + RRateLimiter limiter = mock(RRateLimiter.class); + when(redissonClient.getRateLimiter("rate:key")).thenReturn(limiter); + when(limiter.tryAcquire()).thenReturn(true, false); + when(limiter.availablePermits()).thenReturn(3L); + + assertEquals(3L, RedisUtils.rateLimiter("rate:key", RateType.OVERALL, 4, 10)); + assertEquals(-1L, RedisUtils.rateLimiter("rate:key", RateType.OVERALL, 4, 10, 5)); + + verify(limiter).trySetRate(RateType.OVERALL, 4, Duration.ofSeconds(10), Duration.ZERO); + verify(limiter).trySetRate(RateType.OVERALL, 4, Duration.ofSeconds(10), Duration.ofSeconds(5)); + verify(limiter, times(2)).tryAcquire(); + } + + /** + * 验证对象、List、Set 和 Map 监听器被注册到正确的 Redisson 数据结构。 + */ + @Test + @DisplayName("注册 Redis 对象监听器") + void shouldRegisterObjectListeners() { + ObjectListener listener = mock(ObjectListener.class); + RBucket bucket = mock(RBucket.class); + RList list = mock(RList.class); + RSet set = mock(RSet.class); + RMap map = mock(RMap.class); + when(redissonClient.getBucket("bucket")).thenReturn(bucket); + when(redissonClient.getList("list")).thenReturn(list); + when(redissonClient.getSet("set")).thenReturn(set); + when(redissonClient.getMap("map")).thenReturn(map); + + RedisUtils.addObjectListener("bucket", listener); + RedisUtils.addListListener("list", listener); + RedisUtils.addSetListener("set", listener); + RedisUtils.addMapListener("map", listener); + + verify(bucket).addListener(listener); + verify(list).addListener(listener); + verify(set).addListener(listener); + verify(map).addListener(listener); + assertSame(redissonClient, RedisUtils.getClient()); + } + + /** + * 验证普通阻塞队列和优先队列的写入、读取、删除、销毁及元素订阅委托给正确的 Redisson 类型。 + */ + @Test + @DisplayName("委托 Redisson 阻塞队列") + void shouldDelegateQueueOperations() { + @SuppressWarnings("unchecked") + RBlockingQueue queue = mock(RBlockingQueue.class); + @SuppressWarnings("unchecked") + RPriorityBlockingQueue priorityQueue = mock(RPriorityBlockingQueue.class); + Function> consumer = value -> CompletableFuture.completedFuture(null); + when(redissonClient.getBlockingQueue("normal")).thenReturn(queue); + when(redissonClient.getPriorityBlockingQueue("priority")).thenReturn(priorityQueue); + when(queue.offer("a")).thenReturn(true); + when(queue.poll()).thenReturn("a"); + when(queue.remove("a")).thenReturn(true); + when(queue.delete()).thenReturn(true); + when(priorityQueue.offer("b")).thenReturn(true); + when(priorityQueue.poll()).thenReturn("b"); + when(priorityQueue.remove("b")).thenReturn(true); + when(priorityQueue.delete()).thenReturn(true); + + assertTrue(QueueUtils.addQueueObject("normal", "a")); + assertEquals("a", QueueUtils.getQueueObject("normal")); + assertTrue(QueueUtils.removeQueueObject("normal", "a")); + assertTrue(QueueUtils.destroyQueue("normal")); + QueueUtils.subscribeBlockingQueue("normal", consumer); + assertTrue(QueueUtils.addPriorityQueueObject("priority", "b")); + assertEquals("b", QueueUtils.getPriorityQueueObject("priority")); + assertTrue(QueueUtils.removePriorityQueueObject("priority", "b")); + assertTrue(QueueUtils.destroyPriorityQueue("priority")); + + verify(queue).subscribeOnElements(consumer); + assertSame(redissonClient, QueueUtils.getClient()); + } + + /** + * 验证发号器会将非法初始值和步长恢复为默认值,并传递过期时间。 + */ + @Test + @DisplayName("初始化 Redisson 发号器") + void shouldInitializeIdGeneratorWithSafeDefaults() { + RIdGenerator idGenerator = mock(RIdGenerator.class); + Duration expiration = Duration.ofMinutes(3); + when(redissonClient.getIdGenerator("order")).thenReturn(idGenerator); + when(idGenerator.nextId()).thenReturn(12L, 13L); + + assertSame(idGenerator, SequenceUtils.getIdGenerator("order", expiration, 0, -1)); + assertEquals(12L, SequenceUtils.getNextId("order", expiration)); + assertEquals("13", SequenceUtils.getNextIdString("order", expiration)); + + verify(idGenerator, times(3)).tryInit(SequenceUtils.DEFAULT_INIT_VALUE, SequenceUtils.DEFAULT_STEP_VALUE); + verify(idGenerator, times(3)).expire(expiration); + } + + /** + * 验证日期和日期时间发号格式使用稳定的 Redis Key,并正确处理业务前缀与左补零。 + */ + @Test + @DisplayName("格式化日期序列号") + void shouldFormatDateBasedSequenceIds() { + RIdGenerator dateGenerator = mock(RIdGenerator.class); + RIdGenerator dateTimeGenerator = mock(RIdGenerator.class); + LocalDate date = LocalDate.of(2026, 9, 15); + LocalDateTime dateTime = LocalDateTime.of(2026, 9, 15, 12, 34, 56); + when(redissonClient.getIdGenerator("ORD20260915")).thenReturn(dateGenerator); + when(redissonClient.getIdGenerator("INV20260915123456")).thenReturn(dateTimeGenerator); + when(dateGenerator.nextId()).thenReturn(7L); + when(dateTimeGenerator.nextId()).thenReturn(42L); + + assertEquals("ORD202609150007", SequenceUtils.getDateId("ORD", true, 4, date, 10, 2)); + assertEquals("2026091512345600042", + SequenceUtils.getDateTimeId("INV", false, 5, dateTime, 3, 5)); + + verify(dateGenerator).tryInit(10, 2); + verify(dateGenerator).expire(SequenceUtils.DEFAULT_EXPIRE_TIME_DAY); + verify(dateTimeGenerator).tryInit(3, 5); + verify(dateTimeGenerator).expire(SequenceUtils.DEFAULT_EXPIRE_TIME_MINUTE); + } + + /** + * 验证 CacheUtils 通过 Spring CacheManager 完成读写、驱逐和清理,缓存不存在时立即报错。 + */ + @Test + @DisplayName("委托 Spring Cache 操作") + void shouldDelegateSpringCacheOperations() { + Cache cache = mock(Cache.class); + Cache.ValueWrapper wrapper = mock(Cache.ValueWrapper.class); + when(cacheManager.getCache("users")).thenReturn(cache); + when(cache.get("1")).thenReturn(wrapper); + when(wrapper.get()).thenReturn("alice"); + + assertEquals("alice", CacheUtils.get("users", "1")); + CacheUtils.put("users", "2", "bob"); + CacheUtils.evict("users", "1"); + CacheUtils.clear("users"); + + verify(cache).put("2", "bob"); + verify(cache).evict("1"); + verify(cache).clear(); + assertThrows(IllegalArgumentException.class, () -> CacheUtils.get("missing", "1")); + } + + /** + * 验证限流切面按注解配置调用 Redisson 令牌桶,并在无剩余令牌时抛出业务异常。 + */ + @Test + @DisplayName("执行默认接口限流") + void shouldAcquireRateLimiterTokenAndRejectWhenExhausted() throws Exception { + RateLimiter annotation = annotation("limitedEndpoint", RateLimiter.class); + JoinPoint point = mock(JoinPoint.class); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/orders"); + RateLimiterAspect aspect = new RateLimiterAspect(); + RRateLimiter limiter = mock(RRateLimiter.class); + when(redissonClient.getRateLimiter("global:rate_limit:/orders:order")).thenReturn(limiter); + when(limiter.tryAcquire()).thenReturn(true, false); + when(limiter.availablePermits()).thenReturn(1L); + + try (MockedStatic servlet = mockStatic(ServletUtils.class)) { + servlet.when(ServletUtils::getRequest).thenReturn(request); + + assertDoesNotThrow(() -> aspect.doBefore(point, annotation)); + ServiceException exception = assertThrows(ServiceException.class, + () -> aspect.doBefore(point, annotation)); + + assertEquals("请求过于频繁", exception.getMessage()); + verify(limiter, times(2)).trySetRate(RateType.OVERALL, 2, Duration.ofSeconds(10), + Duration.ofSeconds(60)); + verify(limiter, times(2)).tryAcquire(); + } + } + + /** + * 验证防重间隔下限在访问请求和 Redis 前生效,避免无效配置进入运行期。 + */ + @Test + @DisplayName("拒绝过短的防重复提交间隔") + void shouldRejectTooShortRepeatSubmitInterval() throws Exception { + RepeatSubmit annotation = annotation("invalidRepeat", RepeatSubmit.class); + + ServiceException exception = assertThrows(ServiceException.class, + () -> new RepeatSubmitAspect().doBefore(mock(JoinPoint.class), annotation)); + + assertEquals("重复提交间隔时间不能小于'1'秒", exception.getMessage()); + } + + /** + * 验证失败响应会删除本次写入的防重键,而成功写入使用请求参数生成稳定摘要。 + */ + @Test + @DisplayName("失败响应释放防重复提交键") + void shouldReleaseRepeatSubmitKeyAfterFailedResponse() throws Throwable { + RepeatSubmit annotation = annotation("validRepeat", RepeatSubmit.class); + JoinPoint point = mock(JoinPoint.class); + when(point.getArgs()).thenReturn(new Object[]{Map.of("orderId", 1L)}); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/orders"); + RepeatSubmitAspect aspect = new RepeatSubmitAspect(); + @SuppressWarnings("unchecked") + RBucket bucket = mock(RBucket.class); + when(redissonClient.getBucket(anyString())).thenReturn(bucket); + when(bucket.setIfAbsent("", Duration.ofSeconds(2))).thenReturn(true); + when(bucket.delete()).thenReturn(true); + + try (MockedStatic servlet = mockStatic(ServletUtils.class)) { + servlet.when(ServletUtils::getRequest).thenReturn(request); + + aspect.doBefore(point, annotation); + aspect.doAfterReturning(point, annotation, R.fail("failed")); + + verify(redissonClient, times(2)).getBucket(startsWith("global:repeat_submit:/orders")); + verify(bucket).setIfAbsent("", Duration.ofSeconds(2)); + verify(bucket).delete(); + } + } + + /** + * 验证防重参数摘要会过滤上传文件和嵌套容器中的 Servlet 对象,避免序列化基础设施对象。 + */ + @Test + @DisplayName("过滤不可序列化的防重参数") + void shouldFilterInfrastructureObjectsFromRepeatSubmitArguments() { + RepeatSubmitAspect aspect = new RepeatSubmitAspect(); + MockMultipartFile file = new MockMultipartFile("file", new byte[]{1}); + + assertTrue(aspect.isFilterObject(file)); + assertTrue(aspect.isFilterObject(List.of("value", file))); + assertTrue(aspect.isFilterObject(Map.of("request", mock(HttpServletRequest.class)))); + assertFalse(aspect.isFilterObject(List.of("value", 1L))); + } + + /** + * 读取测试方法上的目标注解,确保测试使用与运行期相同的注解代理对象。 + * + * @param methodName 测试方法名 + * @param annotationType 注解类型 + * @param 注解类型 + * @return 方法注解 + */ + private static A annotation(String methodName, Class annotationType) + throws Exception { + Method method = AnnotatedEndpoints.class.getDeclaredMethod(methodName); + return method.getAnnotation(annotationType); + } + + private static class AnnotatedEndpoints { + + /** + * 提供默认类型的限流配置供切面测试。 + */ + @RateLimiter(key = "order", time = 10, count = 2, timeout = 60, message = "请求过于频繁") + private void limitedEndpoint() { + } + + /** + * 提供不满足最小间隔的防重配置供校验测试。 + */ + @RepeatSubmit(interval = 999) + private void invalidRepeat() { + } + + /** + * 提供有效防重配置供 Redis 键生命周期测试。 + */ + @RepeatSubmit(interval = 2, timeUnit = java.util.concurrent.TimeUnit.SECONDS, message = "重复请求") + private void validRepeat() { + } + } +} diff --git a/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/RedisConfigurationTest.java b/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/RedisConfigurationTest.java new file mode 100644 index 000000000..789e54eaf --- /dev/null +++ b/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/RedisConfigurationTest.java @@ -0,0 +1,66 @@ +package org.dromara.common.redis; + +import cn.hutool.http.HttpStatus; +import com.baomidou.lock.exception.LockFailureException; +import org.dromara.common.core.domain.R; +import org.dromara.common.redis.aspectj.RateLimiterAspect; +import org.dromara.common.redis.aspectj.RepeatSubmitAspect; +import org.dromara.common.redis.config.CacheConfig; +import org.dromara.common.redis.config.IdempotentConfig; +import org.dromara.common.redis.config.RateLimiterConfig; +import org.dromara.common.redis.handler.RedisExceptionHandler; +import org.dromara.common.redis.manager.PlusSpringCacheManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.cache.CacheManager; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +@DisplayName("common-redis 配置与异常单元测试") +class RedisConfigurationTest { + + /** + * 验证缓存配置创建可用的 Caffeine 实例和项目自定义缓存管理器。 + */ + @Test + @DisplayName("创建缓存基础组件") + void shouldCreateCacheInfrastructureBeans() { + CacheConfig configuration = new CacheConfig(); + com.github.benmanes.caffeine.cache.Cache caffeine = configuration.caffeine(); + CacheManager manager = configuration.cacheManager(caffeine); + + caffeine.put("key", "value"); + assertEquals("value", caffeine.getIfPresent("key")); + assertInstanceOf(PlusSpringCacheManager.class, manager); + } + + /** + * 验证幂等和限流自动配置能够独立创建对应切面 Bean。 + */ + @Test + @DisplayName("创建幂等与限流切面") + void shouldCreateIdempotentAndRateLimiterAspects() { + RepeatSubmitAspect repeatSubmitAspect = new IdempotentConfig().repeatSubmitAspect(); + RateLimiterAspect rateLimiterAspect = new RateLimiterConfig().rateLimiterAspect(); + + assertNotNull(repeatSubmitAspect); + assertNotNull(rateLimiterAspect); + } + + /** + * 验证分布式锁获取失败会转换为服务不可用业务响应。 + */ + @Test + @DisplayName("转换分布式锁异常") + void shouldConvertLockFailureToUnavailableResponse() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/orders/submit"); + + R response = new RedisExceptionHandler() + .handleLockFailureException(mock(LockFailureException.class), request); + + assertEquals(HttpStatus.HTTP_UNAVAILABLE, response.getCode()); + assertEquals("业务处理中,请稍后再试...", response.getMsg()); + } +} diff --git a/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/RedisFunctionTest.java b/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/RedisFunctionTest.java new file mode 100644 index 000000000..9912d0238 --- /dev/null +++ b/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/RedisFunctionTest.java @@ -0,0 +1,40 @@ +package org.dromara.common.redis; + +import org.dromara.common.redis.handler.KeyPrefixHandler; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@DisplayName("common-redis 功能单元测试") +class RedisFunctionTest { + + /** + * 验证 Redis Key 在写入时增加前缀、读取时移除前缀,且不会重复添加。 + */ + @Test + @DisplayName("映射和还原 Redis Key 前缀") + void shouldMapAndUnmapKeyPrefix() { + KeyPrefixHandler handler = new KeyPrefixHandler("app"); + + assertEquals("app:user:1", handler.map("user:1")); + assertEquals("app:user:1", handler.map("app:user:1")); + assertEquals("user:1", handler.unmap("app:user:1")); + assertEquals("other:user:1", handler.unmap("other:user:1")); + } + + /** + * 验证空前缀不会改变有效 Key,空白 Key 按无效输入返回空值。 + */ + @Test + @DisplayName("处理空 Redis Key 前缀") + void shouldHandleBlankKeyPrefixAndName() { + KeyPrefixHandler handler = new KeyPrefixHandler(" "); + + assertEquals("user:1", handler.map("user:1")); + assertEquals("user:1", handler.unmap("user:1")); + assertNull(handler.map(" ")); + assertNull(handler.unmap(null)); + } +} diff --git a/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/manager/CaffeineCacheDecoratorTest.java b/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/manager/CaffeineCacheDecoratorTest.java new file mode 100644 index 000000000..f482cef6a --- /dev/null +++ b/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/manager/CaffeineCacheDecoratorTest.java @@ -0,0 +1,77 @@ +package org.dromara.common.redis.manager; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.concurrent.ConcurrentMapCache; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("CaffeineCacheDecorator 单元测试") +class CaffeineCacheDecoratorTest { + + /** + * 验证首次读取回源到底层缓存,后续读取命中本地一级缓存,并在写入时失效旧值。 + */ + @Test + @DisplayName("读取并刷新一级缓存") + void shouldReadThroughAndInvalidateLocalCacheOnPut() { + ConcurrentMapCache remote = new ConcurrentMapCache("remote"); + com.github.benmanes.caffeine.cache.Cache local = Caffeine.newBuilder().build(); + CaffeineCacheDecorator decorator = new CaffeineCacheDecorator("users", remote, local); + remote.put("1", "alice"); + + assertEquals("alice", decorator.get("1", String.class)); + remote.put("1", "changed-remotely"); + assertEquals("alice", decorator.get("1", String.class)); + + decorator.put("1", "bob"); + assertEquals("bob", decorator.get("1", String.class)); + assertEquals("users:1", decorator.getUniqueKey("1")); + assertSame(remote.getNativeCache(), decorator.getNativeCache()); + } + + /** + * 验证 Callable 加载、条件写入与删除会同步维护底层缓存和一级缓存。 + */ + @Test + @DisplayName("维护缓存写入和删除一致性") + void shouldKeepLocalAndRemoteCacheConsistent() { + ConcurrentMapCache remote = new ConcurrentMapCache("remote"); + com.github.benmanes.caffeine.cache.Cache local = Caffeine.newBuilder().build(); + CaffeineCacheDecorator decorator = new CaffeineCacheDecorator("users", remote, local); + + assertEquals("loaded", decorator.get("1", () -> "loaded")); + Cache.ValueWrapper existing = decorator.putIfAbsent("1", "other"); + assertEquals("loaded", existing.get()); + assertTrue(decorator.evictIfPresent("1")); + assertNull(decorator.get("1")); + assertFalse(decorator.evictIfPresent("missing")); + } + + /** + * 验证清空操作只移除当前缓存命名空间的本地键,并支持整体失效。 + */ + @Test + @DisplayName("按命名空间清理一级缓存") + void shouldClearOnlyCurrentLocalNamespace() { + ConcurrentMapCache remote = new ConcurrentMapCache("remote"); + com.github.benmanes.caffeine.cache.Cache local = Caffeine.newBuilder().build(); + CaffeineCacheDecorator decorator = new CaffeineCacheDecorator("users", remote, local); + local.put("users:1", "alice"); + local.put("roles:1", "admin"); + remote.put("1", "alice"); + + decorator.clear(); + + assertNull(local.getIfPresent("users:1")); + assertEquals("admin", local.getIfPresent("roles:1")); + assertNull(remote.get("1")); + + remote.put("2", "bob"); + local.put("users:2", "bob"); + assertTrue(decorator.invalidate()); + assertNull(local.getIfPresent("users:2")); + } +} diff --git a/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/manager/PlusSpringCacheManagerTest.java b/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/manager/PlusSpringCacheManagerTest.java new file mode 100644 index 000000000..6c0c1ede0 --- /dev/null +++ b/ruoyi-common/ruoyi-common-redis/src/test/java/org/dromara/common/redis/manager/PlusSpringCacheManagerTest.java @@ -0,0 +1,57 @@ +package org.dromara.common.redis.manager; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.redisson.spring.cache.CacheConfig; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("PlusSpringCacheManager 单元测试") +class PlusSpringCacheManagerTest { + + /** + * 验证扩展缓存名称能够解析 TTL、最大空闲时间、容量和本地缓存开关。 + */ + @Test + @DisplayName("解析扩展缓存名称") + void shouldResolveExtendedCacheNameOptions() { + PlusSpringCacheManager manager = new PlusSpringCacheManager(); + CacheConfig template = new CacheConfig(); + manager.setConfig(Map.of("users", template)); + String cacheName = "users#5m#30s#200#0"; + String[] parts = cacheName.split("#"); + + CacheConfig resolved = ReflectionTestUtils.invokeMethod( + manager, "resolveCacheConfig", cacheName, "users", parts); + Integer local = ReflectionTestUtils.invokeMethod(manager, "resolveLocal", (Object) parts); + + assertNotNull(resolved); + assertEquals(300_000L, resolved.getTTL()); + assertEquals(30_000L, resolved.getMaxIdleTime()); + assertEquals(200, resolved.getMaxSize()); + assertEquals(0, local); + assertEquals(0L, template.getTTL()); + assertTrue(manager.getCacheNames().contains(cacheName)); + } + + /** + * 验证空配置会重置缓存配置集合,默认配置和本地缓存开关使用零值与启用状态。 + */ + @Test + @DisplayName("创建默认缓存配置") + void shouldCreateDefaultCacheConfiguration() { + PlusSpringCacheManager manager = new PlusSpringCacheManager(); + manager.setConfig(null); + + CacheConfig config = ReflectionTestUtils.invokeMethod(manager, "createDefaultConfig"); + Integer local = ReflectionTestUtils.invokeMethod(manager, "resolveLocal", (Object) new String[]{"users"}); + + assertNotNull(config); + assertEquals(0L, config.getTTL()); + assertEquals(1, local); + assertTrue(manager.getCacheNames().isEmpty()); + } +} diff --git a/ruoyi-common/ruoyi-common-satoken/src/test/java/org/dromara/common/satoken/SaTokenFunctionTest.java b/ruoyi-common/ruoyi-common-satoken/src/test/java/org/dromara/common/satoken/SaTokenFunctionTest.java new file mode 100644 index 000000000..1276572ef --- /dev/null +++ b/ruoyi-common/ruoyi-common-satoken/src/test/java/org/dromara/common/satoken/SaTokenFunctionTest.java @@ -0,0 +1,139 @@ +package org.dromara.common.satoken; + +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.exception.NotPermissionException; +import cn.hutool.http.HttpStatus; +import cn.hutool.extra.spring.SpringUtil; +import org.dromara.common.core.domain.R; +import org.dromara.common.satoken.core.dao.PlusSaTokenDao; +import org.dromara.common.satoken.core.service.SaPermissionImpl; +import org.dromara.common.satoken.handler.SaTokenExceptionHandler; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.PermissionService; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.system.api.model.LoginUser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@DisplayName("common-satoken 功能单元测试") +class SaTokenFunctionTest { + + /** + * 验证权限校验失败统一返回 HTTP 403 业务响应。 + */ + @Test + @DisplayName("处理权限校验异常") + void shouldHandlePermissionException() { + SaTokenExceptionHandler handler = new SaTokenExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/system/user"); + + R result = handler.handleNotAccessException(new NotPermissionException("system:user:list"), request); + + assertEquals(HttpStatus.HTTP_FORBIDDEN, result.getCode()); + assertEquals("没有访问权限,请联系管理员授权", result.getMsg()); + } + + /** + * 验证不同未登录类型转换为对应的用户提示。 + */ + @Test + @DisplayName("处理登录超时和被顶下线异常") + void shouldHandleNotLoginExceptionByType() { + SaTokenExceptionHandler handler = new SaTokenExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/profile"); + + NotLoginException timeout = NotLoginException.newInstance("login", NotLoginException.TOKEN_TIMEOUT, "timeout", "token"); + NotLoginException replaced = NotLoginException.newInstance("login", NotLoginException.BE_REPLACED, "replaced", "token"); + + assertEquals("登录已过期,请重新登录", handler.handleNotLoginException(timeout, request).getMsg()); + assertEquals("当前账号已在其他设备登录,您已被强制下线", handler.handleNotLoginException(replaced, request).getMsg()); + } + + /** + * 验证 Redis 毫秒 TTL 转换为 Sa-Token 秒 TTL 时包含精度补偿,并保留特殊负值。 + */ + @Test + @DisplayName("转换 Sa-Token 过期时间") + void shouldConvertRedisTimeoutToSeconds() { + PlusSaTokenDao dao = new PlusSaTokenDao(); + + assertEquals(2L, ((Long) ReflectionTestUtils.invokeMethod(dao, "toTimeoutSeconds", 1000L)).longValue()); + assertEquals(-1L, ((Long) ReflectionTestUtils.invokeMethod(dao, "toTimeoutSeconds", -1L)).longValue()); + assertEquals(-2L, ((Long) ReflectionTestUtils.invokeMethod(dao, "toTimeoutSeconds", -2L)).longValue()); + } + + /** + * 验证当前登录对象的菜单和角色权限直接来自会话快照,并对空权限集合返回可修改空列表。 + */ + @Test + @DisplayName("读取当前会话权限快照") + void shouldReadPermissionsFromCurrentLoginUser() { + LoginUser loginUser = new LoginUser(); + loginUser.setUserType("sys_user"); + loginUser.setUserId(7L); + loginUser.setMenuPermission(Set.of("system:user:list", "system:user:add")); + loginUser.setRolePermission(Set.of()); + + try (var login = mockStatic(LoginHelper.class)) { + login.when(LoginHelper::getLoginUser).thenReturn(loginUser); + SaPermissionImpl permission = new SaPermissionImpl(); + + List menus = permission.getPermissionList("sys_user:7", "login"); + List roles = permission.getRoleList("sys_user:7", "login"); + + assertEquals(Set.of("system:user:list", "system:user:add"), Set.copyOf(menus)); + assertEquals(List.of(), roles); + roles.add("temporary"); + } + } + + /** + * 验证查询其他登录对象时按登录 ID 提取用户 ID 并委托权限服务,格式错误时立即失败。 + */ + @Test + @DisplayName("通过权限服务查询其他用户权限") + void shouldLoadRemotePermissionsAndRejectMalformedLoginId() { + PermissionService service = mock(PermissionService.class); + when(service.getMenuPermission(9L)).thenReturn(Set.of("system:dept:list")); + + try (var login = mockStatic(LoginHelper.class); + var spring = mockStatic(SpringUtil.class)) { + login.when(LoginHelper::getLoginUser).thenReturn(null); + spring.when(() -> SpringUtil.getBean(PermissionService.class)).thenReturn(service); + SaPermissionImpl permission = new SaPermissionImpl(); + + assertEquals(List.of("system:dept:list"), permission.getPermissionList("sys_user:9", "login")); + assertThrows(ServiceException.class, () -> permission.getPermissionList("invalid", "login")); + verify(service).getMenuPermission(9L); + } + } + + /** + * 验证系统未提供权限服务时返回明确业务错误,而不是空权限导致静默拒绝。 + */ + @Test + @DisplayName("缺少权限服务时明确失败") + void shouldFailClearlyWhenPermissionServiceIsMissing() { + try (var login = mockStatic(LoginHelper.class); + var spring = mockStatic(SpringUtil.class)) { + login.when(LoginHelper::getLoginUser).thenReturn(null); + spring.when(() -> SpringUtil.getBean(PermissionService.class)) + .thenThrow(new IllegalStateException("missing bean")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> new SaPermissionImpl().getRoleList("sys_user:9", "login")); + + assertEquals("PermissionService 实现类不存在", exception.getMessage()); + } + } +} diff --git a/ruoyi-common/ruoyi-common-security/src/test/java/org/dromara/common/security/SecurityConfigTest.java b/ruoyi-common/ruoyi-common-security/src/test/java/org/dromara/common/security/SecurityConfigTest.java new file mode 100644 index 000000000..0bb52eecc --- /dev/null +++ b/ruoyi-common/ruoyi-common-security/src/test/java/org/dromara/common/security/SecurityConfigTest.java @@ -0,0 +1,118 @@ +package org.dromara.common.security; + +import cn.dev33.satoken.filter.SaTokenContextFilterForJakartaServlet; +import cn.dev33.satoken.exception.NotPermissionException; +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.extra.spring.SpringUtil; +import jakarta.servlet.DispatcherType; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.security.config.SecurityConfig; +import org.dromara.common.security.config.properties.SecurityProperties; +import org.dromara.common.security.handler.AllUrlHandler; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.core.Ordered; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.condition.PathPatternsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.pattern.PathPatternParser; + +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +@DisplayName("common-security 功能单元测试") +class SecurityConfigTest { + + /** + * 验证 Sa-Token 上下文过滤器覆盖请求、异步和错误分发,并保持最高优先级。 + */ + @Test + @DisplayName("注册 Sa-Token 上下文过滤器") + void shouldRegisterSaTokenContextFilterForAsyncDispatch() { + SecurityConfig config = new SecurityConfig(new SecurityProperties()); + SaTokenContextFilterForJakartaServlet filter = mock(SaTokenContextFilterForJakartaServlet.class); + + FilterRegistrationBean registration = + config.saTokenContextFilterRegistration(filter); + + assertSame(filter, registration.getFilter()); + assertEquals("saTokenContextFilterForServlet", registration.getFilterName()); + assertEquals(Set.of("/*"), registration.getUrlPatterns()); + assertEquals(Set.of(DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.ERROR), + ReflectionTestUtils.getField(registration, "dispatcherTypes")); + assertTrue(registration.isAsyncSupported()); + assertEquals(Ordered.HIGHEST_PRECEDENCE, registration.getOrder()); + } + + /** + * 验证安全排除路径配置可以完整保存,供拦截器注册时使用。 + */ + @Test + @DisplayName("保存安全排除路径") + void shouldStoreSecurityExcludePaths() { + SecurityProperties properties = new SecurityProperties(); + properties.setExcludes(new String[]{"/login", "/captcha"}); + + assertArrayEquals(new String[]{"/login", "/captcha"}, properties.getExcludes()); + } + + /** + * 验证控制器路径中的变量会归一化为通配符,并去重保存为统一鉴权 URL 列表。 + */ + @Test + @DisplayName("收集并归一化全部控制器路径") + void shouldCollectNormalizedControllerUrls() { + RequestMappingHandlerMapping mapping = mock(RequestMappingHandlerMapping.class); + RequestMappingInfo info = mock(RequestMappingInfo.class); + PathPatternsRequestCondition condition = mock(PathPatternsRequestCondition.class); + when(condition.getPatterns()).thenReturn(Set.of( + PathPatternParser.defaultInstance.parse("/users/{id}"), + PathPatternParser.defaultInstance.parse("/users/list"))); + when(info.getPathPatternsCondition()).thenReturn(condition); + when(mapping.getHandlerMethods()).thenReturn(Map.of(info, mock(HandlerMethod.class))); + + try (var spring = mockStatic(SpringUtil.class)) { + spring.when(() -> SpringUtil.getBean("requestMappingHandlerMapping", RequestMappingHandlerMapping.class)) + .thenReturn(mapping); + AllUrlHandler handler = new AllUrlHandler(); + + handler.afterPropertiesSet(); + + assertEquals(Set.of("/users/*", "/users/list"), Set.copyOf(handler.getUrls())); + } + } + + /** + * 验证客户端授权路径和 IP 白名单都通过时允许访问,任一规则不匹配时拒绝请求。 + */ + @Test + @DisplayName("校验客户端路径和 IP 访问规则") + void shouldValidateClientPathAndIpRules() { + SecurityConfig config = new SecurityConfig(new SecurityProperties()); + MockHttpServletRequest allowed = new MockHttpServletRequest("GET", "/system/users"); + allowed.setServletPath("/system/users"); + allowed.addHeader("X-Forwarded-For", "10.0.0.8"); + + try (var stp = mockStatic(StpUtil.class)) { + stp.when(() -> StpUtil.getExtra("clientAccessPath")).thenReturn("/system/**,/profile"); + stp.when(() -> StpUtil.getExtra("clientIpWhitelist")).thenReturn("10.0.0.0/24;127.0.0.1"); + + assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(config, "validateClientAccessRules", allowed)); + + MockHttpServletRequest denied = new MockHttpServletRequest("GET", "/admin/users"); + denied.setServletPath("/admin/users"); + denied.addHeader("X-Forwarded-For", "10.0.0.8"); + assertThrows(NotPermissionException.class, + () -> ReflectionTestUtils.invokeMethod(config, "validateClientAccessRules", denied)); + } + } +} diff --git a/ruoyi-common/ruoyi-common-sensitive/src/test/java/org/dromara/common/sensitive/SensitiveStrategyTest.java b/ruoyi-common/ruoyi-common-sensitive/src/test/java/org/dromara/common/sensitive/SensitiveStrategyTest.java new file mode 100644 index 000000000..259482606 --- /dev/null +++ b/ruoyi-common/ruoyi-common-sensitive/src/test/java/org/dromara/common/sensitive/SensitiveStrategyTest.java @@ -0,0 +1,93 @@ +package org.dromara.common.sensitive; + +import org.dromara.common.sensitive.core.SensitiveStrategy; +import org.dromara.common.sensitive.annotation.Sensitive; +import org.dromara.common.sensitive.core.SensitiveService; +import org.dromara.common.sensitive.handler.SensitiveJsonFieldProcessor; +import org.dromara.common.json.enhance.JsonEnhancementContext; +import org.dromara.common.json.enhance.JsonFieldContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import org.springframework.test.util.ReflectionTestUtils; + +@DisplayName("common-sensitive 功能单元测试") +class SensitiveStrategyTest { + + /** + * 验证手机号、邮箱和中文名称使用预期的标准脱敏规则。 + */ + @Test + @DisplayName("执行常用身份信息脱敏") + void shouldDesensitizeCommonIdentityValues() { + assertEquals("138****8000", SensitiveStrategy.PHONE.desensitizer().apply("13812348000")); + assertEquals("a****@example.com", SensitiveStrategy.EMAIL.desensitizer().apply("admin@example.com")); + assertEquals("张**", SensitiveStrategy.CHINESE_NAME.desensitizer().apply("张三丰")); + } + + /** + * 验证通用掩码和高安全掩码保留配置要求的首尾字符。 + */ + @Test + @DisplayName("执行字符串安全掩码") + void shouldMaskStringWithConfiguredVisibility() { + assertEquals("abcd****mnop", SensitiveStrategy.STRING_MASK.desensitizer().apply("abcdefghijklmnop")); + String highSecurity = SensitiveStrategy.MASK_HIGH_SECURITY.desensitizer().apply("abcdefghijklmnop"); + assertTrue(highSecurity.startsWith("ab")); + assertTrue(highSecurity.endsWith("op")); + assertEquals(16, highSecurity.length()); + } + + /** + * 验证清空策略分别返回空字符串和 null。 + */ + @Test + @DisplayName("执行清空脱敏策略") + void shouldClearSensitiveValue() { + assertEquals("", SensitiveStrategy.CLEAR.desensitizer().apply("secret")); + assertNull(SensitiveStrategy.CLEAR_TO_NULL.desensitizer().apply("secret")); + } + + /** + * 验证响应字段处理器只在注解和权限服务同时允许时脱敏,并对非字符串值保持透传。 + */ + @Test + @DisplayName("按字段注解和当前权限执行脱敏") + void shouldProcessSensitiveFieldOnlyWhenAuthorized() { + Sensitive annotation = mock(Sensitive.class); + when(annotation.strategy()).thenReturn(SensitiveStrategy.PHONE); + when(annotation.roleKey()).thenReturn(new String[]{"admin"}); + when(annotation.perms()).thenReturn(new String[]{"system:user:list"}); + JsonFieldContext fieldContext = mock(JsonFieldContext.class); + when(fieldContext.getAnnotation(Sensitive.class)).thenReturn(annotation); + SensitiveService service = mock(SensitiveService.class); + SensitiveJsonFieldProcessor processor = new SensitiveJsonFieldProcessor(); + ReflectionTestUtils.setField(processor, "sensitiveService", service); + + when(service.isSensitive(annotation.roleKey(), annotation.perms())).thenReturn(true); + assertTrue(processor.supports(fieldContext)); + assertEquals("138****8000", processor.process(fieldContext, "13812348000", + new JsonEnhancementContext(null))); + + when(service.isSensitive(annotation.roleKey(), annotation.perms())).thenReturn(false); + assertEquals("13812348000", processor.process(fieldContext, "13812348000", + new JsonEnhancementContext(null))); + assertEquals(100L, processor.process(fieldContext, 100L, new JsonEnhancementContext(null))); + } + + /** + * 验证缺少脱敏注解或权限服务时保持原值,避免响应增强误处理普通字段。 + */ + @Test + @DisplayName("未命中脱敏条件时保留原值") + void shouldKeepOriginalValueWithoutAnnotationOrService() { + JsonFieldContext plainField = mock(JsonFieldContext.class); + SensitiveJsonFieldProcessor processor = new SensitiveJsonFieldProcessor(); + + assertFalse(processor.supports(plainField)); + assertEquals("plain", processor.process(plainField, "plain", new JsonEnhancementContext(null))); + } +} diff --git a/ruoyi-common/ruoyi-common-sms/src/test/java/org/dromara/common/sms/SmsExceptionHandlerTest.java b/ruoyi-common/ruoyi-common-sms/src/test/java/org/dromara/common/sms/SmsExceptionHandlerTest.java new file mode 100644 index 000000000..be3b64f52 --- /dev/null +++ b/ruoyi-common/ruoyi-common-sms/src/test/java/org/dromara/common/sms/SmsExceptionHandlerTest.java @@ -0,0 +1,30 @@ +package org.dromara.common.sms; + +import cn.hutool.http.HttpStatus; +import org.dromara.common.core.domain.R; +import org.dromara.common.sms.handler.SmsExceptionHandler; +import org.dromara.sms4j.comm.exception.SmsBlendException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +@DisplayName("common-sms 功能单元测试") +class SmsExceptionHandlerTest { + + /** + * 验证短信服务异常会被转换为稳定的 HTTP 500 业务响应,避免向调用方泄露供应商细节。 + */ + @Test + @DisplayName("转换短信服务异常") + void shouldConvertSmsExceptionToFailureResponse() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/sms/send"); + + R response = new SmsExceptionHandler().handleSmsBlendException(mock(SmsBlendException.class), request); + + assertEquals(HttpStatus.HTTP_INTERNAL_ERROR, response.getCode()); + assertEquals("短信发送失败,请稍后再试...", response.getMsg()); + } +} diff --git a/ruoyi-common/ruoyi-common-social/src/test/java/org/dromara/common/social/SocialConfigurationTest.java b/ruoyi-common/ruoyi-common-social/src/test/java/org/dromara/common/social/SocialConfigurationTest.java new file mode 100644 index 000000000..b4266341c --- /dev/null +++ b/ruoyi-common/ruoyi-common-social/src/test/java/org/dromara/common/social/SocialConfigurationTest.java @@ -0,0 +1,47 @@ +package org.dromara.common.social; + +import me.zhyd.oauth.cache.AuthStateCache; +import org.dromara.common.social.config.SocialAutoConfiguration; +import org.dromara.common.social.config.properties.SocialLoginConfigProperties; +import org.dromara.common.social.config.properties.SocialProperties; +import org.dromara.common.social.utils.AuthRedisStateCache; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +@DisplayName("common-social 功能单元测试") +class SocialConfigurationTest { + + /** + * 验证社交登录自动配置提供项目约定的 Redis 授权状态缓存实现。 + */ + @Test + @DisplayName("创建授权状态缓存") + void shouldCreateRedisAuthStateCache() { + AuthStateCache cache = new SocialAutoConfiguration().authStateCache(); + + assertInstanceOf(AuthRedisStateCache.class, cache); + } + + /** + * 验证不同社交平台的客户端凭据和授权范围可以按类型完整保存。 + */ + @Test + @DisplayName("保存社交登录配置") + void shouldStoreSocialProviderConfiguration() { + SocialLoginConfigProperties github = new SocialLoginConfigProperties(); + github.setClientId("client-id"); + github.setClientSecret("client-secret"); + github.setScopes(List.of("user:email")); + SocialProperties properties = new SocialProperties(); + properties.setType(Map.of("github", github)); + + assertEquals("client-id", properties.getType().get("github").getClientId()); + assertEquals(List.of("user:email"), properties.getType().get("github").getScopes()); + } +} diff --git a/ruoyi-common/ruoyi-common-social/src/test/java/org/dromara/common/social/SocialUtilsTest.java b/ruoyi-common/ruoyi-common-social/src/test/java/org/dromara/common/social/SocialUtilsTest.java new file mode 100644 index 000000000..4edade230 --- /dev/null +++ b/ruoyi-common/ruoyi-common-social/src/test/java/org/dromara/common/social/SocialUtilsTest.java @@ -0,0 +1,155 @@ +package org.dromara.common.social; + +import cn.hutool.extra.spring.SpringUtil; +import me.zhyd.oauth.config.AuthConfig; +import me.zhyd.oauth.exception.AuthException; +import me.zhyd.oauth.request.AuthGithubRequest; +import me.zhyd.oauth.request.AuthMicrosoftRequest; +import me.zhyd.oauth.request.AuthRequest; +import me.zhyd.oauth.request.AuthStackOverflowRequest; +import me.zhyd.oauth.request.AuthWeChatEnterpriseQrcodeV2Request; +import org.dromara.common.social.config.properties.SocialLoginConfigProperties; +import org.dromara.common.social.config.properties.SocialProperties; +import org.dromara.common.social.utils.AuthRedisStateCache; +import org.dromara.common.social.utils.SocialUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.support.StaticApplicationContext; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +@DisplayName("SocialUtils 本地构造契约单元测试") +class SocialUtilsTest { + + /** + * 注册授权状态缓存 mock,使 SocialUtils 可以完成请求对象构造而不连接 Redis。 + */ + @BeforeAll + static void initializeSocialContext() { + AuthRedisStateCache stateCache = mock(AuthRedisStateCache.class); + StaticApplicationContext context = new StaticApplicationContext(); + context.getBeanFactory().registerSingleton("authRedisStateCache", stateCache); + context.refresh(); + new SpringUtil().setApplicationContext(context); + } + + /** + * 验证 GitHub 配置映射为对应请求类型,并完整保留基础授权配置。 + */ + @Test + @DisplayName("构造 GitHub 授权请求") + void shouldBuildGithubRequestWithBaseConfig() throws Exception { + SocialLoginConfigProperties config = config(); + + AuthRequest request = SocialUtils.getAuthRequest("github", properties("github", config)); + + assertInstanceOf(AuthGithubRequest.class, request); + AuthConfig authConfig = requestConfig(request); + assertEquals("client-id", authConfig.getClientId()); + assertEquals("client-secret", authConfig.getClientSecret()); + assertEquals("https://example.test/callback", authConfig.getRedirectUri()); + assertEquals(List.of("user", "email"), authConfig.getScopes()); + } + + /** + * 验证 Microsoft、Stack Overflow 和企业微信的专用配置会写入 JustAuth AuthConfig。 + */ + @Test + @DisplayName("映射平台专用授权配置") + void shouldMapProviderSpecificConfiguration() throws Exception { + SocialLoginConfigProperties microsoft = config(); + microsoft.setTenantId("tenant-id"); + SocialLoginConfigProperties stackOverflow = config(); + stackOverflow.setStackOverflowKey("stack-key"); + SocialLoginConfigProperties enterprise = config(); + enterprise.setAgentId("agent-id"); + SocialProperties properties = new SocialProperties(); + properties.setType(Map.of( + "microsoft", microsoft, + "stack_overflow", stackOverflow, + "wechat_enterprise", enterprise)); + + assertInstanceOf(AuthMicrosoftRequest.class, SocialUtils.getAuthRequest("microsoft", properties)); + assertEquals("tenant-id", requestConfig(SocialUtils.getAuthRequest("microsoft", properties)).getTenantId()); + assertEquals("stack-key", requestConfig(SocialUtils.getAuthRequest("stack_overflow", properties)).getStackOverflowKey()); + assertInstanceOf(AuthStackOverflowRequest.class, SocialUtils.getAuthRequest("stack_overflow", properties)); + assertEquals("agent-id", requestConfig(SocialUtils.getAuthRequest("wechat_enterprise", properties)).getAgentId()); + assertInstanceOf(AuthWeChatEnterpriseQrcodeV2Request.class, + SocialUtils.getAuthRequest("wechat_enterprise", properties)); + } + + /** + * 验证缺少平台配置和 switch 未支持的平台分别返回清晰的授权异常。 + */ + @Test + @DisplayName("拒绝不支持的平台配置") + void shouldRejectMissingAndUnknownProviderConfiguration() { + SocialProperties empty = new SocialProperties(); + empty.setType(Map.of()); + AuthException missing = assertThrows(AuthException.class, + () -> SocialUtils.getAuthRequest("github", empty)); + + SocialProperties unknown = properties("future_provider", config()); + AuthException unsupported = assertThrows(AuthException.class, + () -> SocialUtils.getAuthRequest("future_provider", unknown)); + + assertEquals("不支持的第三方登录类型", missing.getMessage()); + assertEquals("未获取到有效的Auth配置", unsupported.getMessage()); + } + + /** + * 创建包含公共授权参数的社交平台配置。 + * + * @return 测试配置 + */ + private static SocialLoginConfigProperties config() { + SocialLoginConfigProperties config = new SocialLoginConfigProperties(); + config.setClientId("client-id"); + config.setClientSecret("client-secret"); + config.setRedirectUri("https://example.test/callback"); + config.setServerUrl("https://example.test"); + config.setScopes(List.of("user", "email")); + return config; + } + + /** + * 创建只包含一个平台配置的容器。 + * + * @param source 平台标识 + * @param config 平台配置 + * @return 社交平台属性 + */ + private static SocialProperties properties(String source, SocialLoginConfigProperties config) { + SocialProperties properties = new SocialProperties(); + properties.setType(Map.of(source, config)); + return properties; + } + + /** + * 读取 JustAuth 请求对象继承层级中的 AuthConfig,验证工具到第三方库的映射结果。 + * + * @param request 授权请求 + * @return JustAuth 配置 + * @throws Exception 反射读取失败 + */ + private static AuthConfig requestConfig(AuthRequest request) throws Exception { + Class type = request.getClass(); + while (type != null) { + try { + Field field = type.getDeclaredField("config"); + field.setAccessible(true); + return (AuthConfig) field.get(request); + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + fail("授权请求未找到 AuthConfig 字段"); + return null; + } +} diff --git a/ruoyi-common/ruoyi-common-translation/src/test/java/org/dromara/common/translation/core/TranslationInterfaceTest.java b/ruoyi-common/ruoyi-common-translation/src/test/java/org/dromara/common/translation/core/TranslationInterfaceTest.java new file mode 100644 index 000000000..157fcc0ef --- /dev/null +++ b/ruoyi-common/ruoyi-common-translation/src/test/java/org/dromara/common/translation/core/TranslationInterfaceTest.java @@ -0,0 +1,55 @@ +package org.dromara.common.translation.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DisplayName("TranslationInterface 单元测试") +class TranslationInterfaceTest { + + private final TranslationInterface translation = (key, other) -> other + "-" + key; + + /** + * 验证默认批量实现按输入顺序逐项调用单值翻译。 + */ + @Test + @DisplayName("默认批量翻译保持输入顺序并逐项翻译") + void defaultBatchTranslationShouldPreserveOrder() { + Set keys = new LinkedHashSet<>(List.of(2L, 1L)); + + Map result = translation.translationBatch(keys, "name"); + + assertEquals(List.of(2L, 1L), result.keySet().stream().toList()); + assertEquals(Map.of(2L, "name-2", 1L, "name-1"), result); + } + + /** + * 验证数字及逗号分隔字符串能够去重收集为 Long ID。 + */ + @Test + @DisplayName("收集数字和逗号字符串中的 Long ID") + void shouldCollectLongIds() { + Set result = translation.collectLongIds(List.of("1, 2,1", 3L, " ")); + + assertEquals(new LinkedHashSet<>(List.of(1L, 2L, 3L)), result); + assertEquals(List.of(4L, 5L), translation.parseLongIds("4, 5")); + } + + /** + * 验证映射值按照原始 ID 顺序拼接,并忽略空映射结果。 + */ + @Test + @DisplayName("按原始 ID 顺序拼接已映射值并忽略空结果") + void shouldJoinMappedValuesInSourceOrder() { + String result = translation.joinMappedValues("2,1,3", id -> id == 1L ? null : "user-" + id); + + assertEquals("user-2,user-3", result); + } + +} diff --git a/ruoyi-common/ruoyi-common-translation/src/test/java/org/dromara/common/translation/core/handler/TranslationJsonFieldProcessorTest.java b/ruoyi-common/ruoyi-common-translation/src/test/java/org/dromara/common/translation/core/handler/TranslationJsonFieldProcessorTest.java new file mode 100644 index 000000000..652bd2db1 --- /dev/null +++ b/ruoyi-common/ruoyi-common-translation/src/test/java/org/dromara/common/translation/core/handler/TranslationJsonFieldProcessorTest.java @@ -0,0 +1,207 @@ +package org.dromara.common.translation.core.handler; + +import org.dromara.common.json.enhance.JsonEnhancementContext; +import org.dromara.common.json.enhance.JsonFieldContext; +import org.dromara.common.translation.annotation.Translation; +import org.dromara.common.translation.annotation.TranslationType; +import org.dromara.common.translation.core.TranslationInterface; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("TranslationJsonFieldProcessor 单元测试") +class TranslationJsonFieldProcessorTest { + + /** + * 验证重复翻译键被去重,并优先使用一次批量查询的结果。 + */ + @Test + @DisplayName("收集重复键后只执行一次批量翻译") + void shouldCollectDistinctKeysAndUseBatchResult() { + RecordingTranslation translation = new RecordingTranslation(); + TranslationJsonFieldProcessor processor = new TranslationJsonFieldProcessor(List.of(translation)); + JsonEnhancementContext context = new JsonEnhancementContext(null); + JsonFieldContext first = fieldContext(1L, "test", "", "dict"); + JsonFieldContext duplicate = fieldContext(1L, "test", "", "dict"); + JsonFieldContext second = fieldContext(2L, "test", "", "dict"); + + processor.collect(first, context); + processor.collect(duplicate, context); + processor.collect(second, context); + processor.prepare(context); + + assertEquals(Set.of(1L, 2L), translation.lastBatchKeys); + assertEquals(1, translation.batchCalls); + assertEquals("dict-batch-1", processor.process(first, null, context)); + assertEquals("dict-batch-2", processor.process(second, null, context)); + assertEquals(0, translation.singleCalls); + } + + /** + * 验证批量结果缺少当前键时会回退到单值翻译。 + */ + @Test + @DisplayName("批量结果缺失时回退到单值翻译") + void shouldFallbackToSingleTranslationWhenBatchMissesKey() { + RecordingTranslation translation = new RecordingTranslation(); + translation.omitBatchValue = true; + TranslationJsonFieldProcessor processor = new TranslationJsonFieldProcessor(List.of(translation)); + JsonEnhancementContext context = new JsonEnhancementContext(null); + JsonFieldContext fieldContext = fieldContext(3L, "test", "", "dict"); + + processor.collect(fieldContext, context); + processor.prepare(context); + Object result = processor.process(fieldContext, null, context); + + assertEquals("dict-single-3", result); + assertEquals(1, translation.singleCalls); + } + + /** + * 验证翻译异常和未注册类型不会中断响应,而是保留原值。 + */ + @Test + @DisplayName("翻译异常或类型不存在时保留原值") + void shouldKeepOriginalValueWhenTranslationFailsOrIsMissing() { + RecordingTranslation translation = new RecordingTranslation(); + translation.throwOnSingle = true; + TranslationJsonFieldProcessor processor = new TranslationJsonFieldProcessor(List.of(translation)); + + assertEquals("original", processor.process(fieldContext(4L, "test", "", "dict"), "original", + new JsonEnhancementContext(null))); + assertEquals("original", processor.process(fieldContext(4L, "missing", "", "dict"), "original", + new JsonEnhancementContext(null))); + } + + /** + * 验证 Translation.mapper 指定的所属对象属性作为翻译源值。 + */ + @Test + @DisplayName("mapper 属性作为翻译源值") + void shouldUseMappedOwnerPropertyAsSourceValue() { + RecordingTranslation translation = new RecordingTranslation(); + TranslationJsonFieldProcessor processor = new TranslationJsonFieldProcessor(List.of(translation)); + SourceOwner owner = new SourceOwner(9L); + JsonFieldContext fieldContext = fieldContext(owner, "displayName", "ignored", "test", "sourceId", "dict"); + + Object result = processor.process(fieldContext, "original", new JsonEnhancementContext(null)); + + assertEquals("dict-single-9", result); + } + + /** + * 创建直接使用字段值的翻译字段上下文。 + * + * @param value 字段值 + * @param type 翻译类型 + * @param mapper 映射属性 + * @param other 额外参数 + * @return 翻译字段上下文 + */ + private static JsonFieldContext fieldContext(Object value, String type, String mapper, String other) { + return fieldContext(new Object(), "value", value, type, mapper, other); + } + + /** + * 创建可指定所属对象和属性的翻译字段上下文。 + * + * @param owner 字段所属对象 + * @param propertyName 字段名 + * @param value 字段原始值 + * @param type 翻译类型 + * @param mapper 映射属性 + * @param other 额外参数 + * @return 翻译字段上下文 + */ + private static JsonFieldContext fieldContext(Object owner, String propertyName, Object value, String type, + String mapper, String other) { + Translation annotation = mock(Translation.class); + when(annotation.type()).thenReturn(type); + when(annotation.mapper()).thenReturn(mapper); + when(annotation.other()).thenReturn(other); + JsonFieldContext fieldContext = mock(JsonFieldContext.class); + when(fieldContext.getAnnotation(Translation.class)).thenReturn(annotation); + when(fieldContext.owner()).thenReturn(owner); + when(fieldContext.propertyName()).thenReturn(propertyName); + when(fieldContext.value()).thenReturn(value); + return fieldContext; + } + + @TranslationType(type = "test") + private static class RecordingTranslation implements TranslationInterface { + + private int batchCalls; + private int singleCalls; + private Set lastBatchKeys; + private boolean omitBatchValue; + private boolean throwOnSingle; + + /** + * 记录单值翻译次数并返回可断言的翻译结果。 + * + * @param key 翻译键 + * @param other 额外参数 + * @return 单值翻译结果 + */ + @Override + public String translation(Object key, String other) { + singleCalls++; + if (throwOnSingle) { + throw new IllegalStateException("translation failed"); + } + return other + "-single-" + key; + } + + /** + * 记录批量翻译参数,并按测试开关生成或省略结果。 + * + * @param keys 翻译键集合 + * @param other 额外参数 + * @return 批量翻译结果 + */ + @Override + public Map translationBatch(Set keys, String other) { + batchCalls++; + lastBatchKeys = Set.copyOf(keys); + Map result = new LinkedHashMap<>(); + if (!omitBatchValue) { + keys.forEach(key -> result.put(key, other + "-batch-" + key)); + } + return result; + } + + } + + private static class SourceOwner { + + private final Long sourceId; + + /** + * 创建带翻译源属性的测试对象。 + * + * @param sourceId 翻译源 ID + */ + private SourceOwner(Long sourceId) { + this.sourceId = sourceId; + } + + /** + * 返回 Translation.mapper 需要读取的源 ID。 + * + * @return 翻译源 ID + */ + public Long getSourceId() { + return sourceId; + } + + } + +} diff --git a/ruoyi-common/ruoyi-common-translation/src/test/java/org/dromara/common/translation/core/impl/TranslationImplementationsTest.java b/ruoyi-common/ruoyi-common-translation/src/test/java/org/dromara/common/translation/core/impl/TranslationImplementationsTest.java new file mode 100644 index 000000000..aeb19d585 --- /dev/null +++ b/ruoyi-common/ruoyi-common-translation/src/test/java/org/dromara/common/translation/core/impl/TranslationImplementationsTest.java @@ -0,0 +1,148 @@ +package org.dromara.common.translation.core.impl; + +import org.dromara.common.core.service.DictService; +import org.dromara.system.api.DeptService; +import org.dromara.system.api.OssService; +import org.dromara.system.api.UserService; +import org.dromara.system.api.domain.OssDTO; +import org.dromara.system.api.domain.UserDTO; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("内置翻译实现单元测试") +class TranslationImplementationsTest { + + /** + * 验证部门翻译兼容 Long、逗号 ID 字符串和批量映射,并忽略无法识别的键类型。 + */ + @Test + @DisplayName("翻译部门名称") + void shouldTranslateDepartmentNames() { + DeptService service = mock(DeptService.class); + when(service.selectDeptNameByIds("1")).thenReturn("研发部"); + when(service.selectDeptNameByIds("1,2")).thenReturn("研发部,财务部"); + when(service.selectDeptNamesByIds(Set.of(1L, 2L))).thenReturn(Map.of(1L, "研发部", 2L, "财务部")); + DeptNameTranslationImpl translation = new DeptNameTranslationImpl(service); + + assertEquals("研发部", translation.translation(1L, null)); + assertEquals("研发部,财务部", translation.translation("1,2", null)); + assertNull(translation.translation(1, null)); + assertEquals(Map.of(1L, "研发部", "2,1", "财务部,研发部"), + translation.translationBatch(new LinkedHashSet<>(List.of(1L, "2,1")), null)); + } + + /** + * 验证昵称翻译批量查询一次后,按原始复合 ID 顺序重新组装显示值。 + */ + @Test + @DisplayName("批量翻译用户昵称") + void shouldTranslateNicknamesInBatch() { + UserService service = mock(UserService.class); + when(service.selectNicknameById(1L)).thenReturn("管理员"); + when(service.selectNicknameByIds("2,1")).thenReturn("访客,管理员"); + when(service.selectUserNicksByIds(Set.of(1L, 2L))).thenReturn(Map.of(1L, "管理员", 2L, "访客")); + NicknameTranslationImpl translation = new NicknameTranslationImpl(service); + + assertEquals("管理员", translation.translation(1L, null)); + assertEquals("访客,管理员", translation.translation("2,1", null)); + assertNull(translation.translation(1, null)); + assertEquals(Map.of("2,1", "访客,管理员", 1L, "管理员"), + translation.translationBatch(new LinkedHashSet<>(List.of("2,1", 1L)), null)); + } + + /** + * 验证用户名批量翻译从 DTO 列表构建映射,并跳过没有查询结果的 ID。 + */ + @Test + @DisplayName("批量翻译用户名") + void shouldTranslateUserNamesFromDtoList() { + UserService service = mock(UserService.class); + when(service.selectUserNameById(1L)).thenReturn("admin"); + when(service.selectListByIds(Set.of(1L, 2L, 3L))).thenReturn(List.of( + user(1L, "admin"), user(2L, "guest"))); + UserNameTranslationImpl translation = new UserNameTranslationImpl(service); + + assertEquals("admin", translation.translation("1", null)); + assertEquals(Map.of("1,3,2", "admin,guest", 2L, "guest"), + translation.translationBatch(new LinkedHashSet<>(List.of("1,3,2", 2L)), null)); + assertEquals(Map.of(), translation.translationBatch(Set.of(), null)); + verify(service).selectListByIds(Set.of(1L, 2L, 3L)); + } + + /** + * 验证 OSS 翻译合并所有 ID 后只查询一次,并按每个原始键恢复 URL 顺序。 + */ + @Test + @DisplayName("批量翻译 OSS 地址") + void shouldTranslateOssUrlsInBatch() { + OssService service = mock(OssService.class); + when(service.selectUrlByIds("1")).thenReturn("https://file/1"); + when(service.selectByIds("2,1")).thenReturn(List.of( + oss(1L, "https://file/1"), oss(2L, "https://file/2"))); + OssUrlTranslationImpl translation = new OssUrlTranslationImpl(service); + + assertEquals("https://file/1", translation.translation(1L, null)); + assertNull(translation.translation(1, null)); + assertEquals(Map.of("2,1", "https://file/2,https://file/1", 1L, "https://file/1"), + translation.translationBatch(new LinkedHashSet<>(List.of("2,1", 1L)), null)); + verify(service).selectByIds("2,1"); + } + + /** + * 验证字典翻译处理逗号分隔值、空片段和空字典类型,并保持原键映射关系。 + */ + @Test + @DisplayName("批量翻译字典标签") + void shouldTranslateDictionaryLabelsInBatch() { + DictService service = mock(DictService.class); + when(service.getDictLabel("status", "1")).thenReturn("启用"); + when(service.getAllDictByDictType("status")).thenReturn(Map.of("0", "停用", "1", "启用")); + DictTypeTranslationImpl translation = new DictTypeTranslationImpl(service); + + assertEquals("启用", translation.translation("1", "status")); + assertNull(translation.translation(1L, "status")); + assertNull(translation.translation("1", " ")); + assertEquals(Map.of("1, ,0", "启用,停用", "0", "停用"), + translation.translationBatch(new LinkedHashSet<>(List.of("1, ,0", "0")), "status")); + assertEquals(Map.of(), translation.translationBatch(Set.of("1"), " ")); + } + + /** + * 创建包含用户名的测试用户 DTO。 + * + * @param id 用户 ID + * @param userName 用户名 + * @return 用户 DTO + */ + private static UserDTO user(Long id, String userName) { + UserDTO user = new UserDTO(); + user.setUserId(id); + user.setUserName(userName); + return user; + } + + /** + * 创建包含访问地址的测试 OSS DTO。 + * + * @param id OSS ID + * @param url 访问地址 + * @return OSS DTO + */ + private static OssDTO oss(Long id, String url) { + OssDTO oss = new OssDTO(); + oss.setOssId(id); + oss.setUrl(url); + return oss; + } +} diff --git a/ruoyi-common/ruoyi-common-web/src/test/java/org/dromara/common/web/WebFunctionTest.java b/ruoyi-common/ruoyi-common-web/src/test/java/org/dromara/common/web/WebFunctionTest.java new file mode 100644 index 000000000..d93260740 --- /dev/null +++ b/ruoyi-common/ruoyi-common-web/src/test/java/org/dromara/common/web/WebFunctionTest.java @@ -0,0 +1,136 @@ +package org.dromara.common.web; + +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.common.web.core.I18nLocaleResolver; +import org.dromara.common.web.core.WaveAndCircleCaptcha; +import org.dromara.common.web.filter.XssHttpServletRequestWrapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.nio.charset.StandardCharsets; +import java.awt.Image; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("common-web 功能单元测试") +class WebFunctionTest { + + /** + * 验证请求头可以使用短横线或下划线解析区域信息,并支持默认区域回退。 + */ + @Test + @DisplayName("解析请求语言区域") + void shouldResolveRequestLocale() { + I18nLocaleResolver resolver = new I18nLocaleResolver(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("content-language", "zh_CN"); + + assertEquals(Locale.SIMPLIFIED_CHINESE, resolver.resolveLocale(request)); + + MockHttpServletRequest emptyRequest = new MockHttpServletRequest(); + assertEquals(Locale.getDefault(), resolver.resolveLocale(emptyRequest)); + assertDoesNotThrow(() -> resolver.setLocale(request, new MockHttpServletResponse(), Locale.ENGLISH)); + } + + /** + * 验证普通参数和多值参数中的 HTML 标签会被移除并清理首尾空白。 + */ + @Test + @DisplayName("清洗请求参数中的 HTML 标签") + void shouldSanitizeRequestParameters() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("name", " admin "); + request.setParameter("roles", "user", " "); + XssHttpServletRequestWrapper wrapper = new XssHttpServletRequestWrapper(request); + + assertEquals("admin", wrapper.getParameter("name")); + assertArrayEquals(new String[]{"user", "root"}, wrapper.getParameterValues("roles")); + assertArrayEquals(new String[]{"user", "root"}, wrapper.getParameterMap().get("roles")); + } + + /** + * 验证 JSON 请求体执行 XSS 清洗,非 JSON 请求仍使用原始输入流。 + * + * @throws Exception 读取测试请求体失败 + */ + @Test + @DisplayName("按内容类型清洗 JSON 请求体") + void shouldSanitizeOnlyJsonRequestBody() throws Exception { + MockHttpServletRequest jsonRequest = new MockHttpServletRequest(); + jsonRequest.setContentType(MediaType.APPLICATION_JSON_VALUE); + jsonRequest.setContent("{\"name\":\"admin\"}".getBytes(StandardCharsets.UTF_8)); + XssHttpServletRequestWrapper jsonWrapper = new XssHttpServletRequestWrapper(jsonRequest); + + String sanitized = new String(jsonWrapper.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(jsonWrapper.isJsonRequest()); + assertEquals("{\"name\":\"admin\"}", sanitized); + + MockHttpServletRequest textRequest = new MockHttpServletRequest(); + textRequest.setContentType(MediaType.TEXT_PLAIN_VALUE); + textRequest.setContent("raw".getBytes(StandardCharsets.UTF_8)); + XssHttpServletRequestWrapper textWrapper = new XssHttpServletRequestWrapper(textRequest); + assertFalse(textWrapper.isJsonRequest()); + assertEquals("raw", new String(textWrapper.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + } + + /** + * 验证控制器通用响应转换和重定向字符串格式。 + */ + @Test + @DisplayName("转换控制器操作结果") + void shouldConvertControllerResults() { + TestController controller = new TestController(); + + assertEquals(HttpStatus.SUCCESS, controller.rows(1).getCode()); + assertEquals(HttpStatus.ERROR, controller.rows(0).getCode()); + assertEquals(HttpStatus.SUCCESS, controller.result(true).getCode()); + assertEquals("redirect:/index", controller.redirect("/index")); + } + + /** + * 验证自定义验证码仍兼容 Hutool 的验证码生命周期,并生成符合配置尺寸和字符数的图片。 + */ + @Test + @DisplayName("生成带干扰元素的验证码") + void shouldGenerateCaptchaThroughHutoolContract() { + WaveAndCircleCaptcha captcha = new WaveAndCircleCaptcha(160, 60, 5, 4); + + captcha.createCode(); + Image image = captcha.getImage(); + + assertEquals(5, captcha.getCode().length()); + assertEquals(160, image.getWidth(null)); + assertEquals(60, image.getHeight(null)); + assertTrue(captcha.verify(captcha.getCode())); + assertFalse(captcha.verify("incorrect")); + } + + private static class TestController extends BaseController { + + /** + * 暴露受保护的行数转换方法供测试调用。 + * + * @param rows 影响行数 + * @return 统一响应 + */ + private R rows(int rows) { + return toAjax(rows); + } + + /** + * 暴露受保护的布尔转换方法供测试调用。 + * + * @param result 操作结果 + * @return 统一响应 + */ + private R result(boolean result) { + return toAjax(result); + } + } +} diff --git a/ruoyi-common/ruoyi-common-web/src/test/java/org/dromara/common/web/WebInfrastructureTest.java b/ruoyi-common/ruoyi-common-web/src/test/java/org/dromara/common/web/WebInfrastructureTest.java new file mode 100644 index 000000000..602e4b8bb --- /dev/null +++ b/ruoyi-common/ruoyi-common-web/src/test/java/org/dromara/common/web/WebInfrastructureTest.java @@ -0,0 +1,227 @@ +package org.dromara.common.web; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.json.enhance.JsonValueEnhancer; +import org.dromara.common.web.advice.ResponseEnhancementAdvice; +import org.dromara.common.web.config.properties.XssProperties; +import org.dromara.common.web.filter.RepeatableFilter; +import org.dromara.common.web.filter.RepeatedlyRequestWrapper; +import org.dromara.common.web.filter.XssFilter; +import org.dromara.common.web.filter.XssHttpServletRequestWrapper; +import org.dromara.common.web.handler.GlobalExceptionHandler; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.boot.json.JsonParseException; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.expression.ExpressionException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.servlet.NoHandlerFoundException; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("common-web 基础设施单元测试") +class WebInfrastructureTest { + + /** + * 验证请求体被缓存后可以通过输入流和字符读取器分别完整读取。 + */ + @Test + @DisplayName("重复读取缓存的请求体") + void shouldReadCachedRequestBodyRepeatedly() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.setContent("中文-body".getBytes(StandardCharsets.UTF_8)); + + RepeatedlyRequestWrapper wrapper = new RepeatedlyRequestWrapper(request, response); + + assertEquals("中文-body", new String(wrapper.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + assertEquals("中文-body", wrapper.getReader().readLine()); + assertEquals(StandardCharsets.UTF_8.name(), request.getCharacterEncoding()); + assertEquals(StandardCharsets.UTF_8.name(), response.getCharacterEncoding()); + } + + /** + * 验证可重复读过滤器只包装 JSON 请求,普通文本请求保持原对象透传。 + */ + @Test + @DisplayName("仅包装 JSON 请求") + void shouldWrapOnlyJsonRequest() throws Exception { + RepeatableFilter filter = new RepeatableFilter(); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference forwarded = new AtomicReference<>(); + FilterChain chain = (request, servletResponse) -> forwarded.set(request); + MockHttpServletRequest jsonRequest = request("POST", "/json", MediaType.APPLICATION_JSON_VALUE); + jsonRequest.setContent("{}".getBytes(StandardCharsets.UTF_8)); + + filter.doFilter(jsonRequest, response, chain); + assertInstanceOf(RepeatedlyRequestWrapper.class, forwarded.get()); + + MockHttpServletRequest textRequest = request("POST", "/text", MediaType.TEXT_PLAIN_VALUE); + filter.doFilter(textRequest, response, chain); + assertSame(textRequest, forwarded.get()); + } + + /** + * 验证 XSS 过滤器跳过只读和排除路径请求,并包装需要清洗的写请求。 + */ + @Test + @DisplayName("按请求方法和排除路径执行 XSS 包装") + void shouldApplyXssWrapperOnlyToIncludedWriteRequests() throws Exception { + XssProperties properties = new XssProperties(); + properties.setExcludeUrls(List.of("/upload/**")); + XssFilter filter = new XssFilter(properties); + filter.init(null); + AtomicReference forwarded = new AtomicReference<>(); + FilterChain chain = (request, response) -> forwarded.set(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + + MockHttpServletRequest getRequest = request("GET", "/users", null); + filter.doFilter(getRequest, response, chain); + assertSame(getRequest, forwarded.get()); + + MockHttpServletRequest excludedRequest = request("POST", "/upload/avatar", null); + filter.doFilter(excludedRequest, response, chain); + assertSame(excludedRequest, forwarded.get()); + + MockHttpServletRequest writeRequest = request("POST", "/users", null); + filter.doFilter(writeRequest, response, chain); + assertInstanceOf(XssHttpServletRequestWrapper.class, forwarded.get()); + } + + /** + * 验证响应增强仅作用于 JSON 响应,并将转换器支持判断委托给增强器。 + */ + @Test + @DisplayName("仅增强 JSON 响应体") + void shouldEnhanceOnlyJsonResponseBody() { + JsonValueEnhancer enhancer = mock(JsonValueEnhancer.class); + when(enhancer.supports(JacksonJsonHttpMessageConverter.class)).thenReturn(true); + when(enhancer.enhance("body")).thenReturn("enhanced"); + ResponseEnhancementAdvice advice = new ResponseEnhancementAdvice(enhancer); + + assertTrue(advice.supports(null, JacksonJsonHttpMessageConverter.class)); + assertEquals("enhanced", advice.beforeBodyWrite("body", null, MediaType.APPLICATION_JSON, + JacksonJsonHttpMessageConverter.class, null, null)); + assertEquals("body", advice.beforeBodyWrite("body", null, MediaType.TEXT_PLAIN, + JacksonJsonHttpMessageConverter.class, null, null)); + verify(enhancer).enhance("body"); + } + + /** + * 验证业务异常的自定义状态码和默认失败状态码均被正确映射到统一响应。 + */ + @Test + @DisplayName("映射业务异常状态码") + void shouldMapServiceExceptionCodes() { + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = request("POST", "/orders", null); + + R forbidden = handler.handleServiceException( + new ServiceException("forbidden", HttpStatus.FORBIDDEN), request); + R failed = handler.handleServiceException(new ServiceException("failed"), request); + + assertEquals(HttpStatus.FORBIDDEN, forbidden.getCode()); + assertEquals("forbidden", forbidden.getMsg()); + assertEquals(HttpStatus.ERROR, failed.getCode()); + assertEquals("failed", failed.getMsg()); + } + + /** + * 验证参数类型不匹配异常会返回包含参数名、目标类型和原始值的可诊断消息。 + */ + @Test + @DisplayName("映射参数类型不匹配异常") + void shouldMapArgumentTypeMismatchDetails() { + MethodArgumentTypeMismatchException exception = mock(MethodArgumentTypeMismatchException.class); + when(exception.getName()).thenReturn("userId"); + doReturn(Long.class).when(exception).getRequiredType(); + when(exception.getValue()).thenReturn("abc"); + + R result = new GlobalExceptionHandler().handleMethodArgumentTypeMismatchException( + exception, request("GET", "/users/abc", null)); + + assertEquals(HttpStatus.ERROR, result.getCode()); + assertEquals("请求参数类型不匹配,参数[userId]要求类型为:'java.lang.Long',但输入值为:'abc'", result.getMsg()); + } + + /** + * 验证 Spring Web 与 Jackson 的常见异常被映射为项目约定的状态码和稳定提示。 + */ + @Test + @DisplayName("映射框架解析与路由异常") + void shouldMapFrameworkExceptionsToStableResponses() { + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = request("POST", "/missing", null); + HttpRequestMethodNotSupportedException method = mock(HttpRequestMethodNotSupportedException.class); + when(method.getMethod()).thenReturn("POST"); + when(method.getMessage()).thenReturn("Method POST is not supported"); + NoHandlerFoundException noHandler = mock(NoHandlerFoundException.class); + + R methodResult = handler.handleHttpRequestMethodNotSupported(method, request); + R routeResult = handler.handleNoHandlerFoundException(noHandler, request); + R jsonResult = handler.handleJsonParseException(mock(JsonParseException.class), request); + R bodyResult = handler.handleHttpMessageNotReadableException( + mock(HttpMessageNotReadableException.class), request); + R spelResult = handler.handleSpelException(new ExpressionException("bad expression"), request); + + assertEquals(405, methodResult.getCode()); + assertEquals("Method POST is not supported", methodResult.getMsg()); + assertEquals(404, routeResult.getCode()); + assertEquals("请求地址不存在", routeResult.getMsg()); + assertEquals(400, jsonResult.getCode()); + assertEquals("请求数据格式错误", jsonResult.getMsg()); + assertEquals(400, bodyResult.getCode()); + assertEquals("请求参数格式错误", bodyResult.getMsg()); + assertEquals(500, spelResult.getCode()); + assertEquals("SpEL解析失败:bad expression", spelResult.getMsg()); + } + + /** + * 验证未知异常返回带可追踪编号的统一提示,而不会泄漏原始异常内容。 + */ + @Test + @DisplayName("隐藏未知异常并生成错误编号") + void shouldHideUnexpectedFailureBehindTraceableErrorId() { + R result = new GlobalExceptionHandler().handleRuntimeException( + new RuntimeException("database password leaked"), request("GET", "/users", null)); + + assertEquals(HttpStatus.ERROR, result.getCode()); + assertTrue(result.getMsg().matches("发生未知异常,请联系管理员 \\[错误编号: \\d{8}]")); + assertTrue(!result.getMsg().contains("password")); + } + + /** + * 创建带请求方法、路径和内容类型的 Mock 请求。 + * + * @param method HTTP 方法 + * @param path servlet 路径 + * @param contentType 内容类型 + * @return Mock 请求 + */ + private static MockHttpServletRequest request(String method, String path, String contentType) { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.setServletPath(path); + request.setContentType(contentType); + return request; + } +}