feat: 完善工作流 Public API 调用能力
- 支持 JSON 文件 URL 简写与 Multipart 单请求文件上传 - 完善执行拓扑、枚举状态、节点名称、恢复校验和安全错误响应 - 增加临时上传生命周期清理并升级 MinIO SDK - 重构工作流接口调用说明弹窗的扁平响应式布局
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
package tech.easyflow.publicapi.controller;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.NodeStatus;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.WorkflowApiPermissionService;
|
||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.utils.WorkFlowUtil;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
|
||||
import tech.easyflow.publicapi.service.PublicWorkflowStatusSanitizer;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link PublicWorkflowController} 状态查询与恢复行为测试。
|
||||
*/
|
||||
public class PublicWorkflowControllerBehaviorTest {
|
||||
|
||||
private static final String EXECUTE_ID = "execute-1";
|
||||
|
||||
private PublicWorkflowController controller;
|
||||
private ChainExecutor chainExecutor;
|
||||
private TinyFlowService tinyFlowService;
|
||||
private HttpServletRequest request;
|
||||
|
||||
/**
|
||||
* 创建通过 API Key 执行归属校验的控制器测试夹具。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
controller = new PublicWorkflowController();
|
||||
chainExecutor = Mockito.mock(ChainExecutor.class);
|
||||
tinyFlowService = Mockito.mock(TinyFlowService.class);
|
||||
WorkflowApiPermissionService permissionService =
|
||||
Mockito.mock(WorkflowApiPermissionService.class);
|
||||
WorkflowExecResultService execResultService =
|
||||
Mockito.mock(WorkflowExecResultService.class);
|
||||
WorkflowService workflowService =
|
||||
Mockito.mock(WorkflowService.class);
|
||||
request = Mockito.mock(HttpServletRequest.class);
|
||||
|
||||
SysApiKey apiKey = new SysApiKey();
|
||||
apiKey.setId(BigInteger.TEN);
|
||||
when(request.getHeader("ApiKey")).thenReturn("api-key");
|
||||
when(request.getRequestURI()).thenReturn(
|
||||
"/public-api/workflow/getChainStatus");
|
||||
when(permissionService.assertWorkflowApi(any(), anyString()))
|
||||
.thenReturn(apiKey);
|
||||
when(execResultService.getByExecKey(EXECUTE_ID))
|
||||
.thenReturn(executionRecord());
|
||||
when(workflowService.getById(BigInteger.ONE))
|
||||
.thenReturn(publishedWorkflow());
|
||||
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"chainExecutor",
|
||||
chainExecutor);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"tinyFlowService",
|
||||
tinyFlowService);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"workflowApiPermissionService",
|
||||
permissionService);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"workflowExecResultService",
|
||||
execResultService);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"workflowService",
|
||||
workflowService);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"publicWorkflowStatusSanitizer",
|
||||
new PublicWorkflowStatusSanitizer());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非暂停状态恢复返回稳定冲突错误,且不会调用旧的无条件恢复入口。
|
||||
*/
|
||||
@Test
|
||||
public void resumeShouldRejectNonSuspendedExecution() {
|
||||
when(request.getRequestURI()).thenReturn(
|
||||
"/public-api/workflow/resume");
|
||||
when(chainExecutor.resumeAsyncIfSuspended(
|
||||
EXECUTE_ID,
|
||||
Map.of("approved", true)))
|
||||
.thenReturn(false);
|
||||
|
||||
try {
|
||||
controller.resume(
|
||||
EXECUTE_ID,
|
||||
Map.of("approved", true),
|
||||
request);
|
||||
Assert.fail("非暂停状态必须拒绝恢复");
|
||||
} catch (BusinessException exception) {
|
||||
Assert.assertEquals(409, exception.getHttpStatus());
|
||||
Assert.assertEquals(40901, exception.getErrorCode());
|
||||
}
|
||||
|
||||
verify(chainExecutor).resumeAsyncIfSuspended(
|
||||
EXECUTE_ID,
|
||||
Map.of("approved", true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证状态查询使用定义快照补齐节点名称并返回可读枚举。
|
||||
*/
|
||||
@Test
|
||||
public void statusShouldEnrichNodeNameAndReadableEnum() {
|
||||
ChainInfo chainInfo = new ChainInfo();
|
||||
chainInfo.setExecuteId(EXECUTE_ID);
|
||||
chainInfo.setStatus(ChainStatus.RUNNING.getValue());
|
||||
NodeInfo node = new NodeInfo();
|
||||
node.setNodeId("node-1");
|
||||
node.setNodeName("文档解析");
|
||||
node.setStatus(NodeStatus.RUNNING.getValue());
|
||||
chainInfo.setNodes(Map.of("node-1", node));
|
||||
when(tinyFlowService.getChainStatus(
|
||||
EXECUTE_ID,
|
||||
List.of(node)))
|
||||
.thenReturn(chainInfo);
|
||||
|
||||
Result<PublicWorkflowChainStatus> result =
|
||||
controller.getChainStatus(
|
||||
EXECUTE_ID,
|
||||
List.of(node),
|
||||
request);
|
||||
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.RUNNING,
|
||||
result.getData().status());
|
||||
Assert.assertFalse(result.getData().terminal());
|
||||
Assert.assertEquals(
|
||||
"文档解析",
|
||||
result.getData().nodes().get("node-1").nodeName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建属于当前 API Key 的执行记录。
|
||||
*
|
||||
* @return 执行记录
|
||||
*/
|
||||
private WorkflowExecResult executionRecord() {
|
||||
WorkflowExecResult result = new WorkflowExecResult();
|
||||
result.setExecKey(EXECUTE_ID);
|
||||
result.setWorkflowId(BigInteger.ONE);
|
||||
result.setCreatedKey(WorkFlowUtil.API_KEY);
|
||||
result.setCreatedBy(BigInteger.TEN.toString());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可恢复校验使用的已发布工作流。
|
||||
*
|
||||
* @return 已发布工作流
|
||||
*/
|
||||
private Workflow publishedWorkflow() {
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.ONE);
|
||||
workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||
workflow.setPublishedSnapshotJson(Map.of("nodes", List.of()));
|
||||
return workflow;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
package tech.easyflow.publicapi.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowInfo;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowRunResult;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowTopology;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link PublicWorkflowController} HTTP 响应契约测试。
|
||||
@@ -23,4 +37,139 @@ public class PublicWorkflowControllerContractTest {
|
||||
requestMapping.produces()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 multipart 调用复用 runAsync 路径并明确声明媒体类型。
|
||||
*/
|
||||
@Test
|
||||
public void shouldExposeMultipartRunAsyncOnCompatiblePath() {
|
||||
Method multipartMethod = Arrays.stream(
|
||||
PublicWorkflowController.class.getDeclaredMethods())
|
||||
.filter(method -> "runAsyncMultipart".equals(
|
||||
method.getName()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
PostMapping postMapping =
|
||||
multipartMethod.getAnnotation(PostMapping.class);
|
||||
|
||||
Assert.assertNotNull(postMapping);
|
||||
Assert.assertArrayEquals(
|
||||
new String[]{"/runAsync"},
|
||||
postMapping.value());
|
||||
Assert.assertArrayEquals(
|
||||
new String[]{MediaType.MULTIPART_FORM_DATA_VALUE},
|
||||
postMapping.consumes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 JSON 调用显式声明媒体类型,避免与 Multipart 路由混淆。
|
||||
*/
|
||||
@Test
|
||||
public void shouldExposeJsonRunAsyncWithExplicitMediaType() {
|
||||
Method jsonMethod = Arrays.stream(
|
||||
PublicWorkflowController.class.getDeclaredMethods())
|
||||
.filter(method -> "runAsync".equals(method.getName()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
PostMapping postMapping =
|
||||
jsonMethod.getAnnotation(PostMapping.class);
|
||||
|
||||
Assert.assertNotNull(postMapping);
|
||||
Assert.assertArrayEquals(
|
||||
new String[]{MediaType.APPLICATION_JSON_VALUE},
|
||||
postMapping.consumes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证富响应继续把执行 ID 保存在 data 字符串中。
|
||||
*
|
||||
* @throws Exception JSON 序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void runResultShouldKeepLegacyExecuteIdData()
|
||||
throws Exception {
|
||||
PublicWorkflowTopology topology =
|
||||
new PublicWorkflowTopology(
|
||||
"1",
|
||||
null,
|
||||
"测试工作流",
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
false,
|
||||
List.of());
|
||||
|
||||
PublicWorkflowRunResult result =
|
||||
PublicWorkflowRunResult.success(
|
||||
"execution-1",
|
||||
topology);
|
||||
|
||||
Assert.assertEquals("execution-1", result.getData());
|
||||
Assert.assertSame(topology, result.getWorkflow());
|
||||
Assert.assertEquals(0, result.getErrorCode());
|
||||
String json = new ObjectMapper().writeValueAsString(result);
|
||||
Assert.assertTrue(json.contains("\"data\":\"execution-1\""));
|
||||
Assert.assertTrue(json.contains("\"workflow\""));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证详情接口 DTO 只公开调用所需的基础字段。
|
||||
*
|
||||
* @throws Exception JSON 序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void workflowInfoShouldExcludeInternalFields()
|
||||
throws Exception {
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(new BigInteger("9007199254740993"));
|
||||
workflow.setAlias("document-parser");
|
||||
workflow.setTitle("文档解析");
|
||||
workflow.setContent("internal-content");
|
||||
workflow.setTenantId(BigInteger.TEN);
|
||||
workflow.setDeptId(BigInteger.ONE);
|
||||
workflow.setPublishedSnapshotJson(
|
||||
Map.of("secret", "snapshot"));
|
||||
|
||||
String json = new ObjectMapper().writeValueAsString(
|
||||
PublicWorkflowInfo.from(workflow));
|
||||
|
||||
Assert.assertTrue(json.contains(
|
||||
"\"id\":\"9007199254740993\""));
|
||||
Assert.assertTrue(json.contains(
|
||||
"\"alias\":\"document-parser\""));
|
||||
Assert.assertFalse(json.contains("content"));
|
||||
Assert.assertFalse(json.contains("tenantId"));
|
||||
Assert.assertFalse(json.contains("deptId"));
|
||||
Assert.assertFalse(json.contains("publishedSnapshotJson"));
|
||||
Assert.assertFalse(json.contains("secret"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证链路状态以小写可读枚举返回,不再公开内部数值。
|
||||
*
|
||||
* @throws Exception JSON 序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void chainStatusShouldSerializeReadableEnum()
|
||||
throws Exception {
|
||||
PublicWorkflowChainStatus status =
|
||||
new PublicWorkflowChainStatus(
|
||||
"execute-1",
|
||||
PublicWorkflowExecutionStatus.DONE,
|
||||
true,
|
||||
null,
|
||||
Map.of("output", "ok"),
|
||||
Map.of(),
|
||||
null);
|
||||
|
||||
String json = new ObjectMapper().writeValueAsString(status);
|
||||
|
||||
Assert.assertTrue(json.contains("\"status\":\"done\""));
|
||||
Assert.assertTrue(json.contains("\"terminal\":true"));
|
||||
Assert.assertFalse(json.contains("\"status\":20"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
package tech.easyflow.publicapi.controller;
|
||||
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload;
|
||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.WorkflowApiPermissionService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.common.web.error.GlobalErrorResolver;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowTopology;
|
||||
import tech.easyflow.publicapi.interceptor.PublicApiRequestContextFilter;
|
||||
import tech.easyflow.publicapi.service.PublicWorkflowTopologyService;
|
||||
import tech.easyflow.publicapi.service.WorkflowApiMultipartParameterMapper;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* {@link PublicWorkflowController} 真实 MVC 路由与错误契约测试。
|
||||
*/
|
||||
public class PublicWorkflowControllerRoutingTest {
|
||||
|
||||
private static final String RUN_PATH =
|
||||
"/public-api/workflow/runAsync";
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private WorkflowService workflowService;
|
||||
private WorkflowRunningParameterResolver parameterResolver;
|
||||
private WorkflowApiMultipartParameterMapper multipartMapper;
|
||||
private WorkflowApiUploadLifecycleService uploadLifecycleService;
|
||||
|
||||
/**
|
||||
* 创建控制器和全部最小依赖。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
PublicWorkflowController controller =
|
||||
new PublicWorkflowController();
|
||||
workflowService = Mockito.mock(WorkflowService.class);
|
||||
WorkflowCheckService workflowCheckService =
|
||||
Mockito.mock(WorkflowCheckService.class);
|
||||
WorkflowApiPermissionService permissionService =
|
||||
Mockito.mock(WorkflowApiPermissionService.class);
|
||||
PublicWorkflowTopologyService topologyService =
|
||||
Mockito.mock(PublicWorkflowTopologyService.class);
|
||||
ChainExecutor chainExecutor =
|
||||
Mockito.mock(ChainExecutor.class);
|
||||
parameterResolver = Mockito.mock(
|
||||
WorkflowRunningParameterResolver.class);
|
||||
multipartMapper = Mockito.mock(
|
||||
WorkflowApiMultipartParameterMapper.class);
|
||||
uploadLifecycleService = Mockito.mock(
|
||||
WorkflowApiUploadLifecycleService.class);
|
||||
|
||||
Workflow workflow = publishedWorkflow();
|
||||
SysApiKey apiKey = new SysApiKey();
|
||||
apiKey.setId(BigInteger.TEN);
|
||||
when(permissionService.assertWorkflowApi(any(), anyString()))
|
||||
.thenReturn(apiKey);
|
||||
when(workflowService.getPublishedById(BigInteger.ONE))
|
||||
.thenReturn(workflow);
|
||||
when(workflowService.getPublishedDetail("document-parser"))
|
||||
.thenReturn(workflow);
|
||||
when(topologyService.resolve(workflow))
|
||||
.thenReturn(topology());
|
||||
when(parameterResolver.normalizeRuntimeVariables(
|
||||
eq("{}"),
|
||||
anyMap()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(1));
|
||||
when(chainExecutor.executeAsync(
|
||||
anyString(),
|
||||
anyMap(),
|
||||
Mockito.<Consumer<String>>any()))
|
||||
.thenAnswer(invocation -> {
|
||||
Consumer<String> beforeStart =
|
||||
invocation.getArgument(2);
|
||||
if (beforeStart != null) {
|
||||
beforeStart.accept("execute-1");
|
||||
}
|
||||
return "execute-1";
|
||||
});
|
||||
when(multipartMapper.map(any()))
|
||||
.thenReturn(Map.of("file", List.of()));
|
||||
when(uploadLifecycleService.prepare(
|
||||
eq("{}"),
|
||||
anyMap(),
|
||||
anyMap()))
|
||||
.thenReturn(new WorkflowApiPreparedUpload(
|
||||
"upload-1",
|
||||
Map.of()));
|
||||
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"workflowService",
|
||||
workflowService);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"workflowCheckService",
|
||||
workflowCheckService);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"workflowApiPermissionService",
|
||||
permissionService);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"publicWorkflowTopologyService",
|
||||
topologyService);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"chainExecutor",
|
||||
chainExecutor);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"workflowRunningParameterResolver",
|
||||
parameterResolver);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"workflowApiMultipartParameterMapper",
|
||||
multipartMapper);
|
||||
ReflectionTestUtils.setField(
|
||||
controller,
|
||||
"workflowApiUploadLifecycleService",
|
||||
uploadLifecycleService);
|
||||
|
||||
LocalValidatorFactoryBean validator =
|
||||
new LocalValidatorFactoryBean();
|
||||
validator.afterPropertiesSet();
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setValidator(validator)
|
||||
.setHandlerExceptionResolvers(
|
||||
new GlobalErrorResolver())
|
||||
.addFilters(new PublicApiRequestContextFilter())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 application/json 只进入 JSON 调用链。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void jsonRequestShouldUseJsonHandler() throws Exception {
|
||||
mockMvc.perform(post(RUN_PATH)
|
||||
.header("ApiKey", "key")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"id\":1,\"variables\":{}}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data").value("execute-1"));
|
||||
|
||||
verify(parameterResolver).normalizeRuntimeVariables(
|
||||
eq("{}"),
|
||||
anyMap());
|
||||
verify(uploadLifecycleService, never()).prepare(
|
||||
anyString(),
|
||||
anyMap(),
|
||||
anyMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 multipart/form-data 只进入文件直传调用链。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void multipartRequestShouldUseMultipartHandler()
|
||||
throws Exception {
|
||||
MockMultipartFile metadata = new MockMultipartFile(
|
||||
"metadata",
|
||||
"metadata.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"{\"id\":1,\"variables\":{}}"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"files.file",
|
||||
"report.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"pdf".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
mockMvc.perform(multipart(RUN_PATH)
|
||||
.file(metadata)
|
||||
.file(file)
|
||||
.header("ApiKey", "key"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data").value("execute-1"));
|
||||
|
||||
verify(multipartMapper).map(any());
|
||||
verify(uploadLifecycleService).prepare(
|
||||
eq("{}"),
|
||||
anyMap(),
|
||||
anyMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证错误顶层媒体类型返回 41501。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void unsupportedContentTypeShouldExplainExpectedTypes()
|
||||
throws Exception {
|
||||
mockMvc.perform(post(RUN_PATH)
|
||||
.header("ApiKey", "key")
|
||||
.header("X-Request-Id", "request-415")
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnsupportedMediaType())
|
||||
.andExpect(jsonPath("$.errorCode").value(41501))
|
||||
.andExpect(jsonPath("$.data.field")
|
||||
.value("Content-Type"))
|
||||
.andExpect(jsonPath("$.data.requestId")
|
||||
.value("request-415"))
|
||||
.andExpect(header().string(
|
||||
"X-Request-Id",
|
||||
"request-415"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缺少顶层媒体类型时返回 41501。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void missingContentTypeShouldExplainExpectedTypes()
|
||||
throws Exception {
|
||||
mockMvc.perform(post(RUN_PATH)
|
||||
.header("ApiKey", "key")
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnsupportedMediaType())
|
||||
.andExpect(jsonPath("$.errorCode").value(41501))
|
||||
.andExpect(jsonPath("$.data.expected.length()")
|
||||
.value(2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证无效 JSON 返回 40011,不再误报 ID 为空。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void malformedJsonShouldReturnBodyMismatchError()
|
||||
throws Exception {
|
||||
mockMvc.perform(post(RUN_PATH)
|
||||
.header("ApiKey", "key")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("not-json"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.errorCode").value(40011));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Multipart 缺少 metadata 时返回 40013。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void missingMetadataShouldReturnSpecificError()
|
||||
throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"files.file",
|
||||
"report.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"pdf".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
mockMvc.perform(multipart(RUN_PATH)
|
||||
.file(file)
|
||||
.header("ApiKey", "key"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.errorCode").value(40013));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 metadata Part 媒体类型错误时返回 41502。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void metadataWithWrongContentTypeShouldReturnSpecificError()
|
||||
throws Exception {
|
||||
MockMultipartFile metadata = new MockMultipartFile(
|
||||
"metadata",
|
||||
"metadata.txt",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
"{\"id\":1,\"variables\":{}}"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
mockMvc.perform(multipart(RUN_PATH)
|
||||
.file(metadata)
|
||||
.header("ApiKey", "key"))
|
||||
.andExpect(status().isUnsupportedMediaType())
|
||||
.andExpect(jsonPath("$.errorCode").value(41502))
|
||||
.andExpect(jsonPath("$.data.expected[0]")
|
||||
.value(MediaType.APPLICATION_JSON_VALUE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Multipart metadata 缺少工作流 ID 时返回 40015。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void metadataWithoutIdShouldReturnSpecificError()
|
||||
throws Exception {
|
||||
MockMultipartFile metadata = new MockMultipartFile(
|
||||
"metadata",
|
||||
"metadata.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"{\"variables\":{}}"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
mockMvc.perform(multipart(RUN_PATH)
|
||||
.file(metadata)
|
||||
.header("ApiKey", "key"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.errorCode").value(40015))
|
||||
.andExpect(jsonPath("$.data.field").value("id"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 metadata Part 内容不是合法 JSON 时返回 40014。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void malformedMetadataShouldReturnSpecificError()
|
||||
throws Exception {
|
||||
MockMultipartFile metadata = new MockMultipartFile(
|
||||
"metadata",
|
||||
"metadata.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"not-json".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
mockMvc.perform(multipart(RUN_PATH)
|
||||
.file(metadata)
|
||||
.header("ApiKey", "key"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.errorCode").value(40014));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证详情接口只返回安全基础字段。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void workflowDetailShouldReturnSafeInfo()
|
||||
throws Exception {
|
||||
mockMvc.perform(get(
|
||||
"/public-api/workflow/getByIdOrAlias")
|
||||
.header("ApiKey", "key")
|
||||
.param("key", "document-parser"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value("1"))
|
||||
.andExpect(jsonPath("$.data.content").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.tenantId").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.deptId").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.publishedSnapshotJson")
|
||||
.doesNotExist());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证运行参数解析失败时返回真实 HTTP 500 和稳定错误码。
|
||||
*
|
||||
* @throws Exception 请求执行失败
|
||||
*/
|
||||
@Test
|
||||
public void invalidRunningParametersShouldReturnServerError()
|
||||
throws Exception {
|
||||
when(parameterResolver.buildRunningParametersView(any()))
|
||||
.thenReturn(null);
|
||||
|
||||
mockMvc.perform(get(
|
||||
"/public-api/workflow/getRunningParameters")
|
||||
.header("ApiKey", "key")
|
||||
.param("id", "1"))
|
||||
.andExpect(status().isInternalServerError())
|
||||
.andExpect(jsonPath("$.errorCode").value(50001));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建已发布工作流桩。
|
||||
*
|
||||
* @return 已发布工作流
|
||||
*/
|
||||
private Workflow publishedWorkflow() {
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.ONE);
|
||||
workflow.setAlias("document-parser");
|
||||
workflow.setTitle("文档解析");
|
||||
workflow.setContent("{}");
|
||||
workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||
workflow.setPublishedSnapshotJson(Map.of("nodes", List.of()));
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建最小公开拓扑。
|
||||
*
|
||||
* @return 公开拓扑
|
||||
*/
|
||||
private PublicWorkflowTopology topology() {
|
||||
return new PublicWorkflowTopology(
|
||||
"1",
|
||||
null,
|
||||
"测试工作流",
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
false,
|
||||
List.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package tech.easyflow.publicapi.error;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.multipart.MultipartException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import tech.easyflow.common.web.error.GlobalErrorResolver;
|
||||
import tech.easyflow.common.web.error.RequestErrorProfile;
|
||||
import tech.easyflow.common.web.error.RequestIdContext;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link WorkflowRunAsyncErrorProfile} 错误契约测试。
|
||||
*/
|
||||
public class WorkflowRunAsyncErrorProfileTest {
|
||||
|
||||
private final GlobalErrorResolver resolver =
|
||||
new GlobalErrorResolver();
|
||||
|
||||
/**
|
||||
* 验证缺少 Multipart boundary 时返回可执行修复信息。
|
||||
*/
|
||||
@Test
|
||||
public void shouldExplainMissingMultipartBoundary() {
|
||||
Resolution resolution = resolve(
|
||||
MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
new MultipartException("failed to parse multipart"));
|
||||
|
||||
Assert.assertEquals(400, resolution.response.getStatus());
|
||||
Assert.assertEquals(
|
||||
40012,
|
||||
resolution.modelAndView.getModel().get("errorCode"));
|
||||
Assert.assertTrue(String.valueOf(
|
||||
resolution.modelAndView.getModel().get("message"))
|
||||
.contains("boundary"));
|
||||
Assert.assertEquals(
|
||||
"request-1",
|
||||
detail(resolution).getString("requestId"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缺少 metadata Part 时不会再返回 ID 为空。
|
||||
*/
|
||||
@Test
|
||||
public void shouldExplainMissingMetadataPart() {
|
||||
Resolution resolution = resolve(
|
||||
"multipart/form-data; boundary=test",
|
||||
new MissingServletRequestPartException("metadata"));
|
||||
|
||||
Assert.assertEquals(400, resolution.response.getStatus());
|
||||
Assert.assertEquals(
|
||||
40013,
|
||||
resolution.modelAndView.getModel().get("errorCode"));
|
||||
Assert.assertTrue(String.valueOf(
|
||||
resolution.modelAndView.getModel().get("message"))
|
||||
.contains("metadata Part"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证不支持的顶层媒体类型会同时提示两种合法模式。
|
||||
*/
|
||||
@Test
|
||||
public void shouldExplainSupportedTopLevelMediaTypes() {
|
||||
Resolution resolution = resolve(
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
new HttpMediaTypeNotSupportedException(
|
||||
MediaType.TEXT_PLAIN,
|
||||
List.of(
|
||||
MediaType.APPLICATION_JSON,
|
||||
MediaType.MULTIPART_FORM_DATA)));
|
||||
|
||||
Assert.assertEquals(415, resolution.response.getStatus());
|
||||
Assert.assertEquals(
|
||||
41501,
|
||||
resolution.modelAndView.getModel().get("errorCode"));
|
||||
Assert.assertTrue(String.valueOf(
|
||||
resolution.modelAndView.getModel().get("message"))
|
||||
.contains("application/json"));
|
||||
Assert.assertEquals(
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
detail(resolution).getString("actual"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证旧发布门禁异常统一转换为不可枚举的 40401。
|
||||
*/
|
||||
@Test
|
||||
public void shouldNormalizeLegacyUnpublishedWorkflowError() {
|
||||
Resolution resolution = resolve(
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
new BusinessException("工作流尚未发布"));
|
||||
|
||||
Assert.assertEquals(404, resolution.response.getStatus());
|
||||
Assert.assertEquals(
|
||||
40401,
|
||||
resolution.modelAndView.getModel().get("errorCode"));
|
||||
Assert.assertEquals(
|
||||
"工作流不存在或当前不可公开调用",
|
||||
resolution.modelAndView.getModel().get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证服务端业务异常不会把内部依赖详情返回调用方。
|
||||
*/
|
||||
@Test
|
||||
public void shouldHideInternalBusinessErrorDetails() {
|
||||
Resolution resolution = resolve(
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
new BusinessException(
|
||||
500,
|
||||
50001,
|
||||
"minio endpoint=http://internal:9000 signature=secret"));
|
||||
|
||||
Assert.assertEquals(500, resolution.response.getStatus());
|
||||
Assert.assertEquals(
|
||||
"服务暂时不可用,请稍后重试",
|
||||
resolution.modelAndView.getModel().get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证状态查询接口沿用工作流 Public API 稳定鉴权错误码。
|
||||
*/
|
||||
@Test
|
||||
public void shouldNormalizeStatusApiAuthenticationError() {
|
||||
Resolution resolution = resolve(
|
||||
"/public-api/workflow/getChainStatus",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
new BusinessException(
|
||||
401,
|
||||
401,
|
||||
"apiKey 已过期"));
|
||||
|
||||
Assert.assertEquals(401, resolution.response.getStatus());
|
||||
Assert.assertEquals(
|
||||
40103,
|
||||
resolution.modelAndView.getModel().get("errorCode"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证工作流执行状态缺失不会被误判为工作流资源缺失。
|
||||
*/
|
||||
@Test
|
||||
public void shouldNormalizeMissingExecutionStateError() {
|
||||
Resolution resolution = resolve(
|
||||
"/public-api/workflow/getChainStatus",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
new BusinessException(
|
||||
404,
|
||||
404,
|
||||
"工作流执行状态不存在或已过期"));
|
||||
|
||||
Assert.assertEquals(404, resolution.response.getStatus());
|
||||
Assert.assertEquals(
|
||||
40402,
|
||||
resolution.modelAndView.getModel().get("errorCode"));
|
||||
Assert.assertEquals(
|
||||
"执行记录不存在、已过期或不可访问",
|
||||
resolution.modelAndView.getModel().get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 API Key 无效和两层权限错误保持可区分。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepAuthenticationAndAuthorizationCodesDistinct() {
|
||||
assertBusinessCode(
|
||||
new BusinessException(
|
||||
401,
|
||||
401,
|
||||
"apiKey 不存在或已禁用"),
|
||||
401,
|
||||
40102);
|
||||
assertBusinessCode(
|
||||
new BusinessException(
|
||||
403,
|
||||
403,
|
||||
"该apiKey无权限访问该接口"),
|
||||
403,
|
||||
40301);
|
||||
assertBusinessCode(
|
||||
new BusinessException(
|
||||
403,
|
||||
403,
|
||||
"该apiKey无权限调用工作流 API"),
|
||||
403,
|
||||
40302);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Multipart 请求超限返回 41301。
|
||||
*/
|
||||
@Test
|
||||
public void shouldTranslateMultipartUploadLimit() {
|
||||
Resolution resolution = resolve(
|
||||
"multipart/form-data; boundary=test",
|
||||
new MaxUploadSizeExceededException(1024L));
|
||||
|
||||
Assert.assertEquals(413, resolution.response.getStatus());
|
||||
Assert.assertEquals(
|
||||
41301,
|
||||
resolution.modelAndView.getModel().get("errorCode"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证状态查询中的未知异常也统一返回安全 50001。
|
||||
*/
|
||||
@Test
|
||||
public void shouldHideUnknownStatusApiFailure() {
|
||||
Resolution resolution = resolve(
|
||||
"/public-api/workflow/getChainStatus",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
new IllegalStateException(
|
||||
"redis endpoint=internal password=secret"));
|
||||
|
||||
Assert.assertEquals(500, resolution.response.getStatus());
|
||||
Assert.assertEquals(
|
||||
50001,
|
||||
resolution.modelAndView.getModel().get("errorCode"));
|
||||
Assert.assertEquals(
|
||||
"服务暂时不可用,请稍后重试",
|
||||
resolution.modelAndView.getModel().get("message"));
|
||||
Assert.assertEquals(
|
||||
"request-1",
|
||||
detail(resolution).getString("requestId"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证单个旧业务异常的稳定状态与错误码。
|
||||
*
|
||||
* @param exception 旧业务异常
|
||||
* @param expectedStatus 期望 HTTP 状态
|
||||
* @param expectedCode 期望业务码
|
||||
*/
|
||||
private void assertBusinessCode(
|
||||
BusinessException exception,
|
||||
int expectedStatus,
|
||||
int expectedCode) {
|
||||
Resolution resolution = resolve(
|
||||
"/public-api/workflow/getChainStatus",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
exception);
|
||||
Assert.assertEquals(
|
||||
expectedStatus,
|
||||
resolution.response.getStatus());
|
||||
Assert.assertEquals(
|
||||
expectedCode,
|
||||
resolution.modelAndView.getModel().get("errorCode"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行异常解析并返回响应模型。
|
||||
*
|
||||
* @param contentType 顶层媒体类型
|
||||
* @param exception 原始异常
|
||||
* @return 解析结果
|
||||
*/
|
||||
private Resolution resolve(
|
||||
String contentType,
|
||||
Exception exception) {
|
||||
return resolve(
|
||||
"/public-api/workflow/runAsync",
|
||||
contentType,
|
||||
exception);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行指定工作流接口的异常解析。
|
||||
*
|
||||
* @param uri 请求地址
|
||||
* @param contentType 顶层媒体类型
|
||||
* @param exception 原始异常
|
||||
* @return 解析结果
|
||||
*/
|
||||
private Resolution resolve(
|
||||
String uri,
|
||||
String contentType,
|
||||
Exception exception) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(
|
||||
"POST",
|
||||
uri);
|
||||
request.setContentType(contentType);
|
||||
request.setAttribute(
|
||||
RequestIdContext.ATTRIBUTE_NAME,
|
||||
"request-1");
|
||||
request.setAttribute(
|
||||
RequestErrorProfile.ATTRIBUTE_NAME,
|
||||
WorkflowRunAsyncErrorProfile.INSTANCE);
|
||||
MockHttpServletResponse response =
|
||||
new MockHttpServletResponse();
|
||||
ModelAndView modelAndView = resolver.resolveException(
|
||||
request,
|
||||
response,
|
||||
this,
|
||||
exception);
|
||||
return new Resolution(response, modelAndView);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误详情 JSON。
|
||||
*
|
||||
* @param resolution 解析结果
|
||||
* @return 错误详情
|
||||
*/
|
||||
private JSONObject detail(Resolution resolution) {
|
||||
Object data = resolution.modelAndView.getModel().get("data");
|
||||
return data instanceof JSONObject object
|
||||
? object
|
||||
: JSONObject.parseObject(String.valueOf(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误解析结果。
|
||||
*
|
||||
* @param response HTTP 响应
|
||||
* @param modelAndView JSON 视图模型
|
||||
*/
|
||||
private record Resolution(
|
||||
MockHttpServletResponse response,
|
||||
ModelAndView modelAndView) {
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ public class PublicApiInterceptorTest {
|
||||
if ("getHeader".equals(method.getName())) {
|
||||
return null;
|
||||
}
|
||||
if ("getAttribute".equals(method.getName())) {
|
||||
return "request-1";
|
||||
}
|
||||
throw new AssertionError(
|
||||
"测试路径不应调用 HttpServletRequest."
|
||||
+ method.getName());
|
||||
@@ -66,8 +69,9 @@ public class PublicApiInterceptorTest {
|
||||
Assert.assertEquals(
|
||||
HttpServletResponse.SC_UNAUTHORIZED,
|
||||
status.get());
|
||||
Assert.assertTrue(body.toString().contains("\"errorCode\":401"));
|
||||
Assert.assertTrue(body.toString().contains("密钥不正确"));
|
||||
Assert.assertTrue(body.toString().contains("\"errorCode\":40101"));
|
||||
Assert.assertTrue(body.toString().contains("缺少 ApiKey 请求头"));
|
||||
Assert.assertTrue(body.toString().contains("request-1"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package tech.easyflow.publicapi.interceptor;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import tech.easyflow.common.web.error.RequestErrorProfile;
|
||||
import tech.easyflow.common.web.error.RequestIdContext;
|
||||
|
||||
/**
|
||||
* {@link PublicApiRequestContextFilter} 请求上下文测试。
|
||||
*/
|
||||
public class PublicApiRequestContextFilterTest {
|
||||
|
||||
/**
|
||||
* 验证合法客户端请求 ID 会进入响应头,且请求结束后清理 MDC。
|
||||
*
|
||||
* @throws Exception 过滤器执行失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepValidRequestIdAndClearMdc() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(
|
||||
"POST",
|
||||
"/public-api/workflow/runAsync");
|
||||
request.addHeader(RequestIdContext.HEADER_NAME, "client-123");
|
||||
MockHttpServletResponse response =
|
||||
new MockHttpServletResponse();
|
||||
|
||||
new PublicApiRequestContextFilter().doFilter(
|
||||
request,
|
||||
response,
|
||||
new MockFilterChain());
|
||||
|
||||
Assert.assertEquals(
|
||||
"client-123",
|
||||
response.getHeader(RequestIdContext.HEADER_NAME));
|
||||
Assert.assertEquals(
|
||||
"client-123",
|
||||
request.getAttribute(RequestIdContext.ATTRIBUTE_NAME));
|
||||
Assert.assertNotNull(request.getAttribute(
|
||||
RequestErrorProfile.ATTRIBUTE_NAME));
|
||||
Assert.assertNull(MDC.get(RequestIdContext.MDC_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法请求 ID 会被替换,避免响应头注入。
|
||||
*
|
||||
* @throws Exception 过滤器执行失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldReplaceInvalidRequestId() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(
|
||||
"POST",
|
||||
"/public-api/workflow/runAsync");
|
||||
request.addHeader(
|
||||
RequestIdContext.HEADER_NAME,
|
||||
"bad\r\nX-Injected: yes");
|
||||
MockHttpServletResponse response =
|
||||
new MockHttpServletResponse();
|
||||
|
||||
new PublicApiRequestContextFilter().doFilter(
|
||||
request,
|
||||
response,
|
||||
new MockFilterChain());
|
||||
|
||||
String generated = response.getHeader(
|
||||
RequestIdContext.HEADER_NAME);
|
||||
Assert.assertNotNull(generated);
|
||||
Assert.assertFalse(generated.contains("\r"));
|
||||
Assert.assertFalse(generated.contains("\n"));
|
||||
Assert.assertNotEquals("bad\r\nX-Injected: yes", generated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证后续工作流接口也会注册稳定业务错误翻译规则。
|
||||
*
|
||||
* @throws Exception 过滤器执行失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldAttachErrorProfileToWorkflowStatusApi()
|
||||
throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(
|
||||
"POST",
|
||||
"/public-api/workflow/getChainStatus");
|
||||
MockHttpServletResponse response =
|
||||
new MockHttpServletResponse();
|
||||
|
||||
new PublicApiRequestContextFilter().doFilter(
|
||||
request,
|
||||
response,
|
||||
new MockFilterChain());
|
||||
|
||||
Assert.assertNotNull(request.getAttribute(
|
||||
RequestErrorProfile.ATTRIBUTE_NAME));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.NodeStatus;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link PublicWorkflowStatusSanitizer} 公共错误脱敏测试。
|
||||
*/
|
||||
public class PublicWorkflowStatusSanitizerTest {
|
||||
|
||||
private final PublicWorkflowStatusSanitizer sanitizer =
|
||||
new PublicWorkflowStatusSanitizer();
|
||||
|
||||
/**
|
||||
* 验证内部异常类名和依赖详情不会进入公共状态。
|
||||
*/
|
||||
@Test
|
||||
public void shouldHideInternalExecutionErrorDetails() {
|
||||
ChainInfo source = new ChainInfo();
|
||||
source.setExecuteId("execute-1");
|
||||
source.setStatus(ChainStatus.FAILED.getValue());
|
||||
source.setMessage(
|
||||
"io.minio.errors.ErrorResponseException --> signature mismatch at http://internal:9000");
|
||||
NodeInfo node = new NodeInfo();
|
||||
node.setNodeId("node-1");
|
||||
node.setNodeName("文档解析");
|
||||
node.setStatus(NodeStatus.FAILED.getValue());
|
||||
node.setMessage("java.lang.IllegalStateException --> bucket-secret");
|
||||
source.setNodes(Map.of("node-1", node));
|
||||
|
||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||
|
||||
Assert.assertEquals(
|
||||
"工作流执行失败,请检查输入或稍后重试",
|
||||
result.message());
|
||||
Assert.assertFalse(result.message().contains("minio"));
|
||||
Assert.assertEquals(
|
||||
"节点执行失败,请检查输入或稍后重试",
|
||||
result.nodes().get("node-1").message());
|
||||
Assert.assertEquals("node-1", result.error().getNodeId());
|
||||
Assert.assertFalse(result.error().isRetryable());
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.FAILED,
|
||||
result.status());
|
||||
Assert.assertTrue(result.terminal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证成功状态保持原有响应且不增加错误对象。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepSuccessfulStatusWithoutError() {
|
||||
ChainInfo source = new ChainInfo();
|
||||
source.setExecuteId("execute-1");
|
||||
source.setStatus(ChainStatus.SUCCEEDED.getValue());
|
||||
source.setResult(Map.of("output", "ok"));
|
||||
|
||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||
|
||||
Assert.assertEquals(source.getResult(), result.result());
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.DONE,
|
||||
result.status());
|
||||
Assert.assertTrue(result.terminal());
|
||||
Assert.assertNull(result.message());
|
||||
Assert.assertNull(result.error());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证节点名称保持不变,并将节点状态转换为可读枚举。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepNodeNameAndReadableStatus() {
|
||||
ChainInfo source = new ChainInfo();
|
||||
source.setExecuteId("execute-1");
|
||||
source.setStatus(ChainStatus.RUNNING.getValue());
|
||||
NodeInfo node = new NodeInfo();
|
||||
node.setNodeId("node-1");
|
||||
node.setNodeName("文档解析");
|
||||
node.setStatus(NodeStatus.RUNNING.getValue());
|
||||
source.setNodes(Map.of("node-1", node));
|
||||
|
||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.RUNNING,
|
||||
result.status());
|
||||
Assert.assertFalse(result.terminal());
|
||||
Assert.assertEquals(
|
||||
"文档解析",
|
||||
result.nodes().get("node-1").nodeName());
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.RUNNING,
|
||||
result.nodes().get("node-1").status());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowTopology;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link PublicWorkflowTopologyService} 公开拓扑契约测试。
|
||||
*/
|
||||
public class PublicWorkflowTopologyServiceTest {
|
||||
|
||||
private final PublicWorkflowTopologyService service =
|
||||
new PublicWorkflowTopologyService();
|
||||
|
||||
/**
|
||||
* 验证分支工作流返回稳定拓扑序、并行层级和完整邻接信息。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldReturnStableTopologyAndNodeMetadata() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "branch-b",
|
||||
"type": "codeNode",
|
||||
"data": {
|
||||
"title": "分支 B",
|
||||
"description": "处理 B",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "input-1",
|
||||
"name": "content",
|
||||
"formLabel": "内容",
|
||||
"dataType": "String",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"script": "private-secret-script"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "start",
|
||||
"type": "startNode",
|
||||
"data": {"title": "开始"}
|
||||
},
|
||||
{
|
||||
"id": "branch-a",
|
||||
"type": "llmNode",
|
||||
"data": {
|
||||
"title": "分支 A",
|
||||
"systemPrompt": "private-secret-prompt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "isolated",
|
||||
"type": "codeNode",
|
||||
"data": {"title": "孤立节点"}
|
||||
},
|
||||
{
|
||||
"id": "end",
|
||||
"type": "endNode",
|
||||
"data": {
|
||||
"title": "结束",
|
||||
"outputDefs": [
|
||||
{
|
||||
"id": "output-1",
|
||||
"name": "answer",
|
||||
"dataType": "String"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "start-b",
|
||||
"source": "start",
|
||||
"target": "branch-b"
|
||||
},
|
||||
{
|
||||
"id": "start-a",
|
||||
"source": "start",
|
||||
"target": "branch-a"
|
||||
},
|
||||
{
|
||||
"id": "b-end",
|
||||
"source": "branch-b",
|
||||
"target": "end"
|
||||
},
|
||||
{
|
||||
"id": "a-end",
|
||||
"source": "branch-a",
|
||||
"target": "end"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
"start",
|
||||
"branch-b",
|
||||
"branch-a",
|
||||
"isolated",
|
||||
"end"),
|
||||
topology.topologicalOrder());
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
List.of("start", "isolated"),
|
||||
List.of("branch-b", "branch-a"),
|
||||
List.of("end")),
|
||||
topology.topologyLevels());
|
||||
Assert.assertFalse(topology.hasCycle());
|
||||
Assert.assertEquals(5, topology.nodes().size());
|
||||
Assert.assertEquals(4, topology.edges().size());
|
||||
|
||||
PublicWorkflowTopology.Node start = topology.nodes().get(0);
|
||||
Assert.assertTrue(start.startNode());
|
||||
Assert.assertEquals(
|
||||
List.of("branch-b", "branch-a"),
|
||||
start.successorNodeIds());
|
||||
Assert.assertEquals(
|
||||
List.of("start-b", "start-a"),
|
||||
start.outgoingEdgeIds());
|
||||
Assert.assertFalse(topology.nodes().get(3).startNode());
|
||||
Assert.assertFalse(topology.nodes().get(3).endNode());
|
||||
|
||||
PublicWorkflowTopology.Node branchB = topology.nodes().get(1);
|
||||
Assert.assertEquals(1, branchB.topologyLevel());
|
||||
Assert.assertEquals(1, branchB.inputParameters().size());
|
||||
Assert.assertEquals(
|
||||
"content",
|
||||
branchB.inputParameters().get(0).name());
|
||||
|
||||
String serialized = JSON.toJSONString(topology);
|
||||
Assert.assertFalse(serialized.contains("private-secret-script"));
|
||||
Assert.assertFalse(serialized.contains("private-secret-prompt"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证意外环路会显式标记,并且响应仍包含全部节点。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldExposeCycleWithoutDroppingNodes() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "a", "type": "codeNode", "data": {"title": "A"}},
|
||||
{"id": "b", "type": "codeNode", "data": {"title": "B"}},
|
||||
{"id": "c", "type": "endNode", "data": {"title": "C"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "a-b", "source": "a", "target": "b"},
|
||||
{"id": "b-a", "source": "b", "target": "a"},
|
||||
{"id": "b-c", "source": "b", "target": "c"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
Assert.assertTrue(topology.hasCycle());
|
||||
Assert.assertEquals(
|
||||
List.of("a", "b", "c"),
|
||||
topology.unresolvedNodeIds());
|
||||
Assert.assertEquals(
|
||||
List.of("a", "b", "c"),
|
||||
topology.topologicalOrder());
|
||||
Assert.assertEquals(-1, topology.nodes().get(0).topologyLevel());
|
||||
Assert.assertEquals(3, topology.nodes().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证循环体完成后才会进入循环节点的同作用域后继。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldPlaceLoopBodyBeforeLoopDownstream() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "after", "type": "codeNode", "data": {"title": "循环后"}},
|
||||
{"id": "inside-end", "type": "codeNode", "parentId": "loop", "data": {"title": "循环体末节点"}},
|
||||
{"id": "start", "type": "startNode", "data": {"title": "开始"}},
|
||||
{"id": "loop", "type": "loopNode", "data": {"title": "循环"}},
|
||||
{"id": "inside-start", "type": "codeNode", "parentId": "loop", "data": {"title": "循环体入口"}},
|
||||
{"id": "end", "type": "endNode", "data": {"title": "结束"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start-loop", "source": "start", "target": "loop"},
|
||||
{"id": "loop-inside", "source": "loop", "target": "inside-start"},
|
||||
{"id": "inside-next", "source": "inside-start", "target": "inside-end"},
|
||||
{"id": "loop-after", "source": "loop", "target": "after"},
|
||||
{"id": "after-end", "source": "after", "target": "end"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
"start",
|
||||
"loop",
|
||||
"inside-start",
|
||||
"inside-end",
|
||||
"after",
|
||||
"end"),
|
||||
topology.topologicalOrder());
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
List.of("start"),
|
||||
List.of("loop"),
|
||||
List.of("inside-start"),
|
||||
List.of("inside-end"),
|
||||
List.of("after"),
|
||||
List.of("end")),
|
||||
topology.topologyLevels());
|
||||
Assert.assertFalse(topology.hasCycle());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证开始节点文件参数公开无歧义的 multipart Part 名。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldExposeNamespacedMultipartPartName() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "startNode",
|
||||
"data": {
|
||||
"title": "开始",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "file-1",
|
||||
"name": "metadata",
|
||||
"dataType": "File",
|
||||
"contentType": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{"id": "end", "type": "endNode", "data": {"title": "结束"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start-end", "source": "start", "target": "end"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
PublicWorkflowTopology.Parameter parameter =
|
||||
topology.nodes().get(0).inputParameters().get(0);
|
||||
Assert.assertEquals("metadata", parameter.name());
|
||||
Assert.assertEquals(
|
||||
"files.metadata",
|
||||
parameter.multipartPartName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试工作流。
|
||||
*
|
||||
* @param content 发布快照内容
|
||||
* @return 测试工作流
|
||||
*/
|
||||
private Workflow workflow(String content) {
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.valueOf(101));
|
||||
workflow.setAlias("public-demo");
|
||||
workflow.setTitle("公开工作流");
|
||||
workflow.setDescription("公开描述");
|
||||
workflow.setRevision(7);
|
||||
workflow.setPublishedAt(Date.from(
|
||||
Instant.parse("2026-08-07T00:00:00Z")));
|
||||
workflow.setContent(content);
|
||||
return workflow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link WorkflowApiMultipartParameterMapper} 参数映射测试。
|
||||
*/
|
||||
public class WorkflowApiMultipartParameterMapperTest {
|
||||
|
||||
private final WorkflowApiMultipartParameterMapper mapper =
|
||||
new WorkflowApiMultipartParameterMapper();
|
||||
|
||||
/**
|
||||
* 验证 API metadata 与同名工作流文件参数互不冲突。
|
||||
*/
|
||||
@Test
|
||||
public void mapShouldSeparateApiMetadataAndWorkflowParameter() {
|
||||
MultiValueMap<String, MultipartFile> parts =
|
||||
new LinkedMultiValueMap<>();
|
||||
parts.add(
|
||||
"metadata",
|
||||
file("metadata", "metadata.json", "{}"));
|
||||
parts.add(
|
||||
"files.metadata",
|
||||
file("files.metadata", "one.pdf", "one"));
|
||||
parts.add(
|
||||
"files.metadata",
|
||||
file("files.metadata", "two.pdf", "two"));
|
||||
|
||||
Map<String, List<MultipartFile>> mapped = mapper.map(parts);
|
||||
|
||||
Assert.assertEquals(1, mapped.size());
|
||||
Assert.assertEquals(2, mapped.get("metadata").size());
|
||||
Assert.assertEquals(
|
||||
"one.pdf",
|
||||
mapped.get("metadata").get(0).getOriginalFilename());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未使用文件命名空间的 Part 会被明确拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void mapShouldRejectUnnamespacedFilePart() {
|
||||
MultiValueMap<String, MultipartFile> parts =
|
||||
new LinkedMultiValueMap<>();
|
||||
parts.add(
|
||||
"documents",
|
||||
file("documents", "one.pdf", "one"));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> mapper.map(parts));
|
||||
|
||||
Assert.assertTrue(
|
||||
exception.getMessage().contains("files.<"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Part 参数名不会被隐式 trim 后误映射到另一个工作流参数。
|
||||
*/
|
||||
@Test
|
||||
public void mapShouldRejectWhitespaceAlteredParameterName() {
|
||||
MultiValueMap<String, MultipartFile> parts =
|
||||
new LinkedMultiValueMap<>();
|
||||
parts.add(
|
||||
"files. metadata",
|
||||
file("files. metadata", "one.pdf", "one"));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> mapper.map(parts));
|
||||
|
||||
Assert.assertTrue(
|
||||
exception.getMessage().contains("参数名无效"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试文件 Part。
|
||||
*
|
||||
* @param partName Part 名
|
||||
* @param filename 文件名
|
||||
* @param content 文件内容
|
||||
* @return 测试文件
|
||||
*/
|
||||
private MultipartFile file(
|
||||
String partName,
|
||||
String filename,
|
||||
String content) {
|
||||
return new TestMultipartFile(
|
||||
partName,
|
||||
filename,
|
||||
"application/octet-stream",
|
||||
content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* 无需 Spring Test 依赖的最小 MultipartFile 测试实现。
|
||||
*/
|
||||
private record TestMultipartFile(
|
||||
String name,
|
||||
String originalFilename,
|
||||
String contentType,
|
||||
byte[] bytes) implements MultipartFile {
|
||||
|
||||
/**
|
||||
* 获取 Part 名。
|
||||
*
|
||||
* @return Part 名
|
||||
*/
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始文件名。
|
||||
*
|
||||
* @return 原始文件名
|
||||
*/
|
||||
@Override
|
||||
public String getOriginalFilename() {
|
||||
return originalFilename;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容类型。
|
||||
*
|
||||
* @return 内容类型
|
||||
*/
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为空。
|
||||
*
|
||||
* @return 是否为空
|
||||
*/
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return bytes.length == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件字节数。
|
||||
*
|
||||
* @return 文件字节数
|
||||
*/
|
||||
@Override
|
||||
public long getSize() {
|
||||
return bytes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件内容。
|
||||
*
|
||||
* @return 文件内容副本
|
||||
*/
|
||||
@Override
|
||||
public byte[] getBytes() {
|
||||
return bytes.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开文件内容流。
|
||||
*
|
||||
* @return 文件内容流
|
||||
*/
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将测试文件写入目标文件。
|
||||
*
|
||||
* @param destination 目标文件
|
||||
* @throws IOException 写入失败
|
||||
*/
|
||||
@Override
|
||||
public void transferTo(File destination) throws IOException {
|
||||
Files.write(destination.toPath(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user