feat: enhance S3StorageAdapter tests with presigner integration and improved key handling
All checks were successful
Build and Test / run-test (20.x) (push) Successful in 43s
All checks were successful
Build and Test / run-test (20.x) (push) Successful in 43s
This commit is contained in:
@@ -1,113 +1,131 @@
|
|||||||
import { S3StorageAdapter } from '@/lib/storage/storage.adapter';
|
import {
|
||||||
|
S3StorageAdapter,
|
||||||
|
S3StorageConfig,
|
||||||
|
} from '@/lib/storage/storage.adapter';
|
||||||
import {
|
import {
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
S3Client,
|
S3Client,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
|
import * as presigner from '@aws-sdk/s3-request-presigner';
|
||||||
import { mockClient } from 'aws-sdk-client-mock';
|
import { mockClient } from 'aws-sdk-client-mock';
|
||||||
|
|
||||||
|
jest.mock('@aws-sdk/s3-request-presigner');
|
||||||
|
|
||||||
describe('S3StorageAdapter', () => {
|
describe('S3StorageAdapter', () => {
|
||||||
let s3Mock: ReturnType<typeof mockClient>;
|
let s3Mock: ReturnType<typeof mockClient>;
|
||||||
let mockS3Client: S3Client;
|
let mockS3Client: S3Client;
|
||||||
let adapter: S3StorageAdapter;
|
let adapter: S3StorageAdapter;
|
||||||
|
|
||||||
|
const config: S3StorageConfig = {
|
||||||
|
endpoint: 'http://localhost:9000',
|
||||||
|
bucket: 'test-bucket',
|
||||||
|
region: 'us-east-1',
|
||||||
|
accessKey: 'test-access-key',
|
||||||
|
secretKey: 'test-secret-key',
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
s3Mock = mockClient(S3Client);
|
s3Mock = mockClient(S3Client);
|
||||||
mockS3Client = new S3Client({ region: 'us-east-1' });
|
mockS3Client = new S3Client({ region: 'us-east-1' });
|
||||||
adapter = new S3StorageAdapter(
|
adapter = new S3StorageAdapter(config, mockS3Client);
|
||||||
'test-bucket',
|
|
||||||
'us-east-1',
|
|
||||||
mockS3Client
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
s3Mock.restore();
|
s3Mock.restore();
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('put', () => {
|
describe('get', () => {
|
||||||
it('should upload file to S3 with correct parameters', async () => {
|
it('should return public URL for key', async () => {
|
||||||
const key = 'test-image.jpg';
|
const key = 'test-image.jpg';
|
||||||
const fileContent = 'test file content';
|
|
||||||
const file = Buffer.from(fileContent);
|
|
||||||
|
|
||||||
s3Mock.on(PutObjectCommand).resolves({});
|
const result = await adapter.get(key);
|
||||||
|
|
||||||
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({
|
expect(result).toEqual({
|
||||||
type: 's3',
|
ok: true,
|
||||||
provider: 'aws-s3',
|
value: `http://localhost:9000/test-bucket/${key}`,
|
||||||
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 () => {
|
it('should handle nested keys', async () => {
|
||||||
const key = 'articles/2026/04/image.jpg';
|
const key = 'articles/2026/04/image.jpg';
|
||||||
const file = Buffer.from('test');
|
|
||||||
|
|
||||||
s3Mock.on(PutObjectCommand).resolves({});
|
const result = await adapter.get(key);
|
||||||
|
|
||||||
const result = await adapter.put(key, file, 'image/jpeg');
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: `http://localhost:9000/test-bucket/${key}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.key).toBe(key);
|
describe('put', () => {
|
||||||
expect(result.publicUrl).toBe(
|
it('should call presigner with correct command parameters', async () => {
|
||||||
`https://test-bucket.s3.us-east-1.amazonaws.com/${key}`
|
const key = 'test-image.jpg';
|
||||||
|
jest.mocked(presigner.getSignedUrl).mockResolvedValue(
|
||||||
|
'https://presigned-url.example.com'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await adapter.put(key, 'image/jpeg');
|
||||||
|
|
||||||
|
const [, command] = jest.mocked(presigner.getSignedUrl).mock
|
||||||
|
.calls[0];
|
||||||
|
expect(command).toBeInstanceOf(PutObjectCommand);
|
||||||
|
expect((command as PutObjectCommand).input.Bucket).toBe(
|
||||||
|
'test-bucket'
|
||||||
|
);
|
||||||
|
expect((command as PutObjectCommand).input.Key).toBe(key);
|
||||||
|
expect((command as PutObjectCommand).input.ContentType).toBe(
|
||||||
|
'image/jpeg'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use 3600 second expiry', async () => {
|
||||||
|
jest.mocked(presigner.getSignedUrl).mockResolvedValue(
|
||||||
|
'https://presigned-url.example.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
await adapter.put('test-image.jpg', 'image/jpeg');
|
||||||
|
|
||||||
|
const [, , options] = jest.mocked(presigner.getSignedUrl).mock
|
||||||
|
.calls[0];
|
||||||
|
expect(options).toEqual({ expiresIn: 3600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ok result with the presigned URL', async () => {
|
||||||
|
const presignedUrl = 'https://presigned-url.example.com';
|
||||||
|
jest.mocked(presigner.getSignedUrl).mockResolvedValue(presignedUrl);
|
||||||
|
|
||||||
|
const result = await adapter.put('test-image.jpg', 'image/jpeg');
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, value: presignedUrl });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct presigned URL for different content types', async () => {
|
||||||
|
const presignedUrl =
|
||||||
|
'https://presigned-url.example.com/my-image.png';
|
||||||
|
jest.mocked(presigner.getSignedUrl).mockResolvedValue(presignedUrl);
|
||||||
|
|
||||||
|
const result = await adapter.put('my-image.png', 'image/png');
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, value: presignedUrl });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error result on presigner failure', async () => {
|
||||||
|
jest.mocked(presigner.getSignedUrl).mockRejectedValue(
|
||||||
|
new Error('Presigner error')
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await adapter.put('test-image.jpg', 'image/jpeg');
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('delete', () => {
|
describe('delete', () => {
|
||||||
it('should delete object from S3 with correct parameters', async () => {
|
it('should delete object from S3 with correct parameters', async () => {
|
||||||
const key = 'test-image.jpg';
|
const key = 'test-image.jpg';
|
||||||
|
|
||||||
s3Mock.on(DeleteObjectCommand).resolves({});
|
s3Mock.on(DeleteObjectCommand).resolves({});
|
||||||
|
|
||||||
await adapter.delete(key);
|
await adapter.delete(key);
|
||||||
@@ -118,20 +136,24 @@ describe('S3StorageAdapter', () => {
|
|||||||
expect(command.input.Key).toBe(key);
|
expect(command.input.Key).toBe(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not throw error if deletion fails', async () => {
|
it('should return ok result on success', async () => {
|
||||||
const key = 'test-image.jpg';
|
s3Mock.on(DeleteObjectCommand).resolves({});
|
||||||
|
|
||||||
|
const result = await adapter.delete('test-image.jpg');
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, value: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error result on failure', async () => {
|
||||||
s3Mock.on(DeleteObjectCommand).rejects(new Error('S3 error'));
|
s3Mock.on(DeleteObjectCommand).rejects(new Error('S3 error'));
|
||||||
|
|
||||||
// Should not throw
|
const result = await adapter.delete('test-image.jpg');
|
||||||
expect(async () => {
|
|
||||||
await adapter.delete(key);
|
expect(result.ok).toBe(false);
|
||||||
}).not.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle nested keys', async () => {
|
it('should handle nested keys', async () => {
|
||||||
const key = 'articles/2026/04/image.jpg';
|
const key = 'articles/2026/04/image.jpg';
|
||||||
|
|
||||||
s3Mock.on(DeleteObjectCommand).resolves({});
|
s3Mock.on(DeleteObjectCommand).resolves({});
|
||||||
|
|
||||||
await adapter.delete(key);
|
await adapter.delete(key);
|
||||||
|
|||||||
Reference in New Issue
Block a user