refactor: 重命名 backend-single 目录为 server

This commit is contained in:
2026-06-27 17:23:53 +08:00
parent 8daf51301a
commit 0d77d76858
464 changed files with 0 additions and 0 deletions
@@ -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);
}
}