feat: implement storage provider with local and S3 adapters

This commit is contained in:
2026-04-10 00:59:35 -03:00
parent fb8c07d32e
commit 98515550ca
8 changed files with 2260 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
import { LocalStorageAdapter } from '@/lib/storage/storage.adapter';
import { promises as fs } from 'fs';
import path from 'path';
describe('LocalStorageAdapter', () => {
const testUploadDir = 'tests/tmp/uploads';
let adapter: LocalStorageAdapter;
beforeEach(async () => {
adapter = new LocalStorageAdapter(testUploadDir);
// Clean up test directory before each test
try {
await fs.rm(testUploadDir, { recursive: true, force: true });
} catch {
// Directory doesn't exist yet
}
});
afterEach(async () => {
// Clean up test directory after each test
try {
await fs.rm(testUploadDir, { recursive: true, force: true });
} catch {
// Directory might not exist
}
});
describe('put', () => {
it('should create uploads directory if it does not exist', async () => {
const key = 'test-image.jpg';
const file = Buffer.from('test file content');
await adapter.put(key, file, 'image/jpeg');
const dirExists = await fs
.stat(testUploadDir)
.then(() => true)
.catch(() => false);
expect(dirExists).toBe(true);
});
it('should save file to disk with provided key', async () => {
const key = 'test-image.jpg';
const fileContent = 'test file content';
const file = Buffer.from(fileContent);
await adapter.put(key, file, 'image/jpeg');
const savedContent = await fs.readFile(
path.join(testUploadDir, key),
'utf-8'
);
expect(savedContent).toBe(fileContent);
});
it('should return StorageResult with local type', async () => {
const key = 'test-image.jpg';
const file = Buffer.from('test file content');
const result = await adapter.put(key, file, 'image/jpeg');
expect(result).toEqual({
type: 'local',
provider: null,
key,
publicUrl: `/tests/tmp/uploads/test-image.jpg`,
});
});
it('should handle Blob input', async () => {
const key = 'test-blob.jpg';
const fileContent = 'blob file content';
const blob = new Blob([fileContent], { type: 'image/jpeg' });
const result = await adapter.put(key, blob, 'image/jpeg');
const savedContent = await fs.readFile(
path.join(testUploadDir, key),
'utf-8'
);
expect(savedContent).toBe(fileContent);
expect(result.type).toBe('local');
});
it('should create nested directories for keys with paths', async () => {
const key = 'articles/2026/04/image.jpg';
const file = Buffer.from('nested file content');
const result = await adapter.put(key, file, 'image/jpeg');
const savedContent = await fs.readFile(
path.join(testUploadDir, key),
'utf-8'
);
expect(savedContent).toBe('nested file content');
expect(result.publicUrl).toBe(
`/tests/tmp/uploads/articles/2026/04/image.jpg`
);
});
});
describe('delete', () => {
it('should delete file from disk', async () => {
const key = 'test-image.jpg';
const file = Buffer.from('test file content');
await adapter.put(key, file, 'image/jpeg');
const filePathBeforeDelete = path.join(testUploadDir, key);
expect(
await fs
.stat(filePathBeforeDelete)
.then(() => true)
.catch(() => false)
).toBe(true);
await adapter.delete(key);
const fileExists = await fs
.stat(filePathBeforeDelete)
.then(() => true)
.catch(() => false);
expect(fileExists).toBe(false);
});
it('should not throw error if file does not exist', async () => {
const key = 'non-existent-file.jpg';
// Should not throw
await expect(adapter.delete(key)).resolves.toBeUndefined();
});
it('should delete nested files', async () => {
const key = 'articles/2026/04/image.jpg';
const file = Buffer.from('nested file content');
await adapter.put(key, file, 'image/jpeg');
await adapter.delete(key);
const fileExists = await fs
.stat(path.join(testUploadDir, key))
.then(() => true)
.catch(() => false);
expect(fileExists).toBe(false);
});
});
});

View File

@@ -0,0 +1,143 @@
import { S3StorageAdapter } from '@/lib/storage/storage.adapter';
import {
DeleteObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { mockClient } from 'aws-sdk-client-mock';
describe('S3StorageAdapter', () => {
let s3Mock: ReturnType<typeof mockClient>;
let mockS3Client: S3Client;
let adapter: S3StorageAdapter;
beforeEach(() => {
s3Mock = mockClient(S3Client);
mockS3Client = new S3Client({ region: 'us-east-1' });
adapter = new S3StorageAdapter(
'test-bucket',
'us-east-1',
mockS3Client
);
});
afterEach(() => {
s3Mock.restore();
});
describe('put', () => {
it('should upload file to S3 with correct parameters', async () => {
const key = 'test-image.jpg';
const fileContent = 'test file content';
const file = Buffer.from(fileContent);
s3Mock.on(PutObjectCommand).resolves({});
await adapter.put(key, file, 'image/jpeg');
expect(s3Mock.call(0).args[0]).toBeInstanceOf(PutObjectCommand);
const command = s3Mock.call(0).args[0] as PutObjectCommand;
expect(command.input.Bucket).toBe('test-bucket');
expect(command.input.Key).toBe(key);
expect(command.input.ContentType).toBe('image/jpeg');
});
it('should return StorageResult with s3 type', async () => {
const key = 'test-image.jpg';
const file = Buffer.from('test file content');
s3Mock.on(PutObjectCommand).resolves({});
const result = await adapter.put(key, file, 'image/jpeg');
expect(result).toEqual({
type: 's3',
provider: 'aws-s3',
key,
publicUrl: `https://test-bucket.s3.us-east-1.amazonaws.com/${key}`,
});
});
it('should return correct public URL with different regions', async () => {
const adapter2 = new S3StorageAdapter(
'my-bucket',
'eu-west-1',
mockS3Client
);
const key = 'my-image.png';
const file = Buffer.from('test');
s3Mock.on(PutObjectCommand).resolves({});
const result = await adapter2.put(key, file, 'image/png');
expect(result.publicUrl).toBe(
`https://my-bucket.s3.eu-west-1.amazonaws.com/${key}`
);
});
it('should handle Blob input', async () => {
const key = 'test-blob.jpg';
const fileContent = 'blob content';
const blob = new Blob([fileContent], { type: 'image/jpeg' });
s3Mock.on(PutObjectCommand).resolves({});
const result = await adapter.put(key, blob, 'image/jpeg');
expect(result.type).toBe('s3');
expect(result.key).toBe(key);
});
it('should handle nested keys', async () => {
const key = 'articles/2026/04/image.jpg';
const file = Buffer.from('test');
s3Mock.on(PutObjectCommand).resolves({});
const result = await adapter.put(key, file, 'image/jpeg');
expect(result.key).toBe(key);
expect(result.publicUrl).toBe(
`https://test-bucket.s3.us-east-1.amazonaws.com/${key}`
);
});
});
describe('delete', () => {
it('should delete object from S3 with correct parameters', async () => {
const key = 'test-image.jpg';
s3Mock.on(DeleteObjectCommand).resolves({});
await adapter.delete(key);
expect(s3Mock.call(0).args[0]).toBeInstanceOf(DeleteObjectCommand);
const command = s3Mock.call(0).args[0] as DeleteObjectCommand;
expect(command.input.Bucket).toBe('test-bucket');
expect(command.input.Key).toBe(key);
});
it('should not throw error if deletion fails', async () => {
const key = 'test-image.jpg';
s3Mock.on(DeleteObjectCommand).rejects(new Error('S3 error'));
// Should not throw
expect(async () => {
await adapter.delete(key);
}).not.toThrow();
});
it('should handle nested keys', async () => {
const key = 'articles/2026/04/image.jpg';
s3Mock.on(DeleteObjectCommand).resolves({});
await adapter.delete(key);
const command = s3Mock.call(0).args[0] as DeleteObjectCommand;
expect(command.input.Key).toBe(key);
});
});
});