mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 20:06:42 +02:00
feat(storage): add deleteByPrefix, copy, getMetadata and prod lifecycle rules
- Add deleteByPrefix(prefix) for bulk user data deletion (account cleanup) - Add copy(sourceKey, destKey) via CopyObjectCommand for file duplication - Add getMetadata(key) via HeadObjectCommand for content-type/size/metadata - Add FileMetadata type for structured metadata responses - Add minio-init container to docker-compose.macmini.yml with bucket creation, public access policies, and lifecycle rules (matching dev compose) - 96 tests passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b0e5a9c5ff
commit
152fa5fe08
5 changed files with 191 additions and 0 deletions
|
|
@ -31,6 +31,9 @@ vi.mock('@aws-sdk/client-s3', () => ({
|
|||
HeadObjectCommand: vi.fn(function (this: any, input: any) {
|
||||
Object.assign(this, input);
|
||||
}),
|
||||
CopyObjectCommand: vi.fn(function (this: any, input: any) {
|
||||
Object.assign(this, input);
|
||||
}),
|
||||
CreateMultipartUploadCommand: vi.fn(function (this: any, input: any) {
|
||||
Object.assign(this, input);
|
||||
}),
|
||||
|
|
@ -418,6 +421,88 @@ describe('StorageClient', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('deleteByPrefix', () => {
|
||||
it('lists and deletes all files with prefix', async () => {
|
||||
mockSend
|
||||
.mockResolvedValueOnce({
|
||||
Contents: [
|
||||
{ Key: 'users/123/a.png', Size: 100, LastModified: new Date() },
|
||||
{ Key: 'users/123/b.png', Size: 200, LastModified: new Date() },
|
||||
],
|
||||
IsTruncated: false,
|
||||
})
|
||||
.mockResolvedValue({}); // deleteMany
|
||||
|
||||
const count = await storage.deleteByPrefix('users/123/');
|
||||
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 when prefix has no files', async () => {
|
||||
mockSend.mockResolvedValue({ Contents: undefined, IsTruncated: false });
|
||||
|
||||
const count = await storage.deleteByPrefix('users/nonexistent/');
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(mockSend).toHaveBeenCalledTimes(1); // only list, no delete
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy', () => {
|
||||
it('copies a file and returns new key', async () => {
|
||||
mockSend.mockResolvedValue({ CopyObjectResult: { ETag: '"copied"' } });
|
||||
|
||||
const result = await storage.copy('old/file.png', 'new/file.png');
|
||||
|
||||
expect(result.key).toBe('new/file.png');
|
||||
expect(result.etag).toBe('"copied"');
|
||||
expect(result.url).toBe('http://localhost:9000/test-bucket/new/file.png');
|
||||
});
|
||||
|
||||
it('sends CopyObjectCommand with correct source', async () => {
|
||||
mockSend.mockResolvedValue({ CopyObjectResult: {} });
|
||||
const { CopyObjectCommand } = await import('@aws-sdk/client-s3');
|
||||
|
||||
await storage.copy('src.png', 'dst.png');
|
||||
|
||||
expect(CopyObjectCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Bucket: 'test-bucket',
|
||||
CopySource: 'test-bucket/src.png',
|
||||
Key: 'dst.png',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetadata', () => {
|
||||
it('returns file metadata', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
ContentType: 'image/png',
|
||||
ContentLength: 4096,
|
||||
LastModified: new Date('2024-06-15'),
|
||||
ETag: '"meta-etag"',
|
||||
Metadata: { author: 'test' },
|
||||
});
|
||||
|
||||
const meta = await storage.getMetadata('file.png');
|
||||
|
||||
expect(meta.contentType).toBe('image/png');
|
||||
expect(meta.size).toBe(4096);
|
||||
expect(meta.etag).toBe('"meta-etag"');
|
||||
expect(meta.metadata).toEqual({ author: 'test' });
|
||||
});
|
||||
|
||||
it('handles missing optional fields', async () => {
|
||||
mockSend.mockResolvedValue({});
|
||||
|
||||
const meta = await storage.getMetadata('file.png');
|
||||
|
||||
expect(meta.size).toBe(0);
|
||||
expect(meta.contentType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('presigned multipart upload', () => {
|
||||
it('createMultipartUpload returns upload ID', async () => {
|
||||
mockSend.mockResolvedValue({ UploadId: 'mp-123' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue