test(infra): 对象存储真实往返集成测试(LocalFileClient,反假绿)(U9 Part1)
FileServiceLocalStorageRoundTripTest:真实 LocalFileClient(临时目录)+真 FileServiceImpl+嵌入式 DB,跑 createFileReturningPath→真实落盘→按 path 字节级回读全链路;内容含 CJK+全字节(0..255)二进制,断言磁盘字节==上传字节、回读字节级一致、未知 path fail-closed(FILE_NOT_EXISTS)。无需 MinIO 即真后端真往返。2 测试绿(亲跑验证)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ceca21f73
commit
878acef255
@ -0,0 +1,148 @@
|
||||
package cn.iocoder.muse.module.infra.service.file;
|
||||
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.muse.module.infra.dal.dataobject.file.FileDO;
|
||||
import cn.iocoder.muse.module.infra.dal.mysql.file.FileMapper;
|
||||
import cn.iocoder.muse.module.infra.framework.file.core.client.local.LocalFileClient;
|
||||
import cn.iocoder.muse.module.infra.framework.file.core.client.local.LocalFileClientConfig;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static cn.iocoder.muse.framework.test.core.util.AssertUtils.assertServiceException;
|
||||
import static cn.iocoder.muse.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 对象存储真实往返集成测试(反 false-green / R7)。
|
||||
*
|
||||
* <p>WHY:{@link FileServiceImplTest} 用 {@code mock(FileClient.class)} 桩掉了字节通路,只能验证调用编排,
|
||||
* <b>无法</b>证明字节真的落盘、真的能按 path 字节级回读。本测试改用框架内真实的本地文件存储
|
||||
* {@link LocalFileClient}({@code FileStorageEnum.LOCAL=10},仅 basePath+domain、<b>无凭据</b>),
|
||||
* 指向 JUnit {@link TempDir} 临时目录,接入真实的 {@link FileServiceImpl},跑真实的“上传→落盘→落库→按 path 回读”
|
||||
* 全链路,断言字节级一致(含 CJK / 二进制字节)。这样 LOCAL 后端即可提供真实存储后端,无需 MinIO。</p>
|
||||
*
|
||||
* <p>装配沿用 {@link FileServiceImplTest}:{@link BaseDbUnitTest} 让 {@code FileDO} 的 insert /
|
||||
* {@code selectByPath} 命中嵌入式 DB;仅把 {@link FileConfigService} 改成 {@code @MockitoBean},
|
||||
* 让其 master / 按 id 两条取客户端的方法都返回<b>同一个真实</b>的 {@link LocalFileClient}。</p>
|
||||
*/
|
||||
@Import({FileServiceImpl.class})
|
||||
public class FileServiceLocalStorageRoundTripTest extends BaseDbUnitTest {
|
||||
|
||||
/**
|
||||
* 与真实 LocalFileClient 构造参数一致的配置编号;它会经 {@code client.getId()} 落入 {@code FileDO.configId},
|
||||
* 也是 {@code getFileContentByPath} 反查后据以取客户端的 key,故两处 mock 必须用同一值。
|
||||
*/
|
||||
private static final Long CONFIG_ID = 10L;
|
||||
|
||||
@Resource
|
||||
private FileServiceImpl fileService;
|
||||
|
||||
@Resource
|
||||
private FileMapper fileMapper;
|
||||
|
||||
@MockitoBean
|
||||
private FileConfigService fileConfigService;
|
||||
|
||||
/**
|
||||
* 真实本地存储后端的根目录:每个用例独立,断言文件确实落在此目录下。
|
||||
*/
|
||||
@TempDir
|
||||
private Path storageRoot;
|
||||
|
||||
private LocalFileClient localFileClient;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
// path 生成策略与 FileServiceImplTest 对齐:目录前缀 + 日期 + 时间戳子目录 + 原文件名。
|
||||
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = true;
|
||||
FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = true;
|
||||
FileServiceImpl.PATH_SUFFIX_AS_DIRECTORY = true;
|
||||
|
||||
// 按 LocalFileClientConfig + LocalFileClientTest 的方式初始化真实本地客户端:
|
||||
// basePath 指向 @TempDir 临时目录;domain 仅用于拼访问 URL,需满足 @URL 校验格式;最后必须 init()。
|
||||
LocalFileClientConfig config = new LocalFileClientConfig();
|
||||
config.setBasePath(storageRoot.toString());
|
||||
config.setDomain("http://127.0.0.1:48080");
|
||||
localFileClient = new LocalFileClient(CONFIG_ID, config);
|
||||
localFileClient.init();
|
||||
|
||||
// master(上传)与按 id(读取 / getFileContentByPath 反查后)两条路径都返回同一个真实客户端。
|
||||
when(fileConfigService.getMasterFileClient()).thenReturn(localFileClient);
|
||||
when(fileConfigService.getFileClient(eq(CONFIG_ID))).thenReturn(localFileClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实往返核心断言:createFileReturningPath 真的把字节写到临时目录下的文件,并返回稳定 path;
|
||||
* getFileContentByPath 按该 path 字节级读回,且内容含 CJK / 二进制字节也完全一致。
|
||||
*/
|
||||
@Test
|
||||
public void testRoundTrip_realLocalStorage_byteIdentical() throws Exception {
|
||||
// 构造混合内容:CJK 文本 + 全字节范围(0..255)的二进制片段,确保不是只验 ASCII 的假绿。
|
||||
byte[] cjk = "缪斯导出包·真实往返✅\n第二行:龍鳳".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] binary = new byte[256];
|
||||
for (int i = 0; i < 256; i++) {
|
||||
binary[i] = (byte) i;
|
||||
}
|
||||
byte[] content = new byte[cjk.length + binary.length];
|
||||
System.arraycopy(cjk, 0, content, 0, cjk.length);
|
||||
System.arraycopy(binary, 0, content, cjk.length, binary.length);
|
||||
|
||||
String name = "导出包.bin";
|
||||
// 租户 / 用户命名空间目录:断言稳定 path 会带上该目录前缀。
|
||||
String directory = "t1/u9/content/export";
|
||||
String type = "application/octet-stream";
|
||||
|
||||
// 调用真实上传:返回稳定存储 path(非签名 URL)。
|
||||
String path = fileService.createFileReturningPath(content, name, directory, type);
|
||||
|
||||
// 断言 1:返回的 path 体现租户 / 用户命名空间的 directory 入参。
|
||||
assertNotNull(path);
|
||||
assertTrue(path.startsWith(directory + "/"),
|
||||
"稳定 path 应以租户/用户命名空间目录为前缀,实际=" + path);
|
||||
assertTrue(path.matches(directory + "/\\d{8}/\\d+/导出包\\.bin"),
|
||||
"稳定 path 应为 directory/yyyyMMdd/时间戳/原文件名,实际=" + path);
|
||||
|
||||
// 断言 2:真实落盘 —— 临时目录下确实存在该文件,且磁盘字节与上传内容字节级一致。
|
||||
File onDisk = new File(storageRoot.toString() + File.separator + path);
|
||||
assertTrue(onDisk.isFile(), "应在临时存储目录下真实落盘:" + onDisk.getAbsolutePath());
|
||||
byte[] diskBytes = Files.readAllBytes(onDisk.toPath());
|
||||
assertArrayEquals(content, diskBytes, "磁盘上的字节必须与上传内容字节级一致");
|
||||
|
||||
// 断言 3:落库可按 path 反查,且 configId 为真实客户端 id。
|
||||
FileDO fileDO = fileMapper.selectByPath(path);
|
||||
assertNotNull(fileDO, "应能按稳定 path 反查到 FileDO");
|
||||
assertEquals(CONFIG_ID, fileDO.getConfigId());
|
||||
assertEquals((long) content.length, fileDO.getSize());
|
||||
|
||||
// 断言 4:真实回读 —— getFileContentByPath 走 selectByPath→getFileClient→真实读盘,字节级一致。
|
||||
byte[] readBack = fileService.getFileContentByPath(path);
|
||||
assertArrayEquals(content, readBack, "按 path 回读的字节必须与上传内容字节级一致(含 CJK/二进制)");
|
||||
// 显式再核对 CJK 头部,确保不是被截断后的“巧合相等”。
|
||||
assertEquals("缪斯导出包·真实往返✅\n第二行:龍鳳",
|
||||
new String(Arrays.copyOfRange(readBack, 0, cjk.length), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败关闭:未知 path(DB 无对应记录)必须抛 {@code FILE_NOT_EXISTS},绝不裸取磁盘字节,避免越权读任意对象。
|
||||
*/
|
||||
@Test
|
||||
public void testGetFileContentByPath_unknownPath_failClosed() {
|
||||
// 该 path 从未上传过,DB 无记录;即便磁盘上不存在对应文件,也应在 DB 层先失败关闭。
|
||||
assertServiceException(
|
||||
() -> fileService.getFileContentByPath("t1/u9/content/export/不存在.bin"),
|
||||
FILE_NOT_EXISTS);
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user