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:
lili 2026-06-19 01:09:49 -07:00
parent 4ceca21f73
commit 878acef255

View File

@ -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);
}
}