From eacb1d9f59a82ad34234f51198ed215c41a64b41 Mon Sep 17 00:00:00 2001 From: KevenFuentes9 <111793213+KevenFuentes9@users.noreply.github.com> Date: Mon, 6 Feb 2023 16:01:56 -0500 Subject: [PATCH] feat(idempotency): Add function wrapper and decorator (#1262) * feat: initial idempotency classes * feat: refactor persistence layer classes into their own folder * feat: rename idempotency config to differentiate from idempotency options * feat: added type for a generic function * feat: remove idempotency configuration for this FR * feat: refactored type of function to accept any combo of parameters * feat: adding PersistenceLayer * feat: PersistenceLayer unit tests for saveInProgress * feat: added saveSuccess * feat: added getRecord * feat: added delete record * feat: branch coverage and cleaning up imports * feat: added more tests * feat: deleted unused methods * feat: added comments * feat: implement get command for dynamo persistence layer * feat: implement get command for dynamo persistence layer * feat: allow for data attr to be passed and return in persistence layer get * feat: added implementation for delete, update, put * feat: create condition on put for not in progress status * feat: use inprogress enum for status * feat: added error when unable to get record for idempotency key * feat: added error for conditional write of an existing record * feat: tests added for put record on dynamo persistence layer * feat: implemented the idempotency record functions for status, expiry, and json response * test: check if the status is expired * test: idempotency record is not expired and status maintained * feat: added tests for get record * feat: add aws-sdk-client-mock jest assertion library * feat: add unit tests for update record and delete record * feat: remove optional chaining from item made unnecessary with error branch * feat: remove unused block * feat: refactored mock child class to be shared amongst dynamo persistence layer tests * test: add path to get the response data from the data record * feat: added branch to handle conditional check failure * feat: add configuration option to dynamo client creation to remove undefined values * feat: change how time is measured to seconds * feat: change type of the response/result to a record * feat:updated imports * feat: added save in progress to handle already existing records in dynamo * feat: add log message for the already in progress error * feat: change the anyfunction type definition to also include a sync function * refactor: create constructor object for dynamo persistence layer * fix: remove temp eslint disable * fix: adjust verbiage on test blocks Co-authored-by: ijemmy * style: put constructor parameters onto one line for readability * fix: update dynamo persistence layer tests to use new construtor options * fix: remove unneeded eslint ignore from persistence layer * style: put parameters for dynamo client command object onto one line for readability * fix: move lib-dynamo dep under the correct package * refactor: change idempotency record to use options object in contructor * feat: add consistent read to dynamo persistence layer * fix: revert changes to layer-publisher package-lock * feat added the call to the function in the idempotency handler and add overarching error scenario * feat: add logic to invoke function if it has not already been called; add logic to call the idempotency hander * chore: move and enhance comment on question * chore: update comments * feat: use new record formatting for idempotent function wrapper * test: add test case for issues even getting to save the record in persistence layer * chore: refactoring test suite for idempotent wrapper * chore: clean up comments * feat: added decorator for idempotency * chore: get rid of extra private member on the idempotency handler class * chore: refactor to use class for options * chore: bring in old version of package-locks * chore: update paths for interface * chore: remove env files * chore: rename file * chore: renaming test names and function names for idempotency decorator/wrapper --------- Co-authored-by: vgphoenixcampos <111440293+vgphoenixcampos@users.noreply.github.com> Co-authored-by: jeffrey-baker-vg Co-authored-by: Phoenix Campos Co-authored-by: ijemmy --- package-lock.json | 29 ++-- package.json | 2 +- packages/idempotency/src/Exceptions.ts | 17 +- .../idempotency/src/IdempotencyHandler.ts | 39 +++++ .../idempotency/src/idempotentDecorator.ts | 19 ++ .../idempotency/src/makeFunctionIdempotent.ts | 26 ++- packages/idempotency/src/types/AnyFunction.ts | 9 +- packages/idempotency/src/types/index.ts | 3 +- .../helpers/populateEnvironmentVariables.ts | 2 +- .../tests/unit/idempotentDecorator.test.ts | 163 ++++++++++++++++++ .../tests/unit/makeFunctionIdempotent.test.ts | 163 ++++++++++++++++++ .../DynamoDbPersistenceLayer.test.ts | 1 - 12 files changed, 444 insertions(+), 29 deletions(-) create mode 100644 packages/idempotency/src/IdempotencyHandler.ts create mode 100644 packages/idempotency/src/idempotentDecorator.ts create mode 100644 packages/idempotency/tests/unit/idempotentDecorator.test.ts create mode 100644 packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts diff --git a/package-lock.json b/package-lock.json index 4eceee618..1c7a720b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16623,7 +16623,7 @@ }, "packages/commons": { "name": "@aws-lambda-powertools/commons", - "version": "1.5.1", + "version": "1.2.1", "license": "MIT-0" }, "packages/idempotency": { @@ -16641,11 +16641,13 @@ }, "packages/logger": { "name": "@aws-lambda-powertools/logger", - "version": "1.5.1", + "version": "1.2.1", "license": "MIT", "dependencies": { - "@aws-lambda-powertools/commons": "^1.5.1", - "lodash.merge": "^4.6.2" + "@aws-lambda-powertools/commons": "^1.2.1", + "lodash.clonedeep": "^4.5.0", + "lodash.merge": "^4.6.2", + "lodash.pickby": "^4.6.0" }, "devDependencies": { "@types/lodash.merge": "^4.6.7" @@ -16653,10 +16655,10 @@ }, "packages/metrics": { "name": "@aws-lambda-powertools/metrics", - "version": "1.5.1", + "version": "1.2.1", "license": "MIT-0", "dependencies": { - "@aws-lambda-powertools/commons": "^1.5.1" + "@aws-lambda-powertools/commons": "^1.2.1" }, "devDependencies": { "@types/promise-retry": "^1.1.3", @@ -16694,11 +16696,11 @@ }, "packages/tracer": { "name": "@aws-lambda-powertools/tracer", - "version": "1.5.1", + "version": "1.2.1", "license": "MIT-0", "dependencies": { - "@aws-lambda-powertools/commons": "^1.5.1", - "aws-xray-sdk-core": "^3.4.0" + "@aws-lambda-powertools/commons": "^1.2.1", + "aws-xray-sdk-core": "^3.3.6" }, "devDependencies": { "@aws-sdk/client-dynamodb": "^3.231.0", @@ -16943,7 +16945,8 @@ "@aws-lambda-powertools/logger": { "version": "file:packages/logger", "requires": { - "@aws-lambda-powertools/commons": "^1.5.1", + "@aws-lambda-powertools/commons": "^1.2.1", + "@types/lodash.clonedeep": "^4.5.7", "@types/lodash.merge": "^4.6.7", "lodash.merge": "^4.6.2" } @@ -16951,7 +16954,7 @@ "@aws-lambda-powertools/metrics": { "version": "file:packages/metrics", "requires": { - "@aws-lambda-powertools/commons": "^1.5.1", + "@aws-lambda-powertools/commons": "^1.2.1", "@types/promise-retry": "^1.1.3", "promise-retry": "^2.0.1" } @@ -16983,8 +16986,8 @@ "@aws-lambda-powertools/tracer": { "version": "file:packages/tracer", "requires": { - "@aws-lambda-powertools/commons": "^1.5.1", - "@aws-sdk/client-dynamodb": "^3.231.0", + "@aws-lambda-powertools/commons": "^1.2.1", + "@aws-sdk/client-dynamodb": "^3.100.0", "@types/promise-retry": "^1.1.3", "aws-sdk": "^2.1276.0", "aws-xray-sdk-core": "^3.4.0", diff --git a/package.json b/package.json index c7ca00b1e..b937ffeb0 100644 --- a/package.json +++ b/package.json @@ -86,4 +86,4 @@ "dependencies": { "hosted-git-info": "^6.1.1" } -} \ No newline at end of file +} diff --git a/packages/idempotency/src/Exceptions.ts b/packages/idempotency/src/Exceptions.ts index 405a59aa6..d39a01b37 100644 --- a/packages/idempotency/src/Exceptions.ts +++ b/packages/idempotency/src/Exceptions.ts @@ -10,8 +10,23 @@ class IdempotencyInvalidStatusError extends Error { } +class IdempotencyInconsistentStateError extends Error { + +} + +class IdempotencyAlreadyInProgressError extends Error { + +} + +class IdempotencyPersistenceLayerError extends Error { + +} + export { IdempotencyItemNotFoundError, IdempotencyItemAlreadyExistsError, - IdempotencyInvalidStatusError + IdempotencyInvalidStatusError, + IdempotencyInconsistentStateError, + IdempotencyAlreadyInProgressError, + IdempotencyPersistenceLayerError }; \ No newline at end of file diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts new file mode 100644 index 000000000..e629fa917 --- /dev/null +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { AnyFunctionWithRecord, IdempotencyRecordStatus } from './types'; +import { IdempotencyOptions } from './types/IdempotencyOptions'; +import { IdempotencyRecord } from 'persistence'; +import { IdempotencyInconsistentStateError, IdempotencyItemAlreadyExistsError, IdempotencyAlreadyInProgressError, IdempotencyPersistenceLayerError } from './Exceptions'; + +export class IdempotencyHandler { + + public constructor(private functionToMakeIdempotent: AnyFunctionWithRecord, private functionPayloadToBeHashed: unknown, + private idempotencyOptions: IdempotencyOptions, private fullFunctionPayload: Record) {} + + public determineResultFromIdempotencyRecord(idempotencyRecord: IdempotencyRecord): Promise | U{ + if (idempotencyRecord.getStatus() === IdempotencyRecordStatus.EXPIRED) { + throw new IdempotencyInconsistentStateError('Item has expired during processing and may not longer be valid.'); + } else if (idempotencyRecord.getStatus() === IdempotencyRecordStatus.INPROGRESS){ + throw new IdempotencyAlreadyInProgressError(`There is already an execution in progress with idempotency key: ${idempotencyRecord.idempotencyKey}`); + } else { + // Currently recalling the method as this fulfills FR1. FR3 will address using the previously stored value https://github.com/awslabs/aws-lambda-powertools-typescript/issues/447 + return this.functionToMakeIdempotent(this.fullFunctionPayload); + } + } + + public async processIdempotency(): Promise { + try { + await this.idempotencyOptions.persistenceStore.saveInProgress(this.functionPayloadToBeHashed); + } catch (e) { + if (e instanceof IdempotencyItemAlreadyExistsError) { + const idempotencyRecord: IdempotencyRecord = await this.idempotencyOptions.persistenceStore.getRecord(this.functionPayloadToBeHashed); + + return this.determineResultFromIdempotencyRecord(idempotencyRecord); + } else { + throw new IdempotencyPersistenceLayerError(); + } + } + + return this.functionToMakeIdempotent(this.fullFunctionPayload); + } +} \ No newline at end of file diff --git a/packages/idempotency/src/idempotentDecorator.ts b/packages/idempotency/src/idempotentDecorator.ts new file mode 100644 index 000000000..51e387b42 --- /dev/null +++ b/packages/idempotency/src/idempotentDecorator.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { IdempotencyOptions } from './types/IdempotencyOptions'; +import { IdempotencyHandler } from './IdempotencyHandler'; + +const idempotent = function (options: IdempotencyOptions) { + return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { + const childFunction = descriptor.value; + descriptor.value = function(record: Record){ + const idempotencyHandler: IdempotencyHandler = new IdempotencyHandler(childFunction, record[options.dataKeywordArgument], options, record); + + return idempotencyHandler.processIdempotency(); + }; + + return descriptor; + }; +}; + +export { idempotent }; + \ No newline at end of file diff --git a/packages/idempotency/src/makeFunctionIdempotent.ts b/packages/idempotency/src/makeFunctionIdempotent.ts index 40b6e52ac..fd416ccfc 100644 --- a/packages/idempotency/src/makeFunctionIdempotent.ts +++ b/packages/idempotency/src/makeFunctionIdempotent.ts @@ -1,11 +1,19 @@ -import type { AnyFunction } from './types/AnyFunction'; -import type { IdempotencyOptions } from './types/IdempotencyOptions'; - -const makeFunctionIdempotent = ( - fn: AnyFunction, - _options: IdempotencyOptions - // TODO: revisit this with a more specific type if possible - /* eslint-disable @typescript-eslint/no-explicit-any */ -): (...args: Array) => Promise => (...args) => fn(...args); +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { AnyFunctionWithRecord, AnyIdempotentFunction } from './types/AnyFunction'; +import { IdempotencyOptions } from './types/IdempotencyOptions'; +import { IdempotencyHandler } from './IdempotencyHandler'; + +const makeFunctionIdempotent = function ( + fn: AnyFunctionWithRecord, + options: IdempotencyOptions +): AnyIdempotentFunction { + const wrappedFn: AnyIdempotentFunction = function (record: Record): Promise { + const idempotencyHandler: IdempotencyHandler = new IdempotencyHandler(fn, record[options.dataKeywordArgument], options, record); + + return idempotencyHandler.processIdempotency(); + }; + + return wrappedFn; +}; export { makeFunctionIdempotent }; diff --git a/packages/idempotency/src/types/AnyFunction.ts b/packages/idempotency/src/types/AnyFunction.ts index bddcd8fc1..4b2a894a1 100644 --- a/packages/idempotency/src/types/AnyFunction.ts +++ b/packages/idempotency/src/types/AnyFunction.ts @@ -1,6 +1,11 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyFunction = (...args: Array) => Promise; +type AnyFunctionWithRecord = (record: Record) => Promise | U; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyIdempotentFunction = (record: Record) => Promise; export { - AnyFunction + // AnyFunction, + AnyFunctionWithRecord, + AnyIdempotentFunction }; \ No newline at end of file diff --git a/packages/idempotency/src/types/index.ts b/packages/idempotency/src/types/index.ts index 307c6bc09..c725c5a6b 100644 --- a/packages/idempotency/src/types/index.ts +++ b/packages/idempotency/src/types/index.ts @@ -1,3 +1,4 @@ export * from './AnyFunction'; export * from './IdempotencyRecordStatus'; -export * from './PersistenceLayer'; \ No newline at end of file +export * from './IdempotencyRecordOptions'; +export * from './PersistenceLayer'; diff --git a/packages/idempotency/tests/helpers/populateEnvironmentVariables.ts b/packages/idempotency/tests/helpers/populateEnvironmentVariables.ts index 0511a6195..cac6f9992 100644 --- a/packages/idempotency/tests/helpers/populateEnvironmentVariables.ts +++ b/packages/idempotency/tests/helpers/populateEnvironmentVariables.ts @@ -6,4 +6,4 @@ process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE = '128'; if (process.env.AWS_REGION === undefined && process.env.CDK_DEFAULT_REGION === undefined) { process.env.AWS_REGION = 'eu-west-1'; } -process.env._HANDLER = 'index.handler'; \ No newline at end of file +process.env._HANDLER = 'index.handler'; diff --git a/packages/idempotency/tests/unit/idempotentDecorator.test.ts b/packages/idempotency/tests/unit/idempotentDecorator.test.ts new file mode 100644 index 000000000..3df268911 --- /dev/null +++ b/packages/idempotency/tests/unit/idempotentDecorator.test.ts @@ -0,0 +1,163 @@ +/** + * Test Function Wrapper + * + * @group unit/idempotency/all + */ + +import { IdempotencyOptions } from '../../src/types/IdempotencyOptions'; +import { PersistenceLayer, IdempotencyRecord } from '../../src/persistence'; +import { idempotent } from '../../src/idempotentDecorator'; +import { IdempotencyRecordStatus, IdempotencyRecordOptions } from '../../src/types'; +import { IdempotencyItemAlreadyExistsError, IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError, IdempotencyPersistenceLayerError } from '../../src/Exceptions'; + +const mockSaveInProgress = jest.spyOn(PersistenceLayer.prototype, 'saveInProgress').mockImplementation(); +const mockGetRecord = jest.spyOn(PersistenceLayer.prototype, 'getRecord').mockImplementation(); + +class PersistenceLayerTestClass extends PersistenceLayer { + protected _deleteRecord = jest.fn(); + protected _getRecord = jest.fn(); + protected _putRecord = jest.fn(); + protected _updateRecord = jest.fn(); +} + +const options: IdempotencyOptions = { persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }; +const functionalityToDecorate = jest.fn(); + +class TestingClass { + @idempotent(options) + public testing(record: Record): string { + functionalityToDecorate(record); + + return 'Hi'; + } +} + +describe('Given a class with a function to decorate', (classWithFunction = new TestingClass()) => { + const keyValueToBeSaved = 'thisWillBeSaved'; + const inputRecord = { testingKey: keyValueToBeSaved, otherKey: 'thisWillNot' }; + beforeEach(()=> jest.clearAllMocks()); + describe('When wrapping a function with no previous executions', () => { + beforeEach(async () => { + classWithFunction.testing(inputRecord); + }); + + test('Then it will save the record to INPROGRESS', () => { + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then it will call the function that was decorated', () => { + expect(functionalityToDecorate).toBeCalledWith(inputRecord); + }); + }); + + describe('When decorating a function with previous execution that is INPROGRESS', () => { + let resultingError: Error; + beforeEach(async () => { + mockSaveInProgress.mockRejectedValue(new IdempotencyItemAlreadyExistsError()); + const idempotencyOptions: IdempotencyRecordOptions = { + idempotencyKey: 'key', + status: IdempotencyRecordStatus.INPROGRESS + }; + mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); + try { + await classWithFunction.testing(inputRecord); + } catch (e) { + resultingError = e as Error; + } + }); + + test('Then it will attempt to save the record to INPROGRESS', () => { + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then it will get the previous execution record', () => { + expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then it will not call the function that was decorated', () => { + expect(functionalityToDecorate).not.toBeCalled(); + }); + + test('Then an IdempotencyAlreadyInProgressError is thrown', () => { + expect(resultingError).toBeInstanceOf(IdempotencyAlreadyInProgressError); + }); + }); + + describe('When decorating a function with previous execution that is EXPIRED', () => { + let resultingError: Error; + beforeEach(async () => { + mockSaveInProgress.mockRejectedValue(new IdempotencyItemAlreadyExistsError()); + const idempotencyOptions: IdempotencyRecordOptions = { + idempotencyKey: 'key', + status: IdempotencyRecordStatus.EXPIRED + }; + mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); + try { + await classWithFunction.testing(inputRecord); + } catch (e) { + resultingError = e as Error; + } + }); + + test('Then it will attempt to save the record to INPROGRESS', () => { + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then it will get the previous execution record', () => { + expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then it will not call the function that was decorated', () => { + expect(functionalityToDecorate).not.toBeCalled(); + }); + + test('Then an IdempotencyInconsistentStateError is thrown', () => { + expect(resultingError).toBeInstanceOf(IdempotencyInconsistentStateError); + }); + }); + + describe('When wrapping a function with previous execution that is COMPLETED', () => { + beforeEach(async () => { + mockSaveInProgress.mockRejectedValue(new IdempotencyItemAlreadyExistsError()); + const idempotencyOptions: IdempotencyRecordOptions = { + idempotencyKey: 'key', + status: IdempotencyRecordStatus.COMPLETED + }; + mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); + await classWithFunction.testing(inputRecord); + }); + + test('Then it will attempt to save the record to INPROGRESS', () => { + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then it will get the previous execution record', () => { + expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + }); + + //This should be the saved record once FR3 is complete https://github.com/awslabs/aws-lambda-powertools-typescript/issues/447 + test('Then it will call the function that was decorated with the whole input record', () => { + expect(functionalityToDecorate).toBeCalledWith(inputRecord); + }); + }); + + describe('When wrapping a function with issues saving the record', () => { + let resultingError: Error; + beforeEach(async () => { + mockSaveInProgress.mockRejectedValue(new Error('RandomError')); + try { + await classWithFunction.testing(inputRecord); + } catch (e) { + resultingError = e as Error; + } + }); + + test('Then it will attempt to save the record to INPROGRESS', () => { + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then an IdempotencyPersistenceLayerError is thrown', () => { + expect(resultingError).toBeInstanceOf(IdempotencyPersistenceLayerError); + }); + }); +}); \ No newline at end of file diff --git a/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts new file mode 100644 index 000000000..95fc50b06 --- /dev/null +++ b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts @@ -0,0 +1,163 @@ +/** + * Test Function Wrapper + * + * @group unit/idempotency/all + */ + +import { IdempotencyOptions } from '../../src/types/IdempotencyOptions'; +import { IdempotencyRecord, PersistenceLayer } from '../../src/persistence'; +import { makeFunctionIdempotent } from '../../src/makeFunctionIdempotent'; +import { AnyIdempotentFunction, IdempotencyRecordStatus, IdempotencyRecordOptions } from '../../src/types'; +import { IdempotencyItemAlreadyExistsError, IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError, IdempotencyPersistenceLayerError } from '../../src/Exceptions'; + +const mockSaveInProgress = jest.spyOn(PersistenceLayer.prototype, 'saveInProgress').mockImplementation(); +const mockGetRecord = jest.spyOn(PersistenceLayer.prototype, 'getRecord').mockImplementation(); + +class PersistenceLayerTestClass extends PersistenceLayer { + protected _deleteRecord = jest.fn(); + protected _getRecord = jest.fn(); + protected _putRecord = jest.fn(); + protected _updateRecord = jest.fn(); +} + +describe('Given a function to wrap', (functionToWrap = jest.fn()) => { + beforeEach(()=> jest.clearAllMocks()); + describe('Given options for idempotency', (options: IdempotencyOptions = { persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }) => { + const keyValueToBeSaved = 'thisWillBeSaved'; + const inputRecord = { testingKey: keyValueToBeSaved, otherKey: 'thisWillNot' }; + describe('When wrapping a function with no previous executions', () => { + let resultingFunction: AnyIdempotentFunction; + beforeEach(async () => { + resultingFunction = makeFunctionIdempotent(functionToWrap, options); + await resultingFunction(inputRecord); + }); + + test('Then it will save the record to INPROGRESS', () => { + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then it will call the function that was wrapped with the whole input record', () => { + expect(functionToWrap).toBeCalledWith(inputRecord); + }); + }); + + describe('When wrapping a function with previous execution that is INPROGRESS', () => { + let resultingFunction: AnyIdempotentFunction; + let resultingError: Error; + beforeEach(async () => { + mockSaveInProgress.mockRejectedValue(new IdempotencyItemAlreadyExistsError()); + const idempotencyOptions: IdempotencyRecordOptions = { + idempotencyKey: 'key', + status: IdempotencyRecordStatus.INPROGRESS + }; + mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); + resultingFunction = makeFunctionIdempotent(functionToWrap, options); + try { + await resultingFunction(inputRecord); + } catch (e) { + resultingError = e as Error; + } + }); + + test('Then it will attempt to save the record to INPROGRESS', () => { + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then it will get the previous execution record', () => { + expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then the function that was wrapped is not called again', () => { + expect(functionToWrap).not.toBeCalled(); + }); + + test('Then an IdempotencyAlreadyInProgressError is thrown', ()=> { + expect(resultingError).toBeInstanceOf(IdempotencyAlreadyInProgressError); + }); + }); + + describe('When wrapping a function with previous execution that is EXPIRED', () => { + let resultingFunction: AnyIdempotentFunction; + let resultingError: Error; + beforeEach(async () => { + mockSaveInProgress.mockRejectedValue(new IdempotencyItemAlreadyExistsError()); + const idempotencyOptions: IdempotencyRecordOptions = { + idempotencyKey: 'key', + status: IdempotencyRecordStatus.EXPIRED + }; + mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); + resultingFunction = makeFunctionIdempotent(functionToWrap, options); + try { + await resultingFunction(inputRecord); + } catch (e) { + resultingError = e as Error; + } + }); + + test('Then it will attempt to save the record to INPROGRESS', () => { + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then it will get the previous execution record', () => { + expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then the function that was wrapped is not called again', () => { + expect(functionToWrap).not.toBeCalled(); + }); + + test('Then an IdempotencyInconsistentStateError is thrown', ()=> { + expect(resultingError).toBeInstanceOf(IdempotencyInconsistentStateError); + }); + }); + + describe('When wrapping a function with previous execution that is COMPLETED', () => { + let resultingFunction: AnyIdempotentFunction; + beforeEach(async () => { + mockSaveInProgress.mockRejectedValue(new IdempotencyItemAlreadyExistsError()); + const idempotencyOptions: IdempotencyRecordOptions = { + idempotencyKey: 'key', + status: IdempotencyRecordStatus.COMPLETED + }; + mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); + resultingFunction = makeFunctionIdempotent(functionToWrap, options); + await resultingFunction(inputRecord); + }); + + test('Then it will attempt to save the record to INPROGRESS', () => { + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then it will get the previous execution record', () => { + expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + }); + + //This should be the saved record once FR3 is complete https://github.com/awslabs/aws-lambda-powertools-typescript/issues/447 + test('Then it will call the function that was wrapped with the whole input record', () => { + expect(functionToWrap).toBeCalledWith(inputRecord); + }); + }); + + describe('When wrapping a function with issues saving the record', () => { + let resultingFunction: AnyIdempotentFunction; + let resultingError: Error; + beforeEach(async () => { + mockSaveInProgress.mockRejectedValue(new Error('RandomError')); + resultingFunction = makeFunctionIdempotent(functionToWrap, options); + try { + await resultingFunction(inputRecord); + } catch (e) { + resultingError = e as Error; + } + }); + + test('Then it will attempt to save the record to INPROGRESS', () => { + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + }); + + test('Then an IdempotencyPersistenceLayerError is thrown', ()=> { + expect(resultingError).toBeInstanceOf(IdempotencyPersistenceLayerError); + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/idempotency/tests/unit/persistence/DynamoDbPersistenceLayer.test.ts b/packages/idempotency/tests/unit/persistence/DynamoDbPersistenceLayer.test.ts index 46d3b3e23..8d95de55e 100644 --- a/packages/idempotency/tests/unit/persistence/DynamoDbPersistenceLayer.test.ts +++ b/packages/idempotency/tests/unit/persistence/DynamoDbPersistenceLayer.test.ts @@ -34,7 +34,6 @@ describe('Class: DynamoDBPersistenceLayer', () => { public _updateRecord(record: IdempotencyRecord): Promise { return super._updateRecord(record); } - } beforeEach(() => {