import { AbstractDynamoDbRepository, QueryOptions } from './AbstractDynamoDbRepository'; import { DeleteCommand, DeleteCommandInput, DynamoDBDocument, DynamoDBDocumentClient, GetCommand, GetCommandInput, PutCommand, PutCommandInput, QueryCommand, QueryCommandInput, UpdateCommand, UpdateCommandInput, TransactWriteCommand, TransactWriteCommandInput, BatchGetCommand, BatchGetCommandInput, BatchWriteCommand, 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 = mockClient(DynamoDBDocumentClient); 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', ), ); }); }); });