import { AbstractDynamoDbRepository, QueryOptions } from './AbstractDynamoDbRepository'; import { DeleteCommand as DeleteCommandClass, DeleteCommandInput, DynamoDBDocument, DynamoDBDocumentClient, GetCommand as GetCommandClass, GetCommandInput, PutCommand as PutCommandClass, PutCommandInput, QueryCommand as QueryCommandClass, QueryCommandInput, UpdateCommand as UpdateCommandClass, UpdateCommandInput, TransactWriteCommand as TransactWriteCommandClass, TransactWriteCommandInput, BatchGetCommand as BatchGetCommandClass, BatchGetCommandInput, BatchWriteCommand as BatchWriteCommandClass, BatchWriteCommandInput, } from '@aws-sdk/lib-dynamodb'; import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { DynamoDbManager, Transaction } from './DynamoDbManager'; import { MissingKeyValuesError } from '../error/MissingKeyValuesError'; import { InvalidDbSchemaError } from '../error/InvalidDbSchemaError'; import { mockClient } from 'aws-sdk-client-mock'; import 'aws-sdk-client-mock-jest'; import { DynamoDB } from '@aws-sdk/client-dynamodb'; import crypto from 'crypto'; import { InvalidDataFormatError } from '../error/InvalidDataFormatError'; const ddbClientMock: any = mockClient(DynamoDBDocumentClient as any); const DeleteCommand: any = DeleteCommandClass; const GetCommand: any = GetCommandClass; const PutCommand: any = PutCommandClass; const QueryCommand: any = QueryCommandClass; const UpdateCommand: any = UpdateCommandClass; const TransactWriteCommand: any = TransactWriteCommandClass; const BatchGetCommand: any = BatchGetCommandClass; const BatchWriteCommand: any = BatchWriteCommandClass; const ddbDoc = DynamoDBDocument.from(new DynamoDB({})); interface ITestItem { name: string; age: number; country: string; data?: object; data2?: object; email?: string; } class TestItem implements ITestItem { public name: string; public age: number; public country: string; public data: object; public data2?: object; public email?: string; constructor(data: Partial = {}) { this.name = data.name ?? 'default name'; this.age = data.age ?? 0; this.country = data.country ?? 'default country'; this.data = data.data ?? {}; this.data2 = data.data2 ?? {}; if (typeof this.name !== 'string') { throw Error('Invalid "name"'); } if (typeof this.age !== 'number') { throw Error('Invalid "age"'); } if (typeof this.country !== 'string') { throw Error('Invalid "country"'); } if (typeof this.data !== 'object' || Array.isArray(this.data)) { throw Error('Invalid "data"'); } if (data.email !== undefined) { if (typeof data.email !== 'string') { throw Error('Invalid "name"'); } this.email = data.email; } } } interface ITestItem2 { date: string; info: { id: string; name: string; age: number; address: { country: string; city?: string; }; }; } class TestItem2 implements ITestItem2 { public date: string; public info: ITestItem2['info']; constructor(data: Partial = {}) { this.date = data.date ?? ''; this.info = data.info as ITestItem2['info']; } } export type TestRepositories = { testItem: TestItemRepository; testItem2: TestItem2Repository; }; export type TestDbManager = DynamoDbManager; const TABLE_NAME = 'test-table'; const TEST_ITEM_ENTITY_NAME = 'test-item-entity'; const TEST_ITEM_ENTITY_DEFINITION = { keys: { pk: { format: 'test_item#{name}', attributeName: 'pk', }, sk: { format: '#meta', attributeName: 'sk', }, }, indexes: { 'gsi1_pk-gsi1_sk-index': { pk: { format: 'country#{country}', attributeName: 'gsi1_pk', }, sk: { format: 'age#{age}', attributeName: 'gsi1_sk', }, }, 'gsi2_pk-gsi2_sk-index': { pk: { format: 'email#{email}', attributeName: 'gsi2_pk', }, sk: { format: '#meta', attributeName: 'gsi2_sk', }, }, }, // field to be stored as JSON string fieldsAsJsonString: ['data2'], optionalIndexes: ['gsi2_pk-gsi2_sk-index'], }; const TEST_ITEM2_ENTITY_NAME = 'test-item-entity2'; const TEST_ITEM2_ENTITY_DEFINITION = { keys: { pk: { format: 'c#{info.address.country}', attributeName: 'pk', }, sk: { format: 'ci#{info.address.city}#{info.id}', attributeName: 'sk', }, }, indexes: { 'gsi1_pk-gsi1_sk-index': { pk: { format: 'dc#{date}#{info.address.country}', attributeName: 'gsi1_pk', }, sk: { format: '#meta', attributeName: 'gsi1_sk', }, }, }, fieldsAsJsonString: [], }; class TestItemRepository extends AbstractDynamoDbRepository { constructor(tableName: string, dbManager: TestDbManager) { super(tableName, dbManager, TEST_ITEM_ENTITY_NAME, TEST_ITEM_ENTITY_DEFINITION, TestItem); } } class TestItem2Repository extends AbstractDynamoDbRepository { constructor(tableName: string, dbManager: TestDbManager) { super(tableName, dbManager, TEST_ITEM2_ENTITY_NAME, TEST_ITEM2_ENTITY_DEFINITION, TestItem2); } } const ddbManager = new DynamoDbManager(ddbDoc, (dbManager: TestDbManager) => { return { testItem: new TestItemRepository(TABLE_NAME, dbManager), testItem2: new TestItem2Repository(TABLE_NAME, dbManager), }; }); // Test start //////////////////////////////////// describe('AbstractRepository', () => { let repository: TestItemRepository; beforeEach(() => { ddbClientMock.reset(); repository = new TestItemRepository(TABLE_NAME, ddbManager); }); describe('createItem()', () => { it('should create and return the item object if valid input', async () => { ddbClientMock.on(PutCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const input: PutCommandInput = { TableName: TABLE_NAME, Item: { pk: 'test_item#foo', sk: '#meta', gsi1_pk: 'country#au', gsi1_sk: 'age#99', name: 'foo', age: 99, country: 'au', data: {}, // "data2" property is defined to be stored as JSON string data2: '{"foo":"bar","num":123}', }, ConditionExpression: `attribute_not_exists(pk)`, }; const item = { name: 'foo', age: 99, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, }; const result = await repository.createItem(item); expect(ddbClientMock).toHaveReceivedCommandWith(PutCommand, input); expect(result).toEqual( new TestItem({ name: 'foo', age: 99, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, }), ); }); it('should create and return the item with multiple gsi fields', async () => { ddbClientMock.on(PutCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const input: PutCommandInput = { TableName: TABLE_NAME, Item: { pk: 'test_item#foo', sk: '#meta', gsi1_pk: 'country#au', gsi1_sk: 'age#99', gsi2_pk: 'email#foo@bar.xyz', gsi2_sk: '#meta', name: 'foo', age: 99, country: 'au', data: {}, // "data2" property is defined to be stored as JSON string data2: '{"foo":"bar","num":123}', email: 'foo@bar.xyz', }, ConditionExpression: `attribute_not_exists(pk)`, }; const item = { name: 'foo', age: 99, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, email: 'foo@bar.xyz', }; const result = await repository.createItem(item); expect(ddbClientMock).toHaveReceivedCommandWith(PutCommand, input); expect(result).toEqual( new TestItem({ name: 'foo', age: 99, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, email: 'foo@bar.xyz', }), ); }); it('should throw error if invalid input', async () => { const item = { name: 'foo', age: 99, country: 'au', data: [], // should be non-array object }; await expect(repository.createItem(item)).rejects.toEqual(new Error('Invalid "data"')); }); it('should throw error if excess column in input', async () => { const item = { name: 'foo', age: 99, country: 'au', data: {}, extraColumn: '123', extraColumn2: '', }; await expect(repository.createItem(item)).rejects.toEqual( new InvalidDbSchemaError('Excess properties in entity test-item-entity: extraColumn, extraColumn2'), ); }); it('should throw error if input does not includes key field(s)', async () => { const partialItem = { age: 99, country: 'au', data: {}, }; await expect(repository.createItem(partialItem as unknown as TestItem)).rejects.toEqual( new MissingKeyValuesError('Key field "name" must be specified in the input item in entity test-item-entity'), ); }); }); describe('updateItem()', () => { it('should update and return the item object if valid input', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Item: { name: 'foo', age: 99, country: 'au', data: {}, }, }); ddbClientMock.on(UpdateCommand).resolves({ $metadata: { httpStatusCode: 200, }, Attributes: { name: 'foo', age: 99, // country attribute is part of gsi key // hence updating this will also update gsi key value country: 'au-updated', data: {}, }, }); const input: UpdateCommandInput = { TableName: TABLE_NAME, Key: { pk: 'test_item#foo', sk: '#meta' }, UpdateExpression: 'SET #country = :country, #gsi1_pk = :gsi1_pk', ExpressionAttributeNames: { '#country': 'country', '#gsi1_pk': 'gsi1_pk', }, ExpressionAttributeValues: { ':country': 'au-updated', ':gsi1_pk': 'country#au-updated', }, ConditionExpression: `attribute_exists(pk)`, }; const updateItem = { name: 'foo', country: 'au-updated', }; const result = await repository.updateItem(updateItem); expect(ddbClientMock).toHaveReceivedNthCommandWith(2, UpdateCommand, input); expect(result).toEqual( new TestItem({ name: 'foo', age: 99, country: 'au-updated', data: {}, }), ); }); it('should only update the changed attributes', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Item: { name: 'foo', age: 99, country: 'au', data: {}, }, }); ddbClientMock.on(UpdateCommand).resolves({ $metadata: { httpStatusCode: 200, }, Attributes: { name: 'foo', age: 99, country: 'au', data: { active: true }, }, }); const input: UpdateCommandInput = { TableName: TABLE_NAME, Key: { pk: 'test_item#foo', sk: '#meta' }, UpdateExpression: 'SET #data = :data', ExpressionAttributeNames: { '#data': 'data', }, ExpressionAttributeValues: { ':data': { active: true }, }, ConditionExpression: `attribute_exists(pk)`, }; const updateItem = { name: 'foo', age: 99, // this is the only change attribute value data: { active: true }, }; const result = await repository.updateItem(updateItem); expect(ddbClientMock).toHaveReceivedNthCommandWith(2, UpdateCommand, input); expect(result).toEqual( new TestItem({ name: 'foo', age: 99, country: 'au', data: { active: true }, }), ); }); it('should not trigger update request if the input attributes are same as in the existing item', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Item: { name: 'foo', age: 99, country: 'au', data: {}, }, }); ddbClientMock.on(UpdateCommand).rejects(new Error('updateItem() called when not expected')); // update input attributes are same as in the existing item const updateItem = { name: 'foo', country: 'au', }; const result = await repository.updateItem(updateItem); expect(result).toEqual( new TestItem({ name: 'foo', age: 99, country: 'au', data: {}, }), ); }); it('should return undefined if item does does not exist', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const updateItem = { name: 'foo', country: 'au-updated', }; const result = await repository.updateItem(updateItem); expect(result).toEqual(undefined); }); it('should return undefined if update cmd conditional check fails', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Item: { name: 'foo', age: 99, country: 'au', data: {}, }, }); ddbClientMock.on(UpdateCommand).rejects( new ConditionalCheckFailedException({ $metadata: {}, message: 'not found', }), ); const updateItem = { name: 'foo', country: 'au-updated', }; const result = await repository.updateItem(updateItem); expect(result).toEqual(undefined); }); it('should throw error update data has invalid data', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Item: { name: 'foo', age: 99, country: 'au', data: {}, }, }); const updateItem = { name: 'foo', country: 61, // should be "string" type }; await expect(repository.updateItem(updateItem as unknown as Partial)).rejects.toEqual( new Error('Invalid "country"'), ); }); it('should throw error if excess column in input', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Item: { name: 'foo', age: 99, country: 'au', data: {}, }, }); const updateItem = { name: 'foo', country: 'au-updated', extra: '', }; await expect(repository.updateItem(updateItem as unknown as Partial)).rejects.toEqual( new InvalidDbSchemaError('Excess properties in entity test-item-entity: extra'), ); }); it('should throw error if input does not includes key field(s)', async () => { const updateItem = { age: 99, country: 'au-updated', // should be "string" type }; await expect(repository.updateItem(updateItem)).rejects.toEqual( new MissingKeyValuesError('Key field "name" must be specified in the input item in entity test-item-entity'), ); }); }); describe('getItem()', () => { it('should return the item object if found', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Item: { name: 'foo', age: 99, country: 'au', data: {}, data2: '{"foo":"bar","num":123}', }, }); const input: GetCommandInput = { TableName: TABLE_NAME, Key: { pk: 'test_item#foo', sk: '#meta' }, }; const partialItem = { name: 'foo' }; const result = await repository.getItem(partialItem); expect(ddbClientMock).toHaveReceivedCommandWith(GetCommand, input); expect(result).toEqual( new TestItem({ name: 'foo', age: 99, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, }), ); }); it('should return undefined if item not found', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const input: GetCommandInput = { TableName: TABLE_NAME, Key: { pk: 'test_item#foo', sk: '#meta' }, }; const partialItem = { name: 'foo' }; const result = await repository.getItem(partialItem); expect(ddbClientMock).toHaveReceivedCommandWith(GetCommand, input); expect(result).toEqual(undefined); }); it('should throw error if item schema validation fails', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Item: { name: 'foo', age: '99', // should be number country: 'au', data: {}, }, }); const partialItem = { name: 'foo' }; await expect(repository.getItem(partialItem)).rejects.toEqual(new Error('Invalid "age"')); }); it('should throw error if input does not includes key field(s)', async () => { const partialItem = { age: 99 }; await expect(repository.getItem(partialItem)).rejects.toEqual( new MissingKeyValuesError('Key field "name" must be specified in the input item in entity test-item-entity'), ); }); it('should throw error if JSON string field has non-string data', async () => { ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Item: { name: 'foo', age: 99, // should be number country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, }, }); const partialItem = { name: 'foo' }; await expect(repository.getItem(partialItem)).rejects.toEqual( new InvalidDataFormatError(`Field 'data2' defined as JSON String has a non-string data`), ); }); }); describe('getItems()', () => { it('should use BatchGetItem to get result', async () => { ddbClientMock.on(BatchGetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Responses: { [TABLE_NAME]: [ { name: 'foo', age: 99, country: 'au', data: {}, data2: '{"foo":"bar","num":123}', }, { name: 'foo2', age: 999, country: 'au', data: {}, data2: '{"foo":"bar","num":123}', }, ], }, }); const input: BatchGetCommandInput = { RequestItems: { [TABLE_NAME]: { Keys: [ { pk: 'test_item#foo', sk: '#meta' }, { pk: 'test_item#foo2', sk: '#meta' }, ], }, }, }; const requestItems = [{ name: 'foo' }, { name: 'foo2' }]; const result = await repository.getItems(requestItems); expect(ddbClientMock).toHaveReceivedCommandWith(BatchGetCommand, input); expect(result).toEqual([ new TestItem({ name: 'foo', age: 99, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, }), new TestItem({ name: 'foo2', age: 999, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, }), ]); }); it('should remove duplicate items in BatchGetItem request', async () => { ddbClientMock.on(BatchGetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Responses: { [TABLE_NAME]: [ { name: 'foo', age: 99, country: 'au', data: {}, data2: '{"foo":"bar","num":123}', }, { name: 'foo2', age: 999, country: 'au', data: {}, data2: '{"foo":"bar","num":123}', }, ], }, }); const input: BatchGetCommandInput = { RequestItems: { [TABLE_NAME]: { Keys: [ { pk: 'test_item#foo', sk: '#meta' }, { pk: 'test_item#foo2', sk: '#meta' }, ], }, }, }; const requestItems = [{ name: 'foo' }, { name: 'foo2' }, { name: 'foo2' }]; const result = await repository.getItems(requestItems); expect(ddbClientMock).toHaveReceivedCommandWith(BatchGetCommand, input); expect(result).toEqual([ new TestItem({ name: 'foo', age: 99, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, }), new TestItem({ name: 'foo2', age: 999, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, }), ]); }); it('should retry if unprocessed keys returned', async () => { const input1: BatchGetCommandInput = { RequestItems: { [TABLE_NAME]: { Keys: [ { pk: 'test_item#foo', sk: '#meta' }, { pk: 'test_item#foo2', sk: '#meta' }, ], }, }, }; const input2: BatchGetCommandInput = { RequestItems: { [TABLE_NAME]: { Keys: [{ pk: 'test_item#foo2', sk: '#meta' }], }, }, }; ddbClientMock.on(BatchGetCommand, input1).resolves({ $metadata: { httpStatusCode: 200, }, Responses: { [TABLE_NAME]: [ { name: 'foo', age: 99, country: 'au', data: {}, data2: '{"foo":"bar","num":123}', }, ], }, UnprocessedKeys: { [TABLE_NAME]: { Keys: [{ pk: 'test_item#foo2', sk: '#meta' }], }, }, }); ddbClientMock.on(BatchGetCommand, input2).resolves({ $metadata: { httpStatusCode: 200, }, Responses: { [TABLE_NAME]: [ { name: 'foo2', age: 999, country: 'au', data: {}, data2: '{"foo":"bar","num":123}', }, ], }, UnprocessedKeys: {}, }); const requestItems = [{ name: 'foo' }, { name: 'foo2' }]; const result = await repository.getItems(requestItems); expect(ddbClientMock).toHaveReceivedNthCommandWith(1, BatchGetCommand, input1); expect(ddbClientMock).toHaveReceivedNthCommandWith(2, BatchGetCommand, input2); expect(result).toEqual([ new TestItem({ name: 'foo', age: 99, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, }), new TestItem({ name: 'foo2', age: 999, country: 'au', data: {}, data2: { foo: 'bar', num: 123, }, }), ]); }); it('should fail after max retries for unprocessed keys', async () => { const input1: BatchGetCommandInput = { RequestItems: { [TABLE_NAME]: { Keys: [ { pk: 'test_item#foo', sk: '#meta' }, { pk: 'test_item#foo2', sk: '#meta' }, ], }, }, }; const input2: BatchGetCommandInput = { RequestItems: { [TABLE_NAME]: { Keys: [{ pk: 'test_item#foo2', sk: '#meta' }], }, }, }; ddbClientMock.on(BatchGetCommand, input1).resolves({ $metadata: { httpStatusCode: 200, }, Responses: { [TABLE_NAME]: [ { name: 'foo', age: 99, country: 'au', data: {}, data2: '{"foo":"bar","num":123}', }, ], }, UnprocessedKeys: { [TABLE_NAME]: { Keys: [{ pk: 'test_item#foo2', sk: '#meta' }], }, }, }); ddbClientMock.on(BatchGetCommand, input2).resolves({ $metadata: { httpStatusCode: 200, }, Responses: {}, UnprocessedKeys: { [TABLE_NAME]: { Keys: [{ pk: 'test_item#foo2', sk: '#meta' }], }, }, }); const requestItems = [{ name: 'foo' }, { name: 'foo2' }]; await expect(repository.getItems(requestItems)).rejects.toEqual( new Error('Maximum allowed retries exceeded for unprocessed items'), ); expect(ddbClientMock).toHaveReceivedNthCommandWith(1, BatchGetCommand, input1); expect(ddbClientMock).toHaveReceivedNthCommandWith(2, BatchGetCommand, input2); expect(ddbClientMock).toHaveReceivedNthCommandWith(3, BatchGetCommand, input2); expect(ddbClientMock).toHaveReceivedNthCommandWith(4, BatchGetCommand, input2); }); it('should request BatchGetItem in batch of 100 items to get result', async () => { ddbClientMock.on(BatchGetCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const requestItems = []; for (let i = 0; i < 120; i++) { requestItems.push({ name: `foo${i}` }); } // keys for first batch request const keys1 = []; for (let i = 0; i < 100; i++) { keys1.push({ pk: `test_item#foo${i}`, sk: '#meta' }); } // keys for second batch request const keys2 = []; for (let i = 100; i < 120; i++) { keys2.push({ pk: `test_item#foo${i}`, sk: '#meta' }); } const input1: BatchGetCommandInput = { RequestItems: { [TABLE_NAME]: { Keys: keys1, }, }, }; const input2: BatchGetCommandInput = { RequestItems: { [TABLE_NAME]: { Keys: keys2, }, }, }; await repository.getItems(requestItems); expect(ddbClientMock).toHaveReceivedCommandTimes(BatchGetCommand, 2); expect(ddbClientMock).toHaveReceivedNthCommandWith(1, BatchGetCommand, input1); expect(ddbClientMock).toHaveReceivedNthCommandWith(2, BatchGetCommand, input2); }); it('should throw error if any input item does not includes key field(s)', async () => { const requestItems = [{ name: 'foo' }, { age: 22 }]; await expect(repository.getItems(requestItems)).rejects.toEqual( new MissingKeyValuesError('Key field "name" must be specified in the input item in entity test-item-entity'), ); }); }); describe('deleteItems()', () => { it('should use batchWrite() to get result', async () => { ddbClientMock.on(BatchWriteCommand).resolves({ $metadata: { httpStatusCode: 200, }, ItemCollectionMetrics: { [TABLE_NAME]: [{}], }, }); const input: BatchWriteCommandInput = { RequestItems: { [TABLE_NAME]: [ { DeleteRequest: { Key: { pk: 'test_item#foo', sk: '#meta' }, }, }, { DeleteRequest: { Key: { pk: 'test_item#foo2', sk: '#meta' }, }, }, ], }, }; const requestItems = [{ name: 'foo' }, { name: 'foo2' }]; await repository.deleteItems(requestItems); expect(ddbClientMock).toHaveReceivedCommandWith(BatchWriteCommand, input); }); it('should remove duplicate items in batchWrite() request', async () => { ddbClientMock.on(BatchWriteCommand).resolves({ $metadata: { httpStatusCode: 200, }, ItemCollectionMetrics: { [TABLE_NAME]: [{}], }, }); const input: BatchWriteCommandInput = { RequestItems: { [TABLE_NAME]: [ { DeleteRequest: { Key: { pk: 'test_item#foo', sk: '#meta' }, }, }, { DeleteRequest: { Key: { pk: 'test_item#foo2', sk: '#meta' }, }, }, ], }, }; const requestItems = [{ name: 'foo' }, { name: 'foo2' }, { name: 'foo2' }, { name: 'foo' }]; await repository.deleteItems(requestItems); expect(ddbClientMock).toHaveReceivedCommandWith(BatchWriteCommand, input); }); it('should use re-try if unprocessed items returned', async () => { const input1: BatchWriteCommandInput = { RequestItems: { [TABLE_NAME]: [ { DeleteRequest: { Key: { pk: 'test_item#foo', sk: '#meta' }, }, }, { DeleteRequest: { Key: { pk: 'test_item#foo2', sk: '#meta' }, }, }, ], }, }; const input2: BatchWriteCommandInput = { RequestItems: { [TABLE_NAME]: [ { DeleteRequest: { Key: { pk: 'test_item#foo2', sk: '#meta' }, }, }, ], }, }; ddbClientMock.on(BatchWriteCommand, input1).resolves({ $metadata: { httpStatusCode: 200, }, UnprocessedItems: { [TABLE_NAME]: [ { DeleteRequest: { Key: { pk: 'test_item#foo2', sk: '#meta' }, }, }, ], }, }); ddbClientMock.on(BatchWriteCommand, input2).resolves({ $metadata: { httpStatusCode: 200, }, UnprocessedItems: {}, }); const requestItems = [{ name: 'foo' }, { name: 'foo2' }]; await repository.deleteItems(requestItems); expect(ddbClientMock).toHaveReceivedNthCommandWith(1, BatchWriteCommand, input1); expect(ddbClientMock).toHaveReceivedNthCommandWith(2, BatchWriteCommand, input2); }); it('should fail after max number of retries', async () => { const input1: BatchWriteCommandInput = { RequestItems: { [TABLE_NAME]: [ { DeleteRequest: { Key: { pk: 'test_item#foo', sk: '#meta' }, }, }, { DeleteRequest: { Key: { pk: 'test_item#foo2', sk: '#meta' }, }, }, ], }, }; const input2: BatchWriteCommandInput = { RequestItems: { [TABLE_NAME]: [ { DeleteRequest: { Key: { pk: 'test_item#foo2', sk: '#meta' }, }, }, ], }, }; ddbClientMock.on(BatchWriteCommand).resolves({ $metadata: { httpStatusCode: 200, }, UnprocessedItems: { [TABLE_NAME]: [ { DeleteRequest: { Key: { pk: 'test_item#foo2', sk: '#meta' }, }, }, ], }, }); const requestItems = [{ name: 'foo' }, { name: 'foo2' }]; await expect(repository.deleteItems(requestItems)).rejects.toEqual( new Error('Maximum allowed retries exceeded for unprocessed items'), ); expect(ddbClientMock).toHaveReceivedNthCommandWith(1, BatchWriteCommand, input1); expect(ddbClientMock).toHaveReceivedNthCommandWith(2, BatchWriteCommand, input2); expect(ddbClientMock).toHaveReceivedNthCommandWith(3, BatchWriteCommand, input2); expect(ddbClientMock).toHaveReceivedNthCommandWith(4, BatchWriteCommand, input2); }); it('should request batchWrite in batch of 25 items to get result', async () => { ddbClientMock.on(BatchWriteCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const requestItems = []; for (let i = 0; i < 30; i++) { requestItems.push({ name: `foo${i}` }); } // keys for first batch request const keys1 = []; for (let i = 0; i < 25; i++) { keys1.push({ pk: `test_item#foo${i}`, sk: '#meta' }); } // keys for second batch request const keys2 = []; for (let i = 25; i < 30; i++) { keys2.push({ pk: `test_item#foo${i}`, sk: '#meta' }); } const input1: BatchWriteCommandInput = { RequestItems: { [TABLE_NAME]: keys1.map((key) => { return { DeleteRequest: { Key: key, }, }; }), }, }; const input2: BatchWriteCommandInput = { RequestItems: { [TABLE_NAME]: keys2.map((key) => { return { DeleteRequest: { Key: key, }, }; }), }, }; await repository.deleteItems(requestItems); expect(ddbClientMock).toHaveReceivedCommandTimes(BatchWriteCommand, 2); expect(ddbClientMock).toHaveReceivedNthCommandWith(1, BatchWriteCommand, input1); expect(ddbClientMock).toHaveReceivedNthCommandWith(2, BatchWriteCommand, input2); }); it('should throw error if any input item does not includes key field(s)', async () => { const requestItems = [{ name: 'foo' }, { age: 22 }]; await expect(repository.deleteItems(requestItems)).rejects.toEqual( new MissingKeyValuesError('Key field "name" must be specified in the input item in entity test-item-entity'), ); }); }); describe('queryItems()', () => { it('should return the items if found', async () => { ddbClientMock.on(QueryCommand).resolves({ $metadata: { httpStatusCode: 200, }, Items: [ { name: 'foo', age: 99, country: 'au', data: {}, }, ], }); const input: QueryCommandInput = { TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue', ExpressionAttributeNames: { '#pkName': 'pk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', }, }; const partialItem = { name: 'foo' }; const result = await repository.queryItems(partialItem); expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input); expect(result).toEqual([ new TestItem({ name: 'foo', age: 99, country: 'au', data: {}, }), ]); }); it('should return empty array if no items found', async () => { ddbClientMock.on(QueryCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const input: QueryCommandInput = { TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue', ExpressionAttributeNames: { '#pkName': 'pk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', }, }; const partialItem = { name: 'foo' }; const result = await repository.queryItems(partialItem); expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input); expect(result).toEqual([]); }); it('should use sort key in query if "useSortKey" param is true', async () => { ddbClientMock.on(QueryCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const input: QueryCommandInput = { TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue AND #skName = :skValue', ExpressionAttributeNames: { '#pkName': 'pk', '#skName': 'sk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', ':skValue': '#meta', }, }; const partialItem = { name: 'foo' }; const _result = await repository.queryItems(partialItem, { useSortKey: true }); expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input); }); it('should return the items if found when using gsi index', async () => { ddbClientMock.on(QueryCommand).resolves({ $metadata: { httpStatusCode: 200, }, Items: [ { name: 'foo', age: 99, country: 'au', data: {}, }, { name: 'fox', age: 11, country: 'au', data: {}, }, ], }); const index = 'gsi1_pk-gsi1_sk-index'; const input: QueryCommandInput = { TableName: TABLE_NAME, IndexName: index, KeyConditionExpression: '#pkName = :pkValue', ExpressionAttributeNames: { '#pkName': 'gsi1_pk', }, ExpressionAttributeValues: { ':pkValue': 'country#au', }, }; const partialItem = { country: 'au' }; const useSortKey = false; const result = await repository.queryItems(partialItem, { useSortKey, index }); expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input); expect(result).toEqual([ new TestItem({ name: 'foo', age: 99, country: 'au', data: {}, }), new TestItem({ name: 'fox', age: 11, country: 'au', data: {}, }), ]); }); it('should use sort key in query if "useSortKey" param is true when using gsi index', async () => { ddbClientMock.on(QueryCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const index = 'gsi1_pk-gsi1_sk-index'; const input: QueryCommandInput = { TableName: TABLE_NAME, IndexName: index, KeyConditionExpression: '#pkName = :pkValue AND #skName = :skValue', ExpressionAttributeNames: { '#pkName': 'gsi1_pk', '#skName': 'gsi1_sk', }, ExpressionAttributeValues: { ':pkValue': 'country#au', ':skValue': 'age#99', }, }; const partialItem = { age: 99, country: 'au' }; const useSortKey = true; const _result = await repository.queryItems(partialItem, { useSortKey, index }); expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input); }); it('should set input query correctly when "filter - begins_with" query option is set', async () => { ddbClientMock.on(QueryCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const input: QueryCommandInput = { TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue AND begins_with(#skName, :skValue)', ExpressionAttributeNames: { '#pkName': 'pk', '#skName': 'sk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', ':skValue': 'keyword-x', }, }; const partialItem = { name: 'foo' }; const queryOptions: QueryOptions = { filter: { type: 'begins_with', keyword: 'keyword-x', }, }; await repository.queryItems(partialItem, queryOptions); expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input); }); it('should throw error invalid "filter" query option is set', async () => { ddbClientMock.on(QueryCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const partialItem = { name: 'foo' }; const queryOptions = { filter: { type: 'invalid-type', keyword: 'keyword-x', }, } as unknown as QueryOptions; await expect(repository.queryItems(partialItem, queryOptions)).rejects.toEqual( new Error(`Invalid query filter type: invalid-type`), ); }); it('should set input query correctly when "limit" query option is set', async () => { ddbClientMock.on(QueryCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); // Limit does not appear in the input query. // Splitting/pagination is now handled in the queryFullPartition sub function. const input: QueryCommandInput = { ExclusiveStartKey: undefined, TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue', ExpressionAttributeNames: { '#pkName': 'pk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', }, }; const partialItem = { name: 'foo' }; const queryOptions = { limit: 33 }; await repository.queryItems(partialItem, queryOptions); expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input); }); it('should set input query correctly when "order asc" query option is set', async () => { ddbClientMock.on(QueryCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const input: QueryCommandInput = { TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue', ExpressionAttributeNames: { '#pkName': 'pk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', }, ScanIndexForward: true, }; const partialItem = { name: 'foo' }; const queryOptions = { order: 'asc' } as unknown as QueryOptions; await repository.queryItems(partialItem, queryOptions); expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input); }); it('should set input query correctly when "order desc" query option is set', async () => { ddbClientMock.on(QueryCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const input: QueryCommandInput = { TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue', ExpressionAttributeNames: { '#pkName': 'pk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', }, ScanIndexForward: false, }; const partialItem = { name: 'foo' }; const queryOptions = { order: 'desc' } as unknown as QueryOptions; await repository.queryItems(partialItem, queryOptions); expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input); }); it('should throw error for invalid index query', async () => { const index = 'undefined-index'; const partialItem = { age: 99, country: 'au' }; await expect(repository.queryItems(partialItem, { index })).rejects.toEqual( new MissingKeyValuesError(`Table index '${index}' not defined on entity test-item-entity`), ); }); it('should throw error for missing key fields', async () => { const partialItem = { age: 99, country: 'au' }; await expect(repository.queryItems(partialItem)).rejects.toEqual( new MissingKeyValuesError(`Key field "name" must be specified in the input item in entity test-item-entity`), ); }); it('should throw error for missing key fields when using index', async () => { const partialItem = { name: 'foo' }; const useSortKey = false; const index = 'gsi1_pk-gsi1_sk-index'; await expect(repository.queryItems(partialItem, { useSortKey, index })).rejects.toEqual( new MissingKeyValuesError(`Key field "country" must be specified in the input item in entity test-item-entity`), ); }); it('should throw error for missing key fields with "useSortKey" param true when using index', async () => { const partialItem = { country: 'au' }; const useSortKey = true; const index = 'gsi1_pk-gsi1_sk-index'; await expect(repository.queryItems(partialItem, { useSortKey, index })).rejects.toEqual( new MissingKeyValuesError(`Key field "age" must be specified in the input item in entity test-item-entity`), ); }); describe('dynamo pagination', () => { const mockItems = [ { name: 'foo', age: 10, country: 'au', data: {}, }, { name: 'fox', age: 11, country: 'au', data: {}, }, { name: 'bar', age: 12, country: 'au', data: {}, }, { name: 'baz', age: 13, country: 'au', data: {}, }, { name: 'qux', age: 14, country: 'au', data: {}, }, ]; it('should return all items when there is only 1 page of results', async () => { ddbClientMock.on(QueryCommand).resolves({ Items: [mockItems[0], mockItems[1], mockItems[2]], }); const input: QueryCommandInput = { TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue', ExpressionAttributeNames: { '#pkName': 'pk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', }, }; const partialItem = { name: 'foo' }; const result = await repository.queryItems(partialItem); expect(ddbClientMock).toHaveReceivedCommandTimes(QueryCommand, 1); expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input); expect(result).toEqual([new TestItem(mockItems[0]), new TestItem(mockItems[1]), new TestItem(mockItems[2])]); }); it('should return all items when there are more than 1 page of results', async () => { const mockLastEvaluatedKey = { pk: { S: 'test_item#foo' }, sk: { S: 'name#bar' } }; ddbClientMock .on(QueryCommand) .resolvesOnce({ Items: [mockItems[0], mockItems[1], mockItems[2]], LastEvaluatedKey: mockLastEvaluatedKey, }) .resolvesOnce({ Items: [mockItems[3], mockItems[4]], }); const input: QueryCommandInput = { TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue', ExpressionAttributeNames: { '#pkName': 'pk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', }, }; const partialItem = { name: 'foo' }; const result = await repository.queryItems(partialItem); expect(ddbClientMock).toHaveReceivedCommandTimes(QueryCommand, 2); expect(ddbClientMock).toHaveReceivedNthCommandWith(1, QueryCommand, input); expect(ddbClientMock).toHaveReceivedNthCommandWith(2, QueryCommand, { ...input, ExclusiveStartKey: mockLastEvaluatedKey, }); expect(result).toEqual([ new TestItem(mockItems[0]), new TestItem(mockItems[1]), new TestItem(mockItems[2]), new TestItem(mockItems[3]), new TestItem(mockItems[4]), ]); }); it('should return items but only up to the provided limit when limit is within first page', async () => { const mockLimit = 2; const mockLastEvaluatedKey = { pk: { S: 'test_item#foo' }, sk: { S: 'name#bar' } }; ddbClientMock .on(QueryCommand) .resolvesOnce({ Items: [mockItems[0], mockItems[1], mockItems[2]], LastEvaluatedKey: mockLastEvaluatedKey, }) .resolvesOnce({ Items: [mockItems[3], mockItems[4]], }); const input: QueryCommandInput = { TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue', ExpressionAttributeNames: { '#pkName': 'pk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', }, }; // Only expect first two items from the first page of results. const partialItem = { name: 'foo' }; const result = await repository.queryItems(partialItem, { limit: mockLimit }); expect(ddbClientMock).toHaveReceivedCommandTimes(QueryCommand, 1); expect(ddbClientMock).toHaveReceivedNthCommandWith(1, QueryCommand, input); expect(result).toEqual([new TestItem(mockItems[0]), new TestItem(mockItems[1])]); }); it('should return items up to the limit when limit spans multiple pages', async () => { const mockLimit = 4; const mockLastEvaluatedKey = { pk: { S: 'test_item#foo' }, sk: { S: 'name#bar' } }; ddbClientMock .on(QueryCommand) .resolvesOnce({ Items: [mockItems[0], mockItems[1], mockItems[2]], LastEvaluatedKey: mockLastEvaluatedKey, }) .resolvesOnce({ Items: [mockItems[3], mockItems[4]], }); const input: QueryCommandInput = { TableName: TABLE_NAME, KeyConditionExpression: '#pkName = :pkValue', ExpressionAttributeNames: { '#pkName': 'pk', }, ExpressionAttributeValues: { ':pkValue': 'test_item#foo', }, }; // Expect only 4 results in total across both pages. // Expect all 3 results from first page, but only 1 result from second page. const partialItem = { name: 'foo' }; const result = await repository.queryItems(partialItem, { limit: mockLimit }); expect(ddbClientMock).toHaveReceivedCommandTimes(QueryCommand, 2); expect(ddbClientMock).toHaveReceivedNthCommandWith(1, QueryCommand, input); expect(ddbClientMock).toHaveReceivedNthCommandWith(2, QueryCommand, { ...input, ExclusiveStartKey: mockLastEvaluatedKey, }); expect(result).toEqual([ new TestItem(mockItems[0]), new TestItem(mockItems[1]), new TestItem(mockItems[2]), new TestItem(mockItems[3]), ]); }); }); }); describe('deleteItem()', () => { it('should return 1 when item is found and deleted', async () => { ddbClientMock.on(DeleteCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const input: DeleteCommandInput = { TableName: TABLE_NAME, Key: { pk: 'test_item#foo', sk: '#meta' }, }; const partialItem = { name: 'foo' }; const result = await repository.deleteItem(partialItem); expect(ddbClientMock).toHaveReceivedCommandWith(DeleteCommand, input); expect(result).toBe(1); }); it('should return 0 when item is not found', async () => { ddbClientMock.on(DeleteCommand).rejects( new ConditionalCheckFailedException({ $metadata: {}, message: 'not found', }), ); const input: DeleteCommandInput = { TableName: TABLE_NAME, Key: { pk: 'test_item#foo', sk: '#meta' }, }; const partialItem = { name: 'foo' }; const result = await repository.deleteItem(partialItem); expect(ddbClientMock).toHaveReceivedCommandWith(DeleteCommand, input); expect(result).toBe(0); }); it('should throw error if request fails', async () => { const partialItem = { name: 'foo' }; ddbClientMock.on(DeleteCommand).rejects('some other error'); await expect(repository.deleteItem(partialItem)).rejects.toEqual(new Error('some other error')); }); it('should throw error if input does not includes key field(s)', async () => { const partialItem = { age: 99 }; await expect(repository.deleteItem(partialItem)).rejects.toEqual( new MissingKeyValuesError('Key field "name" must be specified in the input item in entity test-item-entity'), ); }); }); describe('Writer fns with transaction - DynamoDbManager', () => { it('should execute the multiple transaction write request in a single request', async () => { const spy = jest.spyOn(crypto, 'randomUUID'); spy.mockImplementation(() => 'some-token' as any); ddbClientMock.on(GetCommand).resolves({ $metadata: { httpStatusCode: 200, }, Item: { name: 'foo2', age: 99, country: 'au', data: {}, }, }); ddbClientMock.on(TransactWriteCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const result = await ddbManager.executeInTransaction(async (transaction: Transaction) => { await repository.deleteItem({ name: 'foo' }, transaction); await repository.updateItem( { name: 'foo2', age: 55, }, transaction, ); return await repository.createItem( { name: 'foo3', age: 11, country: 'au', data: {}, }, transaction, ); }); const input: TransactWriteCommandInput = { ClientRequestToken: 'some-token', TransactItems: [ { Delete: { Key: { pk: 'test_item#foo', sk: '#meta', }, TableName: 'test-table', }, }, { Update: { ConditionExpression: 'attribute_exists(pk)', ExpressionAttributeNames: { '#age': 'age', '#gsi1_sk': 'gsi1_sk', }, ExpressionAttributeValues: { ':age': 55, ':gsi1_sk': 'age#55', }, Key: { pk: 'test_item#foo2', sk: '#meta', }, TableName: 'test-table', UpdateExpression: 'SET #age = :age, #gsi1_sk = :gsi1_sk', }, }, { Put: { ConditionExpression: 'attribute_not_exists(pk)', Item: { age: 11, country: 'au', data: {}, gsi1_pk: 'country#au', gsi1_sk: 'age#11', name: 'foo3', pk: 'test_item#foo3', sk: '#meta', }, TableName: 'test-table', }, }, ], }; expect(ddbClientMock).toHaveReceivedNthCommandWith(2, TransactWriteCommand, input); expect(result).toEqual({ name: 'foo3', age: 11, country: 'au', data: {}, }); }); }); describe('Entity definitions having keys with the object properties', () => { it('should use the correct keys in the request', async () => { const repository = new TestItem2Repository(TABLE_NAME, ddbManager); ddbClientMock.on(PutCommand).resolves({ $metadata: { httpStatusCode: 200, }, }); const input: PutCommandInput = { TableName: TABLE_NAME, Item: { pk: 'c#au', sk: 'ci#syd#id-99', gsi1_pk: 'dc#2025-02-26#au', gsi1_sk: '#meta', date: '2025-02-26', info: { id: 'id-99', name: 'john', age: 42, address: { country: 'au', city: 'syd', }, }, }, ConditionExpression: `attribute_not_exists(pk)`, }; const item = { date: '2025-02-26', info: { id: 'id-99', name: 'john', age: 42, address: { country: 'au', city: 'syd', }, }, }; const result = await repository.createItem(item); expect(ddbClientMock).toHaveReceivedCommandWith(PutCommand, input); expect(result).toEqual( new TestItem2({ date: '2025-02-26', info: { id: 'id-99', name: 'john', age: 42, address: { country: 'au', city: 'syd', }, }, }), ); }); it('should throw error if input does not includes key field(s)', async () => { const repository = new TestItem2Repository(TABLE_NAME, ddbManager); const partialItem = { date: '2025-02-26', info: { id: 'id-99', name: 'john', age: 42, address: { country: 'au', }, }, }; await expect(repository.getItem(partialItem)).rejects.toEqual( new MissingKeyValuesError( 'Key field "info.address.city" must be specified in the input item in entity test-item-entity2', ), ); }); }); });