refactor: 重命名 backend-single 目录为 server
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package com.emotion.config;
|
||||
|
||||
import com.emotion.interceptor.WechatApiLoggingInterceptor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.client.BufferingClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* RestTemplate 运行时配置集成测试
|
||||
*
|
||||
* <p>目标:验证 8 任务 WeChat 修复计划的核心成功标志 - RestTemplate 实际发送的
|
||||
* User-Agent 是 "Mozilla/5.0 (compatible; EmotionMuseum/1.0)" 而非
|
||||
* 默认的 "Java/17.0.x"(WAF 会拦截 Java UA 导致 text/plain 响应)。
|
||||
*
|
||||
* <p>策略:复现生产代码 RestTemplateConfig.restTemplate() 的构建逻辑,
|
||||
* 对 httpbin.org/headers 发真实 HTTPS 请求,从返回的 JSON 中验证
|
||||
* 实际发送的 User-Agent。
|
||||
*
|
||||
* <p>不依赖 Spring 上下文,纯 JUnit 构造 + 真实网络调用 = 最直接的端到端证据。
|
||||
*/
|
||||
class RestTemplateRuntimeConfigTest {
|
||||
|
||||
private static final String EXPECTED_USER_AGENT = "Mozilla/5.0 (compatible; EmotionMuseum/1.0)";
|
||||
private static final String FORBIDDEN_USER_AGENT_PREFIX = "Java/";
|
||||
|
||||
@Test
|
||||
void restTemplate_RealHttpCallToHttpbin_ConfirmsMozillaUserAgent() {
|
||||
// 复现生产 RestTemplateConfig.restTemplate() 的构建逻辑
|
||||
SimpleClientHttpRequestFactory simpleFactory = new SimpleClientHttpRequestFactory();
|
||||
simpleFactory.setConnectTimeout((int) Duration.ofSeconds(5).toMillis());
|
||||
simpleFactory.setReadTimeout((int) Duration.ofSeconds(10).toMillis());
|
||||
BufferingClientHttpRequestFactory bufferingFactory =
|
||||
new BufferingClientHttpRequestFactory(simpleFactory);
|
||||
|
||||
RestTemplate restTemplate = new RestTemplateBuilder()
|
||||
.requestFactory(() -> bufferingFactory)
|
||||
.defaultHeader("User-Agent", EXPECTED_USER_AGENT)
|
||||
.defaultHeader("Accept", "application/json, text/plain, */*")
|
||||
.additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8))
|
||||
.additionalInterceptors(new WechatApiLoggingInterceptor())
|
||||
.errorHandler(new WechatResponseErrorHandler())
|
||||
.build();
|
||||
|
||||
// httpbin.org/headers 返回客户端发送的所有请求头(JSON 格式)
|
||||
String response = restTemplate.getForObject(
|
||||
"https://httpbin.org/headers", String.class);
|
||||
|
||||
assertNotNull(response, "httpbin.org should respond");
|
||||
System.out.println("[RestTemplateRuntimeConfigTest] httpbin response: " + response);
|
||||
|
||||
// 核心断言:实际发送的 User-Agent 是 Mozilla/5.0
|
||||
assertTrue(response.contains(EXPECTED_USER_AGENT),
|
||||
"User-Agent 应为 '" + EXPECTED_USER_AGENT + "',但实际响应: " + response);
|
||||
|
||||
// 反向断言:绝对不能是 Java/* 默认 UA(WAF 拦截根因)
|
||||
assertFalse(response.contains(FORBIDDEN_USER_AGENT_PREFIX),
|
||||
"User-Agent 不应是 Java/*(WAF 会拦截),但实际响应: " + response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.emotion.controller;
|
||||
|
||||
import com.emotion.service.AuthService;
|
||||
import com.emotion.service.TokenService;
|
||||
import com.emotion.util.JwtUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/**
|
||||
* AuthController 测试类
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2025-07-26
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
public class AuthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private AuthService authService;
|
||||
|
||||
@MockBean
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@MockBean
|
||||
private TokenService tokenService;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
when(jwtUtil.validateToken("test-token")).thenReturn(true);
|
||||
when(jwtUtil.getUserIdFromToken("test-token")).thenReturn("test-user");
|
||||
when(jwtUtil.getUsernameFromToken("test-token")).thenReturn("tester");
|
||||
when(jwtUtil.getUserTypeFromToken("test-token")).thenReturn("user");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckAccountExists() throws Exception {
|
||||
// 模拟账户存在的情况
|
||||
when(authService.existsByAccount("existingUser")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/api/auth/checkAccount").contextPath("/api")
|
||||
.param("account", "existingUser")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckAccountNotExists() throws Exception {
|
||||
// 模拟账户不存在的情况
|
||||
when(authService.existsByAccount("nonExistingUser")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(get("/api/auth/checkAccount").contextPath("/api")
|
||||
.param("account", "nonExistingUser")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckEmailExists() throws Exception {
|
||||
// 模拟邮箱存在的情况
|
||||
when(authService.existsByEmail("existing@example.com")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/api/auth/checkEmail").contextPath("/api")
|
||||
.param("email", "existing@example.com")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckEmailNotExists() throws Exception {
|
||||
// 模拟邮箱不存在的情况
|
||||
when(authService.existsByEmail("nonexisting@example.com")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(get("/api/auth/checkEmail").contextPath("/api")
|
||||
.param("email", "nonexisting@example.com")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckPhoneExists() throws Exception {
|
||||
// 模拟手机号存在的情况
|
||||
when(authService.existsByPhone("13800138000")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/api/auth/checkPhone").contextPath("/api")
|
||||
.param("phone", "13800138000")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckPhoneNotExists() throws Exception {
|
||||
// 模拟手机号不存在的情况
|
||||
when(authService.existsByPhone("13900139000")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(get("/api/auth/checkPhone").contextPath("/api")
|
||||
.param("phone", "13900139000")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.emotion.exception;
|
||||
|
||||
import com.emotion.common.Result;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* GlobalExceptionHandler 的 WechatApiException handler 单元测试
|
||||
*
|
||||
* 验证:
|
||||
* 1. 400 业务错误 → "微信授权失败,请重试"
|
||||
* 2. 500 内部错误 → "微信登录失败,请重试"
|
||||
* 3. 502 上游错误 → "微信服务暂不可用,请稍后重试"
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GlobalExceptionHandlerWechatTest {
|
||||
|
||||
@InjectMocks
|
||||
private GlobalExceptionHandler handler;
|
||||
|
||||
private MockHttpServletRequest request;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
request = new MockHttpServletRequest();
|
||||
request.setMethod("POST");
|
||||
request.setRequestURI("/api/wechat/login");
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleWechatApiException_Code400_ReturnsBadRequestWithFriendlyMessage() {
|
||||
WechatApiException ex = new WechatApiException(400, "errcode=40029, errmsg=invalid code (internal detail)", "");
|
||||
|
||||
Result<?> result = handler.handleWechatApiException(ex, request);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("微信授权失败,请重试", result.getMessage());
|
||||
// Ensure internal detail is NOT exposed to frontend
|
||||
assertFalse(result.getMessage().contains("errcode"));
|
||||
assertFalse(result.getMessage().contains("40029"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleWechatApiException_Code500_ReturnsInternalErrorWithFriendlyMessage() {
|
||||
WechatApiException ex = new WechatApiException(500, "JSON parse failure at line 1 (internal detail)", "");
|
||||
|
||||
Result<?> result = handler.handleWechatApiException(ex, request);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("微信登录失败,请重试", result.getMessage());
|
||||
assertFalse(result.getMessage().contains("JSON"));
|
||||
assertFalse(result.getMessage().contains("parse"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleWechatApiException_Code502_ReturnsBadGatewayWithFriendlyMessage() {
|
||||
WechatApiException ex = new WechatApiException(502, "Connection refused: api.weixin.qq.com (internal detail)", "");
|
||||
|
||||
Result<?> result = handler.handleWechatApiException(ex, request);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("微信服务暂不可用,请稍后重试", result.getMessage());
|
||||
assertFalse(result.getMessage().contains("Connection"));
|
||||
assertFalse(result.getMessage().contains("refused"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.emotion.interceptor;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.URI;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* WechatApiLoggingInterceptor 单元测试
|
||||
*
|
||||
* 注:desensitize() 是 private 方法,通过反射测试
|
||||
*/
|
||||
class WechatApiLoggingInterceptorTest {
|
||||
|
||||
@Test
|
||||
void desensitize_shouldMaskSessionKey() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String input = "{\"openid\":\"oXyz123\",\"session_key\":\"abc@456\"}";
|
||||
String result = (String) method.invoke(interceptor, input);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.contains("\"openid\":\"***\""), "openid 应被脱敏: " + result);
|
||||
assertTrue(result.contains("\"session_key\":\"***\""), "session_key 应被脱敏: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldMaskAccessToken() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String input = "{\"access_token\":\"ACCESS-123\",\"refresh_token\":\"REFRESH-456\"}";
|
||||
String result = (String) method.invoke(interceptor, input);
|
||||
|
||||
assertTrue(result.contains("\"access_token\":\"***\""));
|
||||
assertTrue(result.contains("\"refresh_token\":\"***\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldHandleNull() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
assertEquals(null, method.invoke(interceptor, (Object) null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldNotModifyNonSensitiveFields() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String input = "{\"errcode\":40013,\"errmsg\":\"invalid appid\"}";
|
||||
String result = (String) method.invoke(interceptor, input);
|
||||
|
||||
assertEquals(input, result, "非敏感字段不应被修改");
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldNotMatchAccessTokenV2() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
// 验证 access_token_v2 不会被 access_token 模式误匹配
|
||||
String input = "{\"access_token_v2\":\"PUBLIC_VALUE\",\"my_reset_secret\":\"OK\"}";
|
||||
String result = (String) method.invoke(interceptor, input);
|
||||
|
||||
// access_token_v2 不应被脱敏(正则精确匹配完整 key 名称)
|
||||
// my_reset_secret 也不应被脱敏(正则需要精确匹配 "secret" 而非子串)
|
||||
assertTrue(result.contains("\"access_token_v2\":\"PUBLIC_VALUE\""), "access_token_v2 不应被脱敏: " + result);
|
||||
assertTrue(result.contains("\"my_reset_secret\":\"OK\""), "my_reset_secret 不应被脱敏: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldHandleEmptyString() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String result = (String) method.invoke(interceptor, "");
|
||||
assertEquals("", result, "空字符串应原样返回");
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldMaskMultipleSensitiveFieldsSimultaneously() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String input = "{\"openid\":\"o123\",\"access_token\":\"tok\",\"unionid\":\"uni456\",\"refresh_token\":\"ref789\"}";
|
||||
String result = (String) method.invoke(interceptor, input);
|
||||
|
||||
assertTrue(result.contains("\"openid\":\"***\""));
|
||||
assertTrue(result.contains("\"access_token\":\"***\""));
|
||||
assertTrue(result.contains("\"unionid\":\"***\""));
|
||||
assertTrue(result.contains("\"refresh_token\":\"***\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void maskSensitiveQuery_shouldMaskWechatCredentialsInUrl() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("maskSensitiveQuery", URI.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String result = (String) method.invoke(interceptor,
|
||||
URI.create("https://api.weixin.qq.com/sns/jscode2session?appid=wx123&secret=s1&js_code=c1&grant_type=authorization_code"));
|
||||
|
||||
assertTrue(result.contains("appid=wx123"));
|
||||
assertTrue(result.contains("secret=***"));
|
||||
assertTrue(result.contains("js_code=***"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.entity.AiConfig;
|
||||
import com.emotion.service.impl.AiChatServiceImpl;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* AI聊天服务测试类
|
||||
* 测试apiBaseUrl字段调整后的接口调用逻辑
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-12-22
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("local")
|
||||
public class AiChatServiceImplTest {
|
||||
|
||||
private AiChatServiceImpl aiChatService;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
aiChatService = new AiChatServiceImpl();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试getApiPath方法返回空字符串")
|
||||
public void testGetApiPath() throws Exception {
|
||||
// 使用反射调用私有方法
|
||||
Method getApiPathMethod = AiChatServiceImpl.class.getDeclaredMethod("getApiPath", AiConfig.class);
|
||||
getApiPathMethod.setAccessible(true);
|
||||
|
||||
AiConfig config = AiConfig.builder()
|
||||
.apiBaseUrl("https://api.coze.cn/v3/chat")
|
||||
.build();
|
||||
|
||||
String result = (String) getApiPathMethod.invoke(aiChatService, config);
|
||||
|
||||
// 验证返回空字符串,因为apiBaseUrl已经是完整URL
|
||||
assertEquals("", result, "getApiPath应该返回空字符串");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试extractBaseUrl方法提取基础URL")
|
||||
public void testExtractBaseUrl() throws Exception {
|
||||
// 使用反射调用私有方法
|
||||
Method extractBaseUrlMethod = AiChatServiceImpl.class.getDeclaredMethod("extractBaseUrl", String.class);
|
||||
extractBaseUrlMethod.setAccessible(true);
|
||||
|
||||
// 测试标准URL
|
||||
String apiBaseUrl1 = "https://api.coze.cn/v3/chat";
|
||||
String result1 = (String) extractBaseUrlMethod.invoke(aiChatService, apiBaseUrl1);
|
||||
assertEquals("https://api.coze.cn", result1, "应该正确提取基础URL");
|
||||
|
||||
// 测试带端口的URL
|
||||
String apiBaseUrl2 = "http://localhost:8080/v3/chat";
|
||||
String result2 = (String) extractBaseUrlMethod.invoke(aiChatService, apiBaseUrl2);
|
||||
assertEquals("http://localhost:8080", result2, "应该正确提取带端口的基础URL");
|
||||
|
||||
// 测试只有域名的URL
|
||||
String apiBaseUrl3 = "https://api.example.com";
|
||||
String result3 = (String) extractBaseUrlMethod.invoke(aiChatService, apiBaseUrl3);
|
||||
assertEquals("https://api.example.com", result3, "应该正确处理只有域名的URL");
|
||||
|
||||
// 测试空字符串
|
||||
String apiBaseUrl4 = "";
|
||||
String result4 = (String) extractBaseUrlMethod.invoke(aiChatService, apiBaseUrl4);
|
||||
assertEquals("", result4, "空字符串应该返回空字符串");
|
||||
|
||||
// 测试null
|
||||
String result5 = (String) extractBaseUrlMethod.invoke(aiChatService, (String) null);
|
||||
assertEquals("", result5, "null应该返回空字符串");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试完整API URL的构建逻辑")
|
||||
public void testApiUrlConstruction() throws Exception {
|
||||
Method getApiPathMethod = AiChatServiceImpl.class.getDeclaredMethod("getApiPath", AiConfig.class);
|
||||
getApiPathMethod.setAccessible(true);
|
||||
|
||||
// 创建配置对象,apiBaseUrl是完整的API URL
|
||||
AiConfig config = AiConfig.builder()
|
||||
.apiBaseUrl("https://api.coze.cn/v3/chat")
|
||||
.apiToken("test_token")
|
||||
.botId("test_bot_id")
|
||||
.build();
|
||||
|
||||
String apiPath = (String) getApiPathMethod.invoke(aiChatService, config);
|
||||
|
||||
// 模拟实际调用时的URL构建
|
||||
String fullUrl = config.getApiBaseUrl() + apiPath;
|
||||
|
||||
// 验证完整URL就是apiBaseUrl本身
|
||||
assertEquals("https://api.coze.cn/v3/chat", fullUrl,
|
||||
"完整URL应该等于apiBaseUrl,因为apiPath为空");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试状态查询URL的构建逻辑")
|
||||
public void testStatusQueryUrlConstruction() throws Exception {
|
||||
Method extractBaseUrlMethod = AiChatServiceImpl.class.getDeclaredMethod("extractBaseUrl", String.class);
|
||||
extractBaseUrlMethod.setAccessible(true);
|
||||
|
||||
// 创建配置对象
|
||||
AiConfig config = AiConfig.builder()
|
||||
.apiBaseUrl("https://api.coze.cn/v3/chat")
|
||||
.build();
|
||||
|
||||
String baseUrl = (String) extractBaseUrlMethod.invoke(aiChatService, config.getApiBaseUrl());
|
||||
String chatId = "test_chat_id";
|
||||
String conversationId = "test_conversation_id";
|
||||
|
||||
// 模拟状态查询URL构建
|
||||
String statusUrl = baseUrl + "/v3/chat/retrieve?chat_id=" + chatId + "&conversation_id=" + conversationId;
|
||||
|
||||
// 验证状态查询URL格式正确
|
||||
assertEquals("https://api.coze.cn/v3/chat/retrieve?chat_id=test_chat_id&conversation_id=test_conversation_id",
|
||||
statusUrl, "状态查询URL应该正确构建");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试消息查询URL的构建逻辑")
|
||||
public void testMessageQueryUrlConstruction() throws Exception {
|
||||
Method extractBaseUrlMethod = AiChatServiceImpl.class.getDeclaredMethod("extractBaseUrl", String.class);
|
||||
extractBaseUrlMethod.setAccessible(true);
|
||||
|
||||
// 创建配置对象
|
||||
AiConfig config = AiConfig.builder()
|
||||
.apiBaseUrl("https://api.coze.cn/v3/chat")
|
||||
.build();
|
||||
|
||||
String baseUrl = (String) extractBaseUrlMethod.invoke(aiChatService, config.getApiBaseUrl());
|
||||
String chatId = "test_chat_id";
|
||||
String conversationId = "test_conversation_id";
|
||||
|
||||
// 模拟消息查询URL构建
|
||||
String messagesUrl = baseUrl + "/v3/chat/message/list?chat_id=" + chatId + "&conversation_id=" + conversationId;
|
||||
|
||||
// 验证消息查询URL格式正确
|
||||
assertEquals("https://api.coze.cn/v3/chat/message/list?chat_id=test_chat_id&conversation_id=test_conversation_id",
|
||||
messagesUrl, "消息查询URL应该正确构建");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试不同格式的apiBaseUrl")
|
||||
public void testDifferentApiBaseUrlFormats() throws Exception {
|
||||
Method getApiPathMethod = AiChatServiceImpl.class.getDeclaredMethod("getApiPath", AiConfig.class);
|
||||
Method extractBaseUrlMethod = AiChatServiceImpl.class.getDeclaredMethod("extractBaseUrl", String.class);
|
||||
getApiPathMethod.setAccessible(true);
|
||||
extractBaseUrlMethod.setAccessible(true);
|
||||
|
||||
// 测试场景1:标准Coze API URL
|
||||
AiConfig config1 = AiConfig.builder()
|
||||
.apiBaseUrl("https://api.coze.cn/v3/chat")
|
||||
.build();
|
||||
String apiPath1 = (String) getApiPathMethod.invoke(aiChatService, config1);
|
||||
String fullUrl1 = config1.getApiBaseUrl() + apiPath1;
|
||||
assertEquals("https://api.coze.cn/v3/chat", fullUrl1);
|
||||
|
||||
// 测试场景2:自定义API URL
|
||||
AiConfig config2 = AiConfig.builder()
|
||||
.apiBaseUrl("https://custom-api.example.com/ai/chat")
|
||||
.build();
|
||||
String apiPath2 = (String) getApiPathMethod.invoke(aiChatService, config2);
|
||||
String fullUrl2 = config2.getApiBaseUrl() + apiPath2;
|
||||
assertEquals("https://custom-api.example.com/ai/chat", fullUrl2);
|
||||
|
||||
// 测试场景3:本地开发环境URL
|
||||
AiConfig config3 = AiConfig.builder()
|
||||
.apiBaseUrl("http://localhost:8080/api/v1/chat")
|
||||
.build();
|
||||
String apiPath3 = (String) getApiPathMethod.invoke(aiChatService, config3);
|
||||
String fullUrl3 = config3.getApiBaseUrl() + apiPath3;
|
||||
assertEquals("http://localhost:8080/api/v1/chat", fullUrl3);
|
||||
|
||||
// 验证extractBaseUrl对这些URL的处理
|
||||
String baseUrl1 = (String) extractBaseUrlMethod.invoke(aiChatService, config1.getApiBaseUrl());
|
||||
assertEquals("https://api.coze.cn", baseUrl1);
|
||||
|
||||
String baseUrl2 = (String) extractBaseUrlMethod.invoke(aiChatService, config2.getApiBaseUrl());
|
||||
assertEquals("https://custom-api.example.com", baseUrl2);
|
||||
|
||||
String baseUrl3 = (String) extractBaseUrlMethod.invoke(aiChatService, config3.getApiBaseUrl());
|
||||
assertEquals("http://localhost:8080", baseUrl3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试边界情况")
|
||||
public void testEdgeCases() throws Exception {
|
||||
Method extractBaseUrlMethod = AiChatServiceImpl.class.getDeclaredMethod("extractBaseUrl", String.class);
|
||||
extractBaseUrlMethod.setAccessible(true);
|
||||
|
||||
// 测试只有协议和域名的URL
|
||||
String url1 = "https://api.coze.cn";
|
||||
String result1 = (String) extractBaseUrlMethod.invoke(aiChatService, url1);
|
||||
assertEquals("https://api.coze.cn", result1);
|
||||
|
||||
// 测试带多级路径的URL
|
||||
String url2 = "https://api.coze.cn/v3/chat/stream";
|
||||
String result2 = (String) extractBaseUrlMethod.invoke(aiChatService, url2);
|
||||
assertEquals("https://api.coze.cn", result2);
|
||||
|
||||
// 测试带查询参数的URL(虽然不应该出现在apiBaseUrl中)
|
||||
String url3 = "https://api.coze.cn/v3/chat?version=1";
|
||||
String result3 = (String) extractBaseUrlMethod.invoke(aiChatService, url3);
|
||||
assertEquals("https://api.coze.cn", result3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||
import com.emotion.entity.AiCallLog;
|
||||
import com.emotion.entity.AiEndpointConfig;
|
||||
import com.emotion.entity.AiProvider;
|
||||
import com.emotion.entity.AiSceneBinding;
|
||||
import com.emotion.service.ai.AiProviderAdapter;
|
||||
import com.emotion.service.impl.AiRuntimeServiceImpl;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
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.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AiRuntimeServiceImplTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("流式调用已有输出时,末尾异常应保留输出并按成功完成")
|
||||
void invokeStreamRecoversWhenOutputExists() {
|
||||
AiSceneBindingService sceneBindingService = mock(AiSceneBindingService.class);
|
||||
AiEndpointConfigService endpointConfigService = mock(AiEndpointConfigService.class);
|
||||
AiProviderService providerService = mock(AiProviderService.class);
|
||||
AiCallLogService callLogService = mock(AiCallLogService.class);
|
||||
ScriptContextService scriptContextService = mock(ScriptContextService.class);
|
||||
AiProviderAdapter adapter = mock(AiProviderAdapter.class);
|
||||
|
||||
AiSceneBinding scene = new AiSceneBinding();
|
||||
scene.setSceneCode("script_generate");
|
||||
scene.setEndpointId("endpoint-1");
|
||||
scene.setRequiredStream(1);
|
||||
AiEndpointConfig endpoint = new AiEndpointConfig();
|
||||
endpoint.setId("endpoint-1");
|
||||
endpoint.setEndpointCode("dify.script_generate.chat_messages");
|
||||
endpoint.setProviderId("provider-1");
|
||||
endpoint.setSupportStream(1);
|
||||
AiProvider provider = new AiProvider();
|
||||
provider.setId("provider-1");
|
||||
provider.setProviderCode("dify-local");
|
||||
provider.setProviderType("fake");
|
||||
|
||||
when(sceneBindingService.resolveScene("script_generate")).thenReturn(scene);
|
||||
when(endpointConfigService.getEnabledById("endpoint-1")).thenReturn(endpoint);
|
||||
when(providerService.getEnabledById("provider-1")).thenReturn(provider);
|
||||
when(adapter.supports("fake")).thenReturn(true);
|
||||
when(callLogService.save(any(AiCallLog.class))).thenAnswer(invocation -> {
|
||||
AiCallLog log = invocation.getArgument(0);
|
||||
if (log.getId() == null) {
|
||||
log.setId("log-1");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(callLogService.updateById(any(AiCallLog.class))).thenReturn(true);
|
||||
org.mockito.Mockito.doAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.function.Consumer<AiStreamEvent> consumer = invocation.getArgument(3);
|
||||
consumer.accept(AiStreamEvent.delta("完整输出", 1));
|
||||
throw new IllegalStateException("AI_STREAM_CLIENT_DISCONNECTED");
|
||||
}).when(adapter).stream(any(), any(), any(), any());
|
||||
|
||||
AiRuntimeServiceImpl service = new AiRuntimeServiceImpl(
|
||||
sceneBindingService,
|
||||
endpointConfigService,
|
||||
providerService,
|
||||
callLogService,
|
||||
scriptContextService,
|
||||
List.of(adapter)
|
||||
);
|
||||
|
||||
AiRuntimeRequest request = new AiRuntimeRequest();
|
||||
request.setSceneCode("script_generate");
|
||||
request.setUserId("user-1");
|
||||
request.setRequestId("client-request-1");
|
||||
request.setUserName("测试用户");
|
||||
List<AiStreamEvent> events = new ArrayList<>();
|
||||
|
||||
service.invokeStream(request, events::add);
|
||||
|
||||
assertEquals("完整输出", events.stream()
|
||||
.filter(event -> "delta".equals(event.getType()))
|
||||
.map(AiStreamEvent::getContent)
|
||||
.findFirst()
|
||||
.orElse(""));
|
||||
assertTrue(events.stream().anyMatch(event -> "done".equals(event.getType())));
|
||||
assertFalse(events.stream().anyMatch(event -> "error".equals(event.getType())));
|
||||
|
||||
ArgumentCaptor<AiCallLog> captor = ArgumentCaptor.forClass(AiCallLog.class);
|
||||
org.mockito.Mockito.verify(callLogService).updateById(captor.capture());
|
||||
AiCallLog savedLog = captor.getValue();
|
||||
assertEquals("client-request-1", savedLog.getRequestId());
|
||||
assertEquals("success", savedLog.getStatus());
|
||||
assertEquals("完整输出", savedLog.getOutputText());
|
||||
assertEquals("测试用户", savedLog.getUserName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.request.analytics.AnalyticsEventBatchRequest;
|
||||
import com.emotion.dto.request.analytics.AnalyticsEventRequest;
|
||||
import com.emotion.service.analytics.AnalyticsDictionary;
|
||||
import com.emotion.service.impl.AnalyticsServiceImpl;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
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;
|
||||
|
||||
public class AnalyticsServiceTest {
|
||||
|
||||
@Test
|
||||
void safeEventNameAllowsSnakeCase() {
|
||||
assertTrue(AnalyticsServiceImpl.isSafeEventName("script_generate_success"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void safeEventNameRejectsUnsafeCharacters() {
|
||||
assertFalse(AnalyticsServiceImpl.isSafeEventName("script.generate"));
|
||||
assertFalse(AnalyticsServiceImpl.isSafeEventName("<script>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchRequestShapeCanHoldValidEvent() {
|
||||
AnalyticsEventRequest event = new AnalyticsEventRequest();
|
||||
event.setEventName("page_view");
|
||||
event.setEventType("page");
|
||||
event.setPagePath("/pages/main/index");
|
||||
event.setOccurredAt(LocalDateTime.now());
|
||||
event.setProperties(Map.of("tab", "script"));
|
||||
|
||||
AnalyticsEventBatchRequest request = new AnalyticsEventBatchRequest();
|
||||
request.setAnonymousId("anon_test");
|
||||
request.setSessionId("session_test");
|
||||
request.setEvents(List.of(event));
|
||||
|
||||
assertEquals("session_test", request.getSessionId());
|
||||
assertEquals(1, request.getEvents().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dictionaryReturnsChineseBusinessLabels() {
|
||||
assertEquals("浏览页面", AnalyticsDictionary.eventLabel("page_view"));
|
||||
assertEquals("首页", AnalyticsDictionary.pageLabel("/pages/main/index?tab=script"));
|
||||
assertEquals("AI 流式生成", AnalyticsDictionary.apiLabel("/ai/runtime/stream"));
|
||||
assertEquals("爽文生成", AnalyticsDictionary.valueLabel("tab", "script"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.emotion.entity.AiConfig;
|
||||
import com.emotion.service.impl.AiChatServiceImpl;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.RepeatedTest;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.util.AopTestUtils;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Coze工作流集成测试类
|
||||
* 包含属性测试,验证请求格式正确性、流式响应解析等
|
||||
* 所有配置数据从数据库获取,确保测试使用真实有效的配置
|
||||
*
|
||||
* Feature: coze-ai-integration
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-12-23
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("local")
|
||||
public class CozeWorkflowIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private AiChatService aiChatService;
|
||||
|
||||
private AiChatServiceImpl aiChatServiceImpl;
|
||||
|
||||
@Autowired
|
||||
private AiConfigService aiConfigService;
|
||||
|
||||
private Random random;
|
||||
|
||||
/**
|
||||
* 爽文剧本生成的配置键
|
||||
*/
|
||||
private static final String EPIC_SCRIPT_CONFIG_KEY = "coze.course.life.generate";
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
random = new Random();
|
||||
aiChatServiceImpl = AopTestUtils.getTargetObject(aiChatService);
|
||||
}
|
||||
|
||||
// ==================== Property 1: Request Format Correctness ====================
|
||||
// Feature: coze-ai-integration, Property 1: Request Format Correctness
|
||||
// Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 1: 请求格式正确性 - 使用数据库配置验证工作流请求包含所有必需字段")
|
||||
public void testRequestFormatCorrectnessWithDbConfig() throws Exception {
|
||||
// 从数据库获取真实配置
|
||||
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
|
||||
|
||||
// 如果配置不存在,跳过测试
|
||||
if (config == null) {
|
||||
System.out.println("跳过测试:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成测试数据
|
||||
String userId = "test_user_" + random.nextInt(10000);
|
||||
String input = "测试输入_" + UUID.randomUUID().toString();
|
||||
|
||||
// 构建参数
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
parameters.put("input", input);
|
||||
|
||||
// 使用反射调用私有方法buildWorkflowRequest
|
||||
Method buildWorkflowRequestMethod = AiChatServiceImpl.class.getDeclaredMethod(
|
||||
"buildWorkflowRequest", AiConfig.class, Map.class, String.class);
|
||||
buildWorkflowRequestMethod.setAccessible(true);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> requestBody = (Map<String, Object>) buildWorkflowRequestMethod.invoke(
|
||||
aiChatServiceImpl, config, parameters, userId);
|
||||
|
||||
// 验证必需字段
|
||||
// 2.1: workflow_id - 应该与数据库配置一致
|
||||
if (config.getWorkflowId() != null && !config.getWorkflowId().isEmpty()) {
|
||||
assertEquals(config.getWorkflowId(), requestBody.get("workflow_id"),
|
||||
"请求应包含数据库中配置的workflow_id");
|
||||
}
|
||||
|
||||
// 2.2: user_id
|
||||
assertEquals(userId, requestBody.get("user_id"),
|
||||
"请求应包含正确的user_id");
|
||||
|
||||
// 2.3: stream = true
|
||||
assertEquals(true, requestBody.get("stream"),
|
||||
"请求应设置stream为true");
|
||||
|
||||
// 2.4: parameters.input
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> params = (Map<String, Object>) requestBody.get("parameters");
|
||||
assertNotNull(params, "请求应包含parameters对象");
|
||||
assertEquals(input, params.get("input"),
|
||||
"parameters应包含正确的input值");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 1: 验证数据库配置存在且有效")
|
||||
public void testDbConfigExists() {
|
||||
// 从数据库获取配置
|
||||
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
|
||||
|
||||
if (config != null) {
|
||||
// 验证配置的必要字段
|
||||
assertNotNull(config.getWorkflowId(), "workflowId不应为null");
|
||||
assertNotNull(config.getApiToken(), "apiToken不应为null");
|
||||
assertNotNull(config.getApiBaseUrl(), "apiBaseUrl不应为null");
|
||||
|
||||
assertFalse(config.getWorkflowId().isEmpty(), "workflowId不应为空");
|
||||
assertFalse(config.getApiToken().isEmpty(), "apiToken不应为空");
|
||||
assertFalse(config.getApiBaseUrl().isEmpty(), "apiBaseUrl不应为空");
|
||||
|
||||
System.out.println("配置验证通过: " + EPIC_SCRIPT_CONFIG_KEY);
|
||||
System.out.println(" workflowId: " + config.getWorkflowId());
|
||||
System.out.println(" apiBaseUrl: " + config.getApiBaseUrl());
|
||||
} else {
|
||||
System.out.println("警告:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY + ",请先在数据库中添加配置");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Property 2: Stream Response Parsing ====================
|
||||
// Feature: coze-ai-integration, Property 2: Stream Response Parsing
|
||||
// Validates: Requirements 3.1, 3.2, 3.3
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 2: 流式响应解析 - 验证正确提取End节点的output内容")
|
||||
public void testStreamResponseParsing() throws Exception {
|
||||
// 模拟SSE响应数据
|
||||
String sseResponse = """
|
||||
id: 0
|
||||
event: Message
|
||||
data: {"node_title":"End","node_execute_uuid":"","usage":{"token_count":100},"node_is_finish":true,"node_seq_id":"0","content":"{\\"output\\":\\"这是AI生成的内容\\"}","content_type":"text","node_type":"End","node_id":"900001"}
|
||||
|
||||
id: 1
|
||||
event: Done
|
||||
data: {"node_execute_uuid":"","debug_url":"https://example.com"}
|
||||
""";
|
||||
|
||||
// 使用反射调用私有方法parseWorkflowSseResponse
|
||||
Method parseMethod = AiChatServiceImpl.class.getDeclaredMethod(
|
||||
"parseWorkflowSseResponse", java.util.stream.Stream.class);
|
||||
parseMethod.setAccessible(true);
|
||||
|
||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||
|
||||
// 验证正确提取output内容
|
||||
assertEquals("这是AI生成的内容", result,
|
||||
"应正确提取End节点的output内容");
|
||||
}
|
||||
|
||||
@RepeatedTest(100)
|
||||
@DisplayName("Property 2: 流式响应解析 - 随机output内容提取")
|
||||
public void testStreamResponseParsingWithRandomContent() throws Exception {
|
||||
// 生成随机output内容
|
||||
String randomOutput = "随机内容_" + UUID.randomUUID().toString() + "_" + random.nextInt(10000);
|
||||
|
||||
// 构建SSE响应
|
||||
String sseResponse = String.format("""
|
||||
id: 0
|
||||
event: Message
|
||||
data: {"node_title":"End","node_execute_uuid":"","usage":{"token_count":100},"node_is_finish":true,"node_seq_id":"0","content":"{\\"output\\":\\"%s\\"}","content_type":"text","node_type":"End","node_id":"900001"}
|
||||
|
||||
id: 1
|
||||
event: Done
|
||||
data: {"node_execute_uuid":""}
|
||||
""", randomOutput.replace("\"", "\\\""));
|
||||
|
||||
Method parseMethod = AiChatServiceImpl.class.getDeclaredMethod(
|
||||
"parseWorkflowSseResponse", java.util.stream.Stream.class);
|
||||
parseMethod.setAccessible(true);
|
||||
|
||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||
|
||||
// 验证正确提取随机output内容
|
||||
assertEquals(randomOutput, result,
|
||||
"应正确提取随机生成的output内容");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 2: 流式响应解析 - 忽略非End节点")
|
||||
public void testStreamResponseParsingIgnoresNonEndNodes() throws Exception {
|
||||
// 模拟包含多个节点的SSE响应
|
||||
String sseResponse = """
|
||||
id: 0
|
||||
event: Message
|
||||
data: {"node_title":"Start","node_type":"Start","content":"{\\"output\\":\\"开始节点内容\\"}"}
|
||||
|
||||
id: 1
|
||||
event: Message
|
||||
data: {"node_title":"Process","node_type":"Process","content":"{\\"output\\":\\"处理节点内容\\"}"}
|
||||
|
||||
id: 2
|
||||
event: Message
|
||||
data: {"node_title":"End","node_type":"End","content":"{\\"output\\":\\"最终输出内容\\"}"}
|
||||
|
||||
id: 3
|
||||
event: Done
|
||||
data: {}
|
||||
""";
|
||||
|
||||
Method parseMethod = AiChatServiceImpl.class.getDeclaredMethod(
|
||||
"parseWorkflowSseResponse", java.util.stream.Stream.class);
|
||||
parseMethod.setAccessible(true);
|
||||
|
||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||
|
||||
// 验证只提取End节点的内容
|
||||
assertEquals("最终输出内容", result,
|
||||
"应只提取End节点的output内容,忽略其他节点");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 2: 流式响应解析 - 处理content中没有output字段的情况")
|
||||
public void testStreamResponseParsingWithoutOutputField() throws Exception {
|
||||
// 模拟content中没有output字段的响应
|
||||
String sseResponse = """
|
||||
id: 0
|
||||
event: Message
|
||||
data: {"node_title":"End","node_type":"End","content":"直接内容,没有output字段"}
|
||||
|
||||
id: 1
|
||||
event: Done
|
||||
data: {}
|
||||
""";
|
||||
|
||||
Method parseMethod = AiChatServiceImpl.class.getDeclaredMethod(
|
||||
"parseWorkflowSseResponse", java.util.stream.Stream.class);
|
||||
parseMethod.setAccessible(true);
|
||||
|
||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||
|
||||
// 当content不是JSON或没有output字段时,应返回原始content
|
||||
assertEquals("直接内容,没有output字段", result,
|
||||
"当content没有output字段时,应返回原始content");
|
||||
}
|
||||
|
||||
// ==================== 参数合并测试 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 3: 参数合并 - 使用数据库配置验证运行时参数覆盖配置参数")
|
||||
public void testParameterMergingWithDbConfig() throws Exception {
|
||||
// 从数据库获取真实配置
|
||||
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
|
||||
|
||||
if (config == null) {
|
||||
System.out.println("跳过测试:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
// 运行时参数
|
||||
String runtimeInput = "运行时输入_" + UUID.randomUUID().toString();
|
||||
Map<String, Object> runtimeParams = new HashMap<>();
|
||||
runtimeParams.put("input", runtimeInput);
|
||||
runtimeParams.put("user_id", "runtime_user");
|
||||
|
||||
Method mergeMethod = AiChatServiceImpl.class.getDeclaredMethod(
|
||||
"mergeParameters", AiConfig.class, Map.class);
|
||||
mergeMethod.setAccessible(true);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> mergedParams = (Map<String, Object>) mergeMethod.invoke(
|
||||
aiChatServiceImpl, config, runtimeParams);
|
||||
|
||||
// 验证运行时参数被正确设置
|
||||
assertEquals(runtimeInput, mergedParams.get("input"),
|
||||
"运行时input应被正确设置");
|
||||
assertEquals("runtime_user", mergedParams.get("user_id"),
|
||||
"运行时user_id应被正确设置");
|
||||
}
|
||||
|
||||
// ==================== extractOutputFromContent测试 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("测试extractOutputFromContent - 正常JSON提取")
|
||||
public void testExtractOutputFromContent() throws Exception {
|
||||
Method extractMethod = AiChatServiceImpl.class.getDeclaredMethod(
|
||||
"extractOutputFromContent", String.class);
|
||||
extractMethod.setAccessible(true);
|
||||
|
||||
String content = "{\"output\":\"提取的内容\"}";
|
||||
String result = (String) extractMethod.invoke(aiChatServiceImpl, content);
|
||||
|
||||
assertEquals("提取的内容", result, "应正确提取output字段");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试extractOutputFromContent - 非JSON内容")
|
||||
public void testExtractOutputFromContentNonJson() throws Exception {
|
||||
Method extractMethod = AiChatServiceImpl.class.getDeclaredMethod(
|
||||
"extractOutputFromContent", String.class);
|
||||
extractMethod.setAccessible(true);
|
||||
|
||||
String content = "这不是JSON内容";
|
||||
String result = (String) extractMethod.invoke(aiChatServiceImpl, content);
|
||||
|
||||
assertEquals("这不是JSON内容", result, "非JSON内容应原样返回");
|
||||
}
|
||||
|
||||
@RepeatedTest(100)
|
||||
@DisplayName("Property: extractOutputFromContent - 随机内容提取")
|
||||
public void testExtractOutputFromContentRandom() throws Exception {
|
||||
Method extractMethod = AiChatServiceImpl.class.getDeclaredMethod(
|
||||
"extractOutputFromContent", String.class);
|
||||
extractMethod.setAccessible(true);
|
||||
|
||||
// 生成随机output内容
|
||||
String randomOutput = "随机输出_" + UUID.randomUUID().toString();
|
||||
String content = "{\"output\":\"" + randomOutput + "\"}";
|
||||
|
||||
String result = (String) extractMethod.invoke(aiChatServiceImpl, content);
|
||||
|
||||
assertEquals(randomOutput, result, "应正确提取随机生成的output内容");
|
||||
}
|
||||
|
||||
// ==================== Property 4: Configuration Application ====================
|
||||
// Feature: coze-ai-integration, Property 4: Configuration Application
|
||||
// Validates: Requirements 1.3, 5.2, 5.3
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 4: 配置应用正确性 - 验证数据库配置的超时和重试设置")
|
||||
public void testConfigurationApplicationWithDbConfig() {
|
||||
// 从数据库获取真实配置
|
||||
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
|
||||
|
||||
if (config == null) {
|
||||
System.out.println("跳过测试:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证超时配置
|
||||
int effectiveTimeout = config.getTimeoutMs() != null ? config.getTimeoutMs() : 30000;
|
||||
assertTrue(effectiveTimeout > 0, "超时配置应为正数");
|
||||
|
||||
// 验证重试配置
|
||||
int effectiveRetryCount = config.getRetryCount() != null ? config.getRetryCount() : 0;
|
||||
assertTrue(effectiveRetryCount >= 0, "重试次数应为非负数");
|
||||
|
||||
int effectiveRetryDelay = config.getRetryDelayMs() != null ? config.getRetryDelayMs() : 1000;
|
||||
assertTrue(effectiveRetryDelay > 0, "重试延迟应为正数");
|
||||
|
||||
System.out.println("配置应用验证通过:");
|
||||
System.out.println(" 超时: " + effectiveTimeout + "ms");
|
||||
System.out.println(" 重试次数: " + effectiveRetryCount);
|
||||
System.out.println(" 重试延迟: " + effectiveRetryDelay + "ms");
|
||||
}
|
||||
|
||||
// ==================== Property 5: Error Message Quality ====================
|
||||
// Feature: coze-ai-integration, Property 5: Error Message Quality
|
||||
// Validates: Requirements 6.4, 6.5
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 5: 错误消息质量 - 验证配置不存在时的错误消息")
|
||||
public void testErrorMessageForNonExistentConfig() {
|
||||
String nonExistentConfigKey = "non.existent.config." + UUID.randomUUID().toString();
|
||||
|
||||
try {
|
||||
aiChatService.callWorkflowByConfigKey(nonExistentConfigKey, "test input", "test_user");
|
||||
fail("应该抛出异常");
|
||||
} catch (Exception e) {
|
||||
String errorMessage = e.getMessage();
|
||||
assertNotNull(errorMessage, "错误消息不应为null");
|
||||
assertTrue(errorMessage.length() > 0, "错误消息不应为空");
|
||||
// 验证错误消息包含configKey
|
||||
assertTrue(errorMessage.contains(nonExistentConfigKey) || errorMessage.contains("未找到AI配置"),
|
||||
"错误消息应包含configKey或明确的错误描述");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 5: 错误消息质量 - 验证错误消息不包含敏感信息")
|
||||
public void testErrorMessageDoesNotContainSensitiveInfo() {
|
||||
String nonExistentConfigKey = "non.existent.config";
|
||||
|
||||
try {
|
||||
aiChatService.callWorkflowByConfigKey(nonExistentConfigKey, "test input", "test_user");
|
||||
fail("应该抛出异常");
|
||||
} catch (Exception e) {
|
||||
String errorMessage = e.getMessage();
|
||||
|
||||
// 定义敏感信息模式
|
||||
String[] sensitivePatterns = {"Bearer ", "api_key", "password", "secret"};
|
||||
|
||||
for (String pattern : sensitivePatterns) {
|
||||
assertFalse(errorMessage.toLowerCase().contains(pattern.toLowerCase()),
|
||||
"错误消息不应包含敏感信息: " + pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 集成测试:真实调用(需要有效配置) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("集成测试: 使用数据库配置调用Coze工作流并验证API调用记录")
|
||||
public void testRealWorkflowCallWithDbConfig() {
|
||||
// 从数据库获取真实配置
|
||||
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
|
||||
|
||||
if (config == null) {
|
||||
System.out.println("跳过集成测试:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建测试输入 - 使用真实业务数据
|
||||
String testInput = "【剧本标题】逆袭人生:从底层到巅峰\n" +
|
||||
"【主题渴望】成为行业领袖,实现财务自由\n" +
|
||||
"【剧本风格】职场逆袭\n" +
|
||||
"【篇幅长度】标准篇\n" +
|
||||
"【序幕-低谷回响】我是一个普通的上班族,每天朝九晚五,工资勉强够生活。公司裁员名单上赫然出现了我的名字。\n" +
|
||||
"【转折-契机出现】一次偶然的机会,我遇到了一位行业前辈。他的一番话点醒了我,让我看到了新的可能。\n" +
|
||||
"【高潮-命运抉择】面对两个选择:稳定但平庸的工作,还是充满风险但可能改变人生的创业机会。我必须做出决定。\n" +
|
||||
"【结局-新的开始】经过不懈努力,我终于实现了自己的目标。站在新的起点,我知道这只是开始,更精彩的人生还在前方。";
|
||||
String userId = "integration_test_user_" + System.currentTimeMillis();
|
||||
|
||||
System.out.println("========== 开始集成测试 ==========");
|
||||
System.out.println("配置键: " + EPIC_SCRIPT_CONFIG_KEY);
|
||||
System.out.println("工作流ID: " + config.getWorkflowId());
|
||||
System.out.println("API地址: " + config.getApiBaseUrl());
|
||||
System.out.println("用户ID: " + userId);
|
||||
System.out.println("输入内容长度: " + testInput.length());
|
||||
|
||||
try {
|
||||
long startTime = System.currentTimeMillis();
|
||||
String result = aiChatService.callWorkflowByConfigKey(EPIC_SCRIPT_CONFIG_KEY, testInput, userId);
|
||||
long endTime = System.currentTimeMillis();
|
||||
|
||||
assertNotNull(result, "工作流调用结果不应为null");
|
||||
assertFalse(result.isEmpty(), "工作流调用结果不应为空");
|
||||
|
||||
System.out.println("\n========== 调用成功 ==========");
|
||||
System.out.println("耗时: " + (endTime - startTime) + "ms");
|
||||
System.out.println("结果长度: " + result.length());
|
||||
System.out.println("结果预览: " + (result.length() > 500 ? result.substring(0, 500) + "..." : result));
|
||||
|
||||
// 验证API调用记录已保存到数据库
|
||||
System.out.println("\n========== 验证API调用记录 ==========");
|
||||
System.out.println("请检查 t_coze_api_call 表中是否有 user_id='" + userId + "' 的记录");
|
||||
System.out.println("记录应包含: request_type='workflow', workflow_id='" + config.getWorkflowId() + "'");
|
||||
|
||||
} catch (Exception e) {
|
||||
System.out.println("\n========== 调用失败 ==========");
|
||||
System.out.println("错误信息: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
// 不让测试失败,因为这可能是环境问题
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.response.DashboardStatsResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 仪表盘服务测试类
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-10-31
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("local")
|
||||
public class DashboardServiceTest {
|
||||
|
||||
@Autowired
|
||||
private DashboardService dashboardService;
|
||||
|
||||
@Test
|
||||
public void testGetDashboardStats() {
|
||||
// 测试获取仪表盘统计数据
|
||||
DashboardStatsResponse stats = dashboardService.getDashboardStats();
|
||||
|
||||
assertNotNull(stats, "仪表盘统计数据不应为空");
|
||||
assertNotNull(stats.getUserStats(), "用户统计数据不应为空");
|
||||
assertNotNull(stats.getContentStats(), "内容统计数据不应为空");
|
||||
assertNotNull(stats.getAiServiceStats(), "AI服务统计数据不应为空");
|
||||
assertNotNull(stats.getSystemStats(), "系统统计数据不应为空");
|
||||
assertNotNull(stats.getUpdateTime(), "更新时间不应为空");
|
||||
|
||||
// 验证用户统计数据
|
||||
DashboardStatsResponse.UserStats userStats = stats.getUserStats();
|
||||
assertTrue(userStats.getTotalUsers() >= 0, "总用户数应大于等于0");
|
||||
assertTrue(userStats.getTodayNewUsers() >= 0, "今日新增用户数应大于等于0");
|
||||
assertTrue(userStats.getActiveUsers() >= 0, "活跃用户数应大于等于0");
|
||||
assertTrue(userStats.getGuestUsers() >= 0, "访客用户数应大于等于0");
|
||||
|
||||
// 验证内容统计数据
|
||||
DashboardStatsResponse.ContentStats contentStats = stats.getContentStats();
|
||||
assertTrue(contentStats.getTotalConversations() >= 0, "总对话数应大于等于0");
|
||||
assertTrue(contentStats.getTotalMessages() >= 0, "总消息数应大于等于0");
|
||||
assertTrue(contentStats.getDiaryPosts() >= 0, "日记帖子数应大于等于0");
|
||||
assertTrue(contentStats.getCommunityPosts() >= 0, "社区帖子数应大于等于0");
|
||||
assertTrue(contentStats.getEmotionRecords() >= 0, "情绪记录数应大于等于0");
|
||||
|
||||
// 验证AI服务统计数据
|
||||
DashboardStatsResponse.AiServiceStats aiStats = stats.getAiServiceStats();
|
||||
assertTrue(aiStats.getTotalApiCalls() >= 0, "总API调用次数应大于等于0");
|
||||
assertTrue(aiStats.getTodayApiCalls() >= 0, "今日API调用次数应大于等于0");
|
||||
assertTrue(aiStats.getSuccessfulCalls() >= 0, "成功调用次数应大于等于0");
|
||||
assertTrue(aiStats.getFailedCalls() >= 0, "失败调用次数应大于等于0");
|
||||
assertTrue(aiStats.getAvgResponseTime() >= 0, "平均响应时间应大于等于0");
|
||||
assertTrue(aiStats.getAiConfigCount() >= 0, "AI配置数量应大于等于0");
|
||||
|
||||
// 验证系统统计数据
|
||||
DashboardStatsResponse.SystemStats systemStats = stats.getSystemStats();
|
||||
assertTrue(systemStats.getAdminCount() >= 0, "管理员数量应大于等于0");
|
||||
assertTrue(systemStats.getAchievementCount() >= 0, "成就数量应大于等于0");
|
||||
assertTrue(systemStats.getRewardCount() >= 0, "奖励数量应大于等于0");
|
||||
assertNotNull(systemStats.getUptime(), "系统运行时间不应为空");
|
||||
|
||||
System.out.println("=== 仪表盘统计数据测试结果 ===");
|
||||
System.out.println("总用户数: " + userStats.getTotalUsers());
|
||||
System.out.println("今日新增用户: " + userStats.getTodayNewUsers());
|
||||
System.out.println("活跃用户数: " + userStats.getActiveUsers());
|
||||
System.out.println("访客用户数: " + userStats.getGuestUsers());
|
||||
System.out.println("总对话数: " + contentStats.getTotalConversations());
|
||||
System.out.println("总消息数: " + contentStats.getTotalMessages());
|
||||
System.out.println("日记帖子数: " + contentStats.getDiaryPosts());
|
||||
System.out.println("社区帖子数: " + contentStats.getCommunityPosts());
|
||||
System.out.println("情绪记录数: " + contentStats.getEmotionRecords());
|
||||
System.out.println("总API调用次数: " + aiStats.getTotalApiCalls());
|
||||
System.out.println("今日API调用次数: " + aiStats.getTodayApiCalls());
|
||||
System.out.println("成功调用次数: " + aiStats.getSuccessfulCalls());
|
||||
System.out.println("失败调用次数: " + aiStats.getFailedCalls());
|
||||
System.out.println("平均响应时间: " + aiStats.getAvgResponseTime() + "ms");
|
||||
System.out.println("AI配置数量: " + aiStats.getAiConfigCount());
|
||||
System.out.println("管理员数量: " + systemStats.getAdminCount());
|
||||
System.out.println("成就数量: " + systemStats.getAchievementCount());
|
||||
System.out.println("奖励数量: " + systemStats.getRewardCount());
|
||||
System.out.println("系统运行时间: " + systemStats.getUptime());
|
||||
System.out.println("最近活动数量: " + stats.getRecentActivities().size());
|
||||
System.out.println("用户增长趋势数量: " + (stats.getUserGrowthTrends() != null ? stats.getUserGrowthTrends().size() : 0));
|
||||
System.out.println("最近登录用户数量: " + (stats.getRecentLogins() != null ? stats.getRecentLogins().size() : 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetUserStats() {
|
||||
// 测试获取用户统计数据
|
||||
DashboardStatsResponse.UserStats userStats = dashboardService.getUserStats();
|
||||
|
||||
assertNotNull(userStats, "用户统计数据不应为空");
|
||||
assertTrue(userStats.getTotalUsers() >= 0, "总用户数应大于等于0");
|
||||
assertTrue(userStats.getTodayNewUsers() >= 0, "今日新增用户数应大于等于0");
|
||||
assertTrue(userStats.getActiveUsers() >= 0, "活跃用户数应大于等于0");
|
||||
assertTrue(userStats.getGuestUsers() >= 0, "访客用户数应大于等于0");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetContentStats() {
|
||||
// 测试获取内容统计数据
|
||||
DashboardStatsResponse.ContentStats contentStats = dashboardService.getContentStats();
|
||||
|
||||
assertNotNull(contentStats, "内容统计数据不应为空");
|
||||
assertTrue(contentStats.getTotalConversations() >= 0, "总对话数应大于等于0");
|
||||
assertTrue(contentStats.getTotalMessages() >= 0, "总消息数应大于等于0");
|
||||
assertTrue(contentStats.getDiaryPosts() >= 0, "日记帖子数应大于等于0");
|
||||
assertTrue(contentStats.getCommunityPosts() >= 0, "社区帖子数应大于等于0");
|
||||
assertTrue(contentStats.getEmotionRecords() >= 0, "情绪记录数应大于等于0");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetAiServiceStats() {
|
||||
// 测试获取AI服务统计数据
|
||||
DashboardStatsResponse.AiServiceStats aiStats = dashboardService.getAiServiceStats();
|
||||
|
||||
assertNotNull(aiStats, "AI服务统计数据不应为空");
|
||||
assertTrue(aiStats.getTotalApiCalls() >= 0, "总API调用次数应大于等于0");
|
||||
assertTrue(aiStats.getTodayApiCalls() >= 0, "今日API调用次数应大于等于0");
|
||||
assertTrue(aiStats.getSuccessfulCalls() >= 0, "成功调用次数应大于等于0");
|
||||
assertTrue(aiStats.getFailedCalls() >= 0, "失败调用次数应大于等于0");
|
||||
assertTrue(aiStats.getAvgResponseTime() >= 0, "平均响应时间应大于等于0");
|
||||
assertTrue(aiStats.getAiConfigCount() >= 0, "AI配置数量应大于等于0");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSystemStats() {
|
||||
// 测试获取系统统计数据
|
||||
DashboardStatsResponse.SystemStats systemStats = dashboardService.getSystemStats();
|
||||
|
||||
assertNotNull(systemStats, "系统统计数据不应为空");
|
||||
assertTrue(systemStats.getAdminCount() >= 0, "管理员数量应大于等于0");
|
||||
assertTrue(systemStats.getAchievementCount() >= 0, "成就数量应大于等于0");
|
||||
assertTrue(systemStats.getRewardCount() >= 0, "奖励数量应大于等于0");
|
||||
assertNotNull(systemStats.getUptime(), "系统运行时间不应为空");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetUserGrowthTrends() {
|
||||
// 测试获取用户增长趋势数据
|
||||
var trends = dashboardService.getUserGrowthTrends(7);
|
||||
|
||||
assertNotNull(trends, "用户增长趋势数据不应为空");
|
||||
assertTrue(trends.size() <= 7, "趋势数据数量应不超过7天");
|
||||
|
||||
for (var trend : trends) {
|
||||
assertNotNull(trend.getDate(), "日期不应为空");
|
||||
assertTrue(trend.getNewUsers() >= 0, "新增用户数应大于等于0");
|
||||
assertTrue(trend.getTotalUsers() >= 0, "总用户数应大于等于0");
|
||||
}
|
||||
|
||||
System.out.println("=== 用户增长趋势测试结果 ===");
|
||||
for (var trend : trends) {
|
||||
System.out.println(String.format("日期: %s, 新增: %d, 总计: %d",
|
||||
trend.getDate(), trend.getNewUsers(), trend.getTotalUsers()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetRecentLogins() {
|
||||
// 测试获取最近登录用户
|
||||
var recentLogins = dashboardService.getRecentLogins(10);
|
||||
|
||||
assertNotNull(recentLogins, "最近登录用户数据不应为空");
|
||||
assertTrue(recentLogins.size() <= 10, "最近登录用户数量应不超过10个");
|
||||
|
||||
for (var login : recentLogins) {
|
||||
assertNotNull(login.getUserId(), "用户ID不应为空");
|
||||
assertNotNull(login.getUsername(), "用户名不应为空");
|
||||
assertNotNull(login.getTimeDescription(), "时间描述不应为空");
|
||||
}
|
||||
|
||||
System.out.println("=== 最近登录用户测试结果 ===");
|
||||
for (var login : recentLogins) {
|
||||
System.out.println(String.format("用户: %s (%s), 时间: %s",
|
||||
login.getNickname() != null ? login.getNickname() : login.getUsername(),
|
||||
login.getUsername(),
|
||||
login.getTimeDescription()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.request.EpicScriptCreateRequest;
|
||||
import com.emotion.entity.AiConfig;
|
||||
import com.emotion.service.impl.EpicScriptServiceImpl;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* EpicScriptServiceImpl测试类
|
||||
* 包含属性测试,验证输入组装完整性等
|
||||
* 使用贴近真实业务场景的测试数据
|
||||
*
|
||||
* Feature: coze-ai-integration
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-12-23
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("local")
|
||||
public class EpicScriptServiceImplTest {
|
||||
|
||||
@Autowired
|
||||
private EpicScriptService epicScriptService;
|
||||
|
||||
@Autowired
|
||||
private AiConfigService aiConfigService;
|
||||
|
||||
private Random random;
|
||||
|
||||
/**
|
||||
* 爽文剧本生成的配置键
|
||||
*/
|
||||
private static final String EPIC_SCRIPT_CONFIG_KEY = "coze.course.life.generate";
|
||||
|
||||
/**
|
||||
* 真实的剧本标题示例
|
||||
*/
|
||||
private static final List<String> SAMPLE_TITLES = Arrays.asList(
|
||||
"逆袭人生:从底层到巅峰",
|
||||
"命运转折点",
|
||||
"我的职场逆袭之路",
|
||||
"重生之商业帝国",
|
||||
"平凡人的不平凡故事",
|
||||
"从零开始的创业传奇",
|
||||
"人生赢家养成记",
|
||||
"绝地反击"
|
||||
);
|
||||
|
||||
/**
|
||||
* 真实的主题/渴望示例
|
||||
*/
|
||||
private static final List<String> SAMPLE_THEMES = Arrays.asList(
|
||||
"成为行业领袖,实现财务自由",
|
||||
"找到真爱,拥有幸福家庭",
|
||||
"突破自我,成就非凡人生",
|
||||
"获得认可,证明自己的价值",
|
||||
"改变命运,逆转人生轨迹",
|
||||
"实现梦想,活出精彩人生"
|
||||
);
|
||||
|
||||
/**
|
||||
* 真实的序幕(低谷回响)示例
|
||||
*/
|
||||
private static final List<String> SAMPLE_PLOT_INTROS = Arrays.asList(
|
||||
"我是一个普通的上班族,每天朝九晚五,工资勉强够生活。公司裁员名单上赫然出现了我的名字。",
|
||||
"大学毕业后,我满怀憧憬来到大城市,却发现现实远比想象残酷。租住在狭小的地下室,每天为生计发愁。",
|
||||
"创业失败后,我背负着巨额债务,朋友疏远,家人失望。站在人生的最低谷,我不知道该何去何从。",
|
||||
"三十岁了,事业无成,感情空白。看着同龄人都已成家立业,我感到前所未有的迷茫和焦虑。"
|
||||
);
|
||||
|
||||
/**
|
||||
* 真实的转折(契机出现)示例
|
||||
*/
|
||||
private static final List<String> SAMPLE_PLOT_TURNINGS = Arrays.asList(
|
||||
"一次偶然的机会,我遇到了一位行业前辈。他的一番话点醒了我,让我看到了新的可能。",
|
||||
"在最绝望的时候,我发现了一个被忽视的市场机会。这可能是改变命运的转折点。",
|
||||
"一封意外的邮件,一个久违的电话,让我重新燃起了希望。原来机会一直都在,只是我没有发现。",
|
||||
"参加了一场行业峰会,结识了志同道合的伙伴。我们决定一起做点不一样的事情。"
|
||||
);
|
||||
|
||||
/**
|
||||
* 真实的高潮(命运抉择)示例
|
||||
*/
|
||||
private static final List<String> SAMPLE_PLOT_CLIMAXES = Arrays.asList(
|
||||
"面对两个选择:稳定但平庸的工作,还是充满风险但可能改变人生的创业机会。我必须做出决定。",
|
||||
"关键时刻,曾经的对手提出了合作邀请。是放下过去携手共进,还是坚持己见独自前行?",
|
||||
"项目进入最关键的阶段,资金链即将断裂。是放弃还是孤注一掷?这个决定将决定一切。",
|
||||
"成功近在咫尺,但代价是牺牲与家人相处的时间。事业与家庭,我该如何抉择?"
|
||||
);
|
||||
|
||||
/**
|
||||
* 真实的结局(新的开始)示例
|
||||
*/
|
||||
private static final List<String> SAMPLE_PLOT_ENDINGS = Arrays.asList(
|
||||
"经过不懈努力,我终于实现了自己的目标。站在新的起点,我知道这只是开始,更精彩的人生还在前方。",
|
||||
"回首来时路,那些曾经的困难都成为了宝贵的财富。我不仅收获了成功,更收获了成长。",
|
||||
"梦想成真的那一刻,我流下了激动的泪水。感谢那个在低谷中没有放弃的自己。",
|
||||
"新的篇章已经开启,我带着过去的经验和教训,向着更高的目标前进。人生,永远充满可能。"
|
||||
);
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
random = new Random();
|
||||
}
|
||||
|
||||
// ==================== Property 3: Input Assembly Completeness ====================
|
||||
// Feature: coze-ai-integration, Property 3: Input Assembly Completeness
|
||||
// Validates: Requirements 4.2
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 3: 验证数据库中存在爽文剧本生成配置")
|
||||
public void testEpicScriptConfigExists() {
|
||||
// 从数据库获取配置
|
||||
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
|
||||
|
||||
if (config != null) {
|
||||
// 验证配置的必要字段
|
||||
assertNotNull(config.getWorkflowId(), "workflowId不应为null");
|
||||
assertNotNull(config.getApiToken(), "apiToken不应为null");
|
||||
assertNotNull(config.getApiBaseUrl(), "apiBaseUrl不应为null");
|
||||
|
||||
System.out.println("爽文剧本配置验证通过: " + EPIC_SCRIPT_CONFIG_KEY);
|
||||
System.out.println(" workflowId: " + config.getWorkflowId());
|
||||
System.out.println(" apiBaseUrl: " + config.getApiBaseUrl());
|
||||
} else {
|
||||
System.out.println("警告:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY + ",请先在数据库中添加配置");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 3: 输入组装完整性 - 验证所有非空字段都被包含在输入中")
|
||||
public void testInputAssemblyCompleteness() throws Exception {
|
||||
// 使用反射获取私有方法
|
||||
Method assembleMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
|
||||
"assembleScriptInput", EpicScriptCreateRequest.class);
|
||||
assembleMethod.setAccessible(true);
|
||||
|
||||
// 使用真实业务数据进行多次测试
|
||||
IntStream.range(0, 10).forEach(i -> {
|
||||
try {
|
||||
// 从样本数据中随机选择真实业务数据
|
||||
String title = getRandomSample(SAMPLE_TITLES);
|
||||
String theme = getRandomSample(SAMPLE_THEMES);
|
||||
String style = getRandomStyle();
|
||||
String length = getRandomLength();
|
||||
String plotIntro = getRandomSample(SAMPLE_PLOT_INTROS);
|
||||
String plotTurning = getRandomSample(SAMPLE_PLOT_TURNINGS);
|
||||
String plotClimax = getRandomSample(SAMPLE_PLOT_CLIMAXES);
|
||||
String plotEnding = getRandomSample(SAMPLE_PLOT_ENDINGS);
|
||||
|
||||
// 创建请求对象
|
||||
EpicScriptCreateRequest request = new EpicScriptCreateRequest();
|
||||
request.setTitle(title);
|
||||
request.setTheme(theme);
|
||||
request.setStyle(style);
|
||||
request.setLength(length);
|
||||
request.setPlotIntro(plotIntro);
|
||||
request.setPlotTurning(plotTurning);
|
||||
request.setPlotClimax(plotClimax);
|
||||
request.setPlotEnding(plotEnding);
|
||||
|
||||
String result = (String) assembleMethod.invoke(epicScriptService, request);
|
||||
|
||||
// 验证所有字段都被包含
|
||||
assertTrue(result.contains(title), "输入应包含标题: " + title);
|
||||
assertTrue(result.contains(theme), "输入应包含主题: " + theme);
|
||||
assertTrue(result.contains(plotIntro), "输入应包含序幕");
|
||||
assertTrue(result.contains(plotTurning), "输入应包含转折");
|
||||
assertTrue(result.contains(plotClimax), "输入应包含高潮");
|
||||
assertTrue(result.contains(plotEnding), "输入应包含结局");
|
||||
|
||||
// 验证风格和篇幅描述
|
||||
assertTrue(result.contains("【剧本风格】"), "输入应包含风格标签");
|
||||
assertTrue(result.contains("【篇幅长度】"), "输入应包含篇幅标签");
|
||||
} catch (Exception e) {
|
||||
fail("测试执行失败: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 3: 输入组装 - 验证风格描述转换")
|
||||
public void testStyleDescriptionConversion() throws Exception {
|
||||
Method getStyleDescMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
|
||||
"getStyleDescription", String.class);
|
||||
getStyleDescMethod.setAccessible(true);
|
||||
|
||||
// 验证各种风格的描述转换
|
||||
assertEquals("职场逆袭", getStyleDescMethod.invoke(epicScriptService, "career"));
|
||||
assertEquals("情感圆满", getStyleDescMethod.invoke(epicScriptService, "love"));
|
||||
assertEquals("玄幻觉醒", getStyleDescMethod.invoke(epicScriptService, "fantasy"));
|
||||
assertEquals("unknown", getStyleDescMethod.invoke(epicScriptService, "unknown"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 3: 输入组装 - 验证篇幅描述转换")
|
||||
public void testLengthDescriptionConversion() throws Exception {
|
||||
Method getLengthDescMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
|
||||
"getLengthDescription", String.class);
|
||||
getLengthDescMethod.setAccessible(true);
|
||||
|
||||
// 验证各种篇幅的描述转换
|
||||
assertEquals("标准篇", getLengthDescMethod.invoke(epicScriptService, "medium"));
|
||||
assertEquals("长篇", getLengthDescMethod.invoke(epicScriptService, "long"));
|
||||
assertEquals("short", getLengthDescMethod.invoke(epicScriptService, "short"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 3: 输入组装 - 验证空字段不被包含")
|
||||
public void testInputAssemblyWithEmptyFields() throws Exception {
|
||||
// 创建只有部分字段的请求
|
||||
EpicScriptCreateRequest request = new EpicScriptCreateRequest();
|
||||
request.setTitle("测试标题");
|
||||
request.setTheme("测试主题");
|
||||
// 其他字段为空
|
||||
|
||||
Method assembleMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
|
||||
"assembleScriptInput", EpicScriptCreateRequest.class);
|
||||
assembleMethod.setAccessible(true);
|
||||
|
||||
String result = (String) assembleMethod.invoke(epicScriptService, request);
|
||||
|
||||
// 验证包含非空字段
|
||||
assertTrue(result.contains("测试标题"), "输入应包含标题");
|
||||
assertTrue(result.contains("测试主题"), "输入应包含主题");
|
||||
|
||||
// 验证不包含空字段的标签
|
||||
assertFalse(result.contains("【序幕-低谷回响】"), "空字段不应被包含");
|
||||
assertFalse(result.contains("【转折-契机出现】"), "空字段不应被包含");
|
||||
assertFalse(result.contains("【高潮-命运抉择】"), "空字段不应被包含");
|
||||
assertFalse(result.contains("【结局-新的开始】"), "空字段不应被包含");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 3: 输入组装 - 验证所有字段为空时返回空字符串")
|
||||
public void testInputAssemblyWithAllEmptyFields() throws Exception {
|
||||
EpicScriptCreateRequest request = new EpicScriptCreateRequest();
|
||||
|
||||
Method assembleMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
|
||||
"assembleScriptInput", EpicScriptCreateRequest.class);
|
||||
assembleMethod.setAccessible(true);
|
||||
|
||||
String result = (String) assembleMethod.invoke(epicScriptService, request);
|
||||
|
||||
// 验证返回空字符串
|
||||
assertTrue(result.isEmpty(), "所有字段为空时应返回空字符串");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 3: 输入组装 - 随机部分字段填充测试")
|
||||
public void testInputAssemblyWithRandomPartialFields() throws Exception {
|
||||
Method assembleMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
|
||||
"assembleScriptInput", EpicScriptCreateRequest.class);
|
||||
assembleMethod.setAccessible(true);
|
||||
|
||||
// 使用真实业务数据进行多次测试
|
||||
IntStream.range(0, 10).forEach(i -> {
|
||||
try {
|
||||
EpicScriptCreateRequest request = new EpicScriptCreateRequest();
|
||||
|
||||
// 随机决定哪些字段有值,使用真实业务数据
|
||||
String title = random.nextBoolean() ? getRandomSample(SAMPLE_TITLES) : null;
|
||||
String theme = random.nextBoolean() ? getRandomSample(SAMPLE_THEMES) : null;
|
||||
String style = random.nextBoolean() ? getRandomStyle() : null;
|
||||
String length = random.nextBoolean() ? getRandomLength() : null;
|
||||
String plotIntro = random.nextBoolean() ? getRandomSample(SAMPLE_PLOT_INTROS) : null;
|
||||
String plotTurning = random.nextBoolean() ? getRandomSample(SAMPLE_PLOT_TURNINGS) : null;
|
||||
String plotClimax = random.nextBoolean() ? getRandomSample(SAMPLE_PLOT_CLIMAXES) : null;
|
||||
String plotEnding = random.nextBoolean() ? getRandomSample(SAMPLE_PLOT_ENDINGS) : null;
|
||||
|
||||
request.setTitle(title);
|
||||
request.setTheme(theme);
|
||||
request.setStyle(style);
|
||||
request.setLength(length);
|
||||
request.setPlotIntro(plotIntro);
|
||||
request.setPlotTurning(plotTurning);
|
||||
request.setPlotClimax(plotClimax);
|
||||
request.setPlotEnding(plotEnding);
|
||||
|
||||
String result = (String) assembleMethod.invoke(epicScriptService, request);
|
||||
|
||||
// 验证非空字段被包含,空字段不被包含
|
||||
if (title != null && !title.isEmpty()) {
|
||||
assertTrue(result.contains(title), "非空标题应被包含");
|
||||
}
|
||||
if (theme != null && !theme.isEmpty()) {
|
||||
assertTrue(result.contains(theme), "非空主题应被包含");
|
||||
}
|
||||
if (plotIntro != null && !plotIntro.isEmpty()) {
|
||||
assertTrue(result.contains(plotIntro), "非空序幕应被包含");
|
||||
}
|
||||
if (plotTurning != null && !plotTurning.isEmpty()) {
|
||||
assertTrue(result.contains(plotTurning), "非空转折应被包含");
|
||||
}
|
||||
if (plotClimax != null && !plotClimax.isEmpty()) {
|
||||
assertTrue(result.contains(plotClimax), "非空高潮应被包含");
|
||||
}
|
||||
if (plotEnding != null && !plotEnding.isEmpty()) {
|
||||
assertTrue(result.contains(plotEnding), "非空结局应被包含");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail("测试执行失败: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Property 3: 输入组装 - 验证输出格式正确")
|
||||
public void testInputAssemblyFormat() throws Exception {
|
||||
EpicScriptCreateRequest request = new EpicScriptCreateRequest();
|
||||
request.setTitle("我的逆袭人生");
|
||||
request.setTheme("成为行业领袖");
|
||||
request.setStyle("career");
|
||||
request.setLength("medium");
|
||||
request.setPlotIntro("从一个普通员工开始");
|
||||
request.setPlotTurning("遇到贵人指点");
|
||||
request.setPlotClimax("面临重大抉择");
|
||||
request.setPlotEnding("成功逆袭");
|
||||
|
||||
Method assembleMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
|
||||
"assembleScriptInput", EpicScriptCreateRequest.class);
|
||||
assembleMethod.setAccessible(true);
|
||||
|
||||
String result = (String) assembleMethod.invoke(epicScriptService, request);
|
||||
|
||||
// 验证格式标签
|
||||
assertTrue(result.contains("【剧本标题】我的逆袭人生"), "应包含正确格式的标题");
|
||||
assertTrue(result.contains("【主题渴望】成为行业领袖"), "应包含正确格式的主题");
|
||||
assertTrue(result.contains("【剧本风格】职场逆袭"), "应包含正确格式的风格");
|
||||
assertTrue(result.contains("【篇幅长度】标准篇"), "应包含正确格式的篇幅");
|
||||
assertTrue(result.contains("【序幕-低谷回响】从一个普通员工开始"), "应包含正确格式的序幕");
|
||||
assertTrue(result.contains("【转折-契机出现】遇到贵人指点"), "应包含正确格式的转折");
|
||||
assertTrue(result.contains("【高潮-命运抉择】面临重大抉择"), "应包含正确格式的高潮");
|
||||
assertTrue(result.contains("【结局-新的开始】成功逆袭"), "应包含正确格式的结局");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取随机风格
|
||||
*/
|
||||
private String getRandomStyle() {
|
||||
String[] styles = {"career", "love", "fantasy"};
|
||||
return styles[random.nextInt(styles.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取随机篇幅
|
||||
*/
|
||||
private String getRandomLength() {
|
||||
String[] lengths = {"medium", "long"};
|
||||
return lengths[random.nextInt(lengths.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从样本列表中随机获取一个元素
|
||||
* @param samples 样本列表
|
||||
* @return 随机选择的样本
|
||||
*/
|
||||
private String getRandomSample(List<String> samples) {
|
||||
return samples.get(random.nextInt(samples.size()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.service.impl.TtsTaskServiceImpl;
|
||||
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.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class TtsTaskServiceTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("cleanText strips markdown but keeps Chinese narration rhythm")
|
||||
void cleanTextStripsMarkdownButKeepsChineseNarrationRhythm() {
|
||||
String cleaned = TtsTaskServiceImpl.cleanText("# 第一章\n\n> **她 终于** 看见了自己\n\n- 转身离开");
|
||||
|
||||
assertEquals("第一章。\n\n她终于看见了自己。\n\n转身离开。", cleaned);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cleanText preserves sentence punctuation for natural pauses")
|
||||
void cleanTextPreservesSentencePunctuationForNaturalPauses() {
|
||||
String cleaned = TtsTaskServiceImpl.cleanText("他说: 这一次,我想自己选择!\n\n你听见了吗?");
|
||||
|
||||
assertEquals("他说:这一次,我想自己选择!\n\n你听见了吗?", cleaned);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cleanText returns empty string for null input")
|
||||
void cleanTextReturnsEmptyForNull() {
|
||||
assertEquals("", TtsTaskServiceImpl.cleanText(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("TtsEngineResult exposes synthesis result fields")
|
||||
void ttsEngineResultExposesFields() {
|
||||
TtsEngineClient.SynthesisOptions options = new TtsEngineClient.SynthesisOptions(0.92D, 0D, "story");
|
||||
assertEquals("rate=0.92;pitch=0.0;emotion=story", options.cacheKey());
|
||||
|
||||
TtsEngineClient.TtsEngineResult result =
|
||||
new TtsEngineClient.TtsEngineResult(true, "/tmp/a.mp3", 1200L, null);
|
||||
|
||||
assertTrue(result.isSuccess());
|
||||
assertEquals("/tmp/a.mp3", result.getAudioPath());
|
||||
assertEquals(1200L, result.getDurationMs());
|
||||
assertNull(result.getErrorMessage());
|
||||
assertFalse(new TtsEngineClient.TtsEngineResult(false, null, null, "boom").isSuccess());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.emotion.config.WechatMiniProgramProperties;
|
||||
import com.emotion.dto.wechat.WechatCodeSessionResponse;
|
||||
import com.emotion.exception.WechatApiException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WechatMiniProgramServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
@Mock
|
||||
private WechatMiniProgramProperties properties;
|
||||
|
||||
@InjectMocks
|
||||
private WechatMiniProgramServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(properties.getAppId()).thenReturn("test-appid");
|
||||
when(properties.getAppSecret()).thenReturn("test-secret");
|
||||
when(properties.getBaseUrl()).thenReturn("https://api.weixin.qq.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_applicationJsonSuccess_returnsOpenIdAndSessionKey() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||
"{\"errcode\":0,\"openid\":\"oXyz123\",\"session_key\":\"abc456\"}");
|
||||
|
||||
WechatCodeSessionResponse result = service.code2Session("validJsCode");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("oXyz123", result.getOpenid());
|
||||
assertEquals("abc456", result.getSessionKey());
|
||||
verify(restTemplate, times(1)).getForEntity(anyString(), eq(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_http4xx_throwsWechatApiException502() {
|
||||
mockResponse(HttpStatus.BAD_REQUEST, null, "");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("non-2xx") || ex.getMessage().contains("status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_http5xx_throwsWechatApiException502() {
|
||||
mockResponse(HttpStatus.INTERNAL_SERVER_ERROR, null, "");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_textPlainJsonSuccess_returnsOpenIdAndSessionKey() {
|
||||
mockResponse(HttpStatus.OK, MediaType.TEXT_PLAIN,
|
||||
"{\"session_key\":\"abc456\",\"openid\":\"oXyz123\"}");
|
||||
|
||||
WechatCodeSessionResponse result = service.code2Session("code");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("oXyz123", result.getOpenid());
|
||||
assertEquals("abc456", result.getSessionKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_textHtmlWafChallenge_throwsWechatApiException502() {
|
||||
mockResponse(HttpStatus.OK, MediaType.TEXT_HTML, "<html>WAF Challenge</html>");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_applicationJsonParseFailure_throwsWechatApiException500() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON, "not valid json{");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(500, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("JSON"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_businessErrorInvalidCode_throwsWechatApiException400() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||
"{\"errcode\":40029,\"errmsg\":\"invalid code\"}");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("invalidCode"));
|
||||
|
||||
assertEquals(400, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("40029") || ex.getMessage().contains("invalid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_businessErrorCodeUsed_throwsWechatApiException400() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||
"{\"errcode\":40163,\"errmsg\":\"code been used\"}");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("usedCode"));
|
||||
|
||||
assertEquals(400, ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_resourceAccessException_throwsWechatApiException502() {
|
||||
when(restTemplate.getForEntity(anyString(), eq(String.class)))
|
||||
.thenThrow(new ResourceAccessException("Connection refused"));
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("network"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_missingSessionKey_throwsWechatApiException502() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||
"{\"errcode\":0,\"openid\":\"oXyz123\"}");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("session_key"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_otherRestClientException_throwsWechatApiException502() {
|
||||
when(restTemplate.getForEntity(anyString(), eq(String.class)))
|
||||
.thenThrow(new HttpClientErrorException(HttpStatus.FORBIDDEN, "Forbidden", null, null, null));
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("client error") || ex.getMessage().contains("403"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_missingOpenId_throwsWechatApiException502() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||
"{\"errcode\":0,\"session_key\":\"abc456\"}");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("openid") || ex.getMessage().contains("session_key"));
|
||||
}
|
||||
|
||||
private void mockResponse(HttpStatus status, MediaType contentType, String body) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (contentType != null) {
|
||||
headers.setContentType(contentType);
|
||||
}
|
||||
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, status);
|
||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user