feat: 完善工作流 Public API 调用能力
- 支持 JSON 文件 URL 简写与 Multipart 单请求文件上传 - 完善执行拓扑、枚举状态、节点名称、恢复校验和安全错误响应 - 增加临时上传生命周期清理并升级 MinIO SDK - 重构工作流接口调用说明弹窗的扁平响应式布局
This commit is contained in:
@@ -17,6 +17,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -82,6 +83,8 @@ public class TinyFlowServiceTest {
|
||||
.thenReturn(nodeStateRepository);
|
||||
when(chainStateRepository.load(EXECUTE_ID))
|
||||
.thenReturn(chainState);
|
||||
when(chainExecutor.getInstanceNodeNames(chainState))
|
||||
.thenReturn(Map.of(NODE_ID, "文档解析"));
|
||||
when(nodeStateRepository.load(EXECUTE_ID, NODE_ID))
|
||||
.thenReturn(null);
|
||||
TinyFlowService service = service(chainExecutor);
|
||||
@@ -96,7 +99,12 @@ public class TinyFlowServiceTest {
|
||||
Assert.assertEquals(
|
||||
Integer.valueOf(NodeStatus.READY.getValue()),
|
||||
result.getNodes().get(NODE_ID).getStatus());
|
||||
Assert.assertEquals(
|
||||
"文档解析",
|
||||
result.getNodes().get(NODE_ID).getNodeName());
|
||||
verify(chainStateRepository, times(1)).load(EXECUTE_ID);
|
||||
verify(chainExecutor, times(1))
|
||||
.getInstanceNodeNames(chainState);
|
||||
verify(nodeStateRepository, times(1))
|
||||
.load(EXECUTE_ID, NODE_ID);
|
||||
}
|
||||
|
||||
@@ -159,6 +159,47 @@ public class WorkflowRunningParameterResolverTest {
|
||||
Assert.assertEquals("file", fields.get(0).get("type"));
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart 文件字段名应从解析后的开始节点参数中按定义顺序返回。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testResolveFileParameterNamesShouldReturnStartFileFields()
|
||||
throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
|
||||
Assert.assertEquals(
|
||||
List.of("attachments"),
|
||||
List.copyOf(resolver.resolveFileParameterNames(
|
||||
workflowContentWithStartParameters())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 必填文件字段名应从开始节点参数定义中单独解析。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testResolveRequiredFileParameterNamesShouldKeepOrder()
|
||||
throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
JSONObject startData = data("开始");
|
||||
JSONArray parameters = startParameters();
|
||||
parameters.getJSONObject(1).put("required", true);
|
||||
startData.put("parameters", parameters);
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("s1", "startNode", null, startData),
|
||||
node("e1", "endNode", null, data("结束"))),
|
||||
array(edge("e1", "s1", "e1")));
|
||||
|
||||
Assert.assertEquals(
|
||||
List.of("attachments"),
|
||||
List.copyOf(resolver
|
||||
.resolveRequiredFileParameterNames(content)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件参数运行值应统一归一化为数组并按 filePath 去重。
|
||||
*
|
||||
@@ -178,6 +219,89 @@ public class WorkflowRunningParameterResolverTest {
|
||||
Assert.assertTrue(((List<?>) attachments).get(0) instanceof Map<?, ?>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件参数应接受远程 URL 字符串数组并自动提取文件名。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testNormalizeRuntimeVariablesShouldAcceptRemoteFileUrls()
|
||||
throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
String firstUrl = "https://files.example.com/contracts/"
|
||||
+ "%E5%90%88%E5%90%8C%20v1.docx?signature=test";
|
||||
String secondUrl = "https://files.example.com/contracts/report.pdf";
|
||||
Map<String, Object> variables = new LinkedHashMap<>();
|
||||
variables.put("attachments", List.of(firstUrl, secondUrl));
|
||||
|
||||
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
|
||||
workflowContentWithStartParameters(),
|
||||
variables);
|
||||
|
||||
List<?> attachments = (List<?>) normalized.get("attachments");
|
||||
Assert.assertEquals(2, attachments.size());
|
||||
Assert.assertEquals(
|
||||
"合同 v1.docx",
|
||||
((Map<?, ?>) attachments.get(0)).get("fileName"));
|
||||
Assert.assertEquals(
|
||||
firstUrl,
|
||||
((Map<?, ?>) attachments.get(0)).get("filePath"));
|
||||
Assert.assertEquals(
|
||||
"report.pdf",
|
||||
((Map<?, ?>) attachments.get(1)).get("fileName"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个远程文件 URL 也应归一化为文件对象数组。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testNormalizeRuntimeVariablesShouldAcceptSingleRemoteFileUrl()
|
||||
throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
Map<String, Object> variables = new LinkedHashMap<>();
|
||||
variables.put(
|
||||
"attachments",
|
||||
"https://files.example.com/contracts/contract.docx");
|
||||
|
||||
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
|
||||
workflowContentWithStartParameters(),
|
||||
variables);
|
||||
|
||||
List<?> attachments = (List<?>) normalized.get("attachments");
|
||||
Assert.assertEquals(1, attachments.size());
|
||||
Assert.assertEquals(
|
||||
"contract.docx",
|
||||
((Map<?, ?>) attachments.get(0)).get("fileName"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 无法从 URL 路径识别文件扩展名时应给出可恢复的格式提示。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testNormalizeRuntimeVariablesShouldRejectAmbiguousRemoteUrl()
|
||||
throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
Map<String, Object> variables = new LinkedHashMap<>();
|
||||
variables.put(
|
||||
"attachments",
|
||||
"https://files.example.com/download?id=contract");
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> resolver.normalizeRuntimeVariables(
|
||||
workflowContentWithStartParameters(),
|
||||
variables));
|
||||
|
||||
Assert.assertEquals(
|
||||
"文件参数 attachments 的 URL 路径无法识别带扩展名的文件名,"
|
||||
+ "请改用包含 fileName 和 filePath 的文件对象",
|
||||
exception.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 多文件参数应按 filePath 去重并保留已有非文件变量。
|
||||
*
|
||||
@@ -270,6 +394,36 @@ public class WorkflowRunningParameterResolverTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件参数应拒绝超过十个文件的输入。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testNormalizeRuntimeVariablesShouldEnforceFileCountLimit()
|
||||
throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
List<Map<String, Object>> files = new java.util.ArrayList<>();
|
||||
for (int index = 0; index < 11; index++) {
|
||||
files.add(fileValue(
|
||||
"file-" + index + ".pdf",
|
||||
"/files/file-" + index + ".pdf",
|
||||
1L));
|
||||
}
|
||||
Map<String, Object> variables = new LinkedHashMap<>();
|
||||
variables.put("attachments", files);
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> resolver.normalizeRuntimeVariables(
|
||||
workflowContentWithStartParameters(),
|
||||
variables));
|
||||
|
||||
Assert.assertEquals(
|
||||
"文件参数 attachments 最多上传 10 个文件",
|
||||
exception.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版图片 URL 应归一化为 URL 图片描述。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package tech.easyflow.ai.easyagentsflow.upload;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
/**
|
||||
* {@link WorkflowApiUploadCleanupScheduler} 批量排空测试。
|
||||
*/
|
||||
public class WorkflowApiUploadCleanupSchedulerTest {
|
||||
|
||||
/**
|
||||
* 验证一次调度会连续处理多批到期记录。
|
||||
*/
|
||||
@Test
|
||||
public void cleanupShouldDrainMultipleBatches() {
|
||||
WorkflowApiUploadLifecycleService lifecycleService =
|
||||
Mockito.mock(WorkflowApiUploadLifecycleService.class);
|
||||
Mockito.when(lifecycleService.cleanupExpired(100))
|
||||
.thenReturn(100, 100, 20);
|
||||
WorkflowApiUploadCleanupScheduler scheduler =
|
||||
new WorkflowApiUploadCleanupScheduler(lifecycleService);
|
||||
|
||||
scheduler.cleanup();
|
||||
|
||||
Mockito.verify(lifecycleService, Mockito.times(3))
|
||||
.cleanupExpired(100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package tech.easyflow.ai.easyagentsflow.upload;
|
||||
|
||||
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteResult;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* {@link WorkflowApiUploadLifecycleService} multipart 文件生命周期测试。
|
||||
*/
|
||||
public class WorkflowApiUploadLifecycleServiceTest {
|
||||
|
||||
/**
|
||||
* 验证同名文件 Part 会按顺序保存并注入文件对象数组。
|
||||
*/
|
||||
@Test
|
||||
public void prepareShouldStoreRepeatedFilePartsInOrder() {
|
||||
Fixture fixture = fixture();
|
||||
MultipartFile first = file("first.pdf", "application/pdf", 10L);
|
||||
MultipartFile second = file(
|
||||
"second.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
20L);
|
||||
Mockito.when(fixture.parameterResolver.resolveFileParameterNames("flow"))
|
||||
.thenReturn(Set.of("documents"));
|
||||
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||
Mockito.eq("flow"),
|
||||
Mockito.anyMap()))
|
||||
.thenAnswer(invocation -> new LinkedHashMap<>(
|
||||
invocation.getArgument(1)));
|
||||
FileStorageWriteHandle firstHandle = handle("first.pdf");
|
||||
FileStorageWriteHandle secondHandle = handle("second.docx");
|
||||
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
|
||||
Mockito.anyString(),
|
||||
Mockito.anyString()))
|
||||
.thenReturn(firstHandle, secondHandle);
|
||||
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||
first,
|
||||
firstHandle))
|
||||
.thenReturn(new FileStorageWriteResult(
|
||||
"/files/first.pdf",
|
||||
firstHandle.encodeLocator()));
|
||||
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||
second,
|
||||
secondHandle))
|
||||
.thenReturn(new FileStorageWriteResult(
|
||||
"/files/second.docx",
|
||||
secondHandle.encodeLocator()));
|
||||
|
||||
WorkflowApiPreparedUpload prepared = fixture.service.prepare(
|
||||
"flow",
|
||||
Map.of("user_input", "解析"),
|
||||
Map.of("documents", List.of(first, second)));
|
||||
|
||||
Assert.assertNotNull(prepared.getRequestId());
|
||||
Assert.assertEquals("解析", prepared.getVariables().get("user_input"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> documents =
|
||||
(List<Map<String, Object>>) prepared.getVariables()
|
||||
.get("documents");
|
||||
Assert.assertEquals(2, documents.size());
|
||||
Assert.assertEquals(
|
||||
"/files/first.pdf",
|
||||
documents.get(0).get("filePath"));
|
||||
Assert.assertEquals(
|
||||
"/files/second.docx",
|
||||
documents.get(1).get("filePath"));
|
||||
|
||||
ArgumentCaptor<WorkflowApiUploadRecord> recordCaptor =
|
||||
ArgumentCaptor.forClass(WorkflowApiUploadRecord.class);
|
||||
Mockito.verify(fixture.uploadStore).create(
|
||||
recordCaptor.capture());
|
||||
Assert.assertEquals(
|
||||
List.of("/files/first.pdf", "/files/second.docx"),
|
||||
recordCaptor.getValue().getStoredFiles().stream()
|
||||
.map(WorkflowApiStoredFile::filePath)
|
||||
.toList());
|
||||
InOrder writeOrder = Mockito.inOrder(
|
||||
fixture.uploadStore,
|
||||
fixture.fileStorageService);
|
||||
writeOrder.verify(fixture.uploadStore)
|
||||
.save(Mockito.any(WorkflowApiUploadRecord.class));
|
||||
writeOrder.verify(fixture.fileStorageService)
|
||||
.saveRecoverable(first, firstHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法客户端 MIME 会按扩展名归一化,并同时用于存储和文件描述。
|
||||
*/
|
||||
@Test
|
||||
public void prepareShouldNormalizeInvalidContentType() {
|
||||
Fixture fixture = fixture();
|
||||
MultipartFile file = file(
|
||||
"C:\\fakepath\\report.docx",
|
||||
"Other",
|
||||
10L);
|
||||
Mockito.when(fixture.parameterResolver
|
||||
.resolveFileParameterNames("flow"))
|
||||
.thenReturn(Set.of("documents"));
|
||||
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||
Mockito.eq("flow"),
|
||||
Mockito.anyMap()))
|
||||
.thenAnswer(invocation -> new LinkedHashMap<>(
|
||||
invocation.getArgument(1)));
|
||||
FileStorageWriteHandle handle = handle("report.docx");
|
||||
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
|
||||
Mockito.anyString(),
|
||||
Mockito.anyString()))
|
||||
.thenReturn(handle);
|
||||
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||
Mockito.any(MultipartFile.class),
|
||||
Mockito.eq(handle)))
|
||||
.thenReturn(new FileStorageWriteResult(
|
||||
"/files/report.docx",
|
||||
handle.encodeLocator()));
|
||||
|
||||
WorkflowApiPreparedUpload prepared = fixture.service.prepare(
|
||||
"flow",
|
||||
Map.of(),
|
||||
Map.of("documents", List.of(file)));
|
||||
|
||||
ArgumentCaptor<MultipartFile> storedFile =
|
||||
ArgumentCaptor.forClass(MultipartFile.class);
|
||||
Mockito.verify(fixture.fileStorageService).saveRecoverable(
|
||||
storedFile.capture(),
|
||||
Mockito.eq(handle));
|
||||
Assert.assertEquals(
|
||||
"report.docx",
|
||||
storedFile.getValue().getOriginalFilename());
|
||||
Assert.assertEquals(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
storedFile.getValue().getContentType());
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> documents =
|
||||
(List<Map<String, Object>>) prepared.getVariables()
|
||||
.get("documents");
|
||||
Assert.assertEquals(
|
||||
storedFile.getValue().getContentType(),
|
||||
documents.get(0).get("contentType"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证对象存储超时返回可重试的 50301,并补偿临时文件。
|
||||
*/
|
||||
@Test
|
||||
public void prepareShouldTranslateStorageTimeoutAndCleanup() {
|
||||
Fixture fixture = fixture();
|
||||
MultipartFile file = file(
|
||||
"report.pdf",
|
||||
"application/pdf",
|
||||
10L);
|
||||
Mockito.when(fixture.parameterResolver
|
||||
.resolveFileParameterNames("flow"))
|
||||
.thenReturn(Set.of("documents"));
|
||||
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||
Mockito.eq("flow"),
|
||||
Mockito.anyMap()))
|
||||
.thenAnswer(invocation -> new LinkedHashMap<>(
|
||||
invocation.getArgument(1)));
|
||||
FileStorageWriteHandle handle = handle("report.pdf");
|
||||
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
|
||||
Mockito.anyString(),
|
||||
Mockito.anyString()))
|
||||
.thenReturn(handle);
|
||||
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||
file,
|
||||
handle))
|
||||
.thenThrow(new IllegalStateException(
|
||||
"storage timeout",
|
||||
new java.net.SocketTimeoutException("timeout")));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.service.prepare(
|
||||
"flow",
|
||||
Map.of(),
|
||||
Map.of("documents", List.of(file))));
|
||||
|
||||
Assert.assertEquals(503, exception.getHttpStatus());
|
||||
Assert.assertEquals(50301, exception.getErrorCode());
|
||||
Assert.assertFalse(exception.getMessage().contains("Socket"));
|
||||
Mockito.verify(fixture.fileStorageService)
|
||||
.deleteRecoverable(handle);
|
||||
Mockito.verify(fixture.uploadStore)
|
||||
.remove(Mockito.any(WorkflowApiUploadRecord.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证存储鉴权等非暂时性错误返回安全的 50001,并执行补偿。
|
||||
*/
|
||||
@Test
|
||||
public void prepareShouldHidePermanentStorageFailureAndCleanup() {
|
||||
Fixture fixture = fixture();
|
||||
MultipartFile file = file(
|
||||
"report.pdf",
|
||||
"application/pdf",
|
||||
10L);
|
||||
Mockito.when(fixture.parameterResolver
|
||||
.resolveFileParameterNames("flow"))
|
||||
.thenReturn(Set.of("documents"));
|
||||
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||
Mockito.eq("flow"),
|
||||
Mockito.anyMap()))
|
||||
.thenAnswer(invocation -> new LinkedHashMap<>(
|
||||
invocation.getArgument(1)));
|
||||
FileStorageWriteHandle handle = handle("report.pdf");
|
||||
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
|
||||
Mockito.anyString(),
|
||||
Mockito.anyString()))
|
||||
.thenReturn(handle);
|
||||
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||
file,
|
||||
handle))
|
||||
.thenThrow(new IllegalStateException(
|
||||
"AccessKey=secret, endpoint=http://internal:9000"));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.service.prepare(
|
||||
"flow",
|
||||
Map.of(),
|
||||
Map.of("documents", List.of(file))));
|
||||
|
||||
Assert.assertEquals(500, exception.getHttpStatus());
|
||||
Assert.assertEquals(50001, exception.getErrorCode());
|
||||
Assert.assertFalse(exception.getMessage().contains("secret"));
|
||||
Mockito.verify(fixture.fileStorageService)
|
||||
.deleteRecoverable(handle);
|
||||
Mockito.verify(fixture.uploadStore)
|
||||
.remove(Mockito.any(WorkflowApiUploadRecord.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未知文件 Part 在写入存储前被拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void prepareShouldRejectUnknownFilePartBeforeStorage() {
|
||||
Fixture fixture = fixture();
|
||||
MultipartFile file = file("data.pdf", "application/pdf", 10L);
|
||||
Mockito.when(fixture.parameterResolver.resolveFileParameterNames("flow"))
|
||||
.thenReturn(Set.of("documents"));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.service.prepare(
|
||||
"flow",
|
||||
Map.of(),
|
||||
Map.of("unknown", List.of(file))));
|
||||
|
||||
Assert.assertTrue(exception.getMessage().contains("unknown"));
|
||||
Mockito.verifyNoInteractions(fixture.fileStorageService);
|
||||
Mockito.verifyNoInteractions(fixture.uploadStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缺少开始节点必填文件字段时在存储前返回 40016。
|
||||
*/
|
||||
@Test
|
||||
public void prepareShouldRejectMissingRequiredFileBeforeStorage() {
|
||||
Fixture fixture = fixture();
|
||||
MultipartFile file = file("data.pdf", "application/pdf", 10L);
|
||||
Mockito.when(fixture.parameterResolver
|
||||
.resolveFileParameterNames("flow"))
|
||||
.thenReturn(Set.of("documents", "appendix"));
|
||||
Mockito.when(fixture.parameterResolver
|
||||
.resolveRequiredFileParameterNames("flow"))
|
||||
.thenReturn(Set.of("documents", "appendix"));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.service.prepare(
|
||||
"flow",
|
||||
Map.of(),
|
||||
Map.of("documents", List.of(file))));
|
||||
|
||||
Assert.assertEquals(40016, exception.getErrorCode());
|
||||
Assert.assertTrue(exception.getMessage().contains("appendix"));
|
||||
Mockito.verifyNoInteractions(fixture.fileStorageService);
|
||||
Mockito.verifyNoInteractions(fixture.uploadStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件数量和大小限制统一转换为 41301。
|
||||
*/
|
||||
@Test
|
||||
public void prepareShouldTranslateFileLimitToPayloadTooLarge() {
|
||||
Fixture fixture = fixture();
|
||||
MultipartFile file = file("data.pdf", "application/pdf", 10L);
|
||||
Mockito.when(fixture.parameterResolver
|
||||
.resolveFileParameterNames("flow"))
|
||||
.thenReturn(Set.of("documents"));
|
||||
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||
Mockito.eq("flow"),
|
||||
Mockito.anyMap()))
|
||||
.thenThrow(new BusinessException(
|
||||
"文件参数 documents 最多上传 10 个文件"));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.service.prepare(
|
||||
"flow",
|
||||
Map.of(),
|
||||
Map.of("documents", List.of(file))));
|
||||
|
||||
Assert.assertEquals(413, exception.getHttpStatus());
|
||||
Assert.assertEquals(41301, exception.getErrorCode());
|
||||
Mockito.verifyNoInteractions(fixture.fileStorageService);
|
||||
Mockito.verifyNoInteractions(fixture.uploadStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件写入后 Redis 更新失败时仍使用内存路径执行补偿删除。
|
||||
*/
|
||||
@Test
|
||||
public void prepareShouldDeleteSavedFileWhenRecordUpdateFails() {
|
||||
Fixture fixture = fixture();
|
||||
MultipartFile file = file("data.pdf", "application/pdf", 10L);
|
||||
Mockito.when(fixture.parameterResolver.resolveFileParameterNames("flow"))
|
||||
.thenReturn(Set.of("documents"));
|
||||
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||
Mockito.eq("flow"),
|
||||
Mockito.anyMap()))
|
||||
.thenAnswer(invocation -> new LinkedHashMap<>(
|
||||
invocation.getArgument(1)));
|
||||
FileStorageWriteHandle handle = handle("data.pdf");
|
||||
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
|
||||
Mockito.anyString(),
|
||||
Mockito.anyString()))
|
||||
.thenReturn(handle);
|
||||
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||
file,
|
||||
handle))
|
||||
.thenReturn(new FileStorageWriteResult(
|
||||
"/files/data.pdf",
|
||||
handle.encodeLocator()));
|
||||
Mockito.doNothing()
|
||||
.doThrow(new IllegalStateException("Redis 写入失败"))
|
||||
.when(fixture.uploadStore)
|
||||
.save(Mockito.any(WorkflowApiUploadRecord.class));
|
||||
|
||||
IllegalStateException exception = Assert.assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> fixture.service.prepare(
|
||||
"flow",
|
||||
Map.of(),
|
||||
Map.of("documents", List.of(file))));
|
||||
|
||||
Assert.assertTrue(exception.getMessage().contains("Redis"));
|
||||
Mockito.verify(fixture.fileStorageService)
|
||||
.deleteRecoverable(handle);
|
||||
Mockito.verify(fixture.uploadStore)
|
||||
.remove(Mockito.any(WorkflowApiUploadRecord.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证启动失败后的 abort 会幂等删除已保存文件和上传记录。
|
||||
*/
|
||||
@Test
|
||||
public void abortShouldDeleteStoredFilesAndRecord() {
|
||||
Fixture fixture = fixture();
|
||||
WorkflowApiUploadRecord record = new WorkflowApiUploadRecord();
|
||||
record.setRequestId("request-1");
|
||||
FileStorageWriteHandle firstHandle = handle("a.pdf");
|
||||
FileStorageWriteHandle secondHandle = handle("b.pdf");
|
||||
record.setStoredFiles(List.of(
|
||||
new WorkflowApiStoredFile(
|
||||
"/files/a.pdf",
|
||||
firstHandle.encodeLocator()),
|
||||
new WorkflowApiStoredFile(
|
||||
"/files/b.pdf",
|
||||
secondHandle.encodeLocator())));
|
||||
RedisLockExecutor.LockHandle handle =
|
||||
Mockito.mock(RedisLockExecutor.LockHandle.class);
|
||||
Mockito.when(fixture.redisLockExecutor.tryAcquire(
|
||||
Mockito.anyString(),
|
||||
Mockito.any(),
|
||||
Mockito.any()))
|
||||
.thenReturn(handle);
|
||||
Mockito.when(fixture.uploadStore.find("request-1"))
|
||||
.thenReturn(Optional.of(record));
|
||||
|
||||
fixture.service.abort("request-1");
|
||||
|
||||
Mockito.verify(fixture.fileStorageService)
|
||||
.deleteRecoverable(firstHandle);
|
||||
Mockito.verify(fixture.fileStorageService)
|
||||
.deleteRecoverable(secondHandle);
|
||||
Mockito.verify(fixture.uploadStore).remove(record);
|
||||
Mockito.verify(handle).close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证单条清理失败不会阻塞后续到期记录。
|
||||
*/
|
||||
@Test
|
||||
public void cleanupExpiredShouldContinueAfterFailedRecord() {
|
||||
Fixture fixture = fixture();
|
||||
FileStorageWriteHandle failedFile = handle("failed.pdf");
|
||||
FileStorageWriteHandle goodFile = handle("good.pdf");
|
||||
WorkflowApiUploadRecord failedRecord =
|
||||
storedRecord("failed", failedFile);
|
||||
WorkflowApiUploadRecord goodRecord =
|
||||
storedRecord("good", goodFile);
|
||||
RedisLockExecutor.LockHandle failedLock =
|
||||
Mockito.mock(RedisLockExecutor.LockHandle.class);
|
||||
RedisLockExecutor.LockHandle goodLock =
|
||||
Mockito.mock(RedisLockExecutor.LockHandle.class);
|
||||
Mockito.when(fixture.uploadStore.claimExpired(
|
||||
Mockito.anyLong(),
|
||||
Mockito.anyLong()))
|
||||
.thenReturn(
|
||||
Optional.of("failed"),
|
||||
Optional.of("good"),
|
||||
Optional.empty());
|
||||
Mockito.when(fixture.redisLockExecutor.tryAcquire(
|
||||
Mockito.anyString(),
|
||||
Mockito.any(),
|
||||
Mockito.any()))
|
||||
.thenReturn(failedLock, goodLock);
|
||||
Mockito.when(fixture.uploadStore.find("failed"))
|
||||
.thenReturn(Optional.of(failedRecord));
|
||||
Mockito.when(fixture.uploadStore.find("good"))
|
||||
.thenReturn(Optional.of(goodRecord));
|
||||
Mockito.doThrow(new IllegalStateException("对象存储不可用"))
|
||||
.when(fixture.fileStorageService)
|
||||
.deleteRecoverable(failedFile);
|
||||
|
||||
int processed = fixture.service.cleanupExpired(10);
|
||||
|
||||
Assert.assertEquals(2, processed);
|
||||
Mockito.verify(fixture.fileStorageService)
|
||||
.deleteRecoverable(goodFile);
|
||||
Mockito.verify(fixture.uploadStore).remove(goodRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 multipart 文件桩。
|
||||
*
|
||||
* @param name 文件名
|
||||
* @param contentType MIME 类型
|
||||
* @param size 文件大小
|
||||
* @return multipart 文件桩
|
||||
*/
|
||||
private MultipartFile file(
|
||||
String name,
|
||||
String contentType,
|
||||
long size) {
|
||||
MultipartFile file = Mockito.mock(MultipartFile.class);
|
||||
Mockito.when(file.isEmpty()).thenReturn(false);
|
||||
Mockito.when(file.getOriginalFilename()).thenReturn(name);
|
||||
Mockito.when(file.getContentType()).thenReturn(contentType);
|
||||
Mockito.when(file.getSize()).thenReturn(size);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可恢复文件存储句柄。
|
||||
*
|
||||
* @param filename 固定文件名
|
||||
* @return 测试句柄
|
||||
*/
|
||||
private FileStorageWriteHandle handle(String filename) {
|
||||
return new FileStorageWriteHandle(
|
||||
"localFileStorage",
|
||||
"",
|
||||
"/tmp/easyflow-test",
|
||||
"workflow-api-upload/test",
|
||||
filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含单个临时文件的上传记录。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param handle 文件句柄
|
||||
* @return 上传记录
|
||||
*/
|
||||
private WorkflowApiUploadRecord storedRecord(
|
||||
String requestId,
|
||||
FileStorageWriteHandle handle) {
|
||||
WorkflowApiUploadRecord record =
|
||||
new WorkflowApiUploadRecord();
|
||||
record.setRequestId(requestId);
|
||||
record.setStoredFiles(List.of(
|
||||
new WorkflowApiStoredFile(
|
||||
"/files/" + handle.getFilename(),
|
||||
handle.encodeLocator())));
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建生命周期服务测试夹具。
|
||||
*
|
||||
* @return 测试夹具
|
||||
*/
|
||||
private Fixture fixture() {
|
||||
WorkflowRunningParameterResolver parameterResolver =
|
||||
Mockito.mock(WorkflowRunningParameterResolver.class);
|
||||
FileStorageService fileStorageService =
|
||||
Mockito.mock(FileStorageService.class);
|
||||
WorkflowApiUploadStore uploadStore =
|
||||
Mockito.mock(WorkflowApiUploadStore.class);
|
||||
ChainStateRepository chainStateRepository =
|
||||
Mockito.mock(ChainStateRepository.class);
|
||||
RedisLockExecutor redisLockExecutor =
|
||||
Mockito.mock(RedisLockExecutor.class);
|
||||
return new Fixture(
|
||||
new WorkflowApiUploadLifecycleService(
|
||||
parameterResolver,
|
||||
fileStorageService,
|
||||
new WorkflowApiMultipartFileNormalizer(),
|
||||
uploadStore,
|
||||
chainStateRepository,
|
||||
redisLockExecutor),
|
||||
parameterResolver,
|
||||
fileStorageService,
|
||||
uploadStore,
|
||||
redisLockExecutor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生命周期服务测试夹具。
|
||||
*
|
||||
* @param service 被测服务
|
||||
* @param parameterResolver 参数解析器
|
||||
* @param fileStorageService 文件存储
|
||||
* @param uploadStore 上传记录存储
|
||||
* @param redisLockExecutor 分布式锁执行器
|
||||
*/
|
||||
private record Fixture(
|
||||
WorkflowApiUploadLifecycleService service,
|
||||
WorkflowRunningParameterResolver parameterResolver,
|
||||
FileStorageService fileStorageService,
|
||||
WorkflowApiUploadStore uploadStore,
|
||||
RedisLockExecutor redisLockExecutor) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package tech.easyflow.ai.easyagentsflow.upload;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.data.redis.core.script.RedisScript;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link WorkflowApiUploadStore} Redis 原子性契约测试。
|
||||
*/
|
||||
public class WorkflowApiUploadStoreTest {
|
||||
|
||||
/**
|
||||
* 验证记录与清理索引通过同槽 Lua 脚本原子创建。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public void createShouldUseSameSlotAtomicScript() {
|
||||
StringRedisTemplate redisTemplate =
|
||||
Mockito.mock(StringRedisTemplate.class);
|
||||
Mockito.doReturn(1L).when(redisTemplate).execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
WorkflowApiUploadStore store = new WorkflowApiUploadStore(
|
||||
redisTemplate,
|
||||
new ObjectMapper());
|
||||
WorkflowApiUploadRecord record = record("request-1", null);
|
||||
|
||||
store.create(record);
|
||||
|
||||
ArgumentCaptor<RedisScript<Long>> scriptCaptor =
|
||||
ArgumentCaptor.forClass((Class) RedisScript.class);
|
||||
ArgumentCaptor<List<String>> keysCaptor =
|
||||
ArgumentCaptor.forClass((Class) List.class);
|
||||
Mockito.verify(redisTemplate).execute(
|
||||
scriptCaptor.capture(),
|
||||
keysCaptor.capture(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
Assert.assertTrue(
|
||||
scriptCaptor.getValue().getScriptAsString()
|
||||
.contains("redis.call('zadd'"));
|
||||
Assert.assertEquals(3, keysCaptor.getValue().size());
|
||||
Assert.assertTrue(keysCaptor.getValue().stream()
|
||||
.allMatch(key -> key.contains("{api-upload}")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证重新绑定执行 ID 时会在同一脚本中清除旧索引。
|
||||
*
|
||||
* @throws Exception 上传记录序列化失败
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public void bindExecutionShouldReplacePreviousIndexAtomically()
|
||||
throws Exception {
|
||||
StringRedisTemplate redisTemplate =
|
||||
Mockito.mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> valueOperations =
|
||||
Mockito.mock(ValueOperations.class);
|
||||
Mockito.when(redisTemplate.opsForValue())
|
||||
.thenReturn(valueOperations);
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
WorkflowApiUploadRecord record =
|
||||
record("request-1", "execution-old");
|
||||
Mockito.when(valueOperations.get(
|
||||
"easyflow:workflow:{api-upload}:record:request-1"))
|
||||
.thenReturn(objectMapper.writeValueAsString(record));
|
||||
Mockito.doReturn(1L).when(redisTemplate).execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
WorkflowApiUploadStore store = new WorkflowApiUploadStore(
|
||||
redisTemplate,
|
||||
objectMapper);
|
||||
|
||||
store.bindExecution("request-1", "execution-new");
|
||||
|
||||
ArgumentCaptor<RedisScript<Long>> scriptCaptor =
|
||||
ArgumentCaptor.forClass((Class) RedisScript.class);
|
||||
ArgumentCaptor<List<String>> keysCaptor =
|
||||
ArgumentCaptor.forClass((Class) List.class);
|
||||
Mockito.verify(redisTemplate).execute(
|
||||
scriptCaptor.capture(),
|
||||
keysCaptor.capture(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
Assert.assertEquals(
|
||||
"easyflow:workflow:{api-upload}:execution:execution-new",
|
||||
keysCaptor.getValue().get(1));
|
||||
Assert.assertEquals(
|
||||
"easyflow:workflow:{api-upload}:execution:execution-old",
|
||||
keysCaptor.getValue().get(2));
|
||||
Assert.assertTrue(
|
||||
scriptCaptor.getValue().getScriptAsString()
|
||||
.contains("redis.call('del', KEYS[3])"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试上传记录。
|
||||
*
|
||||
* @param requestId 上传请求 ID
|
||||
* @param executeId 执行 ID
|
||||
* @return 上传记录
|
||||
*/
|
||||
private WorkflowApiUploadRecord record(
|
||||
String requestId,
|
||||
String executeId) {
|
||||
WorkflowApiUploadRecord record = new WorkflowApiUploadRecord();
|
||||
record.setRequestId(requestId);
|
||||
record.setExecuteId(executeId);
|
||||
record.setCleanupAt(1_000L);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package tech.easyflow.ai.easyagentsflow.upload;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* {@link WorkflowApiUploadedFileReader} 上传记录授权边界测试。
|
||||
*/
|
||||
public class WorkflowApiUploadedFileReaderTest {
|
||||
|
||||
private static final String REQUEST_ID =
|
||||
"0123456789abcdef0123456789abcdef";
|
||||
private static final String FILENAME =
|
||||
"000-abcdefabcdefabcdefabcdefabcdefab.docx";
|
||||
private static final String FILE_URL =
|
||||
"http://127.0.0.1:39000/easyflow/attachment/"
|
||||
+ "workflow-api-upload/" + REQUEST_ID + "/" + FILENAME;
|
||||
|
||||
/**
|
||||
* 验证 URL、上传记录与恢复句柄完全匹配后按固定后端读取。
|
||||
*
|
||||
* @throws Exception 测试流读取失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldReadExactRecordedUploadByRecoverableHandle() throws Exception {
|
||||
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
|
||||
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
|
||||
WorkflowApiUploadedFileReader reader =
|
||||
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
|
||||
FileStorageWriteHandle handle = handle();
|
||||
WorkflowApiUploadRecord record = record(FILE_URL, handle);
|
||||
byte[] content = "document-content".getBytes(StandardCharsets.UTF_8);
|
||||
Mockito.when(uploadStore.find(REQUEST_ID)).thenReturn(Optional.of(record));
|
||||
Mockito.when(fileStorageService.readRecoverable(handle))
|
||||
.thenReturn(new ByteArrayInputStream(content));
|
||||
|
||||
Optional<InputStream> opened = reader.openVerified(FILE_URL);
|
||||
|
||||
Assert.assertTrue(opened.isPresent());
|
||||
try (InputStream inputStream = opened.orElseThrow()) {
|
||||
Assert.assertArrayEquals(content, inputStream.readAllBytes());
|
||||
}
|
||||
Mockito.verify(fileStorageService).readRecoverable(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证普通远端 URL 不访问上传记录,也不获得内部读取权限。
|
||||
*
|
||||
* @throws IOException 路径解析失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldIgnoreOrdinaryRemoteUrl() throws IOException {
|
||||
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
|
||||
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
|
||||
WorkflowApiUploadedFileReader reader =
|
||||
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
|
||||
|
||||
Optional<InputStream> opened = reader.openVerified(
|
||||
"http://127.0.0.1:39000/easyflow/attachment/ordinary.docx");
|
||||
|
||||
Assert.assertTrue(opened.isEmpty());
|
||||
Mockito.verifyNoInteractions(uploadStore, fileStorageService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证看似系统目录的 URL 在 Redis 记录不存在时明确判定为失效。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectManagedPathWhenUploadRecordExpired() {
|
||||
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
|
||||
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
|
||||
WorkflowApiUploadedFileReader reader =
|
||||
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
|
||||
Mockito.when(uploadStore.find(REQUEST_ID)).thenReturn(Optional.empty());
|
||||
|
||||
IOException exception = Assert.assertThrows(
|
||||
IOException.class,
|
||||
() -> reader.openVerified(FILE_URL));
|
||||
|
||||
Assert.assertTrue(exception.getMessage().contains("已失效"));
|
||||
Mockito.verifyNoInteractions(fileStorageService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证同一请求 ID 下未记录的 URL 不能复用其他文件的存储 locator。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectUrlThatDoesNotExactlyMatchStoredFile() {
|
||||
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
|
||||
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
|
||||
WorkflowApiUploadedFileReader reader =
|
||||
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
|
||||
Mockito.when(uploadStore.find(REQUEST_ID)).thenReturn(Optional.of(
|
||||
record(FILE_URL + "?different=true", handle())));
|
||||
|
||||
IOException exception = Assert.assertThrows(
|
||||
IOException.class,
|
||||
() -> reader.openVerified(FILE_URL));
|
||||
|
||||
Assert.assertTrue(exception.getMessage().contains("不匹配"));
|
||||
Mockito.verifyNoInteractions(fileStorageService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建与系统上传目录一致的恢复句柄。
|
||||
*
|
||||
* @return 测试句柄
|
||||
*/
|
||||
private FileStorageWriteHandle handle() {
|
||||
return new FileStorageWriteHandle(
|
||||
"local",
|
||||
"",
|
||||
"/tmp/easyflow-test",
|
||||
"workflow-api-upload/" + REQUEST_ID,
|
||||
FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含单个受管文件的上传记录。
|
||||
*
|
||||
* @param fileUrl 记录中的完整文件 URL
|
||||
* @param handle 文件存储句柄
|
||||
* @return 上传记录
|
||||
*/
|
||||
private WorkflowApiUploadRecord record(
|
||||
String fileUrl,
|
||||
FileStorageWriteHandle handle) {
|
||||
WorkflowApiUploadRecord record = new WorkflowApiUploadRecord();
|
||||
record.setRequestId(REQUEST_ID);
|
||||
record.setStoredFiles(List.of(new WorkflowApiStoredFile(
|
||||
fileUrl,
|
||||
handle.encodeLocator())));
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package tech.easyflow.ai.node;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
||||
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
|
||||
import tech.easyflow.ai.document.model.DocumentParseTaskStatus;
|
||||
import tech.easyflow.ai.document.model.DocumentParsedResult;
|
||||
@@ -14,13 +16,12 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* {@link DocNodeFileContentExtractor} 单元测试。
|
||||
@@ -175,42 +176,63 @@ public class DocNodeFileContentExtractorTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证远端素材 URL 的非桥接文件不会误走本地存储读取。
|
||||
* 验证普通远端素材 URL 的非桥接文件仍拒绝访问回环地址。
|
||||
*/
|
||||
@Test
|
||||
public void shouldReadRemoteUrlForUnsupportedType() {
|
||||
public void shouldRejectLoopbackRemoteUrlForUnsupportedType() {
|
||||
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
|
||||
HttpServer server;
|
||||
try {
|
||||
server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
||||
bridgeService,
|
||||
new FailingFileStorageService(),
|
||||
new ReadingReaderManager()
|
||||
);
|
||||
|
||||
RuntimeException exception = Assert.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> extractor.extract(buildFileValue(
|
||||
"note.txt",
|
||||
"http://127.0.0.1:39000/note.txt",
|
||||
"text/plain")));
|
||||
|
||||
Throwable cause = exception;
|
||||
while (cause != null
|
||||
&& !(cause instanceof java.net.UnknownHostException)) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
byte[] body = "remote text".getBytes(StandardCharsets.UTF_8);
|
||||
server.createContext("/note.txt", exchange -> {
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
try {
|
||||
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
||||
Assert.assertNotNull(cause);
|
||||
Assert.assertNull(bridgeService.lastSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证受管上传 URL 的非桥接文件通过记录校验后走内部存储读取。
|
||||
*
|
||||
* @throws IOException 测试流配置失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldReadVerifiedManagedUploadForUnsupportedType() throws IOException {
|
||||
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
|
||||
WorkflowApiUploadedFileReader uploadedFileReader =
|
||||
Mockito.mock(WorkflowApiUploadedFileReader.class);
|
||||
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/"
|
||||
+ "workflow-api-upload/0123456789abcdef0123456789abcdef/note.txt";
|
||||
byte[] body = "managed text".getBytes(StandardCharsets.UTF_8);
|
||||
Mockito.when(uploadedFileReader.isManagedPathCandidate(fileUrl))
|
||||
.thenReturn(true);
|
||||
Mockito.when(uploadedFileReader.openVerified(fileUrl))
|
||||
.thenReturn(Optional.of(new ByteArrayInputStream(body)));
|
||||
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
||||
bridgeService,
|
||||
new FailingFileStorageService(),
|
||||
new ReadingReaderManager()
|
||||
);
|
||||
new ReadingReaderManager(),
|
||||
uploadedFileReader);
|
||||
|
||||
String content = extractor.extract(buildFileValue(
|
||||
String content = extractor.extract(buildFileValue(
|
||||
"note.txt",
|
||||
"http://127.0.0.1:" + server.getAddress().getPort() + "/note.txt",
|
||||
"text/plain"
|
||||
));
|
||||
fileUrl,
|
||||
"text/plain"));
|
||||
|
||||
Assert.assertEquals("remote text", content);
|
||||
Assert.assertNull(bridgeService.lastSource);
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
Assert.assertEquals("managed text", content);
|
||||
Assert.assertNull(bridgeService.lastSource);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user