feat(storage): storage port and minio adapter with forbid-overwrite semantics
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
// packages/storage/test/load-env.ts —— 与 services/api/test/load-env.ts 同约定:
|
||||
// Jest 侧唯一 env 来源是仓库根 .env。本包嵌套层级与 api 不同(packages/storage/test
|
||||
// vs services/api/test),故向上查找 pnpm-workspace.yaml 定位仓库根,不硬编码层数。
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
let dir = __dirname;
|
||||
while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) {
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) {
|
||||
throw new Error('load-env: 未找到 pnpm-workspace.yaml,无法定位仓库根(.env)');
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
config({ path: join(dir, '.env') });
|
||||
@@ -0,0 +1,93 @@
|
||||
// packages/storage/test/minio.adapter.spec.ts —— MinIO 真实集成测试(依赖
|
||||
// docker compose 的 photo-live-minio-1 与仓库根 .env 的 S3_* 配置)。
|
||||
// 每次运行使用唯一前缀 test/<ts>-<uuid>/,避免历史残留对象干扰断言。
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import { StorageError, createStorageFromEnv, type MinioStorageAdapter } from '../src/index';
|
||||
|
||||
const PREFIX = `test/${Date.now()}-${randomUUID()}`;
|
||||
|
||||
describe('MinioStorageAdapter(真实 MinIO 集成)', () => {
|
||||
let storage: MinioStorageAdapter;
|
||||
|
||||
beforeAll(() => {
|
||||
storage = createStorageFromEnv();
|
||||
});
|
||||
|
||||
it('presignPut: 客户端按凭证直接 PUT 后,head 返回精确字节数与 etag', async () => {
|
||||
const key = `${PREFIX}/presign-put.bin`;
|
||||
const body = randomBytes(2048);
|
||||
const credential = await storage.presignPut(key, body.length, 'application/octet-stream', 300);
|
||||
|
||||
expect(credential.mode).toBe('presigned');
|
||||
expect(credential.method).toBe('PUT');
|
||||
expect(credential.headers['Content-Type']).toBe('application/octet-stream');
|
||||
expect(new Date(credential.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
||||
|
||||
const res = await fetch(credential.url, { method: credential.method, headers: credential.headers, body });
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const head = await storage.head(key);
|
||||
expect(head).not.toBeNull();
|
||||
expect(head?.bytes).toBe(body.length);
|
||||
expect(head?.etag).toBeTruthy();
|
||||
});
|
||||
|
||||
it('put + getStream: 写入后流式读回的字节与写入一致', async () => {
|
||||
const key = `${PREFIX}/put-get.bin`;
|
||||
const body = randomBytes(4096);
|
||||
const { etag } = await storage.put(key, body, 'application/octet-stream');
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const stream = await storage.getStream(key);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk as Uint8Array));
|
||||
}
|
||||
expect(Buffer.concat(chunks).equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('copyIfAbsent: 首次 created:true;同参数重复调用幂等 created:false 且 etag 不变', async () => {
|
||||
const srcKey = `${PREFIX}/copy-src.bin`;
|
||||
const destKey = `${PREFIX}/copy-dest.bin`;
|
||||
const body = randomBytes(512);
|
||||
await storage.put(srcKey, body, 'application/octet-stream');
|
||||
|
||||
const first = await storage.copyIfAbsent(srcKey, destKey, body.length);
|
||||
expect(first.created).toBe(true);
|
||||
expect(first.etag).toBeTruthy();
|
||||
|
||||
const second = await storage.copyIfAbsent(srcKey, destKey, body.length);
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.etag).toBe(first.etag);
|
||||
});
|
||||
|
||||
it('copyIfAbsent: dest 已存在但字节数不符 → StorageError(UPLOAD_VERIFY_FAILED)', async () => {
|
||||
const srcKey = `${PREFIX}/mismatch-src.bin`;
|
||||
const destKey = `${PREFIX}/mismatch-dest.bin`;
|
||||
const srcBody = randomBytes(64);
|
||||
await storage.put(srcKey, srcBody, 'application/octet-stream');
|
||||
// 预置一个不同大小的 dest 对象,制造核验失败
|
||||
await storage.put(destKey, randomBytes(128), 'application/octet-stream');
|
||||
|
||||
const promise = storage.copyIfAbsent(srcKey, destKey, srcBody.length);
|
||||
await expect(promise).rejects.toBeInstanceOf(StorageError);
|
||||
await expect(promise).rejects.toMatchObject({ code: ErrorCodes.UPLOAD_VERIFY_FAILED });
|
||||
});
|
||||
|
||||
it('head: 缺失键返回 null(不抛错)', async () => {
|
||||
const head = await storage.head(`${PREFIX}/missing.bin`);
|
||||
expect(head).toBeNull();
|
||||
});
|
||||
|
||||
it('presignGet: 预签名 URL 取回的字节与对象一致', async () => {
|
||||
const key = `${PREFIX}/presign-get.bin`;
|
||||
const body = randomBytes(1024);
|
||||
await storage.put(key, body, 'application/octet-stream');
|
||||
|
||||
const url = await storage.presignGet(key, 300);
|
||||
const res = await fetch(url);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(Buffer.from(await res.arrayBuffer()).equals(body)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user