Merge gitee/master into master
同步上游 codegen 模板收敛、README 精简与工作流退回修复。 冲突处理:保留我方数据源/存储/短信/MQTT 与 client 令牌配置; 沿用上游 @Setter(AccessLevel.NONE) 与注释,恢复 /api/v1/realtime/ws 握手放行。
This commit is contained in:
commit
8d61c63c8b
89 files changed
+1765
-910
No files matched your search
@@ -0,0 +1,173 @@
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration;
|
||||
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.cache.caffeine.CaffeineCache;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
import org.springframework.cache.support.NoOpCacheManager;
|
||||
import org.springframework.data.redis.cache.RedisCache;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* 验证真实缓存注解与 Spring Boot 管理器选择,不连接数据库或 Redis。
|
||||
*/
|
||||
class CacheConfigurationTest {
|
||||
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(CacheAutoConfiguration.class))
|
||||
.withUserConfiguration(CacheConfig.class, RedisCacheConfig.class)
|
||||
.withBean(CountingService.class);
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "prod", "cache-config-test"})
|
||||
void applicationDefaultsDisableCaching(String profile) {
|
||||
runner.withInitializer(new ConfigDataApplicationContextInitializer())
|
||||
.withPropertyValues("spring.profiles.active=" + profile)
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
|
||||
assertThat(context.getBean(CacheManager.class)).isInstanceOf(NoOpCacheManager.class);
|
||||
assertThat(context).doesNotHaveBean(RedisCacheConfig.class);
|
||||
assertThat(context).doesNotHaveBean(RedisConnectionFactory.class);
|
||||
assertThat(context.getEnvironment().getProperty("spring.cache.type")).isEqualTo("none");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void noneRunsEveryInvocationWithoutStoringResults() {
|
||||
runner.withPropertyValues("spring.cache.type=none").run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
|
||||
assertThat(context.getBean(CacheManager.class)).isInstanceOf(NoOpCacheManager.class);
|
||||
var service = context.getBean(CountingService.class);
|
||||
assertThat(service.load("item")).isEqualTo(1);
|
||||
assertThat(service.load("item")).isEqualTo(2);
|
||||
service.evict("item");
|
||||
assertThat(service.load("item")).isEqualTo(3);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void caffeineCachesAndEvictsThroughAnnotations() {
|
||||
runner.withPropertyValues("spring.cache.type=caffeine",
|
||||
"spring.cache.caffeine.spec=maximumSize=7,expireAfterWrite=1m")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
|
||||
assertThat(context.getBean(CacheManager.class)).isInstanceOf(CaffeineCacheManager.class);
|
||||
assertThat(context).doesNotHaveBean(RedisCacheConfig.class);
|
||||
var service = context.getBean(CountingService.class);
|
||||
assertThat(service.load("item")).isEqualTo(1);
|
||||
assertThat(service.load("item")).isEqualTo(1);
|
||||
service.evict("item");
|
||||
assertThat(service.load("item")).isEqualTo(2);
|
||||
|
||||
var cache = (CaffeineCache) context.getBean(CacheManager.class).getCache("probe");
|
||||
assertThat(cache).isNotNull();
|
||||
assertThat(cache.getNativeCache().policy().eviction().orElseThrow().getMaximum()).isEqualTo(7);
|
||||
assertThat(cache.getNativeCache().policy().expireAfterWrite().orElseThrow().getExpiresAfter())
|
||||
.isEqualTo(Duration.ofMinutes(1));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void redisUsesOneManagerAndPreservesExistingStorageFormat() {
|
||||
var connectionFactory = mock(RedisConnectionFactory.class);
|
||||
runner.withBean(RedisConnectionFactory.class, () -> connectionFactory)
|
||||
.withPropertyValues("spring.cache.type=redis", "spring.cache.redis.time-to-live=3600000")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
|
||||
assertThat(context.getBean(CacheManager.class)).isInstanceOf(RedisCacheManager.class);
|
||||
var cache = (RedisCache) context.getBean(CacheManager.class).getCache("probe");
|
||||
assertThat(cache).isNotNull();
|
||||
var config = cache.getCacheConfiguration();
|
||||
assertThat(config.getTtlFunction().getTimeToLive("key", "value")).isEqualTo(Duration.ofHours(1));
|
||||
assertThat(config.getAllowCacheNullValues()).isTrue();
|
||||
assertThat(config.usePrefix()).isTrue();
|
||||
assertThat(config.getKeyPrefixFor("probe")).isEqualTo("probe:");
|
||||
assertThat(StandardCharsets.UTF_8.decode(config.getKeySerializationPair().write("item")).toString())
|
||||
.isEqualTo("item");
|
||||
var encodedValue = config.getValueSerializationPair().write("cached-value");
|
||||
assertThat(StandardCharsets.UTF_8.decode(encodedValue.duplicate()).toString())
|
||||
.isEqualTo("\"cached-value\"");
|
||||
assertThat(config.getValueSerializationPair().read(encodedValue)).isEqualTo("cached-value");
|
||||
verify(connectionFactory, never()).getConnection();
|
||||
verify(connectionFactory, never()).getClusterConnection();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void redisAppliesConfiguredPrefixTtlAndNullPolicy() {
|
||||
runner.withBean(RedisConnectionFactory.class, () -> mock(RedisConnectionFactory.class))
|
||||
.withPropertyValues("spring.cache.type=redis", "spring.cache.redis.time-to-live=90s",
|
||||
"spring.cache.redis.key-prefix=tenant:", "spring.cache.redis.cache-null-values=false")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
|
||||
var cache = (RedisCache) context.getBean(CacheManager.class).getCache("probe");
|
||||
assertThat(cache).isNotNull();
|
||||
var config = cache.getCacheConfiguration();
|
||||
assertThat(config.getTtlFunction().getTimeToLive("key", "value")).isEqualTo(Duration.ofSeconds(90));
|
||||
assertThat(config.getAllowCacheNullValues()).isFalse();
|
||||
assertThat(config.getKeyPrefixFor("probe")).isEqualTo("tenant:probe:");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void redisPrefixCanBeDisabled() {
|
||||
runner.withBean(RedisConnectionFactory.class, () -> mock(RedisConnectionFactory.class))
|
||||
.withPropertyValues("spring.cache.type=redis", "spring.cache.redis.key-prefix=tenant:",
|
||||
"spring.cache.redis.use-key-prefix=false")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
|
||||
var cache = (RedisCache) context.getBean(CacheManager.class).getCache("probe");
|
||||
assertThat(cache).isNotNull();
|
||||
assertThat(cache.getCacheConfiguration().usePrefix()).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"none,true", "caffeine,false"})
|
||||
void legacyEnabledFlagDoesNotOverrideExplicitType(String type, boolean legacyEnabled) {
|
||||
runner.withPropertyValues("spring.cache.type=" + type, "spring.cache.enabled=" + legacyEnabled)
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
|
||||
assertThat(context.getBean(CacheManager.class))
|
||||
.isInstanceOf("none".equals(type) ? NoOpCacheManager.class : CaffeineCacheManager.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void redisWithoutConnectionFactoryFailsInsteadOfSelectingAnotherProvider() {
|
||||
runner.withPropertyValues("spring.cache.type=redis").run(context -> assertThat(context).hasFailed());
|
||||
}
|
||||
|
||||
static class CountingService {
|
||||
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
@Cacheable(cacheNames = "probe", key = "#p0")
|
||||
public int load(String key) {
|
||||
return calls.incrementAndGet();
|
||||
}
|
||||
|
||||
@CacheEvict(cacheNames = "probe", key = "#p0")
|
||||
public void evict(String key) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import cn.hutool.captcha.generator.CodeGenerator;
|
||||
import cn.hutool.captcha.generator.MathGenerator;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.youlai.boot.auth.password.config.CaptchaConfig;
|
||||
import com.youlai.boot.auth.password.config.CaptchaProperties;
|
||||
import com.youlai.boot.auth.password.service.CaptchaService;
|
||||
import com.youlai.boot.framework.security.config.SecurityProperties;
|
||||
import com.youlai.boot.framework.web.ratelimit.RateLimitProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.autoconfigure.cache.CacheType;
|
||||
import org.springframework.boot.cache.autoconfigure.CacheProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties;
|
||||
import org.springframework.boot.servlet.autoconfigure.MultipartProperties;
|
||||
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 验证精简环境配置后,公共设置和代码默认值仍能支撑现有行为;不连接外部服务。
|
||||
*/
|
||||
class ConfigurationDefaultsTest {
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void captchaWorksWithoutAnyExplicitConfiguration() {
|
||||
RedisTemplate<String, Object> redis = mock(RedisTemplate.class);
|
||||
ValueOperations<String, Object> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(CaptchaDefaultsConfiguration.class, CaptchaConfig.class)
|
||||
.withBean(RedisTemplate.class, () -> redis)
|
||||
.withBean(CaptchaService.class)
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
var generator = context.getBean(CodeGenerator.class);
|
||||
assertThat(generator).isInstanceOf(MathGenerator.class);
|
||||
assertThat(generator.verify("1+2=", "3")).isTrue();
|
||||
|
||||
var captcha = context.getBean(CaptchaService.class).generate();
|
||||
assertThat(captcha.getCaptchaId()).isNotBlank();
|
||||
assertThat(captcha.getCaptchaBase64()).startsWith("data:image/png;base64,");
|
||||
var imageBytes = Base64.getDecoder().decode(
|
||||
captcha.getCaptchaBase64().substring(captcha.getCaptchaBase64().indexOf(',') + 1));
|
||||
var image = ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||
assertThat(image).isNotNull();
|
||||
assertThat(image.getWidth()).isEqualTo(120);
|
||||
assertThat(image.getHeight()).isEqualTo(40);
|
||||
verify(values).set(anyString(), anyString(), eq(120L), eq(TimeUnit.SECONDS));
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "prod", "configuration-defaults-test"})
|
||||
void profilesInheritOperationalDefaults(String profile) {
|
||||
contextRunner(profile).run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
var binder = Binder.get(context.getEnvironment());
|
||||
|
||||
var rateLimit = binder.bindOrCreate("rate-limit", RateLimitProperties.class);
|
||||
assertThat(rateLimit.getDefaultLimit()).isEqualTo(switch (profile) {
|
||||
case "dev" -> 60;
|
||||
case "prod" -> 10;
|
||||
default -> 5;
|
||||
});
|
||||
assertThat(rateLimit.getDefaultWindow()).isEqualTo(Duration.ofSeconds(60));
|
||||
assertThat(rateLimit.getIp().isEnabled()).isTrue();
|
||||
assertThat(rateLimit.getIp().getLimit()).isEqualTo(1000);
|
||||
assertThat(rateLimit.getIp().getWindow()).isEqualTo(Duration.ofSeconds(60));
|
||||
|
||||
var security = binder.bindOrCreate("security", SecurityProperties.class);
|
||||
assertThat(security.getToken().getAccessTokenTimeToLive()).isEqualTo(Duration.ofHours(2));
|
||||
assertThat(security.getToken().getRefreshTokenTimeToLive()).isEqualTo(Duration.ofDays(7));
|
||||
assertThat(security.getToken().isAllowMultiLogin()).isTrue();
|
||||
assertThat(security.getPermitAllUrls())
|
||||
.contains("/api/v1/auth/login", "/api/v1/auth/captcha", "/swagger-ui.html")
|
||||
.allSatisfy(url -> assertThat(url).doesNotContain("${"));
|
||||
|
||||
var pool = binder.bindOrCreate("spring.data.redis", DataRedisProperties.class).getLettuce().getPool();
|
||||
assertThat(pool.getMaxActive()).isEqualTo(8);
|
||||
assertThat(pool.getMaxIdle()).isEqualTo(8);
|
||||
assertThat(pool.getMinIdle()).isZero();
|
||||
assertThat(pool.getMaxWait()).isEqualTo(Duration.ofMillis(-1));
|
||||
|
||||
var multipart = binder.bindOrCreate("spring.servlet.multipart", MultipartProperties.class);
|
||||
assertThat(multipart.getMaxFileSize()).isEqualTo(DataSize.ofMegabytes(50));
|
||||
assertThat(multipart.getMaxRequestSize()).isEqualTo(DataSize.ofMegabytes(50));
|
||||
|
||||
var cache = binder.bindOrCreate("spring.cache", CacheProperties.class);
|
||||
assertThat(cache.getType()).isEqualTo(CacheType.NONE);
|
||||
assertThat(cache.getRedis().getTimeToLive()).isEqualTo(Duration.ofHours(1));
|
||||
assertThat(cache.getRedis().isCacheNullValues()).isTrue();
|
||||
var localCache = Caffeine.from(cache.getCaffeine().getSpec()).build();
|
||||
assertThat(localCache.policy().eviction().orElseThrow().getMaximum()).isEqualTo(1000);
|
||||
assertThat(localCache.policy().expireAfterWrite().orElseThrow().getExpiresAfter())
|
||||
.isEqualTo(Duration.ofMinutes(10));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartLimitsCanBeOverriddenIndependently() {
|
||||
contextRunner("dev")
|
||||
.withPropertyValues("spring.servlet.multipart.max-file-size=7MB",
|
||||
"spring.servlet.multipart.max-request-size=8MB")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
var multipart = Binder.get(context.getEnvironment())
|
||||
.bindOrCreate("spring.servlet.multipart", MultipartProperties.class);
|
||||
assertThat(multipart.getMaxFileSize()).isEqualTo(DataSize.ofMegabytes(7));
|
||||
assertThat(multipart.getMaxRequestSize()).isEqualTo(DataSize.ofMegabytes(8));
|
||||
});
|
||||
}
|
||||
|
||||
private ApplicationContextRunner contextRunner(String profile) {
|
||||
return new ApplicationContextRunner()
|
||||
.withInitializer(new ConfigDataApplicationContextInitializer())
|
||||
.withPropertyValues("spring.profiles.active=" + profile);
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(CaptchaProperties.class)
|
||||
static class CaptchaDefaultsConfiguration {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.youlai.boot.config;
|
||||
|
||||
import cn.binarywang.wx.miniapp.api.WxMaService;
|
||||
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
|
||||
import com.youlai.boot.auth.oidc.config.OidcProperties;
|
||||
import com.youlai.boot.auth.wxma.config.WxMaConfig;
|
||||
import com.youlai.boot.auth.wxma.config.WxMaProperties;
|
||||
import com.youlai.boot.support.mail.MailConfig;
|
||||
import com.youlai.boot.support.mail.MailProperties;
|
||||
import com.youlai.boot.support.sms.AliyunSmsProperties;
|
||||
import com.youlai.boot.support.sms.service.impl.AliyunSmsServiceImpl;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
|
||||
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.env.SystemEnvironmentPropertySource;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 读取真实环境配置,验证空凭据和原生环境变量绑定;不发送请求或启动任务执行器。
|
||||
*/
|
||||
class OptionalIntegrationConfigurationTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "prod"})
|
||||
void optionalClientsStartWithoutCredentials(String profile) {
|
||||
profileRunner(profile, Map.of())
|
||||
.withUserConfiguration(WxMaProperties.class, WxMaConfig.class,
|
||||
MailProperties.class, MailConfig.class, AliyunSmsProperties.class,
|
||||
AliyunSmsServiceImpl.class, OidcProperties.class, XxlJobConfig.class)
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed()
|
||||
.hasSingleBean(WxMaService.class)
|
||||
.hasSingleBean(JavaMailSenderImpl.class)
|
||||
.hasSingleBean(AliyunSmsServiceImpl.class)
|
||||
.doesNotHaveBean(OidcProperties.class)
|
||||
.doesNotHaveBean(XxlJobConfig.class)
|
||||
.doesNotHaveBean(XxlJobSpringExecutor.class);
|
||||
|
||||
var wx = context.getBean(WxMaProperties.class);
|
||||
assertThat(wx.getProvider()).isEqualTo("wechat-mini-main");
|
||||
assertThat(wx.getAppid()).isEmpty();
|
||||
assertThat(wx.getSecret()).isEmpty();
|
||||
assertThat(context.getBean(WxMaService.class).getWxMaConfig().getAppid()).isEmpty();
|
||||
|
||||
var mail = context.getBean(MailProperties.class);
|
||||
assertThat(mail.getHost()).isEmpty();
|
||||
assertThat(mail.getUsername()).isEmpty();
|
||||
assertThat(mail.getPassword()).isEmpty();
|
||||
assertThat(mail.getFrom()).isEmpty();
|
||||
var sender = context.getBean(JavaMailSenderImpl.class);
|
||||
assertThat(sender.getPort()).isEqualTo(587);
|
||||
assertThat(sender.getJavaMailProperties())
|
||||
.containsEntry("mail.smtp.auth", true)
|
||||
.containsEntry("mail.smtp.starttls.enable", true);
|
||||
|
||||
var sms = context.getBean(AliyunSmsProperties.class);
|
||||
assertThat(sms.getAccessKeyId()).isEmpty();
|
||||
assertThat(sms.getAccessKeySecret()).isEmpty();
|
||||
assertThat(sms.getSignName()).isEmpty();
|
||||
assertThat(sms.getDomain()).isEqualTo("dysmsapi.aliyuncs.com");
|
||||
assertThat(sms.getRegionId()).isEqualTo("cn-shanghai");
|
||||
assertThat(sms.getTemplates()).containsOnlyKeys("register", "login", "change-mobile");
|
||||
assertThat(sms.getTemplates().values()).containsOnly("");
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "prod"})
|
||||
void nativeEnvironmentVariablesOverridePlainYamlValues(String profile) {
|
||||
profileRunner(profile, Map.of(
|
||||
"SECURITY_OIDC_ENABLED", "true",
|
||||
"SECURITY_OIDC_ISSUER", "https://identity.example.test/realms/demo",
|
||||
"SECURITY_OIDC_CLIENTID", "test-client",
|
||||
"SECURITY_OIDC_CLIENTSECRET", "test-secret",
|
||||
"SECURITY_OIDC_FRONTENDCALLBACKURI", "http://localhost:3000/#/oidc/callback",
|
||||
"WX_MINIAPP_PROVIDER", "wechat-mini-test"))
|
||||
// 仅绑定属性,避免加载 OIDC 客户端并读取远端发现文档。
|
||||
.withUserConfiguration(OidcProperties.class, WxMaProperties.class)
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(OidcProperties.class);
|
||||
var oidc = context.getBean(OidcProperties.class);
|
||||
assertThat(oidc.isEnabled()).isTrue();
|
||||
assertThat(oidc.getIssuer()).isEqualTo("https://identity.example.test/realms/demo");
|
||||
assertThat(oidc.getClientId()).isEqualTo("test-client");
|
||||
assertThat(oidc.getClientSecret()).isEqualTo("test-secret");
|
||||
assertThat(oidc.getFrontendCallbackUri()).isEqualTo("http://localhost:3000/#/oidc/callback");
|
||||
assertThat(context.getBean(WxMaProperties.class).getProvider()).isEqualTo("wechat-mini-test");
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "prod"})
|
||||
void enablingOidcWithoutCredentialsFailsValidation(String profile) {
|
||||
profileRunner(profile, Map.of())
|
||||
.withUserConfiguration(OidcProperties.class)
|
||||
.withPropertyValues("security.oidc.enabled=true")
|
||||
.run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
assertThat(context.getStartupFailure()).hasRootCauseInstanceOf(BindValidationException.class);
|
||||
});
|
||||
}
|
||||
|
||||
private ApplicationContextRunner profileRunner(String profile, Map<String, Object> environment) {
|
||||
return new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration.class,
|
||||
ValidationAutoConfiguration.class))
|
||||
.withPropertyValues("spring.profiles.active=" + profile)
|
||||
.withInitializer(context -> {
|
||||
// 使用真正的系统环境属性源,同时隔离运行测试的机器所配置的第三方凭据。
|
||||
context.getEnvironment().getPropertySources().replace(
|
||||
StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME,
|
||||
new SystemEnvironmentPropertySource(
|
||||
StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, environment));
|
||||
new ConfigDataApplicationContextInitializer().initialize(context);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.youlai.boot.module.workflow.config;
|
||||
|
||||
import com.youlai.boot.module.workflow.listener.ProcessCompletedListener;
|
||||
import org.flowable.spring.boot.condition.ConditionalOnProcessEngine;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* 加载真实公共与环境配置,验证业务和 Flowable 的启用条件一致,不启动引擎或连接数据库
|
||||
*/
|
||||
class WorkflowConfigurationTest {
|
||||
|
||||
private ApplicationContextRunner contextRunner(String profile) {
|
||||
return new ApplicationContextRunner()
|
||||
.withInitializer(new ConfigDataApplicationContextInitializer())
|
||||
.withPropertyValues("spring.profiles.active=" + profile)
|
||||
.withUserConfiguration(WorkflowEngineConfig.class, ProcessEngineConditionProbe.class)
|
||||
.withBean(ProcessCompletedListener.class, () -> mock(ProcessCompletedListener.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "prod"})
|
||||
void workflowAndEngineAreDisabledByDefault(String profile) {
|
||||
contextRunner(profile).run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context).doesNotHaveBean(WorkflowEngineConfig.class);
|
||||
assertThat(context).doesNotHaveBean(ProcessEngineConditionProbe.class);
|
||||
assertThat(context.getEnvironment().getProperty("flowable.process.enabled", Boolean.class)).isFalse();
|
||||
assertThat(context.getEnvironment().getProperty("flowable.idm.enabled", Boolean.class)).isFalse();
|
||||
assertThat(context.getEnvironment().getProperty("flowable.eventregistry.enabled", Boolean.class)).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "prod"})
|
||||
void oneWorkflowSwitchEnablesBusinessAndEngineConditions(String profile) {
|
||||
contextRunner(profile).withPropertyValues("flowable.process.enabled=true").run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context).hasSingleBean(WorkflowEngineConfig.class);
|
||||
assertThat(context).hasSingleBean(ProcessEngineConditionProbe.class);
|
||||
assertThat(context.getEnvironment().getProperty("flowable.process.enabled", Boolean.class)).isTrue();
|
||||
assertThat(context.getEnvironment().getProperty("flowable.idm.enabled", Boolean.class)).isFalse();
|
||||
assertThat(context.getEnvironment().getProperty("flowable.eventregistry.enabled", Boolean.class)).isFalse();
|
||||
assertThat(context.getEnvironment().getProperty("flowable.database-schema-update"))
|
||||
.isEqualTo("dev".equals(profile) ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
// 使用 Flowable 自己的条件注解,避免测试复制第三方开关逻辑,也不实例化真实流程引擎
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProcessEngine
|
||||
static class ProcessEngineConditionProbe {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.youlai.boot.support.ai;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.model.chat.client.autoconfigure.ChatClientAutoConfiguration;
|
||||
import org.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechAutoConfiguration;
|
||||
import org.springframework.ai.model.openai.autoconfigure.OpenAiAudioTranscriptionAutoConfiguration;
|
||||
import org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration;
|
||||
import org.springframework.ai.model.openai.autoconfigure.OpenAiCommonProperties;
|
||||
import org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration;
|
||||
import org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration;
|
||||
import org.springframework.ai.model.openai.autoconfigure.OpenAiModerationAutoConfiguration;
|
||||
import org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 使用真实配置与本地 HTTP 桩验证 AI 装配及兼容协议,不连接数据库或外部模型服务
|
||||
*/
|
||||
class AiConfigurationTest {
|
||||
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withInitializer(new ConfigDataApplicationContextInitializer())
|
||||
.withConfiguration(AutoConfigurations.of(
|
||||
ToolCallingAutoConfiguration.class,
|
||||
ChatClientAutoConfiguration.class,
|
||||
OpenAiChatAutoConfiguration.class,
|
||||
OpenAiEmbeddingAutoConfiguration.class,
|
||||
OpenAiImageAutoConfiguration.class,
|
||||
OpenAiModerationAutoConfiguration.class,
|
||||
OpenAiAudioSpeechAutoConfiguration.class,
|
||||
OpenAiAudioTranscriptionAutoConfiguration.class))
|
||||
.withBean(AiChatService.class);
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "prod", "ai-default-test"})
|
||||
void disabledAiStartsWithoutCredentials(String profile) {
|
||||
runner.withPropertyValues("spring.profiles.active=" + profile, "spring.ai.openai.api-key=")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed().doesNotHaveBean(ChatModel.class);
|
||||
assertThatThrownBy(() -> context.getBean(AiChatService.class).chat("system", "test"))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("spring.ai.model.chat");
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "prod"})
|
||||
void enabledAiBindsCurrentPropertiesAndPreservesVendorParameter(String profile) {
|
||||
runner.withPropertyValues("spring.profiles.active=" + profile,
|
||||
"spring.ai.model.chat=openai", "spring.ai.openai.api-key=test-key")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(ChatModel.class);
|
||||
var options = context.getBean(OpenAiChatModel.class).getOptions();
|
||||
assertThat(options.getModel()).isEqualTo(context.getEnvironment()
|
||||
.getRequiredProperty("spring.ai.openai.chat.model"));
|
||||
assertThat(options.getTemperature()).isEqualTo(0.3);
|
||||
assertThat(options.getExtraBody()).containsEntry("enable_thinking", false)
|
||||
.doesNotContainKey("enablethinking");
|
||||
assertThat(context.getBean(OpenAiCommonProperties.class).getBaseUrl())
|
||||
.isEqualTo("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "prod"})
|
||||
void requestUsesOneVersionPrefixAndBooleanThinkingFlag(String profile) throws Exception {
|
||||
var requestPath = new AtomicReference<String>();
|
||||
var requestBody = new AtomicReference<String>();
|
||||
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
requestPath.set(exchange.getRequestURI().getPath());
|
||||
requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
byte[] response = """
|
||||
{"id":"test-completion","object":"chat.completion","created":1,"model":"test-model",
|
||||
"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}
|
||||
""".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, response.length);
|
||||
try (var output = exchange.getResponseBody()) {
|
||||
output.write(response);
|
||||
}
|
||||
});
|
||||
server.start();
|
||||
try {
|
||||
runner.withPropertyValues("spring.profiles.active=" + profile,
|
||||
"spring.ai.model.chat=openai", "spring.ai.openai.api-key=test-key",
|
||||
"spring.ai.openai.base-url=http://127.0.0.1:" + server.getAddress().getPort()
|
||||
+ "/compatible-mode/v1")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getBean(AiChatService.class).chat("system", "test")).isEqualTo("ok");
|
||||
assertThat(requestPath.get()).isEqualTo("/compatible-mode/v1/chat/completions");
|
||||
var body = JsonMapper.builder().build().readTree(requestBody.get());
|
||||
assertThat(body.has("enable_thinking")).isTrue();
|
||||
assertThat(body.get("enable_thinking").isBoolean()).isTrue();
|
||||
assertThat(body.get("enable_thinking").booleanValue()).isFalse();
|
||||
assertThat(body.has("enablethinking")).isFalse();
|
||||
assertThat(body.get("model").asText()).isEqualTo(context.getEnvironment()
|
||||
.getRequiredProperty("spring.ai.openai.chat.model"));
|
||||
});
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user