import { ExternalizedDynamoDbRepository } from './ExternalizedDynamoDbRepository'; import { S3ExternalStorage, S3StorageLocation } from '../s3/S3ExternalStorage'; import { DynamoDbManager } from '../dynamodb/DynamoDbManager'; import { EntityDefinition } from '../dynamodb/AbstractDynamoDbRepository'; // Mock model class for testing interface TestItemShape { id: string; name: string; data: Array<{ key: string; value: object }>; } class TestItem implements TestItemShape { id: string; name: string; data: Array<{ key: string; value: object }>; storageLocation?: S3StorageLocation; constructor(input: Record = {}) { this.id = (input.id as string) || ''; this.name = (input.name as string) || ''; this.data = (input.data as Array<{ key: string; value: object }>) || []; // Note: storageLocation is intentionally not set from input to simulate model stripping it } } // Test entity definition const testEntityDefinition: EntityDefinition = { keys: { pk: { attributeName: 'pk', format: 'ITEM#${id}', }, sk: { attributeName: 'sk', format: 'ITEM#${id}', }, }, indexes: {}, fieldsAsJsonString: ['data'], }; // Concrete test repository class TestRepo extends ExternalizedDynamoDbRepository { public mockSuperUpdateItem?: jest.Mock; public mockSuperGetItem?: jest.Mock; public mockSuperCreateItem?: jest.Mock; constructor(dbManager: DynamoDbManager, storage: S3ExternalStorage, overrides: Partial = {}) { super('test-table', dbManager, 'test_item', { ...testEntityDefinition, ...overrides }, TestItem, storage); } // Expose protected methods for testing public async testPrepareValueForStorage(value: TestItem): Promise { return await (this as any).prepareValueForStorage(value); } public async testHydrateFromExternalStorage(record?: TestItem): Promise { return await (this as any).hydrateFromExternalStorage(record); } public testIsDynamoItemSizeLimitError(error: unknown): boolean { return (this as any).isDynamoItemSizeLimitError(error); } public testIsContentInS3(item: TestItem): boolean { return (this as any).isContentInS3(item); } public testGetKeyFieldsValues(obj: Record): Record { return (this as any).getKeyFieldsValues(obj); } // Override updateItem to allow mocking super.updateItem public async updateItem(value: Partial): Promise { if (this.mockSuperUpdateItem) { // Use mock if provided const previousLocation = await (this as any).fetchStoredLocation(value); const updatedItem = await this.mockSuperUpdateItem(value); if (!updatedItem) { return undefined; } const newLocation = (updatedItem as any).storageLocation; if (previousLocation && previousLocation.key !== newLocation?.key) { await (this as any).storage.delete(previousLocation); } return updatedItem; } return super.updateItem(value); } } const createDbManager = (clientOverrides: Record = {}) => ({ client: { get: jest.fn(), put: jest.fn(), delete: jest.fn(), update: jest.fn(), query: jest.fn(), ...clientOverrides, }, repositories: {} as any, executeInTransaction: async (fn: (transaction: any) => Promise): Promise => fn({}), addWriteTransactionItem: jest.fn(), } as unknown as DynamoDbManager); const createStorage = () => ({ save: jest.fn(), load: jest.fn(), delete: jest.fn(), } as unknown as S3ExternalStorage); const createTestItem = (overrides: Partial = {}): TestItem => { const item = new TestItem({ id: 'item-1', name: 'Test', data: [{ key: 'sample', value: { main: [] } }], ...overrides, }); return item; }; describe('ExternalizedDynamoDbRepository', () => { describe('prepareValueForStorage', () => { it('externalizes data to S3 when prepareValueForStorage is called', async () => { const storage = { ...createStorage(), save: jest.fn().mockResolvedValue({ location: { type: 's3', key: 'item-key' }, size: 500 }), } as unknown as S3ExternalStorage; const repo = new TestRepo(createDbManager(), storage); const item = createTestItem(); const storedValue = await repo.testPrepareValueForStorage(item); expect(storage.save).toHaveBeenCalled(); // storedValue is a minimal payload - original item is NOT mutated expect(storedValue.data).toEqual([]); expect(storedValue.storageLocation).toEqual({ type: 's3', key: 'item-key' }); }); it('removes existing storageLocation before saving to S3', async () => { const storage = { ...createStorage(), save: jest.fn().mockResolvedValue({ location: { type: 's3', key: 'new-key' }, size: 500 }), } as unknown as S3ExternalStorage; const repo = new TestRepo(createDbManager(), storage); const item = createTestItem(); (item as any).storageLocation = { type: 's3', key: 'old-key' }; await repo.testPrepareValueForStorage(item); // Verify save was called without storageLocation in the payload const savedPayload = (storage.save as jest.Mock).mock.calls[0][2]; expect(savedPayload.storageLocation).toBeUndefined(); }); }); describe('createItem', () => { it('creates item with storageLocation preserved', async () => { const client = createDbManager().client as any; (client.put as jest.Mock).mockResolvedValue({}); const storage = { ...createStorage(), save: jest.fn().mockResolvedValue({ location: { type: 's3', key: 'item-key' }, size: 500 }), load: jest.fn().mockResolvedValue(createTestItem()), } as unknown as S3ExternalStorage; const repo = new TestRepo({ client } as any, storage); const item = createTestItem(); // Use prepareValueForStorage to externalize data and set storageLocation const preparedItem = await repo.testPrepareValueForStorage(item); // Verify the prepared value has minimal payload (empty data) expect(preparedItem.data).toEqual([]); expect(preparedItem.storageLocation).toEqual({ type: 's3', key: 'item-key' }); // Original item should NOT be mutated expect(item.storageLocation).toBeUndefined(); }); it('automatically externalizes to S3 when createItem exceeds DynamoDB size limit', async () => { const client = createDbManager().client as any; let callCount = 0; (client.put as jest.Mock).mockImplementation(() => { callCount++; if (callCount === 1) { // First call fails with size limit error const error = new Error('Item size has exceeded the maximum allowed size'); (error as any).code = 'ValidationException'; throw error; } // Second call succeeds return Promise.resolve({}); }); const storage = { ...createStorage(), save: jest.fn().mockResolvedValue({ location: { type: 's3', key: 'auto-key' }, size: 500 }), load: jest.fn().mockResolvedValue(createTestItem()), } as unknown as S3ExternalStorage; const repo = new TestRepo({ client } as any, storage); const item = createTestItem({ data: [{ key: 'large', value: { content: 'x'.repeat(500000) } }] }); await repo.createItem(item); // Should have called save to externalize expect(storage.save).toHaveBeenCalled(); // Should have retried the put expect(client.put).toHaveBeenCalledTimes(2); }); it('cleans up old S3 file when overrideExisting is true', async () => { const oldLocation = { type: 's3' as const, key: 'old-key' }; const newLocation = { type: 's3' as const, key: 'new-key' }; const client = createDbManager({ get: jest.fn().mockResolvedValue({ Item: { id: 'item-1', name: 'Old', storageLocation: oldLocation, }, }), put: jest.fn().mockResolvedValue({}), }).client as any; const storage = { ...createStorage(), save: jest.fn().mockResolvedValue({ location: newLocation, size: 500 }), load: jest.fn().mockResolvedValue(createTestItem()), } as unknown as S3ExternalStorage; const repo = new TestRepo({ client } as any, storage); const item = createTestItem(); const preparedItem = await repo.testPrepareValueForStorage(item); await repo.createItem(preparedItem, {}, {}, { overrideExisting: true }); expect(storage.delete).toHaveBeenCalledWith(oldLocation); }); }); describe('updateItem', () => { it('cleans up old S3 file when updating with new storageLocation', async () => { const oldLocation = { type: 's3' as const, key: 'old-key' }; const newLocation = { type: 's3' as const, key: 'new-key' }; const client = createDbManager({ get: jest.fn().mockResolvedValue({ Item: { id: 'item-1', name: 'Test', storageLocation: oldLocation, }, }), update: jest.fn().mockResolvedValue({ Attributes: { id: 'item-1', name: 'Updated', storageLocation: newLocation, }, }), }).client as any; const storage = createStorage(); const repo = new TestRepo({ client } as any, storage); const updateValue = { id: 'item-1', name: 'Updated', }; const updatedItem = createTestItem({ id: 'item-1', name: 'Updated' }); (updatedItem as any).storageLocation = newLocation; repo.mockSuperUpdateItem = jest.fn().mockResolvedValue(updatedItem as any); await repo.updateItem(updateValue); expect(storage.delete).toHaveBeenCalledWith(oldLocation); }); it('does not delete S3 file when storageLocation is unchanged', async () => { const location = { type: 's3' as const, key: 'same-key' }; const client = createDbManager({ get: jest.fn().mockResolvedValue({ Item: { id: 'item-1', name: 'Test', storageLocation: location, }, }), update: jest.fn().mockResolvedValue({ Attributes: { id: 'item-1', name: 'Updated', storageLocation: location, }, }), }).client as any; const storage = createStorage(); const repo = new TestRepo({ client } as any, storage); const updateValue = { id: 'item-1', name: 'Updated', }; const updatedItem = createTestItem({ id: 'item-1', name: 'Updated' }); (updatedItem as any).storageLocation = location; repo.mockSuperUpdateItem = jest.fn().mockResolvedValue(updatedItem as any); await repo.updateItem(updateValue); expect(storage.delete).not.toHaveBeenCalled(); }); }); describe('getItem', () => { it('loads payload from external storage when storageLocation is set', async () => { const storage = createStorage(); const fullData = { id: 'item-1', name: 'From S3', data: [{ key: 'sample', value: { main: [] } }], }; (storage.load as jest.Mock).mockResolvedValue(fullData); const repo = new TestRepo(createDbManager({ get: jest.fn() as any }), storage); const result = await repo.testHydrateFromExternalStorage({ id: 'item-1', name: 'Placeholder', data: [], storageLocation: { type: 's3', key: 'item-key' }, } as TestItem); expect(storage.load).toHaveBeenCalledWith({ type: 's3', key: 'item-key' }); expect(result?.name).toBe('From S3'); expect(result?.data).toEqual([{ key: 'sample', value: { main: [] } }]); }); it('returns inline data when no storageLocation is present', async () => { const storage = createStorage(); const repo = new TestRepo(createDbManager({ get: jest.fn() as any }), storage); const inlineRecord = { id: 'item-1', name: 'Inline Item', data: [{ key: 'sample', value: { main: [] } }], } as TestItem; const result = await repo.testHydrateFromExternalStorage(inlineRecord); expect(storage.load).not.toHaveBeenCalled(); expect(result).toBeDefined(); expect(result?.name).toBe('Inline Item'); expect(result?.data).toEqual([{ key: 'sample', value: { main: [] } }]); }); it('never includes storageLocation in response', async () => { const storage = createStorage(); const fullData = { id: 'item-1', name: 'From S3', data: [{ key: 'sample', value: { main: [] } }], }; (storage.load as jest.Mock).mockResolvedValue(fullData); const repo = new TestRepo(createDbManager(), storage); const result = await repo.testHydrateFromExternalStorage({ id: 'item-1', name: 'Placeholder', data: [], storageLocation: { type: 's3', key: 'item-key' }, } as TestItem); // storageLocation is an internal implementation detail and should never be exposed expect(result?.storageLocation).toBeUndefined(); expect(result?.name).toBe('From S3'); expect(result?.data).toEqual([{ key: 'sample', value: { main: [] } }]); }); it('returns empty content when S3 storage is missing or fails to load', async () => { const storage = createStorage(); const s3Error = new Error('The specified key does not exist'); (s3Error as any).code = 'NoSuchKey'; (storage.load as jest.Mock).mockRejectedValue(s3Error); const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); const repo = new TestRepo(createDbManager(), storage); const result = await repo.testHydrateFromExternalStorage({ id: 'item-1', name: 'Test Item', data: [], storageLocation: { type: 's3', key: 'missing-key' }, } as TestItem); // Should attempt to load from S3 expect(storage.load).toHaveBeenCalledWith({ type: 's3', key: 'missing-key' }); // Should log warning about failed load expect(consoleWarnSpy).toHaveBeenCalledWith( 'Failed to load content from S3 for test_item:', 'The specified key does not exist', ); // Should return item with empty content (graceful degradation) expect(result).toBeDefined(); expect(result?.id).toBe('item-1'); expect(result?.name).toBe('Test Item'); expect(result?.data).toEqual([]); // storageLocation should be removed to avoid confusion expect(result?.storageLocation).toBeUndefined(); consoleWarnSpy.mockRestore(); }); }); describe('deleteItem', () => { it('deletes externalized payloads when deleting items', async () => { const client = createDbManager({ get: jest.fn().mockResolvedValue({ Item: { storageLocation: { type: 's3', key: 'item-key' } } }), delete: jest.fn().mockResolvedValue({}), }).client as any; const storage = createStorage(); const repo = new TestRepo({ client } as any, storage); await repo.deleteItem({ id: 'item-1' }); expect(storage.delete).toHaveBeenCalledWith({ type: 's3', key: 'item-key' }); }); it('does not attempt to delete S3 when item has no storageLocation', async () => { const client = createDbManager({ get: jest.fn().mockResolvedValue({ Item: { id: 'item-1', name: 'Test' } }), delete: jest.fn().mockResolvedValue({}), }).client as any; const storage = createStorage(); const repo = new TestRepo({ client } as any, storage); await repo.deleteItem({ id: 'item-1' }); expect(storage.delete).not.toHaveBeenCalled(); }); }); describe('isDynamoItemSizeLimitError', () => { it('detects ValidationException with size message', () => { const repo = new TestRepo(createDbManager(), createStorage()); const error = { code: 'ValidationException', message: 'Item size has exceeded the maximum allowed size', }; expect(repo.testIsDynamoItemSizeLimitError(error)).toBe(true); }); it('detects TransactionCanceledException with size in cancellation reasons', () => { const repo = new TestRepo(createDbManager(), createStorage()); const error = { code: 'TransactionCanceledException', CancellationReasons: [ { Code: 'ValidationException', Message: 'Item size exceeded 400 KB limit', }, ], }; expect(repo.testIsDynamoItemSizeLimitError(error)).toBe(true); }); it('detects size keywords in error message', () => { const repo = new TestRepo(createDbManager(), createStorage()); const error = { message: 'The item size exceeds the maximum allowed size', }; expect(repo.testIsDynamoItemSizeLimitError(error)).toBe(true); }); it('returns false for non-size-related errors', () => { const repo = new TestRepo(createDbManager(), createStorage()); const error = { code: 'ResourceNotFoundException', message: 'Table not found', }; expect(repo.testIsDynamoItemSizeLimitError(error)).toBe(false); }); it('returns false for null/undefined errors', () => { const repo = new TestRepo(createDbManager(), createStorage()); expect(repo.testIsDynamoItemSizeLimitError(null)).toBe(false); expect(repo.testIsDynamoItemSizeLimitError(undefined)).toBe(false); }); }); describe('isContentInS3', () => { it('returns true when storageLocation exists and large content fields are empty', () => { const repo = new TestRepo(createDbManager(), createStorage()); const item = createTestItem({ data: [] }); (item as any).storageLocation = { type: 's3', key: 'item-key' }; expect(repo.testIsContentInS3(item)).toBe(true); }); it('returns false when storageLocation exists but large content fields have data', () => { const repo = new TestRepo(createDbManager(), createStorage()); const item = createTestItem({ data: [{ key: 'sample', value: { main: [] } }] }); (item as any).storageLocation = { type: 's3', key: 'item-key' }; expect(repo.testIsContentInS3(item)).toBe(false); }); it('returns false when no storageLocation', () => { const repo = new TestRepo(createDbManager(), createStorage()); const item = createTestItem(); expect(repo.testIsContentInS3(item)).toBe(false); }); }); describe('getKeyFieldsValues', () => { it('preserves key fields and small metadata', () => { const repo = new TestRepo(createDbManager(), createStorage()); const obj = { id: 'item-1', name: 'Test', data: [{ key: 'large', value: { content: 'big' } }], }; const result = repo.testGetKeyFieldsValues(obj); expect(result.id).toBe('item-1'); expect(result.name).toBe('Test'); }); it('empties large content fields (fieldsAsJsonString)', () => { const repo = new TestRepo(createDbManager(), createStorage()); const obj = { id: 'item-1', name: 'Test', data: [{ key: 'large', value: { content: 'big' } }], }; const result = repo.testGetKeyFieldsValues(obj); // data is in fieldsAsJsonString, so should be empty array expect(result.data).toEqual([]); }); it('removes storageLocation from result', () => { const repo = new TestRepo(createDbManager(), createStorage()); const obj = { id: 'item-1', name: 'Test', data: [], storageLocation: { type: 's3', key: 'item-key' }, }; const result = repo.testGetKeyFieldsValues(obj); expect(result.storageLocation).toBeUndefined(); }); }); });