import { S3ExternalStorage, S3StorageLocation } from './S3ExternalStorage'; import { S3Client } from '@aws-sdk/client-s3'; const TEST_TENANT_ID = 'test-tenant'; const TEST_BUCKET = 'dx-test-us-db-fallback'; type S3MockSet = { sendMock: jest.Mock; putObjectCommandMock: jest.Mock; getObjectCommandMock: jest.Mock; deleteObjectCommandMock: jest.Mock; headBucketCommandMock: jest.Mock; }; declare global { // eslint-disable-next-line no-var var __s3MockSet: S3MockSet | undefined; } jest.mock('@aws-sdk/client-s3', () => { const buildCommandMock = (name: string) => jest.fn().mockImplementation((input) => ({ name, input, })); const state: S3MockSet = { sendMock: jest.fn(), putObjectCommandMock: buildCommandMock('PutObjectCommand'), getObjectCommandMock: buildCommandMock('GetObjectCommand'), deleteObjectCommandMock: buildCommandMock('DeleteObjectCommand'), headBucketCommandMock: buildCommandMock('HeadBucketCommand'), }; (global as any).__s3MockSet = state; return { S3Client: jest.fn().mockImplementation(() => ({ send: state.sendMock, })), PutObjectCommand: state.putObjectCommandMock, GetObjectCommand: state.getObjectCommandMock, DeleteObjectCommand: state.deleteObjectCommandMock, HeadBucketCommand: state.headBucketCommandMock, }; }); const { sendMock, putObjectCommandMock, getObjectCommandMock, deleteObjectCommandMock, headBucketCommandMock } = ( global as any ).__s3MockSet as S3MockSet; jest.mock('crypto', () => ({ randomUUID: jest.fn(() => 'fixed-uuid'), })); describe('S3ExternalStorage', () => { beforeEach(() => { sendMock.mockReset(); putObjectCommandMock.mockClear(); getObjectCommandMock.mockClear(); deleteObjectCommandMock.mockClear(); headBucketCommandMock.mockClear(); }); const createStorage = () => new S3ExternalStorage(TEST_TENANT_ID, new S3Client({ region: 'us-west-2' }), TEST_BUCKET); it('saves payloads to S3 and returns location metadata', async () => { const storage = createStorage(); const payload = { foo: 'bar' }; sendMock.mockResolvedValueOnce({}); // put object const result = await storage.save('test_entity', 'abc', payload); expect(putObjectCommandMock).toHaveBeenCalledWith( expect.objectContaining({ Bucket: 'dx-test-us-db-fallback', Body: JSON.stringify(payload), ContentType: 'application/json', Key: result.location.key, }), ); expect(result.location).toMatchObject({ type: 's3', }); expect(result.location.key).toBe('test-tenant/test_entity/abc.json'); expect(result.size).toBe(JSON.stringify(payload).length); }); it('loads payloads from S3 and parses JSON', async () => { const storage = createStorage(); const storedPayload = { baz: 'qux' }; const transformer = jest.fn().mockResolvedValue(JSON.stringify(storedPayload)); sendMock.mockResolvedValueOnce({ Body: { transformToString: transformer, }, }); const location: S3StorageLocation = { type: 's3', key: 'items/foo/bar.json' }; const result = await storage.load(location); expect(getObjectCommandMock).toHaveBeenCalledWith({ Bucket: 'dx-test-us-db-fallback', Key: 'items/foo/bar.json', }); expect(transformer).toHaveBeenCalled(); expect(result).toEqual(storedPayload); }); it('deletes payloads from S3 when a location is provided', async () => { const storage = createStorage(); const location: S3StorageLocation = { type: 's3', key: 'items/foo.json' }; sendMock.mockResolvedValueOnce({}); // delete await storage.delete(location); expect(deleteObjectCommandMock).toHaveBeenCalledWith({ Bucket: 'dx-test-us-db-fallback', Key: 'items/foo.json', }); }); it('handles S3 errors when saving', async () => { const storage = createStorage(); const payload = { foo: 'bar' }; const s3Error = Object.assign(new Error('AccessDenied'), { name: 'AccessDenied', $metadata: { httpStatusCode: 403 }, }); sendMock.mockRejectedValueOnce(s3Error); await expect(storage.save('test_entity', 'first', payload)).rejects.toThrow('AccessDenied'); }); it('does not delete when location is undefined', async () => { const storage = createStorage(); await storage.delete(undefined); expect(deleteObjectCommandMock).not.toHaveBeenCalled(); }); it('generates unique S3 keys for different entity types', async () => { const storage = createStorage(); sendMock.mockResolvedValue({}); const result1 = await storage.save('entity_type_a', 'id-1', { data: 'test' }); const result2 = await storage.save('entity_type_b', 'id-2', { data: 'test' }); expect(result1.location.key).toContain('entity_type_a/id-1'); expect(result2.location.key).toContain('entity_type_b/id-2'); }); it('throws error when S3 response has no body', async () => { const storage = createStorage(); sendMock.mockResolvedValueOnce({ Body: undefined }); const location: S3StorageLocation = { type: 's3', key: 'items/foo/bar.json' }; await expect(storage.load(location)).rejects.toThrow( 'Failed to load externalised items from S3 object items/foo/bar.json', ); }); it('handles S3 errors gracefully when loading', async () => { const storage = createStorage(); sendMock.mockRejectedValueOnce(new Error('S3 GetObject failed')); const location: S3StorageLocation = { type: 's3', key: 'items/foo/bar.json' }; await expect(storage.load(location)).rejects.toThrow('S3 GetObject failed'); }); it('handles S3 errors gracefully when deleting', async () => { const storage = createStorage(); sendMock.mockRejectedValueOnce(new Error('S3 DeleteObject failed')); const location: S3StorageLocation = { type: 's3', key: 'items/foo.json' }; await expect(storage.delete(location)).rejects.toThrow('S3 DeleteObject failed'); }); });