From c11408c13f53052276f9f587fe411eeeba04d3a9 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Fri, 28 Apr 2023 17:23:25 +0200 Subject: [PATCH 01/16] add flag (default true) to include index, so we can pass same payload multiple times --- packages/commons/tests/utils/e2eUtils.ts | 50 ++++++++++++------------ 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/commons/tests/utils/e2eUtils.ts b/packages/commons/tests/utils/e2eUtils.ts index 1469ea0ab1..e4834f71a7 100644 --- a/packages/commons/tests/utils/e2eUtils.ts +++ b/packages/commons/tests/utils/e2eUtils.ts @@ -1,15 +1,12 @@ -/** +/** * E2E utils is used by e2e tests. They are helper function that calls either CDK or SDK - * to interact with services. -*/ -import { App, CfnOutput, Stack, Duration } from 'aws-cdk-lib'; -import { - NodejsFunction, - NodejsFunctionProps -} from 'aws-cdk-lib/aws-lambda-nodejs'; + * to interact with services. + */ +import { App, CfnOutput, Duration, Stack } from 'aws-cdk-lib'; +import { NodejsFunction, NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs'; import { Runtime, Tracing } from 'aws-cdk-lib/aws-lambda'; import { RetentionDays } from 'aws-cdk-lib/aws-logs'; -import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { fromUtf8 } from '@aws-sdk/util-utf8-node'; import { InvocationLogs } from './InvocationLogs'; @@ -30,7 +27,7 @@ export type StackWithLambdaFunctionOptions = { functionName: string functionEntry: string tracing?: Tracing - environment: {[key: string]: string} + environment: { [key: string]: string } logGroupOutputKey?: string runtime: string bundling?: NodejsFunctionProps['bundling'] @@ -38,12 +35,12 @@ export type StackWithLambdaFunctionOptions = { timeout?: Duration }; -type FunctionPayload = {[key: string]: string | boolean | number}; +type FunctionPayload = { [key: string]: string | boolean | number }; export const isValidRuntimeKey = (runtime: string): runtime is TestRuntimesKey => testRuntimeKeys.includes(runtime); export const createStackWithLambdaFunction = (params: StackWithLambdaFunctionOptions): Stack => { - + const stack = new Stack(params.app, params.stackName); const testFunction = new NodejsFunction(stack, `testFunction`, { functionName: params.functionName, @@ -62,26 +59,27 @@ export const createStackWithLambdaFunction = (params: StackWithLambdaFunctionOpt value: testFunction.logGroup.logGroupName, }); } - + return stack; }; -export const generateUniqueName = (name_prefix: string, uuid: string, runtime: string, testName: string): string => - `${name_prefix}-${runtime}-${uuid.substring(0,5)}-${testName}`.substring(0, 64); +export const generateUniqueName = (name_prefix: string, uuid: string, runtime: string, testName: string): string => + `${name_prefix}-${runtime}-${uuid.substring(0, 5)}-${testName}`.substring(0, 64); -export const invokeFunction = async (functionName: string, times: number = 1, invocationMode: 'PARALLEL' | 'SEQUENTIAL' = 'PARALLEL', payload: FunctionPayload = {}): Promise => { +export const invokeFunction = async (functionName: string, times: number = 1, invocationMode: 'PARALLEL' | 'SEQUENTIAL' = 'PARALLEL', payload: FunctionPayload = {}, includeIndex = true): Promise => { const invocationLogs: InvocationLogs[] = []; - const promiseFactory = (index?: number): Promise => { + const promiseFactory = (index?: number, includeIndex?: boolean): Promise => { + + // in some cases we need to send a payload without the index, i.e. idempotency tests + const payloadToSend = includeIndex ? { invocation: index, ...payload } : { ...payload }; + const invokePromise = lambdaClient .send(new InvokeCommand({ FunctionName: functionName, InvocationType: 'RequestResponse', LogType: 'Tail', // Wait until execution completes and return all logs - Payload: fromUtf8(JSON.stringify({ - invocation: index, - ...payload - })), + Payload: fromUtf8(JSON.stringify(payloadToSend)), })) .then((response) => { if (response?.LogResult) { @@ -93,17 +91,17 @@ export const invokeFunction = async (functionName: string, times: number = 1, in return invokePromise; }; - - const promiseFactories = Array.from({ length: times }, () => promiseFactory ); + + const promiseFactories = Array.from({ length: times }, () => promiseFactory); const invocation = invocationMode == 'PARALLEL' - ? Promise.all(promiseFactories.map((factory, index) => factory(index))) + ? Promise.all(promiseFactories.map((factory, index) => factory(index, includeIndex))) : chainPromises(promiseFactories); await invocation; - return invocationLogs; + return invocationLogs; }; -const chainPromises = async (promiseFactories: ((index?: number) => Promise)[]) : Promise => { +const chainPromises = async (promiseFactories: ((index?: number) => Promise)[]): Promise => { for (let index = 0; index < promiseFactories.length; index++) { await promiseFactories[index](index); } From d7541041b924e7f72f53e1f61b71b576a1580237 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Fri, 28 Apr 2023 17:24:24 +0200 Subject: [PATCH 02/16] split interface for decorator and function wrap --- .../idempotency/src/IdempotencyHandler.ts | 38 ++++++++++++++++--- .../idempotency/src/idempotentDecorator.ts | 16 ++++---- .../idempotency/src/makeFunctionIdempotent.ts | 18 +++++++-- .../src/types/IdempotencyOptions.ts | 13 +++++-- 4 files changed, 65 insertions(+), 20 deletions(-) diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index 651301bbba..af516641de 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -1,4 +1,4 @@ -import type { AnyFunctionWithRecord, IdempotencyOptions } from './types'; +import type { AnyFunctionWithRecord } from './types'; import { IdempotencyRecordStatus } from './types'; import { IdempotencyAlreadyInProgressError, @@ -6,15 +6,18 @@ import { IdempotencyItemAlreadyExistsError, IdempotencyPersistenceLayerError, } from './Exceptions'; -import { IdempotencyRecord } from './persistence'; +import { BasePersistenceLayer, IdempotencyRecord } from './persistence'; +import { IdempotencyConfig } from 'IdempotencyConfig'; export class IdempotencyHandler { public constructor( private functionToMakeIdempotent: AnyFunctionWithRecord, private functionPayloadToBeHashed: Record, - private idempotencyOptions: IdempotencyOptions, + private config: IdempotencyConfig, + private persistenceStore: BasePersistenceLayer, private fullFunctionPayload: Record, ) { + } public determineResultFromIdempotencyRecord( @@ -46,6 +49,28 @@ export class IdempotencyHandler { } } + public async getFunctionResult(): Promise { + let result: U; + try { + result = await this.functionToMakeIdempotent(this.fullFunctionPayload); + + } catch (e) { + try { + await this.persistenceStore.deleteRecord(this.functionPayloadToBeHashed); + } catch (e) { + throw new IdempotencyPersistenceLayerError('Failed to delete record from idempotency store'); + } + throw e; + } + try { + await this.persistenceStore.saveSuccess(this.functionPayloadToBeHashed, result as Record); + } catch (e) { + throw new IdempotencyPersistenceLayerError('Failed to update success record to idempotency store'); + } + + return result; + } + /** * Main entry point for the handler * IdempotencyInconsistentStateError can happen under rare but expected cases @@ -70,13 +95,13 @@ export class IdempotencyHandler { public async processIdempotency(): Promise { try { - await this.idempotencyOptions.persistenceStore.saveInProgress( + await this.persistenceStore.saveInProgress( this.functionPayloadToBeHashed, ); } catch (e) { if (e instanceof IdempotencyItemAlreadyExistsError) { const idempotencyRecord: IdempotencyRecord = - await this.idempotencyOptions.persistenceStore.getRecord( + await this.persistenceStore.getRecord( this.functionPayloadToBeHashed ); @@ -86,6 +111,7 @@ export class IdempotencyHandler { } } - return this.functionToMakeIdempotent(this.fullFunctionPayload); + return this.getFunctionResult(); } + } diff --git a/packages/idempotency/src/idempotentDecorator.ts b/packages/idempotency/src/idempotentDecorator.ts index 7e35cfd0de..86f88d0124 100644 --- a/packages/idempotency/src/idempotentDecorator.ts +++ b/packages/idempotency/src/idempotentDecorator.ts @@ -1,15 +1,17 @@ -import { - GenericTempRecord, - IdempotencyOptions, -} from './types'; +import { GenericTempRecord, IdempotentHandlerOptions, } from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; +import { IdempotencyConfig } from './IdempotencyConfig'; -const idempotent = function (options: IdempotencyOptions) { +const idempotent = function (options: IdempotentHandlerOptions) { return function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) { const childFunction = descriptor.value; // TODO: sort out the type for this - descriptor.value = function(record: GenericTempRecord){ - const idempotencyHandler = new IdempotencyHandler(childFunction, record[options.dataKeywordArgument], options, record); + + descriptor.value = function (record: GenericTempRecord) { + const config = options.config || new IdempotencyConfig({}); + console.log(record); + config.registerLambdaContext(record.context); + const idempotencyHandler = new IdempotencyHandler(childFunction, record, config, options.persistenceStore, record); return idempotencyHandler.handle(); }; diff --git a/packages/idempotency/src/makeFunctionIdempotent.ts b/packages/idempotency/src/makeFunctionIdempotent.ts index 77e0a9e994..926502e37d 100644 --- a/packages/idempotency/src/makeFunctionIdempotent.ts +++ b/packages/idempotency/src/makeFunctionIdempotent.ts @@ -1,17 +1,27 @@ import type { - GenericTempRecord, - IdempotencyOptions, AnyFunctionWithRecord, AnyIdempotentFunction, + GenericTempRecord, + IdempotentFunctionOptions, } from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; +import { IdempotencyConfig } from './IdempotencyConfig'; const makeFunctionIdempotent = function ( fn: AnyFunctionWithRecord, - options: IdempotencyOptions + options: IdempotentFunctionOptions, ): AnyIdempotentFunction { const wrappedFn: AnyIdempotentFunction = function (record: GenericTempRecord): Promise { - const idempotencyHandler: IdempotencyHandler = new IdempotencyHandler(fn, record[options.dataKeywordArgument], options, record); + if (options.dataKeywordArgument === undefined) { + throw new Error(`Missing data keyword argument ${options.dataKeywordArgument}`); + } + const config = new IdempotencyConfig({}); + const idempotencyHandler: IdempotencyHandler = new IdempotencyHandler( + fn, + record[options.dataKeywordArgument], + config, + options.persistenceStore, + record); return idempotencyHandler.handle(); }; diff --git a/packages/idempotency/src/types/IdempotencyOptions.ts b/packages/idempotency/src/types/IdempotencyOptions.ts index 6f53f8efcd..89744a5c32 100644 --- a/packages/idempotency/src/types/IdempotencyOptions.ts +++ b/packages/idempotency/src/types/IdempotencyOptions.ts @@ -1,7 +1,13 @@ import type { Context } from 'aws-lambda'; import { BasePersistenceLayer } from '../persistence/BasePersistenceLayer'; +import { IdempotencyConfig } from 'IdempotencyConfig'; -type IdempotencyOptions = { +type IdempotentHandlerOptions = { + config?: IdempotencyConfig + persistenceStore: BasePersistenceLayer +}; + +type IdempotentFunctionOptions = { dataKeywordArgument: string persistenceStore: BasePersistenceLayer }; @@ -45,6 +51,7 @@ type IdempotencyConfigOptions = { }; export { - IdempotencyOptions, - IdempotencyConfigOptions + IdempotencyConfigOptions, + IdempotentHandlerOptions, + IdempotentFunctionOptions }; From cdce3a7bb37bc5b797cafd0a308a3be9aa272cc5 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Fri, 28 Apr 2023 17:24:36 +0200 Subject: [PATCH 03/16] tests --- packages/idempotency/package.json | 9 +- packages/idempotency/tests/e2e/constants.ts | 6 ++ .../idempotencyDecorator.test.FunctionCode.ts | 30 +++++++ .../tests/e2e/idempotencyDecorator.test.ts | 83 +++++++++++++++++++ .../tests/unit/IdempotencyHandler.test.ts | 58 +++++++++++-- .../tests/unit/idempotentDecorator.test.ts | 44 ++++++---- .../tests/unit/makeFunctionIdempotent.test.ts | 4 +- 7 files changed, 206 insertions(+), 28 deletions(-) create mode 100644 packages/idempotency/tests/e2e/constants.ts create mode 100644 packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts create mode 100644 packages/idempotency/tests/e2e/idempotencyDecorator.test.ts diff --git a/packages/idempotency/package.json b/packages/idempotency/package.json index f8a47fb2d4..bccf73ce77 100644 --- a/packages/idempotency/package.json +++ b/packages/idempotency/package.json @@ -13,10 +13,10 @@ "commit": "commit", "test": "npm run test:unit", "test:unit": "jest --group=unit --detectOpenHandles --coverage --verbose", - "test:e2e:nodejs14x": "echo \"Not implemented\"", - "test:e2e:nodejs16x": "echo \"Not implemented\"", - "test:e2e:nodejs18x": "echo \"Not implemented\"", - "test:e2e": "echo \"Not implemented\"", + "test:e2e:nodejs14x": "RUNTIME=nodejs14x jest --group=e2e", + "test:e2e:nodejs16x": "RUNTIME=nodejs16x jest --group=e2e", + "test:e2e:nodejs18x": "RUNTIME=nodejs18x jest --group=e2e", + "test:e2e": "jest --group=e2e", "watch": "jest --watch --group=unit", "build": "tsc", "lint": "eslint --ext .ts --no-error-on-unmatched-pattern src tests", @@ -57,6 +57,7 @@ ], "devDependencies": { "@types/jmespath": "^0.15.0", + "@aws-sdk/client-dynamodb": "^3.231.0", "aws-sdk-client-mock": "^2.0.1", "aws-sdk-client-mock-jest": "^2.0.1" } diff --git a/packages/idempotency/tests/e2e/constants.ts b/packages/idempotency/tests/e2e/constants.ts new file mode 100644 index 0000000000..e581b3c260 --- /dev/null +++ b/packages/idempotency/tests/e2e/constants.ts @@ -0,0 +1,6 @@ +export const RESOURCE_NAME_PREFIX = 'Idempotency-E2E'; + +export const ONE_MINUTE = 60 * 1_000; +export const TEARDOWN_TIMEOUT = 5 * ONE_MINUTE; +export const SETUP_TIMEOUT = 5 * ONE_MINUTE; +export const TEST_CASE_TIMEOUT = 5 * ONE_MINUTE; \ No newline at end of file diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts new file mode 100644 index 0000000000..af2dea1036 --- /dev/null +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts @@ -0,0 +1,30 @@ +import { LambdaInterface } from '@aws-lambda-powertools/commons'; +import { DynamoDBPersistenceLayer } from '../../src/persistence'; +import { idempotent } from '../../src/idempotentDecorator'; +import { Context } from 'aws-lambda'; +import { Logger } from '../../../logger'; + +const IDEMPOTENCY_TABLE_NAME = process.env.IDEMPOTENCY_TABLE_NAME; +const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({ + tableName: IDEMPOTENCY_TABLE_NAME, +}); + +interface TestEvent { + username: string +} + +const logger = new Logger(); + +class Lambda implements LambdaInterface { + @idempotent({ persistenceStore: dynamoDBPersistenceLayer }) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + public async handler(_event: TestEvent, _context: Context): Promise { + logger.info(JSON.stringify(_event)); + + return 'Hello World ' + _event.username; + } +} + +export const handlerClass = new Lambda(); +export const handler = handlerClass.handler.bind(handlerClass); \ No newline at end of file diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts new file mode 100644 index 0000000000..5d07e4cf52 --- /dev/null +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -0,0 +1,83 @@ +/** + * Test idempotency decorator + * + * @group e2e/idempotency + */ +import { v4 } from 'uuid'; +import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; +import { DynamoDBClient, ScanCommand } from '@aws-sdk/client-dynamodb'; +import { + generateUniqueName, + invokeFunction, + isValidRuntimeKey, + TEST_RUNTIMES +} from '../../../commons/tests/utils/e2eUtils'; +import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEARDOWN_TIMEOUT, TEST_CASE_TIMEOUT } from './constants'; +import * as path from 'path'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { deployStack, destroyStack } from '../../../commons/tests/utils/cdk-cli'; + +const runtime: string = process.env.RUNTIME || 'nodejs18x'; + +if (!isValidRuntimeKey(runtime)) { + throw new Error(`Invalid runtime key value: ${runtime}`); +} + +const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'IdempotencyDecorator'); +const testFunctionName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'IdempotencyDecoratorFunction'); +const app = new App(); +let stack: Stack; +const ddbTableName = stackName + '-idempotency-table'; +describe('Idempotency e2e test, basic features', () => { + + beforeAll(async () => { + stack = new Stack(app, stackName); + const ddbTable = new Table(stack, 'Table', { + tableName: ddbTableName, + partitionKey: { + name: 'id', + type: AttributeType.STRING, + }, + billingMode: BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY + }); + + const testFunction = new NodejsFunction(stack, 'IdemppotentFucntion', { + runtime: TEST_RUNTIMES[runtime], + functionName: testFunctionName, + entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), + handler: 'handler', + environment: { + IDEMPOTENCY_TABLE_NAME: ddbTableName, + POWERTOOLS_LOGGER_LOG_EVENT: 'true', + } + }); + + ddbTable.grantReadWriteData(testFunction); + + await deployStack(app, stack); + + await Promise.all([ + invokeFunction(testFunctionName, 1, 'SEQUENTIAL', { username: 'foo' }, false), + ]); + + }, SETUP_TIMEOUT); + + it('when called, it returns the same value', async () => { + // create dynamodb client to query the table and check the value + const ddb = new DynamoDBClient({ region: 'eu-west-1' }); + await ddb.send(new ScanCommand({ TableName: ddbTableName })).then((data) => { + expect(data.Items?.length).toEqual(1); + expect(data.Items?.[0].data?.S).toEqual('Hello World foo'); + expect(data.Items?.[0].status?.S).toEqual('COMPLETED'); + + }); + }, TEST_CASE_TIMEOUT); + + afterAll(async () => { + if (!process.env.DISABLE_TEARDOWN) { + await destroyStack(app, stack); + } + }, TEARDOWN_TIMEOUT); +}); \ No newline at end of file diff --git a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts index aeff922501..b32c4aca7f 100644 --- a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts +++ b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts @@ -5,13 +5,15 @@ */ import { - IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError, + IdempotencyAlreadyInProgressError, + IdempotencyInconsistentStateError, IdempotencyItemAlreadyExistsError, IdempotencyPersistenceLayerError } from '../../src/Exceptions'; -import { IdempotencyOptions, IdempotencyRecordStatus } from '../../src/types'; +import { IdempotencyRecordStatus, IdempotentHandlerOptions } from '../../src/types'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { IdempotencyHandler } from '../../src/IdempotencyHandler'; +import { IdempotencyConfig } from '../..//src/IdempotencyConfig'; class PersistenceLayerTestClass extends BasePersistenceLayer { protected _deleteRecord = jest.fn(); @@ -22,16 +24,17 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { const mockFunctionToMakeIdempotent = jest.fn(); const mockFunctionPayloadToBeHashed = {}; -const mockIdempotencyOptions: IdempotencyOptions = { +const mockIdempotencyOptions: IdempotentHandlerOptions = { persistenceStore: new PersistenceLayerTestClass(), - dataKeywordArgument: 'testingKey' + config: new IdempotencyConfig({}) }; const mockFullFunctionPayload = {}; const idempotentHandler = new IdempotencyHandler( mockFunctionToMakeIdempotent, mockFunctionPayloadToBeHashed, - mockIdempotencyOptions, + new IdempotencyConfig({}), + mockIdempotencyOptions.persistenceStore, mockFullFunctionPayload, ); @@ -174,5 +177,50 @@ describe('Class IdempotencyHandler', () => { }); }); + describe('Method: getFunctionResult', () => { + test('when function returns a result, it saves the successful result and returns it', async () => { + const mockSaveSuccessfulResult = jest.spyOn(mockIdempotencyOptions.persistenceStore, 'saveSuccess').mockResolvedValue(); + mockFunctionToMakeIdempotent.mockImplementation(() => Promise.resolve('result')); + + await expect( + idempotentHandler.getFunctionResult() + ).resolves.toBe('result'); + expect(mockSaveSuccessfulResult).toHaveBeenCalledTimes(1); + }); + + test('when function throws an error, it deletes the in progress record and throws the error', async () => { + mockFunctionToMakeIdempotent.mockImplementation(() => Promise.reject(new Error('Some error'))); + + const mockDeleteInProgress = jest.spyOn(mockIdempotencyOptions.persistenceStore, 'deleteRecord').mockResolvedValue(); + + await expect( + idempotentHandler.getFunctionResult() + ).rejects.toThrow(Error); + expect(mockDeleteInProgress).toHaveBeenCalledTimes(1); + }); + + test('when deleteRecord throws an error, it wraps the error to IdempotencyPersistenceLayerError', async () => { + mockFunctionToMakeIdempotent.mockImplementation(() => Promise.reject(new Error('Some error'))); + + const mockDeleteInProgress = jest.spyOn(mockIdempotencyOptions.persistenceStore, 'deleteRecord').mockRejectedValue(new Error('Some error')); + + await expect( + idempotentHandler.getFunctionResult() + ).rejects.toThrow(IdempotencyPersistenceLayerError); + expect(mockDeleteInProgress).toHaveBeenCalledTimes(1); + }); + + test('when saveSuccessfulResult throws an error, it wraps the error to IdempotencyPersistenceLayerError', async () => { + mockFunctionToMakeIdempotent.mockImplementation(() => Promise.resolve('result')); + + const mockSaveSuccessfulResult = jest.spyOn(mockIdempotencyOptions.persistenceStore, 'saveSuccess').mockRejectedValue(new Error('Some error')); + + await expect( + idempotentHandler.getFunctionResult() + ).rejects.toThrow(IdempotencyPersistenceLayerError); + expect(mockSaveSuccessfulResult).toHaveBeenCalledTimes(1); + }); + + }); }); diff --git a/packages/idempotency/tests/unit/idempotentDecorator.test.ts b/packages/idempotency/tests/unit/idempotentDecorator.test.ts index 699413456b..7860ec6471 100644 --- a/packages/idempotency/tests/unit/idempotentDecorator.test.ts +++ b/packages/idempotency/tests/unit/idempotentDecorator.test.ts @@ -4,14 +4,20 @@ * @group unit/idempotency/decorator */ -import { IdempotencyOptions } from '../../src/types/IdempotencyOptions'; +import { IdempotentHandlerOptions } from '../../src/types/IdempotencyOptions'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { idempotent } from '../../src/idempotentDecorator'; -import { IdempotencyRecordStatus } from '../../src/types'; import type { IdempotencyRecordOptions } from '../../src/types'; -import { IdempotencyItemAlreadyExistsError, IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError, IdempotencyPersistenceLayerError } from '../../src/Exceptions'; +import { IdempotencyRecordStatus } from '../../src/types'; +import { + IdempotencyAlreadyInProgressError, + IdempotencyInconsistentStateError, + IdempotencyItemAlreadyExistsError, + IdempotencyPersistenceLayerError +} from '../../src/Exceptions'; const mockSaveInProgress = jest.spyOn(BasePersistenceLayer.prototype, 'saveInProgress').mockImplementation(); +const mockSaveSuccess = jest.spyOn(BasePersistenceLayer.prototype, 'saveSuccess').mockImplementation(); const mockGetRecord = jest.spyOn(BasePersistenceLayer.prototype, 'getRecord').mockImplementation(); class PersistenceLayerTestClass extends BasePersistenceLayer { @@ -21,7 +27,7 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { protected _updateRecord = jest.fn(); } -const options: IdempotencyOptions = { persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }; +const options: IdempotentHandlerOptions = { persistenceStore: new PersistenceLayerTestClass() }; const functionalityToDecorate = jest.fn(); class TestingClass { @@ -38,19 +44,23 @@ class TestingClass { describe('Given a class with a function to decorate', (classWithFunction = new TestingClass()) => { const keyValueToBeSaved = 'thisWillBeSaved'; const inputRecord = { testingKey: keyValueToBeSaved, otherKey: 'thisWillNot' }; - beforeEach(()=> jest.clearAllMocks()); + 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); + expect(mockSaveInProgress).toBeCalledWith(inputRecord); }); test('Then it will call the function that was decorated', () => { expect(functionalityToDecorate).toBeCalledWith(inputRecord); }); + + test('Then it will save the record to COMPLETED with function return value', () => { + expect(mockSaveSuccess).toBeCalledWith(inputRecord, 'Hi'); + }); }); describe('When decorating a function with previous execution that is INPROGRESS', () => { @@ -61,7 +71,7 @@ describe('Given a class with a function to decorate', (classWithFunction = new T idempotencyKey: 'key', status: IdempotencyRecordStatus.INPROGRESS }; - mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); + mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); try { await classWithFunction.testing(inputRecord); } catch (e) { @@ -70,11 +80,11 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + expect(mockSaveInProgress).toBeCalledWith(inputRecord); }); test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + expect(mockGetRecord).toBeCalledWith(inputRecord); }); test('Then it will not call the function that was decorated', () => { @@ -94,7 +104,7 @@ describe('Given a class with a function to decorate', (classWithFunction = new T idempotencyKey: 'key', status: IdempotencyRecordStatus.EXPIRED }; - mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); + mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); try { await classWithFunction.testing(inputRecord); } catch (e) { @@ -103,11 +113,11 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + expect(mockSaveInProgress).toBeCalledWith(inputRecord); }); test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + expect(mockGetRecord).toBeCalledWith(inputRecord); }); test('Then it will not call the function that was decorated', () => { @@ -126,22 +136,22 @@ describe('Given a class with a function to decorate', (classWithFunction = new T idempotencyKey: 'key', status: IdempotencyRecordStatus.COMPLETED }; - mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); + mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); await classWithFunction.testing(inputRecord); }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + expect(mockSaveInProgress).toBeCalledWith(inputRecord); }); test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + expect(mockGetRecord).toBeCalledWith(inputRecord); }); - //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', () => { @@ -156,7 +166,7 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + expect(mockSaveInProgress).toBeCalledWith(inputRecord); }); test('Then an IdempotencyPersistenceLayerError is thrown', () => { diff --git a/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts index c8d90b251b..fe8cd8ce19 100644 --- a/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts +++ b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts @@ -3,7 +3,7 @@ * * @group unit/idempotency/makeFunctionIdempotent */ -import { IdempotencyOptions } from '../../src/types/IdempotencyOptions'; +import { IdempotentFunctionOptions } from '../../src/types/IdempotencyOptions'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { makeFunctionIdempotent } from '../../src/makeFunctionIdempotent'; import type { AnyIdempotentFunction, IdempotencyRecordOptions } from '../../src/types'; @@ -27,7 +27,7 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { describe('Given a function to wrap', (functionToWrap = jest.fn()) => { beforeEach(() => jest.clearAllMocks()); - describe('Given options for idempotency', (options: IdempotencyOptions = { + describe('Given options for idempotency', (options: IdempotentFunctionOptions = { persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }) => { From d7c714734beffcdc4d19f3e5691441e0920832c5 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Tue, 2 May 2023 17:08:35 +0200 Subject: [PATCH 04/16] fix record return for sequential calls --- packages/idempotency/src/IdempotencyHandler.ts | 9 +++------ .../idempotencyDecorator.test.FunctionCode.ts | 12 ++++++++---- .../tests/e2e/idempotencyDecorator.test.ts | 18 +++++++++++------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index af516641de..d46716b4d7 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -20,9 +20,7 @@ export class IdempotencyHandler { } - public determineResultFromIdempotencyRecord( - idempotencyRecord: IdempotencyRecord - ): Promise | U { + 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.' @@ -43,10 +41,9 @@ export class IdempotencyHandler { `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); } + + return idempotencyRecord.getResponse() as U; } public async getFunctionResult(): Promise { diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts index af2dea1036..58bc77dfac 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts @@ -16,15 +16,19 @@ interface TestEvent { const logger = new Logger(); class Lambda implements LambdaInterface { + @idempotent({ persistenceStore: dynamoDBPersistenceLayer }) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore public async handler(_event: TestEvent, _context: Context): Promise { - logger.info(JSON.stringify(_event)); + logger.info(`Got test event: ${JSON.stringify(_event)}`); + // sleep for 5 seconds + await new Promise((resolve) => setTimeout(resolve, 5000)); - return 'Hello World ' + _event.username; + return 'Hello World'; } + } -export const handlerClass = new Lambda(); -export const handler = handlerClass.handler.bind(handlerClass); \ No newline at end of file +const handlerClass = new Lambda(); +export const handler = handlerClass.handler.bind(handlerClass); diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts index 5d07e4cf52..b7e3a11930 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -4,7 +4,7 @@ * @group e2e/idempotency */ import { v4 } from 'uuid'; -import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { App, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; import { DynamoDBClient, ScanCommand } from '@aws-sdk/client-dynamodb'; import { @@ -17,6 +17,7 @@ import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEARDOWN_TIMEOUT, TEST_CASE_TIMEOU import * as path from 'path'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; import { deployStack, destroyStack } from '../../../commons/tests/utils/cdk-cli'; +import { InvocationLogs } from '../../../commons/tests/utils/InvocationLogs'; const runtime: string = process.env.RUNTIME || 'nodejs18x'; @@ -31,6 +32,8 @@ let stack: Stack; const ddbTableName = stackName + '-idempotency-table'; describe('Idempotency e2e test, basic features', () => { + let invocationLogs: InvocationLogs[]; + beforeAll(async () => { stack = new Stack(app, stackName); const ddbTable = new Table(stack, 'Table', { @@ -47,20 +50,19 @@ describe('Idempotency e2e test, basic features', () => { runtime: TEST_RUNTIMES[runtime], functionName: testFunctionName, entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), + timeout: Duration.seconds(30), handler: 'handler', environment: { IDEMPOTENCY_TABLE_NAME: ddbTableName, POWERTOOLS_LOGGER_LOG_EVENT: 'true', - } + }, }); ddbTable.grantReadWriteData(testFunction); await deployStack(app, stack); - await Promise.all([ - invokeFunction(testFunctionName, 1, 'SEQUENTIAL', { username: 'foo' }, false), - ]); + invocationLogs = await invokeFunction(testFunctionName, 2, 'SEQUENTIAL', { foo: 'bar' }, false); }, SETUP_TIMEOUT); @@ -69,10 +71,12 @@ describe('Idempotency e2e test, basic features', () => { const ddb = new DynamoDBClient({ region: 'eu-west-1' }); await ddb.send(new ScanCommand({ TableName: ddbTableName })).then((data) => { expect(data.Items?.length).toEqual(1); - expect(data.Items?.[0].data?.S).toEqual('Hello World foo'); + expect(data.Items?.[0].data?.S).toEqual('Hello World'); expect(data.Items?.[0].status?.S).toEqual('COMPLETED'); - + expect(invocationLogs[0].getFunctionLogs().toString()).toContain('Got test event'); + expect(invocationLogs[1].getFunctionLogs().toString()).not.toContain('Got test event'); }); + }, TEST_CASE_TIMEOUT); afterAll(async () => { From cf8ca06515f0d3c6d79d2b8109dd5d2f6048e010 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Wed, 3 May 2023 10:08:27 +0200 Subject: [PATCH 05/16] change to get command --- .../idempotencyDecorator.test.FunctionCode.ts | 3 +- .../tests/e2e/idempotencyDecorator.test.ts | 68 +++++++++++++++---- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts index 58bc77dfac..bffb92e9d5 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts @@ -7,6 +7,7 @@ import { Logger } from '../../../logger'; const IDEMPOTENCY_TABLE_NAME = process.env.IDEMPOTENCY_TABLE_NAME; const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({ tableName: IDEMPOTENCY_TABLE_NAME, + staticPkValue: 'test', }); interface TestEvent { @@ -23,7 +24,7 @@ class Lambda implements LambdaInterface { public async handler(_event: TestEvent, _context: Context): Promise { logger.info(`Got test event: ${JSON.stringify(_event)}`); // sleep for 5 seconds - await new Promise((resolve) => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 3000)); return 'Hello World'; } diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts index b7e3a11930..d9ecedc317 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -6,7 +6,7 @@ import { v4 } from 'uuid'; import { App, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; -import { DynamoDBClient, ScanCommand } from '@aws-sdk/client-dynamodb'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { generateUniqueName, invokeFunction, @@ -17,7 +17,9 @@ import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEARDOWN_TIMEOUT, TEST_CASE_TIMEOU import * as path from 'path'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; import { deployStack, destroyStack } from '../../../commons/tests/utils/cdk-cli'; -import { InvocationLogs } from '../../../commons/tests/utils/InvocationLogs'; +import { InvocationLogs, LEVEL } from '../../../commons/tests/utils/InvocationLogs'; +import { GetCommand } from '@aws-sdk/lib-dynamodb'; +import { createHash } from 'node:crypto'; const runtime: string = process.env.RUNTIME || 'nodejs18x'; @@ -26,13 +28,15 @@ if (!isValidRuntimeKey(runtime)) { } const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'IdempotencyDecorator'); -const testFunctionName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'IdempotencyDecoratorFunction'); +const testFunctionName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp'); const app = new App(); let stack: Stack; const ddbTableName = stackName + '-idempotency-table'; describe('Idempotency e2e test, basic features', () => { - let invocationLogs: InvocationLogs[]; + let invocationLogsSequential: InvocationLogs[]; + let invocationLogsParallel: InvocationLogs[]; + const payload = { foo: 'baz' }; beforeAll(async () => { stack = new Stack(app, stackName); @@ -46,9 +50,9 @@ describe('Idempotency e2e test, basic features', () => { removalPolicy: RemovalPolicy.DESTROY }); - const testFunction = new NodejsFunction(stack, 'IdemppotentFucntion', { + const sequntialExecutionFunction = new NodejsFunction(stack, 'IdempotentFucntionSequential', { runtime: TEST_RUNTIMES[runtime], - functionName: testFunctionName, + functionName: `${testFunctionName}-sequential`, entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), timeout: Duration.seconds(30), handler: 'handler', @@ -58,23 +62,57 @@ describe('Idempotency e2e test, basic features', () => { }, }); - ddbTable.grantReadWriteData(testFunction); + const parallelExecutionFunction = new NodejsFunction(stack, 'IdemppotentFucntionParallel', { + runtime: TEST_RUNTIMES[runtime], + functionName: `${testFunctionName}-parallel`, + entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), + timeout: Duration.seconds(30), + handler: 'handler', + environment: { + IDEMPOTENCY_TABLE_NAME: ddbTableName, + POWERTOOLS_LOGGER_LOG_EVENT: 'true', + }, + }); + + ddbTable.grantReadWriteData(sequntialExecutionFunction); + ddbTable.grantReadWriteData(parallelExecutionFunction); await deployStack(app, stack); - invocationLogs = await invokeFunction(testFunctionName, 2, 'SEQUENTIAL', { foo: 'bar' }, false); + invocationLogsSequential = await invokeFunction(`${testFunctionName}-sequential`, 2, 'SEQUENTIAL', payload, false); + + invocationLogsParallel = await invokeFunction(`${testFunctionName}-parallel`, 2, 'PARALLEL', payload, false); }, SETUP_TIMEOUT); - it('when called, it returns the same value', async () => { + it('when called twice, it returns the same value without calling the inner function', async () => { // create dynamodb client to query the table and check the value const ddb = new DynamoDBClient({ region: 'eu-west-1' }); - await ddb.send(new ScanCommand({ TableName: ddbTableName })).then((data) => { - expect(data.Items?.length).toEqual(1); - expect(data.Items?.[0].data?.S).toEqual('Hello World'); - expect(data.Items?.[0].status?.S).toEqual('COMPLETED'); - expect(invocationLogs[0].getFunctionLogs().toString()).toContain('Got test event'); - expect(invocationLogs[1].getFunctionLogs().toString()).not.toContain('Got test event'); + const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); + const fnHash = `${testFunctionName}-sequential#${payloadHash}`; + console.log(fnHash); + await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: fnHash } })).then((data) => { + console.log(data); + expect(data?.Item?.data).toEqual('Hello World'); + expect(data?.Item?.status).toEqual('COMPLETED'); + // we log events inside the handler, so the 2nd invocation should not log anything + expect(invocationLogsSequential[0].getFunctionLogs().toString()).toContain('Got test event'); + expect(invocationLogsSequential[1].getFunctionLogs().toString()).not.toContain('Got test event'); + }); + + }, TEST_CASE_TIMEOUT); + + it('when called twice in parallel, it trows an error', async () => { + // create dynamodb client to query the table and check the value + const ddb = new DynamoDBClient({ region: 'eu-west-1' }); + const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); + const fnHash = `${testFunctionName}-parallel#${payloadHash}`; + console.log(fnHash); + await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: fnHash } })).then((data) => { + expect(data?.Item?.data).toEqual('Hello World'); + expect(data?.Item?.status).toEqual('COMPLETED'); + + expect(invocationLogsParallel[0].getFunctionLogs(LEVEL.ERROR).toString()).toContain('There is already an execution in progress with idempotency key'); }); }, TEST_CASE_TIMEOUT); From 2b7d2a4d399b4191617b51b3d0f108842d581023 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Wed, 3 May 2023 13:49:55 +0200 Subject: [PATCH 06/16] fix unit tests based on new handler implementation --- .../tests/e2e/idempotencyDecorator.test.ts | 32 ++++++++++--------- .../tests/unit/idempotentDecorator.test.ts | 6 ++-- .../tests/unit/makeFunctionIdempotent.test.ts | 6 ++-- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts index d9ecedc317..2650c1da5e 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -28,15 +28,17 @@ if (!isValidRuntimeKey(runtime)) { } const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'IdempotencyDecorator'); -const testFunctionName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp'); +const testFunctionNameSequential = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-sequential'); +const testFunctionNameParallel = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-parallel'); const app = new App(); let stack: Stack; const ddbTableName = stackName + '-idempotency-table'; -describe('Idempotency e2e test, basic features', () => { +describe('Idempotency e2e test, default settings', () => { let invocationLogsSequential: InvocationLogs[]; let invocationLogsParallel: InvocationLogs[]; const payload = { foo: 'baz' }; + const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); beforeAll(async () => { stack = new Stack(app, stackName); @@ -52,7 +54,7 @@ describe('Idempotency e2e test, basic features', () => { const sequntialExecutionFunction = new NodejsFunction(stack, 'IdempotentFucntionSequential', { runtime: TEST_RUNTIMES[runtime], - functionName: `${testFunctionName}-sequential`, + functionName: testFunctionNameSequential, entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), timeout: Duration.seconds(30), handler: 'handler', @@ -64,7 +66,7 @@ describe('Idempotency e2e test, basic features', () => { const parallelExecutionFunction = new NodejsFunction(stack, 'IdemppotentFucntionParallel', { runtime: TEST_RUNTIMES[runtime], - functionName: `${testFunctionName}-parallel`, + functionName: testFunctionNameParallel, entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), timeout: Duration.seconds(30), handler: 'handler', @@ -79,22 +81,22 @@ describe('Idempotency e2e test, basic features', () => { await deployStack(app, stack); - invocationLogsSequential = await invokeFunction(`${testFunctionName}-sequential`, 2, 'SEQUENTIAL', payload, false); + invocationLogsSequential = await invokeFunction(testFunctionNameSequential, 2, 'SEQUENTIAL', payload, false); - invocationLogsParallel = await invokeFunction(`${testFunctionName}-parallel`, 2, 'PARALLEL', payload, false); + invocationLogsParallel = await invokeFunction(testFunctionNameParallel, 2, 'PARALLEL', payload, false); }, SETUP_TIMEOUT); it('when called twice, it returns the same value without calling the inner function', async () => { // create dynamodb client to query the table and check the value const ddb = new DynamoDBClient({ region: 'eu-west-1' }); - const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); - const fnHash = `${testFunctionName}-sequential#${payloadHash}`; - console.log(fnHash); - await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: fnHash } })).then((data) => { + const idempotencyKey = `${testFunctionNameSequential}#${payloadHash}`; + console.log(idempotencyKey); + await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: idempotencyKey } })).then((data) => { console.log(data); expect(data?.Item?.data).toEqual('Hello World'); expect(data?.Item?.status).toEqual('COMPLETED'); + expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); // we log events inside the handler, so the 2nd invocation should not log anything expect(invocationLogsSequential[0].getFunctionLogs().toString()).toContain('Got test event'); expect(invocationLogsSequential[1].getFunctionLogs().toString()).not.toContain('Got test event'); @@ -105,13 +107,13 @@ describe('Idempotency e2e test, basic features', () => { it('when called twice in parallel, it trows an error', async () => { // create dynamodb client to query the table and check the value const ddb = new DynamoDBClient({ region: 'eu-west-1' }); - const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); - const fnHash = `${testFunctionName}-parallel#${payloadHash}`; - console.log(fnHash); - await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: fnHash } })).then((data) => { + const idempotencyKey = `${testFunctionNameParallel}#${payloadHash}`; + console.log(idempotencyKey); + await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: idempotencyKey } })).then((data) => { + console.log(data); expect(data?.Item?.data).toEqual('Hello World'); expect(data?.Item?.status).toEqual('COMPLETED'); - + expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); expect(invocationLogsParallel[0].getFunctionLogs(LEVEL.ERROR).toString()).toContain('There is already an execution in progress with idempotency key'); }); diff --git a/packages/idempotency/tests/unit/idempotentDecorator.test.ts b/packages/idempotency/tests/unit/idempotentDecorator.test.ts index 7860ec6471..59d9eddbf0 100644 --- a/packages/idempotency/tests/unit/idempotentDecorator.test.ts +++ b/packages/idempotency/tests/unit/idempotentDecorator.test.ts @@ -134,7 +134,7 @@ describe('Given a class with a function to decorate', (classWithFunction = new T mockSaveInProgress.mockRejectedValue(new IdempotencyItemAlreadyExistsError()); const idempotencyOptions: IdempotencyRecordOptions = { idempotencyKey: 'key', - status: IdempotencyRecordStatus.COMPLETED + status: IdempotencyRecordStatus.COMPLETED, }; mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); await classWithFunction.testing(inputRecord); @@ -148,8 +148,8 @@ describe('Given a class with a function to decorate', (classWithFunction = new T expect(mockGetRecord).toBeCalledWith(inputRecord); }); - test('Then it will call the function that was decorated with the whole input record', () => { - expect(functionalityToDecorate).toBeCalledWith(inputRecord); + test('Then it will not call decorated functionality', () => { + expect(functionalityToDecorate).not.toBeCalledWith(inputRecord); }); }); diff --git a/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts index fe8cd8ce19..f092c3a829 100644 --- a/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts +++ b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts @@ -140,10 +140,10 @@ describe('Given a function to wrap', (functionToWrap = jest.fn()) => { 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); + test('Then it will not call the function that was wrapped with the whole input record', () => { + expect(functionToWrap).not.toBeCalledWith(inputRecord); }); + }); describe('When wrapping a function with issues saving the record', () => { From 1467a5b1615c69663f4abe6d8d26cd05eef45602 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Tue, 9 May 2023 16:54:15 +0200 Subject: [PATCH 07/16] extend signature to pass array values to functions --- packages/commons/tests/utils/e2eUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/commons/tests/utils/e2eUtils.ts b/packages/commons/tests/utils/e2eUtils.ts index e4834f71a7..9ede7eb6e5 100644 --- a/packages/commons/tests/utils/e2eUtils.ts +++ b/packages/commons/tests/utils/e2eUtils.ts @@ -35,7 +35,7 @@ export type StackWithLambdaFunctionOptions = { timeout?: Duration }; -type FunctionPayload = { [key: string]: string | boolean | number }; +type FunctionPayload = { [key: string]: string | boolean | number | Array> }; export const isValidRuntimeKey = (runtime: string): runtime is TestRuntimesKey => testRuntimeKeys.includes(runtime); From 49cabcbf57db6efc8e025689902fc8bd8f2b8488 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Tue, 9 May 2023 16:58:42 +0200 Subject: [PATCH 08/16] add e2e and fix unit test for new signature --- .../idempotency/src/IdempotencyHandler.ts | 2 +- .../idempotency/src/idempotentDecorator.ts | 13 +- .../idempotency/src/makeFunctionIdempotent.ts | 9 +- .../src/types/IdempotencyOptions.ts | 13 +- .../idempotencyDecorator.test.FunctionCode.ts | 73 ++++++++++- .../tests/e2e/idempotencyDecorator.test.ts | 121 ++++++++++++++++-- ...akeFunctionIdempotent.test.FunctionCode.ts | 39 ++++++ .../tests/e2e/makeFunctionIdempotent.test.ts | 91 +++++++++++++ .../tests/unit/IdempotencyHandler.test.ts | 4 +- .../tests/unit/idempotentDecorator.test.ts | 58 +++++++-- .../tests/unit/makeFunctionIdempotent.test.ts | 4 +- 11 files changed, 370 insertions(+), 57 deletions(-) create mode 100644 packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts create mode 100644 packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index d46716b4d7..fcf4832425 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -17,7 +17,6 @@ export class IdempotencyHandler { private persistenceStore: BasePersistenceLayer, private fullFunctionPayload: Record, ) { - } public determineResultFromIdempotencyRecord(idempotencyRecord: IdempotencyRecord): Promise | U { @@ -104,6 +103,7 @@ export class IdempotencyHandler { return this.determineResultFromIdempotencyRecord(idempotencyRecord); } else { + console.log(e); throw new IdempotencyPersistenceLayerError(); } } diff --git a/packages/idempotency/src/idempotentDecorator.ts b/packages/idempotency/src/idempotentDecorator.ts index 86f88d0124..d54d79fb2e 100644 --- a/packages/idempotency/src/idempotentDecorator.ts +++ b/packages/idempotency/src/idempotentDecorator.ts @@ -1,17 +1,22 @@ -import { GenericTempRecord, IdempotentHandlerOptions, } from './types'; +import { GenericTempRecord, IdempotentOptions, } from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; import { IdempotencyConfig } from './IdempotencyConfig'; -const idempotent = function (options: IdempotentHandlerOptions) { +const idempotent = function (options: IdempotentOptions) { return function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) { const childFunction = descriptor.value; // TODO: sort out the type for this descriptor.value = function (record: GenericTempRecord) { const config = options.config || new IdempotencyConfig({}); - console.log(record); + const dataKeywordArgument = options?.dataKeywordArgument ? record[options?.dataKeywordArgument] : record; config.registerLambdaContext(record.context); - const idempotencyHandler = new IdempotencyHandler(childFunction, record, config, options.persistenceStore, record); + const idempotencyHandler = new IdempotencyHandler( + childFunction, + dataKeywordArgument, + config, + options.persistenceStore, + record); return idempotencyHandler.handle(); }; diff --git a/packages/idempotency/src/makeFunctionIdempotent.ts b/packages/idempotency/src/makeFunctionIdempotent.ts index 926502e37d..808f204fee 100644 --- a/packages/idempotency/src/makeFunctionIdempotent.ts +++ b/packages/idempotency/src/makeFunctionIdempotent.ts @@ -1,15 +1,10 @@ -import type { - AnyFunctionWithRecord, - AnyIdempotentFunction, - GenericTempRecord, - IdempotentFunctionOptions, -} from './types'; +import type { AnyFunctionWithRecord, AnyIdempotentFunction, GenericTempRecord, IdempotentOptions, } from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; import { IdempotencyConfig } from './IdempotencyConfig'; const makeFunctionIdempotent = function ( fn: AnyFunctionWithRecord, - options: IdempotentFunctionOptions, + options: IdempotentOptions, ): AnyIdempotentFunction { const wrappedFn: AnyIdempotentFunction = function (record: GenericTempRecord): Promise { if (options.dataKeywordArgument === undefined) { diff --git a/packages/idempotency/src/types/IdempotencyOptions.ts b/packages/idempotency/src/types/IdempotencyOptions.ts index 89744a5c32..ca775bc8b7 100644 --- a/packages/idempotency/src/types/IdempotencyOptions.ts +++ b/packages/idempotency/src/types/IdempotencyOptions.ts @@ -2,14 +2,10 @@ import type { Context } from 'aws-lambda'; import { BasePersistenceLayer } from '../persistence/BasePersistenceLayer'; import { IdempotencyConfig } from 'IdempotencyConfig'; -type IdempotentHandlerOptions = { - config?: IdempotencyConfig - persistenceStore: BasePersistenceLayer -}; - -type IdempotentFunctionOptions = { - dataKeywordArgument: string +type IdempotentOptions = { persistenceStore: BasePersistenceLayer + dataKeywordArgument?: string + config?: IdempotencyConfig }; /** @@ -52,6 +48,5 @@ type IdempotencyConfigOptions = { export { IdempotencyConfigOptions, - IdempotentHandlerOptions, - IdempotentFunctionOptions + IdempotentOptions }; diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts index bffb92e9d5..939121dc41 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts @@ -7,29 +7,88 @@ import { Logger } from '../../../logger'; const IDEMPOTENCY_TABLE_NAME = process.env.IDEMPOTENCY_TABLE_NAME; const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({ tableName: IDEMPOTENCY_TABLE_NAME, - staticPkValue: 'test', +}); + +const ddbPersistenceLayerCustomized = new DynamoDBPersistenceLayer({ + tableName: IDEMPOTENCY_TABLE_NAME, + dataAttr: 'dataattr', + keyAttr: 'customId', + expiryAttr: 'expiryattr', + statusAttr: 'statusattr', + inProgressExpiryAttr: 'inprogressexpiryattr', + staticPkValue: 'staticpkvalue', + validationKeyAttr: 'validationkeyattr', }); interface TestEvent { - username: string + [key: string]: string } -const logger = new Logger(); +interface EventRecords { + records: Record[] +} -class Lambda implements LambdaInterface { +class DefaultLambda implements LambdaInterface { @idempotent({ persistenceStore: dynamoDBPersistenceLayer }) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - public async handler(_event: TestEvent, _context: Context): Promise { + public async handler(_event: Record, _context: Context): Promise { logger.info(`Got test event: ${JSON.stringify(_event)}`); - // sleep for 5 seconds + // sleep to enforce error with parallel execution await new Promise((resolve) => setTimeout(resolve, 3000)); return 'Hello World'; } + @idempotent({ persistenceStore: ddbPersistenceLayerCustomized }) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + public async handlerCustomized(_event: TestEvent, _context: Context): Promise { + logger.info(`Got test event customized: ${JSON.stringify(_event)}`); + // sleep for 5 seconds + + return 'Hello World Customized'; + } + + @idempotent({ persistenceStore: dynamoDBPersistenceLayer }) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + public async handlerFails(_event: TestEvent, _context: Context): Promise { + logger.info(`Got test event: ${JSON.stringify(_event)}`); + // sleep for 5 seconds + + throw new Error('Failed'); + } + +} + +const logger = new Logger(); + +class LambdaWithKeywordArgument implements LambdaInterface { + public async handler(_event: EventRecords, _context: Context): Promise { + logger.info(`Got test event: ${JSON.stringify(_event)}`); + for (const record of _event.records) { + logger.info(`Processing event: ${JSON.stringify(record)}`); + await this.process(record); + } + + return 'Hello World Keyword Argument'; + } + + @idempotent({ persistenceStore: dynamoDBPersistenceLayer, dataKeywordArgument: 'foo' }) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + public async process(record: Record): string { + logger.info(`Processing inside: ${JSON.stringify(record)}`); + + return 'idempotent result: ' + record.foo; + } } -const handlerClass = new Lambda(); +const handlerClass = new DefaultLambda(); +const decorateInnerMethodClass = new LambdaWithKeywordArgument(); export const handler = handlerClass.handler.bind(handlerClass); +export const handlerCustomized = handlerClass.handlerCustomized.bind(handlerClass); +export const handlerFails = handlerClass.handlerFails.bind(handlerClass); +export const handlerWithKeywordArgument = decorateInnerMethodClass.handler.bind(decorateInnerMethodClass); diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts index 2650c1da5e..3b19f86fdb 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -30,6 +30,9 @@ if (!isValidRuntimeKey(runtime)) { const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'IdempotencyDecorator'); const testFunctionNameSequential = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-sequential'); const testFunctionNameParallel = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-parallel'); +const testFunctionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-custom'); +const testFunctionNameFails = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-fails'); +const testFunctionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-keywordarg'); const app = new App(); let stack: Stack; const ddbTableName = stackName + '-idempotency-table'; @@ -37,7 +40,11 @@ describe('Idempotency e2e test, default settings', () => { let invocationLogsSequential: InvocationLogs[]; let invocationLogsParallel: InvocationLogs[]; + let invocationLogsCustmozed: InvocationLogs[]; + const ddb = new DynamoDBClient({ region: 'eu-west-1' }); + const payload = { foo: 'baz' }; + const payloadArray = { records: [ { id: 1, foo: 'bar' }, { id: 2, foo: 'baq' }, { id: 3, foo: 'bar' } ] }; const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); beforeAll(async () => { @@ -63,6 +70,7 @@ describe('Idempotency e2e test, default settings', () => { POWERTOOLS_LOGGER_LOG_EVENT: 'true', }, }); + ddbTable.grantReadWriteData(sequntialExecutionFunction); const parallelExecutionFunction = new NodejsFunction(stack, 'IdemppotentFucntionParallel', { runtime: TEST_RUNTIMES[runtime], @@ -71,29 +79,73 @@ describe('Idempotency e2e test, default settings', () => { timeout: Duration.seconds(30), handler: 'handler', environment: { - IDEMPOTENCY_TABLE_NAME: ddbTableName, + IDEMPOTENCY_TABLE_NAME: ddbTable.tableName, POWERTOOLS_LOGGER_LOG_EVENT: 'true', }, }); - - ddbTable.grantReadWriteData(sequntialExecutionFunction); ddbTable.grantReadWriteData(parallelExecutionFunction); - await deployStack(app, stack); + const ddbTableCustomized = new Table(stack, 'TableCustomized', { + tableName: ddbTableName + '-customized', + partitionKey: { + name: 'customId', + type: AttributeType.STRING, + }, + billingMode: BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY + }); - invocationLogsSequential = await invokeFunction(testFunctionNameSequential, 2, 'SEQUENTIAL', payload, false); + const customizedPersistenceLayerFunction = new NodejsFunction(stack, 'CustomisedIdempotencyDecorator', { + runtime: TEST_RUNTIMES[runtime], + functionName: testFunctionNameCustom, + entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), + timeout: Duration.seconds(30), + handler: 'handlerCustomized', + environment: { + IDEMPOTENCY_TABLE_NAME: ddbTableCustomized.tableName, + POWERTOOLS_LOGGER_LOG_EVENT: 'true' + }, + }); - invocationLogsParallel = await invokeFunction(testFunctionNameParallel, 2, 'PARALLEL', payload, false); + ddbTableCustomized.grantReadWriteData(customizedPersistenceLayerFunction); + + const failsFunction = new NodejsFunction(stack, 'IdempotentFucntionFails', { + runtime: TEST_RUNTIMES[runtime], + functionName: testFunctionNameFails, + entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), + timeout: Duration.seconds(30), + handler: 'handlerFails', + environment: { + IDEMPOTENCY_TABLE_NAME: ddbTable.tableName, + POWERTOOLS_LOGGER_LOG_EVENT: 'true' + }, + }); + + ddbTable.grantReadWriteData(failsFunction); + + const dataKeywordArgFunction = new NodejsFunction(stack, 'IdempotentFucntionKeywordArg', { + runtime: TEST_RUNTIMES[runtime], + functionName: testFunctionNameKeywordArg, + entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), + timeout: Duration.seconds(30), + handler: 'handlerWithKeywordArgument', + environment: { + IDEMPOTENCY_TABLE_NAME: ddbTable.tableName, + POWERTOOLS_LOGGER_LOG_EVENT: 'true' + }, + }); + + ddbTable.grantReadWriteData(dataKeywordArgFunction); + + await deployStack(app, stack); }, SETUP_TIMEOUT); it('when called twice, it returns the same value without calling the inner function', async () => { + invocationLogsSequential = await invokeFunction(testFunctionNameSequential, 2, 'SEQUENTIAL', payload, false); // create dynamodb client to query the table and check the value - const ddb = new DynamoDBClient({ region: 'eu-west-1' }); const idempotencyKey = `${testFunctionNameSequential}#${payloadHash}`; - console.log(idempotencyKey); await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: idempotencyKey } })).then((data) => { - console.log(data); expect(data?.Item?.data).toEqual('Hello World'); expect(data?.Item?.status).toEqual('COMPLETED'); expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); @@ -105,20 +157,65 @@ describe('Idempotency e2e test, default settings', () => { }, TEST_CASE_TIMEOUT); it('when called twice in parallel, it trows an error', async () => { + invocationLogsParallel = await invokeFunction(testFunctionNameParallel, 2, 'PARALLEL', payload, false); // create dynamodb client to query the table and check the value - const ddb = new DynamoDBClient({ region: 'eu-west-1' }); const idempotencyKey = `${testFunctionNameParallel}#${payloadHash}`; - console.log(idempotencyKey); await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: idempotencyKey } })).then((data) => { - console.log(data); expect(data?.Item?.data).toEqual('Hello World'); expect(data?.Item?.status).toEqual('COMPLETED'); expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); expect(invocationLogsParallel[0].getFunctionLogs(LEVEL.ERROR).toString()).toContain('There is already an execution in progress with idempotency key'); }); + }, TEST_CASE_TIMEOUT); + it('when called with customized idempotency decorator, it creates ddb entry with custom attributes', async () => { + invocationLogsCustmozed = await invokeFunction(testFunctionNameCustom, 1, 'PARALLEL', payload, false); + const idempotencyKey = `${testFunctionNameCustom}#${payloadHash}`; + await ddb.send(new GetCommand({ + TableName: `${ddbTableName}-customized`, + Key: { customId: idempotencyKey } + })).then((data) => { + expect(data?.Item?.dataattr).toEqual('Hello World Customized'); + expect(data?.Item?.statusattr).toEqual('COMPLETED'); + expect(data?.Item?.expiryattr).toBeGreaterThan(Date.now() / 1000); + expect(invocationLogsCustmozed[0].getFunctionLogs().toString()).toContain('Got test event customized'); + }); }, TEST_CASE_TIMEOUT); + it('when called with a function that fails, it creates ddb entry with error status', async () => { + await invokeFunction(testFunctionNameFails, 1, 'PARALLEL', payload, false); + const idempotencyKey = `${testFunctionNameFails}#${payloadHash}`; + console.log(idempotencyKey); + await ddb.send(new GetCommand({ + TableName: ddbTableName, + Key: { id: idempotencyKey } + })).then((data) => { + console.log(data); + expect(data?.Item).toBeUndefined(); + }); + }, TEST_CASE_TIMEOUT); + + it('when called with a function that has keyword argument, it creates ddb entry with error status', async () => { + await invokeFunction(testFunctionNameKeywordArg, 2, 'SEQUENTIAL', payloadArray, false); + const payloadHashFirst = createHash('md5').update('"bar"').digest('base64'); + const payloadHashSecond = createHash('md5').update('"baq"').digest('base64'); + const resultFirst = await ddb.send(new GetCommand({ + TableName: ddbTableName, + Key: { id: `${testFunctionNameKeywordArg}#${payloadHashFirst}` } + })); + expect(resultFirst?.Item?.data).toEqual('idempotent result: bar'); + expect(resultFirst?.Item?.status).toEqual('COMPLETED'); + expect(resultFirst?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); + + const resultSecond = await ddb.send(new GetCommand({ + TableName: ddbTableName, + Key: { id: `${testFunctionNameKeywordArg}#${payloadHashSecond}` } + })); + expect(resultSecond?.Item?.data).toEqual('idempotent result: baq'); + expect(resultSecond?.Item?.status).toEqual('COMPLETED'); + expect(resultSecond?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); + }); + afterAll(async () => { if (!process.env.DISABLE_TEARDOWN) { await destroyStack(app, stack); diff --git a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts new file mode 100644 index 0000000000..a5046b6682 --- /dev/null +++ b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts @@ -0,0 +1,39 @@ +import { DynamoDBPersistenceLayer } from '../../src/persistence'; +import { makeFunctionIdempotent } from '../../src/makeFunctionIdempotent'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { Context } from 'aws-lambda'; + +const IDEMPOTENCY_TABLE_NAME = process.env.IDEMPOTENCY_TABLE_NAME; +const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({ + tableName: IDEMPOTENCY_TABLE_NAME, +}); + +interface EventRecords { + records: Record[] +} + +const logger = new Logger(); + +const processIdempotently = makeFunctionIdempotent( + processRecord, + { + persistenceStore: dynamoDBPersistenceLayer, + dataKeywordArgument: 'foo' + }); + +function processRecord(record: Record): string { + logger.info(`Got test event: ${JSON.stringify(record)}`); + + return 'Processing done: ' + record['foo']; +} + +export const handler = async (_event: EventRecords, _context: Context): Promise => { + for (const record of _event.records) { + const result = await processIdempotently(record); + logger.info(result.toString()); + + } + + return Promise.resolve(); +}; + diff --git a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts new file mode 100644 index 0000000000..751f222f3d --- /dev/null +++ b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts @@ -0,0 +1,91 @@ +/** + * Test makeFunctionIdempotent + * + * @group e2e/idempotency + */ +import { + generateUniqueName, + invokeFunction, + isValidRuntimeKey, + TEST_RUNTIMES +} from '../../../commons/tests/utils/e2eUtils'; +import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEST_CASE_TIMEOUT } from './constants'; +import { v4 } from 'uuid'; +import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { createHash } from 'node:crypto'; +import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as path from 'path'; +import { deployStack } from '../../../commons/tests/utils/cdk-cli'; +import { GetCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; + +const runtime: string = process.env.RUNTIME || 'nodejs18x'; + +if (!isValidRuntimeKey(runtime)) { + throw new Error(`Invalid runtime key value: ${runtime}`); +} +const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'makeFnIdempotent'); +const testFunctionName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-sequential'); +const ddbTableName = stackName + '-idempotency-makeFnIdempotent'; +const app = new App(); +let stack: Stack; + +describe('Idempotency e2e test, default settings', () => { + const ddb = new DynamoDBClient({ region: 'eu-west-1' }); + + beforeAll(async () => { + stack = new Stack(app, stackName); + const ddbTable = new Table(stack, 'Table', { + tableName: ddbTableName, + partitionKey: { + name: 'id', + type: AttributeType.STRING, + }, + billingMode: BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY + }); + + const idempotentFunction = new NodejsFunction(stack, 'IdempotentFucntion', { + runtime: TEST_RUNTIMES[runtime], + functionName: testFunctionName, + entry: path.resolve(__dirname, 'makeFunctionIdempotent.test.FunctionCode.ts'), + handler: 'handler', + environment: { + IDEMPOTENCY_TABLE_NAME: ddbTableName, + POWERTOOLS_LOGGER_LOG_EVENT: 'true', + }, + }); + + ddbTable.grantReadWriteData(idempotentFunction); + + await deployStack(app, stack); + + const payload = { records: [ { id: 1, foo: 'bar' }, { id: 2, foo: 'baz' }, { id: 3, foo: 'bar' } ] }; + await invokeFunction(testFunctionName, 2, 'SEQUENTIAL', payload, false); + + }, SETUP_TIMEOUT); + + it('when called twice, it returns the same result', async () => { + const payloadHashFirst = createHash('md5').update(JSON.stringify('bar')).digest('base64'); + const payloadHashSecond = createHash('md5').update(JSON.stringify('baz')).digest('base64'); + + const scanResult = await ddb.send(new ScanCommand({ TableName: ddbTableName })); + expect(scanResult?.Items?.length).toEqual(2); + + const idempotencyKeyFirst = `${testFunctionName}#${payloadHashFirst}`; + console.log(idempotencyKeyFirst); + const resultFirst = await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: idempotencyKeyFirst } })); + console.log(resultFirst); + expect(resultFirst?.Item?.data).toEqual('Processing done: bar'); + expect(resultFirst?.Item?.status).toEqual('COMPLETED'); + + const idempotencyKeySecond = `${testFunctionName}#${payloadHashSecond}`; + const resultSecond = await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: idempotencyKeySecond } })); + console.log(resultSecond); + expect(resultSecond?.Item?.data).toEqual('Processing done: baz'); + expect(resultSecond?.Item?.status).toEqual('COMPLETED'); + + }, TEST_CASE_TIMEOUT); + +}); \ No newline at end of file diff --git a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts index b32c4aca7f..469de15386 100644 --- a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts +++ b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts @@ -10,7 +10,7 @@ import { IdempotencyItemAlreadyExistsError, IdempotencyPersistenceLayerError } from '../../src/Exceptions'; -import { IdempotencyRecordStatus, IdempotentHandlerOptions } from '../../src/types'; +import { IdempotencyRecordStatus, IdempotentOptions, } from '../../src/types'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { IdempotencyHandler } from '../../src/IdempotencyHandler'; import { IdempotencyConfig } from '../..//src/IdempotencyConfig'; @@ -24,7 +24,7 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { const mockFunctionToMakeIdempotent = jest.fn(); const mockFunctionPayloadToBeHashed = {}; -const mockIdempotencyOptions: IdempotentHandlerOptions = { +const mockIdempotencyOptions: IdempotentOptions = { persistenceStore: new PersistenceLayerTestClass(), config: new IdempotencyConfig({}) }; diff --git a/packages/idempotency/tests/unit/idempotentDecorator.test.ts b/packages/idempotency/tests/unit/idempotentDecorator.test.ts index 59d9eddbf0..78c9006d66 100644 --- a/packages/idempotency/tests/unit/idempotentDecorator.test.ts +++ b/packages/idempotency/tests/unit/idempotentDecorator.test.ts @@ -4,7 +4,6 @@ * @group unit/idempotency/decorator */ -import { IdempotentHandlerOptions } from '../../src/types/IdempotencyOptions'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { idempotent } from '../../src/idempotentDecorator'; import type { IdempotencyRecordOptions } from '../../src/types'; @@ -27,11 +26,10 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { protected _updateRecord = jest.fn(); } -const options: IdempotentHandlerOptions = { persistenceStore: new PersistenceLayerTestClass() }; const functionalityToDecorate = jest.fn(); class TestingClass { - @idempotent(options) + @idempotent({ persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore public testing(record: Record): string { @@ -41,13 +39,26 @@ class TestingClass { } } -describe('Given a class with a function to decorate', (classWithFunction = new TestingClass()) => { +class TestingClassWithoutKeywordArgument { + @idempotent({ persistenceStore: new PersistenceLayerTestClass() }) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + public testing(record: Record): string { + functionalityToDecorate(record); + + return 'Hi'; + } +} + +describe('Given a class with a function to decorate', (classWithFunction = new TestingClass(), + classWithFunctionNoKeywordArg = new TestingClassWithoutKeywordArgument()) => { 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); + await classWithFunctionNoKeywordArg.testing(inputRecord); }); test('Then it will save the record to INPROGRESS', () => { @@ -61,6 +72,24 @@ describe('Given a class with a function to decorate', (classWithFunction = new T test('Then it will save the record to COMPLETED with function return value', () => { expect(mockSaveSuccess).toBeCalledWith(inputRecord, 'Hi'); }); + + }); + describe('When wrapping a function with no previous executions', () => { + beforeEach(async () => { + await 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); + }); + + test('Then it will save the record to COMPLETED with function return value', () => { + expect(mockSaveSuccess).toBeCalledWith(keyValueToBeSaved, 'Hi'); + }); }); describe('When decorating a function with previous execution that is INPROGRESS', () => { @@ -80,11 +109,11 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(inputRecord); + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); }); test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(inputRecord); + expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); }); test('Then it will not call the function that was decorated', () => { @@ -113,11 +142,11 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(inputRecord); + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); }); test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(inputRecord); + expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); }); test('Then it will not call the function that was decorated', () => { @@ -136,16 +165,17 @@ describe('Given a class with a function to decorate', (classWithFunction = new T 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(inputRecord); + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); }); test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(inputRecord); + expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); }); test('Then it will not call decorated functionality', () => { @@ -166,11 +196,13 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(inputRecord); + 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 index f092c3a829..220e26296f 100644 --- a/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts +++ b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts @@ -3,7 +3,7 @@ * * @group unit/idempotency/makeFunctionIdempotent */ -import { IdempotentFunctionOptions } from '../../src/types/IdempotencyOptions'; +import { IdempotentOptions } from '../../src/types/IdempotencyOptions'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { makeFunctionIdempotent } from '../../src/makeFunctionIdempotent'; import type { AnyIdempotentFunction, IdempotencyRecordOptions } from '../../src/types'; @@ -27,7 +27,7 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { describe('Given a function to wrap', (functionToWrap = jest.fn()) => { beforeEach(() => jest.clearAllMocks()); - describe('Given options for idempotency', (options: IdempotentFunctionOptions = { + describe('Given options for idempotency', (options: IdempotentOptions = { persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }) => { From 9a33f5eb3feb07c1d271c44e18af9cf156db0437 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Wed, 10 May 2023 23:10:17 +0200 Subject: [PATCH 09/16] refactor tests --- packages/idempotency/package.json | 2 +- .../idempotency/src/IdempotencyHandler.ts | 1 - .../idempotencyDecorator.test.FunctionCode.ts | 13 +- .../tests/e2e/idempotencyDecorator.test.ts | 256 ++++++++---------- 4 files changed, 118 insertions(+), 154 deletions(-) diff --git a/packages/idempotency/package.json b/packages/idempotency/package.json index bccf73ce77..aa675ee4ff 100644 --- a/packages/idempotency/package.json +++ b/packages/idempotency/package.json @@ -16,7 +16,7 @@ "test:e2e:nodejs14x": "RUNTIME=nodejs14x jest --group=e2e", "test:e2e:nodejs16x": "RUNTIME=nodejs16x jest --group=e2e", "test:e2e:nodejs18x": "RUNTIME=nodejs18x jest --group=e2e", - "test:e2e": "jest --group=e2e", + "test:e2e": "jest --group=e2e --detectOpenHandles", "watch": "jest --watch --group=unit", "build": "tsc", "lint": "eslint --ext .ts --no-error-on-unmatched-pattern src tests", diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index fcf4832425..f091050589 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -103,7 +103,6 @@ export class IdempotencyHandler { return this.determineResultFromIdempotencyRecord(idempotencyRecord); } else { - console.log(e); throw new IdempotencyPersistenceLayerError(); } } diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts index 939121dc41..4d34d69c5d 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts @@ -63,6 +63,11 @@ class DefaultLambda implements LambdaInterface { } +const defaultLambda = new DefaultLambda(); +export const handler = defaultLambda.handler.bind(defaultLambda); +export const handlerCustomized = defaultLambda.handlerCustomized.bind(defaultLambda); +export const handlerFails = defaultLambda.handlerFails.bind(defaultLambda); + const logger = new Logger(); class LambdaWithKeywordArgument implements LambdaInterface { @@ -86,9 +91,5 @@ class LambdaWithKeywordArgument implements LambdaInterface { } } -const handlerClass = new DefaultLambda(); -const decorateInnerMethodClass = new LambdaWithKeywordArgument(); -export const handler = handlerClass.handler.bind(handlerClass); -export const handlerCustomized = handlerClass.handlerCustomized.bind(handlerClass); -export const handlerFails = handlerClass.handlerFails.bind(handlerClass); -export const handlerWithKeywordArgument = decorateInnerMethodClass.handler.bind(decorateInnerMethodClass); +const lambdaWithKeywordArg = new LambdaWithKeywordArgument(); +export const handlerWithKeywordArgument = lambdaWithKeywordArg.handler.bind(lambdaWithKeywordArg); \ No newline at end of file diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts index 3b19f86fdb..f58a85bb12 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -13,11 +13,11 @@ import { isValidRuntimeKey, TEST_RUNTIMES } from '../../../commons/tests/utils/e2eUtils'; -import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEARDOWN_TIMEOUT, TEST_CASE_TIMEOUT } from './constants'; +import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEST_CASE_TIMEOUT } from './constants'; import * as path from 'path'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; -import { deployStack, destroyStack } from '../../../commons/tests/utils/cdk-cli'; -import { InvocationLogs, LEVEL } from '../../../commons/tests/utils/InvocationLogs'; +import { deployStack } from '../../../commons/tests/utils/cdk-cli'; +import { LEVEL } from '../../../commons/tests/utils/InvocationLogs'; import { GetCommand } from '@aws-sdk/lib-dynamodb'; import { createHash } from 'node:crypto'; @@ -27,125 +27,76 @@ if (!isValidRuntimeKey(runtime)) { throw new Error(`Invalid runtime key value: ${runtime}`); } -const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'IdempotencyDecorator'); -const testFunctionNameSequential = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-sequential'); -const testFunctionNameParallel = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-parallel'); -const testFunctionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-custom'); -const testFunctionNameFails = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-fails'); -const testFunctionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-keywordarg'); +const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'Idempotency'); + const app = new App(); let stack: Stack; -const ddbTableName = stackName + '-idempotency-table'; -describe('Idempotency e2e test, default settings', () => { - let invocationLogsSequential: InvocationLogs[]; - let invocationLogsParallel: InvocationLogs[]; - let invocationLogsCustmozed: InvocationLogs[]; - const ddb = new DynamoDBClient({ region: 'eu-west-1' }); +const createIdempotencyResources = (stack: Stack, ddbTableName: string, functionName: string, handler: string, ddbPkId?: string): void => { + const uniqueTableId = ddbTableName + v4().substring(0, 5); + const ddbTable = new Table(stack, uniqueTableId, { + tableName: ddbTableName, + partitionKey: { + name: ddbPkId ? ddbPkId : 'id', + type: AttributeType.STRING, + }, + billingMode: BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY + }); - const payload = { foo: 'baz' }; - const payloadArray = { records: [ { id: 1, foo: 'bar' }, { id: 2, foo: 'baq' }, { id: 3, foo: 'bar' } ] }; - const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); + const uniqueFunctionId = functionName + v4().substring(0, 5); + const nodeJsFunction = new NodejsFunction(stack, uniqueFunctionId, { + runtime: TEST_RUNTIMES[runtime], + functionName: functionName, + entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), + timeout: Duration.seconds(30), + handler: handler, + environment: { + IDEMPOTENCY_TABLE_NAME: ddbTableName, + POWERTOOLS_LOGGER_LOG_EVENT: 'true', + } + }); - beforeAll(async () => { - stack = new Stack(app, stackName); - const ddbTable = new Table(stack, 'Table', { - tableName: ddbTableName, - partitionKey: { - name: 'id', - type: AttributeType.STRING, - }, - billingMode: BillingMode.PAY_PER_REQUEST, - removalPolicy: RemovalPolicy.DESTROY - }); + ddbTable.grantReadWriteData(nodeJsFunction); - const sequntialExecutionFunction = new NodejsFunction(stack, 'IdempotentFucntionSequential', { - runtime: TEST_RUNTIMES[runtime], - functionName: testFunctionNameSequential, - entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), - timeout: Duration.seconds(30), - handler: 'handler', - environment: { - IDEMPOTENCY_TABLE_NAME: ddbTableName, - POWERTOOLS_LOGGER_LOG_EVENT: 'true', - }, - }); - ddbTable.grantReadWriteData(sequntialExecutionFunction); - - const parallelExecutionFunction = new NodejsFunction(stack, 'IdemppotentFucntionParallel', { - runtime: TEST_RUNTIMES[runtime], - functionName: testFunctionNameParallel, - entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), - timeout: Duration.seconds(30), - handler: 'handler', - environment: { - IDEMPOTENCY_TABLE_NAME: ddbTable.tableName, - POWERTOOLS_LOGGER_LOG_EVENT: 'true', - }, - }); - ddbTable.grantReadWriteData(parallelExecutionFunction); - - const ddbTableCustomized = new Table(stack, 'TableCustomized', { - tableName: ddbTableName + '-customized', - partitionKey: { - name: 'customId', - type: AttributeType.STRING, - }, - billingMode: BillingMode.PAY_PER_REQUEST, - removalPolicy: RemovalPolicy.DESTROY - }); +}; - const customizedPersistenceLayerFunction = new NodejsFunction(stack, 'CustomisedIdempotencyDecorator', { - runtime: TEST_RUNTIMES[runtime], - functionName: testFunctionNameCustom, - entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), - timeout: Duration.seconds(30), - handler: 'handlerCustomized', - environment: { - IDEMPOTENCY_TABLE_NAME: ddbTableCustomized.tableName, - POWERTOOLS_LOGGER_LOG_EVENT: 'true' - }, - }); +describe('Idempotency e2e test, default settings', () => { - ddbTableCustomized.grantReadWriteData(customizedPersistenceLayerFunction); - - const failsFunction = new NodejsFunction(stack, 'IdempotentFucntionFails', { - runtime: TEST_RUNTIMES[runtime], - functionName: testFunctionNameFails, - entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), - timeout: Duration.seconds(30), - handler: 'handlerFails', - environment: { - IDEMPOTENCY_TABLE_NAME: ddbTable.tableName, - POWERTOOLS_LOGGER_LOG_EVENT: 'true' - }, - }); + const ddb = new DynamoDBClient({ region: 'eu-west-1' }); + stack = new Stack(app, stackName); - ddbTable.grantReadWriteData(failsFunction); - - const dataKeywordArgFunction = new NodejsFunction(stack, 'IdempotentFucntionKeywordArg', { - runtime: TEST_RUNTIMES[runtime], - functionName: testFunctionNameKeywordArg, - entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), - timeout: Duration.seconds(30), - handler: 'handlerWithKeywordArgument', - environment: { - IDEMPOTENCY_TABLE_NAME: ddbTable.tableName, - POWERTOOLS_LOGGER_LOG_EVENT: 'true' - }, - }); + const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'default'); + const ddbTableNameDefault = stackName + '-default-table'; + createIdempotencyResources(stack, ddbTableNameDefault, functionNameDefault, 'handler'); + + const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'custom'); + const ddbTableNameCustom = stackName + '-custom-table'; + createIdempotencyResources(stack, ddbTableNameCustom, functionNameCustom, 'handlerCustomized', 'customId'); + + const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'keywordarg'); + const ddbTableNameKeywordArg = stackName + '-keywordarg-table'; + createIdempotencyResources(stack, ddbTableNameKeywordArg, functionNameKeywordArg, 'handlerWithKeywordArgument'); - ddbTable.grantReadWriteData(dataKeywordArgFunction); + const functionNameFails = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'fails'); + const ddbTableNameFails = stackName + '-fails-table'; + createIdempotencyResources(stack, ddbTableNameFails, functionNameFails, 'handlerFails'); + beforeAll(async () => { await deployStack(app, stack); }, SETUP_TIMEOUT); - it('when called twice, it returns the same value without calling the inner function', async () => { - invocationLogsSequential = await invokeFunction(testFunctionNameSequential, 2, 'SEQUENTIAL', payload, false); + test('when called twice, it returns the same value without calling the inner function', async () => { + const payload = { foo: 'baz' }; + const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); + + const invocationLogsSequential = await invokeFunction(functionNameDefault, 2, 'SEQUENTIAL', payload, false); // create dynamodb client to query the table and check the value - const idempotencyKey = `${testFunctionNameSequential}#${payloadHash}`; - await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: idempotencyKey } })).then((data) => { + await ddb.send(new GetCommand({ + TableName: ddbTableNameDefault, + Key: { id: `${functionNameDefault}#${payloadHash}` } + })).then((data) => { expect(data?.Item?.data).toEqual('Hello World'); expect(data?.Item?.status).toEqual('COMPLETED'); expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); @@ -156,24 +107,30 @@ describe('Idempotency e2e test, default settings', () => { }, TEST_CASE_TIMEOUT); - it('when called twice in parallel, it trows an error', async () => { - invocationLogsParallel = await invokeFunction(testFunctionNameParallel, 2, 'PARALLEL', payload, false); - // create dynamodb client to query the table and check the value - const idempotencyKey = `${testFunctionNameParallel}#${payloadHash}`; - await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: idempotencyKey } })).then((data) => { + test('when called twice in parallel, it trows an error', async () => { + const payload = { id: '123' }; + const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); + const invocationLogs = await invokeFunction(functionNameDefault, 2, 'PARALLEL', payload, false); + + await ddb.send(new GetCommand({ + TableName: ddbTableNameDefault, + Key: { id: `${functionNameDefault}#${payloadHash}` } + })).then((data) => { expect(data?.Item?.data).toEqual('Hello World'); expect(data?.Item?.status).toEqual('COMPLETED'); expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - expect(invocationLogsParallel[0].getFunctionLogs(LEVEL.ERROR).toString()).toContain('There is already an execution in progress with idempotency key'); + expect(invocationLogs[0].getFunctionLogs(LEVEL.ERROR).toString()).toContain('There is already an execution in progress with idempotency key'); }); }, TEST_CASE_TIMEOUT); - it('when called with customized idempotency decorator, it creates ddb entry with custom attributes', async () => { - invocationLogsCustmozed = await invokeFunction(testFunctionNameCustom, 1, 'PARALLEL', payload, false); - const idempotencyKey = `${testFunctionNameCustom}#${payloadHash}`; + test('when called with customized idempotency decorator, it creates ddb entry with custom attributes', async () => { + const payload = { foo: 'baz' }; + const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); + + const invocationLogsCustmozed = await invokeFunction(functionNameCustom, 1, 'PARALLEL', payload, false); await ddb.send(new GetCommand({ - TableName: `${ddbTableName}-customized`, - Key: { customId: idempotencyKey } + TableName: ddbTableNameCustom, + Key: { customId: `${functionNameCustom}#${payloadHash}` } })).then((data) => { expect(data?.Item?.dataattr).toEqual('Hello World Customized'); expect(data?.Item?.statusattr).toEqual('COMPLETED'); @@ -182,43 +139,50 @@ describe('Idempotency e2e test, default settings', () => { }); }, TEST_CASE_TIMEOUT); - it('when called with a function that fails, it creates ddb entry with error status', async () => { - await invokeFunction(testFunctionNameFails, 1, 'PARALLEL', payload, false); - const idempotencyKey = `${testFunctionNameFails}#${payloadHash}`; - console.log(idempotencyKey); + test('when called with a function that fails, it creates ddb entry with error status', async () => { + const payload = { foo: 'baz' }; + const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); + + await invokeFunction(functionNameFails, 1, 'PARALLEL', payload, false); await ddb.send(new GetCommand({ - TableName: ddbTableName, - Key: { id: idempotencyKey } + TableName: ddbTableNameFails, + Key: { id: `${functionNameFails}#${payloadHash}` } })).then((data) => { console.log(data); expect(data?.Item).toBeUndefined(); }); }, TEST_CASE_TIMEOUT); - it('when called with a function that has keyword argument, it creates ddb entry with error status', async () => { - await invokeFunction(testFunctionNameKeywordArg, 2, 'SEQUENTIAL', payloadArray, false); + test('when called with a function that has keyword argument, it creates for every entry of keyword argument', async () => { + const payloadArray = { records: [ { id: 1, foo: 'bar' }, { id: 2, foo: 'baq' }, { id: 3, foo: 'bar' } ] }; const payloadHashFirst = createHash('md5').update('"bar"').digest('base64'); + + await invokeFunction(functionNameKeywordArg, 2, 'SEQUENTIAL', payloadArray, false); + await ddb.send(new GetCommand({ + TableName: ddbTableNameKeywordArg, + Key: { id: `${functionNameKeywordArg}#${payloadHashFirst}` } + })).then((data) => { + console.log(data); + expect(data?.Item?.data).toEqual('idempotent result: bar'); + expect(data?.Item?.status).toEqual('COMPLETED'); + expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); + }); + const payloadHashSecond = createHash('md5').update('"baq"').digest('base64'); - const resultFirst = await ddb.send(new GetCommand({ - TableName: ddbTableName, - Key: { id: `${testFunctionNameKeywordArg}#${payloadHashFirst}` } - })); - expect(resultFirst?.Item?.data).toEqual('idempotent result: bar'); - expect(resultFirst?.Item?.status).toEqual('COMPLETED'); - expect(resultFirst?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - - const resultSecond = await ddb.send(new GetCommand({ - TableName: ddbTableName, - Key: { id: `${testFunctionNameKeywordArg}#${payloadHashSecond}` } - })); - expect(resultSecond?.Item?.data).toEqual('idempotent result: baq'); - expect(resultSecond?.Item?.status).toEqual('COMPLETED'); - expect(resultSecond?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - }); + await ddb.send(new GetCommand({ + TableName: ddbTableNameKeywordArg, + Key: { id: `${functionNameKeywordArg}#${payloadHashSecond}` } + })).then((data) => { + console.log(data); + expect(data?.Item?.data).toEqual('idempotent result: baq'); + expect(data?.Item?.status).toEqual('COMPLETED'); + expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); + }); + }, TEST_CASE_TIMEOUT); - afterAll(async () => { - if (!process.env.DISABLE_TEARDOWN) { - await destroyStack(app, stack); - } - }, TEARDOWN_TIMEOUT); + // afterAll(async () => { + // if (!process.env.DISABLE_TEARDOWN) { + // await destroyStack(app, stack); + // } + // }, TEARDOWN_TIMEOUT); }); \ No newline at end of file From decfb53a9ebfde2ae7b0447c592860f428a5bdaa Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Wed, 10 May 2023 23:18:47 +0200 Subject: [PATCH 10/16] get back tear down --- .../tests/e2e/idempotencyDecorator.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts index f58a85bb12..96da5f56dc 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -13,10 +13,10 @@ import { isValidRuntimeKey, TEST_RUNTIMES } from '../../../commons/tests/utils/e2eUtils'; -import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEST_CASE_TIMEOUT } from './constants'; +import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEARDOWN_TIMEOUT, TEST_CASE_TIMEOUT } from './constants'; import * as path from 'path'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; -import { deployStack } from '../../../commons/tests/utils/cdk-cli'; +import { deployStack, destroyStack } from '../../../commons/tests/utils/cdk-cli'; import { LEVEL } from '../../../commons/tests/utils/InvocationLogs'; import { GetCommand } from '@aws-sdk/lib-dynamodb'; import { createHash } from 'node:crypto'; @@ -180,9 +180,9 @@ describe('Idempotency e2e test, default settings', () => { }); }, TEST_CASE_TIMEOUT); - // afterAll(async () => { - // if (!process.env.DISABLE_TEARDOWN) { - // await destroyStack(app, stack); - // } - // }, TEARDOWN_TIMEOUT); + afterAll(async () => { + if (!process.env.DISABLE_TEARDOWN) { + await destroyStack(app, stack); + } + }, TEARDOWN_TIMEOUT); }); \ No newline at end of file From da4b035c5ae33605a54eb8c2e6094b310ec46239 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Thu, 11 May 2023 11:30:40 +0200 Subject: [PATCH 11/16] cleanup test --- .../tests/e2e/idempotencyDecorator.test.ts | 51 ++------ ...akeFunctionIdempotent.test.FunctionCode.ts | 28 +++++ .../tests/e2e/makeFunctionIdempotent.test.ts | 114 ++++++++++-------- .../tests/helpers/idempotencyUtils.ts | 35 ++++++ 4 files changed, 133 insertions(+), 95 deletions(-) create mode 100644 packages/idempotency/tests/helpers/idempotencyUtils.ts diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts index 96da5f56dc..05aa8c5d07 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -4,22 +4,15 @@ * @group e2e/idempotency */ import { v4 } from 'uuid'; -import { App, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; -import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; +import { App, Stack } from 'aws-cdk-lib'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { - generateUniqueName, - invokeFunction, - isValidRuntimeKey, - TEST_RUNTIMES -} from '../../../commons/tests/utils/e2eUtils'; +import { generateUniqueName, invokeFunction, isValidRuntimeKey } from '../../../commons/tests/utils/e2eUtils'; import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEARDOWN_TIMEOUT, TEST_CASE_TIMEOUT } from './constants'; -import * as path from 'path'; -import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; import { deployStack, destroyStack } from '../../../commons/tests/utils/cdk-cli'; import { LEVEL } from '../../../commons/tests/utils/InvocationLogs'; import { GetCommand } from '@aws-sdk/lib-dynamodb'; import { createHash } from 'node:crypto'; +import { createIdempotencyResources } from '../helpers/idempotencyUtils'; const runtime: string = process.env.RUNTIME || 'nodejs18x'; @@ -28,39 +21,11 @@ if (!isValidRuntimeKey(runtime)) { } const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'Idempotency'); +const decoratorFunctionFile = 'idempotencyDecorator.test.FunctionCode.ts'; const app = new App(); let stack: Stack; -const createIdempotencyResources = (stack: Stack, ddbTableName: string, functionName: string, handler: string, ddbPkId?: string): void => { - const uniqueTableId = ddbTableName + v4().substring(0, 5); - const ddbTable = new Table(stack, uniqueTableId, { - tableName: ddbTableName, - partitionKey: { - name: ddbPkId ? ddbPkId : 'id', - type: AttributeType.STRING, - }, - billingMode: BillingMode.PAY_PER_REQUEST, - removalPolicy: RemovalPolicy.DESTROY - }); - - const uniqueFunctionId = functionName + v4().substring(0, 5); - const nodeJsFunction = new NodejsFunction(stack, uniqueFunctionId, { - runtime: TEST_RUNTIMES[runtime], - functionName: functionName, - entry: path.join(__dirname, 'idempotencyDecorator.test.FunctionCode.ts'), - timeout: Duration.seconds(30), - handler: handler, - environment: { - IDEMPOTENCY_TABLE_NAME: ddbTableName, - POWERTOOLS_LOGGER_LOG_EVENT: 'true', - } - }); - - ddbTable.grantReadWriteData(nodeJsFunction); - -}; - describe('Idempotency e2e test, default settings', () => { const ddb = new DynamoDBClient({ region: 'eu-west-1' }); @@ -68,19 +33,19 @@ describe('Idempotency e2e test, default settings', () => { const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'default'); const ddbTableNameDefault = stackName + '-default-table'; - createIdempotencyResources(stack, ddbTableNameDefault, functionNameDefault, 'handler'); + createIdempotencyResources(stack, runtime, ddbTableNameDefault, decoratorFunctionFile, functionNameDefault, 'handler'); const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'custom'); const ddbTableNameCustom = stackName + '-custom-table'; - createIdempotencyResources(stack, ddbTableNameCustom, functionNameCustom, 'handlerCustomized', 'customId'); + createIdempotencyResources(stack, runtime, ddbTableNameCustom, decoratorFunctionFile, functionNameCustom, 'handlerCustomized', 'customId'); const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'keywordarg'); const ddbTableNameKeywordArg = stackName + '-keywordarg-table'; - createIdempotencyResources(stack, ddbTableNameKeywordArg, functionNameKeywordArg, 'handlerWithKeywordArgument'); + createIdempotencyResources(stack, runtime, ddbTableNameKeywordArg, decoratorFunctionFile, functionNameKeywordArg, 'handlerWithKeywordArgument'); const functionNameFails = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'fails'); const ddbTableNameFails = stackName + '-fails-table'; - createIdempotencyResources(stack, ddbTableNameFails, functionNameFails, 'handlerFails'); + createIdempotencyResources(stack, runtime, ddbTableNameFails, decoratorFunctionFile, functionNameFails, 'handlerFails'); beforeAll(async () => { await deployStack(app, stack); diff --git a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts index a5046b6682..2e704a8f59 100644 --- a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts +++ b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts @@ -8,6 +8,17 @@ const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({ tableName: IDEMPOTENCY_TABLE_NAME, }); +const ddbPersistenceLayerCustomized = new DynamoDBPersistenceLayer({ + tableName: IDEMPOTENCY_TABLE_NAME, + dataAttr: 'dataattr', + keyAttr: 'customId', + expiryAttr: 'expiryattr', + statusAttr: 'statusattr', + inProgressExpiryAttr: 'inprogressexpiryattr', + staticPkValue: 'staticpkvalue', + validationKeyAttr: 'validationkeyattr', +}); + interface EventRecords { records: Record[] } @@ -37,3 +48,20 @@ export const handler = async (_event: EventRecords, _context: Context): Promise< return Promise.resolve(); }; +const processIdempotentlyCustomized = makeFunctionIdempotent( + processRecord, + { + persistenceStore: ddbPersistenceLayerCustomized, + dataKeywordArgument: 'foo' + }); + +export const handlerCustomized = async (_event: EventRecords, _context: Context): Promise => { + for (const record of _event.records) { + const result = await processIdempotentlyCustomized(record); + logger.info(result.toString()); + + } + + return Promise.resolve(); +}; + diff --git a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts index 751f222f3d..9b8ba0a0eb 100644 --- a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts +++ b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts @@ -3,22 +3,15 @@ * * @group e2e/idempotency */ -import { - generateUniqueName, - invokeFunction, - isValidRuntimeKey, - TEST_RUNTIMES -} from '../../../commons/tests/utils/e2eUtils'; -import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEST_CASE_TIMEOUT } from './constants'; +import { generateUniqueName, invokeFunction, isValidRuntimeKey } from '../../../commons/tests/utils/e2eUtils'; +import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEARDOWN_TIMEOUT, TEST_CASE_TIMEOUT } from './constants'; import { v4 } from 'uuid'; -import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { App, Stack } from 'aws-cdk-lib'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { createHash } from 'node:crypto'; -import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; -import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; -import * as path from 'path'; -import { deployStack } from '../../../commons/tests/utils/cdk-cli'; +import { deployStack, destroyStack } from '../../../commons/tests/utils/cdk-cli'; import { GetCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { createIdempotencyResources } from '../helpers/idempotencyUtils'; const runtime: string = process.env.RUNTIME || 'nodejs18x'; @@ -26,66 +19,83 @@ if (!isValidRuntimeKey(runtime)) { throw new Error(`Invalid runtime key value: ${runtime}`); } const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'makeFnIdempotent'); -const testFunctionName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'idp-sequential'); -const ddbTableName = stackName + '-idempotency-makeFnIdempotent'; +const makeFunctionIdepmpotentFile = 'makeFunctionIdempotent.test.FunctionCode.ts'; + const app = new App(); let stack: Stack; describe('Idempotency e2e test, default settings', () => { const ddb = new DynamoDBClient({ region: 'eu-west-1' }); + stack = new Stack(app, stackName); - beforeAll(async () => { - stack = new Stack(app, stackName); - const ddbTable = new Table(stack, 'Table', { - tableName: ddbTableName, - partitionKey: { - name: 'id', - type: AttributeType.STRING, - }, - billingMode: BillingMode.PAY_PER_REQUEST, - removalPolicy: RemovalPolicy.DESTROY - }); + const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'default'); + const ddbTableNameDefault = stackName + '-default-table'; + createIdempotencyResources(stack, runtime, ddbTableNameDefault, makeFunctionIdepmpotentFile, functionNameDefault, 'handler'); - const idempotentFunction = new NodejsFunction(stack, 'IdempotentFucntion', { - runtime: TEST_RUNTIMES[runtime], - functionName: testFunctionName, - entry: path.resolve(__dirname, 'makeFunctionIdempotent.test.FunctionCode.ts'), - handler: 'handler', - environment: { - IDEMPOTENCY_TABLE_NAME: ddbTableName, - POWERTOOLS_LOGGER_LOG_EVENT: 'true', - }, - }); + const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'custom'); + const ddbTableNameCustom = stackName + '-custom-table'; + createIdempotencyResources(stack, runtime, ddbTableNameCustom, makeFunctionIdepmpotentFile, functionNameCustom, 'handlerCustomized', 'customId'); - ddbTable.grantReadWriteData(idempotentFunction); + const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'keywordarg'); + const ddbTableNameKeywordArg = stackName + '-keywordarg-table'; + createIdempotencyResources(stack, runtime, ddbTableNameKeywordArg, makeFunctionIdepmpotentFile, functionNameKeywordArg, 'handlerWithKeywordArgument'); - await deployStack(app, stack); + beforeAll(async () => { - const payload = { records: [ { id: 1, foo: 'bar' }, { id: 2, foo: 'baz' }, { id: 3, foo: 'bar' } ] }; - await invokeFunction(testFunctionName, 2, 'SEQUENTIAL', payload, false); + await deployStack(app, stack); }, SETUP_TIMEOUT); it('when called twice, it returns the same result', async () => { + const payload = { records: [ { id: 1, foo: 'bar' }, { id: 2, foo: 'baz' }, { id: 3, foo: 'bar' } ] }; + await invokeFunction(functionNameDefault, 2, 'SEQUENTIAL', payload, false); + const payloadHashFirst = createHash('md5').update(JSON.stringify('bar')).digest('base64'); const payloadHashSecond = createHash('md5').update(JSON.stringify('baz')).digest('base64'); - const scanResult = await ddb.send(new ScanCommand({ TableName: ddbTableName })); - expect(scanResult?.Items?.length).toEqual(2); + await ddb.send(new ScanCommand({ TableName: ddbTableNameDefault })).then((result) => { + expect(result?.Items?.length).toEqual(2); + }); - const idempotencyKeyFirst = `${testFunctionName}#${payloadHashFirst}`; - console.log(idempotencyKeyFirst); - const resultFirst = await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: idempotencyKeyFirst } })); - console.log(resultFirst); - expect(resultFirst?.Item?.data).toEqual('Processing done: bar'); - expect(resultFirst?.Item?.status).toEqual('COMPLETED'); + await ddb.send(new GetCommand({ + TableName: ddbTableNameDefault, + Key: { id: `${functionNameDefault}#${payloadHashFirst}` } + })).then((result) => { + expect(result?.Item?.data).toEqual('Processing done: bar'); + expect(result?.Item?.status).toEqual('COMPLETED'); - const idempotencyKeySecond = `${testFunctionName}#${payloadHashSecond}`; - const resultSecond = await ddb.send(new GetCommand({ TableName: ddbTableName, Key: { id: idempotencyKeySecond } })); - console.log(resultSecond); - expect(resultSecond?.Item?.data).toEqual('Processing done: baz'); - expect(resultSecond?.Item?.status).toEqual('COMPLETED'); + }); + + await ddb.send(new GetCommand({ + TableName: ddbTableNameDefault, + Key: { id: `${functionNameDefault}#${payloadHashSecond}` } + })).then((result) => { + expect(result?.Item?.data).toEqual('Processing done: baz'); + expect(result?.Item?.status).toEqual('COMPLETED'); + }); }, TEST_CASE_TIMEOUT); + test('when called with customized function wrapper, it creates ddb entry with custom attributes', async () => { + const payload = { records: [ { id: 1, foo: 'bar' }, { id: 2, foo: 'baq' }, { id: 3, foo: 'bar' } ] }; + const payloadHash = createHash('md5').update('"bar"').digest('base64'); + + const invocationLogsCustmozed = await invokeFunction(functionNameCustom, 2, 'SEQUENTIAL', payload, false); + await ddb.send(new GetCommand({ + TableName: ddbTableNameCustom, + Key: { customId: `${functionNameCustom}#${payloadHash}` } + })).then((data) => { + console.log(data); + expect(data?.Item?.dataattr).toEqual('Processing done: bar'); + expect(data?.Item?.statusattr).toEqual('COMPLETED'); + expect(data?.Item?.expiryattr).toBeGreaterThan(Date.now() / 1000); + expect(invocationLogsCustmozed[0].getFunctionLogs().toString()).toContain('Got test event'); + }); + }, TEST_CASE_TIMEOUT); + + afterAll(async () => { + if (!process.env.DISABLE_TEARDOWN) { + await destroyStack(app, stack); + } + }, TEARDOWN_TIMEOUT); }); \ No newline at end of file diff --git a/packages/idempotency/tests/helpers/idempotencyUtils.ts b/packages/idempotency/tests/helpers/idempotencyUtils.ts new file mode 100644 index 0000000000..91235cd506 --- /dev/null +++ b/packages/idempotency/tests/helpers/idempotencyUtils.ts @@ -0,0 +1,35 @@ +import { Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { v4 } from 'uuid'; +import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { TEST_RUNTIMES } from '../../../commons/tests/utils/e2eUtils'; +import path from 'path'; + +export const createIdempotencyResources = (stack: Stack, runtime: string, ddbTableName: string, pathToFunction: string, functionName: string, handler: string, ddbPkId?: string): void => { + const uniqueTableId = ddbTableName + v4().substring(0, 5); + const ddbTable = new Table(stack, uniqueTableId, { + tableName: ddbTableName, + partitionKey: { + name: ddbPkId ? ddbPkId : 'id', + type: AttributeType.STRING, + }, + billingMode: BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY + }); + + const uniqueFunctionId = functionName + v4().substring(0, 5); + const nodeJsFunction = new NodejsFunction(stack, uniqueFunctionId, { + runtime: TEST_RUNTIMES[runtime], + functionName: functionName, + entry: path.join(__dirname, `../e2e/${pathToFunction}`), + timeout: Duration.seconds(30), + handler: handler, + environment: { + IDEMPOTENCY_TABLE_NAME: ddbTableName, + POWERTOOLS_LOGGER_LOG_EVENT: 'true', + } + }); + + ddbTable.grantReadWriteData(nodeJsFunction); + +}; \ No newline at end of file From 656f130d73be854a57367a8eec06cfcb9982ed36 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Thu, 11 May 2023 12:05:31 +0200 Subject: [PATCH 12/16] fixed IdempotencyOption name --- packages/idempotency/src/idempotentDecorator.ts | 4 ++-- packages/idempotency/src/makeFunctionIdempotent.ts | 4 ++-- packages/idempotency/src/types/IdempotencyOptions.ts | 4 ++-- packages/idempotency/tests/unit/IdempotencyHandler.test.ts | 4 ++-- .../idempotency/tests/unit/makeFunctionIdempotent.test.ts | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/idempotency/src/idempotentDecorator.ts b/packages/idempotency/src/idempotentDecorator.ts index d54d79fb2e..2ede509b26 100644 --- a/packages/idempotency/src/idempotentDecorator.ts +++ b/packages/idempotency/src/idempotentDecorator.ts @@ -1,8 +1,8 @@ -import { GenericTempRecord, IdempotentOptions, } from './types'; +import { GenericTempRecord, IdempotencyOptions, } from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; import { IdempotencyConfig } from './IdempotencyConfig'; -const idempotent = function (options: IdempotentOptions) { +const idempotent = function (options: IdempotencyOptions) { return function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) { const childFunction = descriptor.value; // TODO: sort out the type for this diff --git a/packages/idempotency/src/makeFunctionIdempotent.ts b/packages/idempotency/src/makeFunctionIdempotent.ts index 808f204fee..79fb579d9a 100644 --- a/packages/idempotency/src/makeFunctionIdempotent.ts +++ b/packages/idempotency/src/makeFunctionIdempotent.ts @@ -1,10 +1,10 @@ -import type { AnyFunctionWithRecord, AnyIdempotentFunction, GenericTempRecord, IdempotentOptions, } from './types'; +import type { AnyFunctionWithRecord, AnyIdempotentFunction, GenericTempRecord, IdempotencyOptions, } from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; import { IdempotencyConfig } from './IdempotencyConfig'; const makeFunctionIdempotent = function ( fn: AnyFunctionWithRecord, - options: IdempotentOptions, + options: IdempotencyOptions, ): AnyIdempotentFunction { const wrappedFn: AnyIdempotentFunction = function (record: GenericTempRecord): Promise { if (options.dataKeywordArgument === undefined) { diff --git a/packages/idempotency/src/types/IdempotencyOptions.ts b/packages/idempotency/src/types/IdempotencyOptions.ts index ca775bc8b7..db3f6766ed 100644 --- a/packages/idempotency/src/types/IdempotencyOptions.ts +++ b/packages/idempotency/src/types/IdempotencyOptions.ts @@ -2,7 +2,7 @@ import type { Context } from 'aws-lambda'; import { BasePersistenceLayer } from '../persistence/BasePersistenceLayer'; import { IdempotencyConfig } from 'IdempotencyConfig'; -type IdempotentOptions = { +type IdempotencyOptions = { persistenceStore: BasePersistenceLayer dataKeywordArgument?: string config?: IdempotencyConfig @@ -48,5 +48,5 @@ type IdempotencyConfigOptions = { export { IdempotencyConfigOptions, - IdempotentOptions + IdempotencyOptions }; diff --git a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts index 469de15386..337967dd9f 100644 --- a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts +++ b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts @@ -10,7 +10,7 @@ import { IdempotencyItemAlreadyExistsError, IdempotencyPersistenceLayerError } from '../../src/Exceptions'; -import { IdempotencyRecordStatus, IdempotentOptions, } from '../../src/types'; +import { IdempotencyRecordStatus, IdempotencyOptions, } from '../../src/types'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { IdempotencyHandler } from '../../src/IdempotencyHandler'; import { IdempotencyConfig } from '../..//src/IdempotencyConfig'; @@ -24,7 +24,7 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { const mockFunctionToMakeIdempotent = jest.fn(); const mockFunctionPayloadToBeHashed = {}; -const mockIdempotencyOptions: IdempotentOptions = { +const mockIdempotencyOptions: IdempotencyOptions = { persistenceStore: new PersistenceLayerTestClass(), config: new IdempotencyConfig({}) }; diff --git a/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts index 220e26296f..70fd949d62 100644 --- a/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts +++ b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts @@ -3,7 +3,7 @@ * * @group unit/idempotency/makeFunctionIdempotent */ -import { IdempotentOptions } from '../../src/types/IdempotencyOptions'; +import { IdempotencyOptions } from '../../src/types/IdempotencyOptions'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { makeFunctionIdempotent } from '../../src/makeFunctionIdempotent'; import type { AnyIdempotentFunction, IdempotencyRecordOptions } from '../../src/types'; @@ -27,7 +27,7 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { describe('Given a function to wrap', (functionToWrap = jest.fn()) => { beforeEach(() => jest.clearAllMocks()); - describe('Given options for idempotency', (options: IdempotentOptions = { + describe('Given options for idempotency', (options: IdempotencyOptions = { persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }) => { From 93da14a66839ecc54feb314f85428bb18870ea31 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Fri, 12 May 2023 09:11:57 +0200 Subject: [PATCH 13/16] fix relative import --- packages/idempotency/src/types/IdempotencyOptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/idempotency/src/types/IdempotencyOptions.ts b/packages/idempotency/src/types/IdempotencyOptions.ts index db3f6766ed..3569728cda 100644 --- a/packages/idempotency/src/types/IdempotencyOptions.ts +++ b/packages/idempotency/src/types/IdempotencyOptions.ts @@ -1,6 +1,6 @@ import type { Context } from 'aws-lambda'; import { BasePersistenceLayer } from '../persistence/BasePersistenceLayer'; -import { IdempotencyConfig } from 'IdempotencyConfig'; +import { IdempotencyConfig } from '../IdempotencyConfig'; type IdempotencyOptions = { persistenceStore: BasePersistenceLayer From f0756402b41b672a64b3636f6440cca3d05bbced Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Fri, 12 May 2023 09:23:44 +0200 Subject: [PATCH 14/16] use same uuid across the test --- packages/idempotency/src/IdempotencyHandler.ts | 2 +- .../tests/e2e/idempotencyDecorator.test.ts | 11 ++++++----- .../tests/e2e/makeFunctionIdempotent.test.ts | 9 +++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index f091050589..98e55f9247 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -7,7 +7,7 @@ import { IdempotencyPersistenceLayerError, } from './Exceptions'; import { BasePersistenceLayer, IdempotencyRecord } from './persistence'; -import { IdempotencyConfig } from 'IdempotencyConfig'; +import { IdempotencyConfig } from './IdempotencyConfig'; export class IdempotencyHandler { public constructor( diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts index 05aa8c5d07..41d4fcd4e9 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -20,7 +20,8 @@ if (!isValidRuntimeKey(runtime)) { throw new Error(`Invalid runtime key value: ${runtime}`); } -const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'Idempotency'); +const uuid = v4(); +const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'Idempotency'); const decoratorFunctionFile = 'idempotencyDecorator.test.FunctionCode.ts'; const app = new App(); @@ -31,19 +32,19 @@ describe('Idempotency e2e test, default settings', () => { const ddb = new DynamoDBClient({ region: 'eu-west-1' }); stack = new Stack(app, stackName); - const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'default'); + const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'default'); const ddbTableNameDefault = stackName + '-default-table'; createIdempotencyResources(stack, runtime, ddbTableNameDefault, decoratorFunctionFile, functionNameDefault, 'handler'); - const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'custom'); + const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'custom'); const ddbTableNameCustom = stackName + '-custom-table'; createIdempotencyResources(stack, runtime, ddbTableNameCustom, decoratorFunctionFile, functionNameCustom, 'handlerCustomized', 'customId'); - const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'keywordarg'); + const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'keywordarg'); const ddbTableNameKeywordArg = stackName + '-keywordarg-table'; createIdempotencyResources(stack, runtime, ddbTableNameKeywordArg, decoratorFunctionFile, functionNameKeywordArg, 'handlerWithKeywordArgument'); - const functionNameFails = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'fails'); + const functionNameFails = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'fails'); const ddbTableNameFails = stackName + '-fails-table'; createIdempotencyResources(stack, runtime, ddbTableNameFails, decoratorFunctionFile, functionNameFails, 'handlerFails'); diff --git a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts index 9b8ba0a0eb..3c05de5104 100644 --- a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts +++ b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts @@ -18,7 +18,8 @@ const runtime: string = process.env.RUNTIME || 'nodejs18x'; if (!isValidRuntimeKey(runtime)) { throw new Error(`Invalid runtime key value: ${runtime}`); } -const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'makeFnIdempotent'); +const uuid = v4(); +const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'makeFnIdempotent'); const makeFunctionIdepmpotentFile = 'makeFunctionIdempotent.test.FunctionCode.ts'; const app = new App(); @@ -28,15 +29,15 @@ describe('Idempotency e2e test, default settings', () => { const ddb = new DynamoDBClient({ region: 'eu-west-1' }); stack = new Stack(app, stackName); - const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'default'); + const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'default'); const ddbTableNameDefault = stackName + '-default-table'; createIdempotencyResources(stack, runtime, ddbTableNameDefault, makeFunctionIdepmpotentFile, functionNameDefault, 'handler'); - const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'custom'); + const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'custom'); const ddbTableNameCustom = stackName + '-custom-table'; createIdempotencyResources(stack, runtime, ddbTableNameCustom, makeFunctionIdepmpotentFile, functionNameCustom, 'handlerCustomized', 'customId'); - const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, v4(), runtime, 'keywordarg'); + const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'keywordarg'); const ddbTableNameKeywordArg = stackName + '-keywordarg-table'; createIdempotencyResources(stack, runtime, ddbTableNameKeywordArg, makeFunctionIdepmpotentFile, functionNameKeywordArg, 'handlerWithKeywordArgument'); From 6a97ad4837dc8ac4fc193c921c5b79e2f74b5a9a Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Fri, 12 May 2023 14:02:11 +0200 Subject: [PATCH 15/16] refactored idempotency options type and fix tests --- .../idempotency/src/IdempotencyHandler.ts | 2 - .../idempotency/src/idempotentDecorator.ts | 29 +++-- .../idempotency/src/makeFunctionIdempotent.ts | 7 +- .../src/types/IdempotencyOptions.ts | 13 ++- .../idempotencyDecorator.test.FunctionCode.ts | 12 +- .../tests/e2e/idempotencyDecorator.test.ts | 110 ++++++++---------- ...akeFunctionIdempotent.test.FunctionCode.ts | 14 +-- .../tests/e2e/makeFunctionIdempotent.test.ts | 66 +++++------ .../tests/unit/IdempotencyHandler.test.ts | 6 +- .../tests/unit/idempotentDecorator.test.ts | 59 +++++----- .../tests/unit/makeFunctionIdempotent.test.ts | 4 +- 11 files changed, 160 insertions(+), 162 deletions(-) diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index 98e55f9247..0343a0dd38 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -7,13 +7,11 @@ import { IdempotencyPersistenceLayerError, } from './Exceptions'; import { BasePersistenceLayer, IdempotencyRecord } from './persistence'; -import { IdempotencyConfig } from './IdempotencyConfig'; export class IdempotencyHandler { public constructor( private functionToMakeIdempotent: AnyFunctionWithRecord, private functionPayloadToBeHashed: Record, - private config: IdempotencyConfig, private persistenceStore: BasePersistenceLayer, private fullFunctionPayload: Record, ) { diff --git a/packages/idempotency/src/idempotentDecorator.ts b/packages/idempotency/src/idempotentDecorator.ts index 2ede509b26..699b44cfdf 100644 --- a/packages/idempotency/src/idempotentDecorator.ts +++ b/packages/idempotency/src/idempotentDecorator.ts @@ -1,20 +1,20 @@ -import { GenericTempRecord, IdempotencyOptions, } from './types'; +import { GenericTempRecord, IdempotencyFunctionOptions, IdempotencyHandlerOptions, } from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; -import { IdempotencyConfig } from './IdempotencyConfig'; -const idempotent = function (options: IdempotencyOptions) { +/** + * use this function to narrow the type of options between IdempotencyHandlerOptions and IdempotencyFunctionOptions + * @param options + */ +const isFunctionOption = (options: IdempotencyHandlerOptions | IdempotencyFunctionOptions): boolean => (options as IdempotencyFunctionOptions).dataKeywordArgument !== undefined; + +const idempotent = function (options: IdempotencyHandlerOptions | IdempotencyFunctionOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor { return function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) { const childFunction = descriptor.value; - // TODO: sort out the type for this - descriptor.value = function (record: GenericTempRecord) { - const config = options.config || new IdempotencyConfig({}); - const dataKeywordArgument = options?.dataKeywordArgument ? record[options?.dataKeywordArgument] : record; - config.registerLambdaContext(record.context); + const functionPayloadtoBeHashed = isFunctionOption(options) ? record[(options as IdempotencyFunctionOptions).dataKeywordArgument] : record; const idempotencyHandler = new IdempotencyHandler( childFunction, - dataKeywordArgument, - config, + functionPayloadtoBeHashed, options.persistenceStore, record); @@ -25,4 +25,11 @@ const idempotent = function (options: IdempotencyOptions) { }; }; -export { idempotent }; +const idempotentLambdaHandler = function (options: IdempotencyHandlerOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor { + return idempotent(options); +}; +const idempotentFunction = function (options: IdempotencyFunctionOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor { + return idempotent(options); +}; + +export { idempotentLambdaHandler, idempotentFunction }; diff --git a/packages/idempotency/src/makeFunctionIdempotent.ts b/packages/idempotency/src/makeFunctionIdempotent.ts index 79fb579d9a..7b5916be9d 100644 --- a/packages/idempotency/src/makeFunctionIdempotent.ts +++ b/packages/idempotency/src/makeFunctionIdempotent.ts @@ -1,20 +1,17 @@ -import type { AnyFunctionWithRecord, AnyIdempotentFunction, GenericTempRecord, IdempotencyOptions, } from './types'; +import type { AnyFunctionWithRecord, AnyIdempotentFunction, GenericTempRecord, IdempotencyFunctionOptions, } from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; -import { IdempotencyConfig } from './IdempotencyConfig'; const makeFunctionIdempotent = function ( fn: AnyFunctionWithRecord, - options: IdempotencyOptions, + options: IdempotencyFunctionOptions, ): AnyIdempotentFunction { const wrappedFn: AnyIdempotentFunction = function (record: GenericTempRecord): Promise { if (options.dataKeywordArgument === undefined) { throw new Error(`Missing data keyword argument ${options.dataKeywordArgument}`); } - const config = new IdempotencyConfig({}); const idempotencyHandler: IdempotencyHandler = new IdempotencyHandler( fn, record[options.dataKeywordArgument], - config, options.persistenceStore, record); diff --git a/packages/idempotency/src/types/IdempotencyOptions.ts b/packages/idempotency/src/types/IdempotencyOptions.ts index 3569728cda..3cc77337a9 100644 --- a/packages/idempotency/src/types/IdempotencyOptions.ts +++ b/packages/idempotency/src/types/IdempotencyOptions.ts @@ -1,11 +1,13 @@ import type { Context } from 'aws-lambda'; import { BasePersistenceLayer } from '../persistence/BasePersistenceLayer'; -import { IdempotencyConfig } from '../IdempotencyConfig'; -type IdempotencyOptions = { +type IdempotencyHandlerOptions = { persistenceStore: BasePersistenceLayer - dataKeywordArgument?: string - config?: IdempotencyConfig + config?: IdempotencyConfigOptions +}; + +type IdempotencyFunctionOptions = IdempotencyHandlerOptions & { + dataKeywordArgument: string }; /** @@ -48,5 +50,6 @@ type IdempotencyConfigOptions = { export { IdempotencyConfigOptions, - IdempotencyOptions + IdempotencyFunctionOptions, + IdempotencyHandlerOptions, }; diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts index 4d34d69c5d..1dda1d338c 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts @@ -1,10 +1,10 @@ import { LambdaInterface } from '@aws-lambda-powertools/commons'; import { DynamoDBPersistenceLayer } from '../../src/persistence'; -import { idempotent } from '../../src/idempotentDecorator'; +import { idempotentFunction, idempotentLambdaHandler } from '../../src/idempotentDecorator'; import { Context } from 'aws-lambda'; import { Logger } from '../../../logger'; -const IDEMPOTENCY_TABLE_NAME = process.env.IDEMPOTENCY_TABLE_NAME; +const IDEMPOTENCY_TABLE_NAME = process.env.IDEMPOTENCY_TABLE_NAME || 'table_name'; const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({ tableName: IDEMPOTENCY_TABLE_NAME, }); @@ -30,7 +30,7 @@ interface EventRecords { class DefaultLambda implements LambdaInterface { - @idempotent({ persistenceStore: dynamoDBPersistenceLayer }) + @idempotentLambdaHandler({ persistenceStore: dynamoDBPersistenceLayer }) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore public async handler(_event: Record, _context: Context): Promise { @@ -41,7 +41,7 @@ class DefaultLambda implements LambdaInterface { return 'Hello World'; } - @idempotent({ persistenceStore: ddbPersistenceLayerCustomized }) + @idempotentLambdaHandler({ persistenceStore: ddbPersistenceLayerCustomized }) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore public async handlerCustomized(_event: TestEvent, _context: Context): Promise { @@ -51,7 +51,7 @@ class DefaultLambda implements LambdaInterface { return 'Hello World Customized'; } - @idempotent({ persistenceStore: dynamoDBPersistenceLayer }) + @idempotentLambdaHandler({ persistenceStore: dynamoDBPersistenceLayer }) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore public async handlerFails(_event: TestEvent, _context: Context): Promise { @@ -81,7 +81,7 @@ class LambdaWithKeywordArgument implements LambdaInterface { return 'Hello World Keyword Argument'; } - @idempotent({ persistenceStore: dynamoDBPersistenceLayer, dataKeywordArgument: 'foo' }) + @idempotentFunction({ persistenceStore: dynamoDBPersistenceLayer, dataKeywordArgument: 'foo' }) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore public async process(record: Record): string { diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts index 41d4fcd4e9..af87c0d270 100644 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -25,28 +25,26 @@ const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'Idemp const decoratorFunctionFile = 'idempotencyDecorator.test.FunctionCode.ts'; const app = new App(); -let stack: Stack; -describe('Idempotency e2e test, default settings', () => { +const ddb = new DynamoDBClient({ region: 'eu-west-1' }); +const stack = new Stack(app, stackName); - const ddb = new DynamoDBClient({ region: 'eu-west-1' }); - stack = new Stack(app, stackName); +const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'default'); +const ddbTableNameDefault = stackName + '-default-table'; +createIdempotencyResources(stack, runtime, ddbTableNameDefault, decoratorFunctionFile, functionNameDefault, 'handler'); - const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'default'); - const ddbTableNameDefault = stackName + '-default-table'; - createIdempotencyResources(stack, runtime, ddbTableNameDefault, decoratorFunctionFile, functionNameDefault, 'handler'); +const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'custom'); +const ddbTableNameCustom = stackName + '-custom-table'; +createIdempotencyResources(stack, runtime, ddbTableNameCustom, decoratorFunctionFile, functionNameCustom, 'handlerCustomized', 'customId'); - const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'custom'); - const ddbTableNameCustom = stackName + '-custom-table'; - createIdempotencyResources(stack, runtime, ddbTableNameCustom, decoratorFunctionFile, functionNameCustom, 'handlerCustomized', 'customId'); +const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'keywordarg'); +const ddbTableNameKeywordArg = stackName + '-keywordarg-table'; +createIdempotencyResources(stack, runtime, ddbTableNameKeywordArg, decoratorFunctionFile, functionNameKeywordArg, 'handlerWithKeywordArgument'); - const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'keywordarg'); - const ddbTableNameKeywordArg = stackName + '-keywordarg-table'; - createIdempotencyResources(stack, runtime, ddbTableNameKeywordArg, decoratorFunctionFile, functionNameKeywordArg, 'handlerWithKeywordArgument'); - - const functionNameFails = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'fails'); - const ddbTableNameFails = stackName + '-fails-table'; - createIdempotencyResources(stack, runtime, ddbTableNameFails, decoratorFunctionFile, functionNameFails, 'handlerFails'); +const functionNameFails = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'fails'); +const ddbTableNameFails = stackName + '-fails-table'; +createIdempotencyResources(stack, runtime, ddbTableNameFails, decoratorFunctionFile, functionNameFails, 'handlerFails'); +describe('Idempotency e2e test decorator, default settings', () => { beforeAll(async () => { await deployStack(app, stack); @@ -59,17 +57,16 @@ describe('Idempotency e2e test, default settings', () => { const invocationLogsSequential = await invokeFunction(functionNameDefault, 2, 'SEQUENTIAL', payload, false); // create dynamodb client to query the table and check the value - await ddb.send(new GetCommand({ + const result = await ddb.send(new GetCommand({ TableName: ddbTableNameDefault, Key: { id: `${functionNameDefault}#${payloadHash}` } - })).then((data) => { - expect(data?.Item?.data).toEqual('Hello World'); - expect(data?.Item?.status).toEqual('COMPLETED'); - expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - // we log events inside the handler, so the 2nd invocation should not log anything - expect(invocationLogsSequential[0].getFunctionLogs().toString()).toContain('Got test event'); - expect(invocationLogsSequential[1].getFunctionLogs().toString()).not.toContain('Got test event'); - }); + })); + expect(result?.Item?.data).toEqual('Hello World'); + expect(result?.Item?.status).toEqual('COMPLETED'); + expect(result?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); + // we log events inside the handler, so the 2nd invocation should not log anything + expect(invocationLogsSequential[0].getFunctionLogs().toString()).toContain('Got test event'); + expect(invocationLogsSequential[1].getFunctionLogs().toString()).not.toContain('Got test event'); }, TEST_CASE_TIMEOUT); @@ -78,15 +75,14 @@ describe('Idempotency e2e test, default settings', () => { const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); const invocationLogs = await invokeFunction(functionNameDefault, 2, 'PARALLEL', payload, false); - await ddb.send(new GetCommand({ + const result = await ddb.send(new GetCommand({ TableName: ddbTableNameDefault, Key: { id: `${functionNameDefault}#${payloadHash}` } - })).then((data) => { - expect(data?.Item?.data).toEqual('Hello World'); - expect(data?.Item?.status).toEqual('COMPLETED'); - expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - expect(invocationLogs[0].getFunctionLogs(LEVEL.ERROR).toString()).toContain('There is already an execution in progress with idempotency key'); - }); + })); + expect(result?.Item?.data).toEqual('Hello World'); + expect(result?.Item?.status).toEqual('COMPLETED'); + expect(result?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); + expect(invocationLogs[0].getFunctionLogs(LEVEL.ERROR).toString()).toContain('There is already an execution in progress with idempotency key'); }, TEST_CASE_TIMEOUT); test('when called with customized idempotency decorator, it creates ddb entry with custom attributes', async () => { @@ -94,15 +90,14 @@ describe('Idempotency e2e test, default settings', () => { const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); const invocationLogsCustmozed = await invokeFunction(functionNameCustom, 1, 'PARALLEL', payload, false); - await ddb.send(new GetCommand({ + const result = await ddb.send(new GetCommand({ TableName: ddbTableNameCustom, Key: { customId: `${functionNameCustom}#${payloadHash}` } - })).then((data) => { - expect(data?.Item?.dataattr).toEqual('Hello World Customized'); - expect(data?.Item?.statusattr).toEqual('COMPLETED'); - expect(data?.Item?.expiryattr).toBeGreaterThan(Date.now() / 1000); - expect(invocationLogsCustmozed[0].getFunctionLogs().toString()).toContain('Got test event customized'); - }); + })); + expect(result?.Item?.dataattr).toEqual('Hello World Customized'); + expect(result?.Item?.statusattr).toEqual('COMPLETED'); + expect(result?.Item?.expiryattr).toBeGreaterThan(Date.now() / 1000); + expect(invocationLogsCustmozed[0].getFunctionLogs().toString()).toContain('Got test event customized'); }, TEST_CASE_TIMEOUT); test('when called with a function that fails, it creates ddb entry with error status', async () => { @@ -110,13 +105,12 @@ describe('Idempotency e2e test, default settings', () => { const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); await invokeFunction(functionNameFails, 1, 'PARALLEL', payload, false); - await ddb.send(new GetCommand({ + const result = await ddb.send(new GetCommand({ TableName: ddbTableNameFails, Key: { id: `${functionNameFails}#${payloadHash}` } - })).then((data) => { - console.log(data); - expect(data?.Item).toBeUndefined(); - }); + })); + console.log(result); + expect(result?.Item).toBeUndefined(); }, TEST_CASE_TIMEOUT); test('when called with a function that has keyword argument, it creates for every entry of keyword argument', async () => { @@ -124,26 +118,24 @@ describe('Idempotency e2e test, default settings', () => { const payloadHashFirst = createHash('md5').update('"bar"').digest('base64'); await invokeFunction(functionNameKeywordArg, 2, 'SEQUENTIAL', payloadArray, false); - await ddb.send(new GetCommand({ + const resultFirst = await ddb.send(new GetCommand({ TableName: ddbTableNameKeywordArg, Key: { id: `${functionNameKeywordArg}#${payloadHashFirst}` } - })).then((data) => { - console.log(data); - expect(data?.Item?.data).toEqual('idempotent result: bar'); - expect(data?.Item?.status).toEqual('COMPLETED'); - expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - }); + })); + console.log(resultFirst); + expect(resultFirst?.Item?.data).toEqual('idempotent result: bar'); + expect(resultFirst?.Item?.status).toEqual('COMPLETED'); + expect(resultFirst?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); const payloadHashSecond = createHash('md5').update('"baq"').digest('base64'); - await ddb.send(new GetCommand({ + const resultSecond = await ddb.send(new GetCommand({ TableName: ddbTableNameKeywordArg, Key: { id: `${functionNameKeywordArg}#${payloadHashSecond}` } - })).then((data) => { - console.log(data); - expect(data?.Item?.data).toEqual('idempotent result: baq'); - expect(data?.Item?.status).toEqual('COMPLETED'); - expect(data?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - }); + })); + console.log(resultSecond); + expect(resultSecond?.Item?.data).toEqual('idempotent result: baq'); + expect(resultSecond?.Item?.status).toEqual('COMPLETED'); + expect(resultSecond?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); }, TEST_CASE_TIMEOUT); afterAll(async () => { diff --git a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts index 2e704a8f59..06fbaae9bc 100644 --- a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts +++ b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts @@ -3,7 +3,7 @@ import { makeFunctionIdempotent } from '../../src/makeFunctionIdempotent'; import { Logger } from '@aws-lambda-powertools/logger'; import { Context } from 'aws-lambda'; -const IDEMPOTENCY_TABLE_NAME = process.env.IDEMPOTENCY_TABLE_NAME; +const IDEMPOTENCY_TABLE_NAME = process.env.IDEMPOTENCY_TABLE_NAME || 'table_name'; const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({ tableName: IDEMPOTENCY_TABLE_NAME, }); @@ -25,6 +25,12 @@ interface EventRecords { const logger = new Logger(); +const processRecord = (record: Record): string => { + logger.info(`Got test event: ${JSON.stringify(record)}`); + + return 'Processing done: ' + record['foo']; +}; + const processIdempotently = makeFunctionIdempotent( processRecord, { @@ -32,12 +38,6 @@ const processIdempotently = makeFunctionIdempotent( dataKeywordArgument: 'foo' }); -function processRecord(record: Record): string { - logger.info(`Got test event: ${JSON.stringify(record)}`); - - return 'Processing done: ' + record['foo']; -} - export const handler = async (_event: EventRecords, _context: Context): Promise => { for (const record of _event.records) { const result = await processIdempotently(record); diff --git a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts index 3c05de5104..4e631258d9 100644 --- a/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts +++ b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts @@ -23,26 +23,25 @@ const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'makeF const makeFunctionIdepmpotentFile = 'makeFunctionIdempotent.test.FunctionCode.ts'; const app = new App(); -let stack: Stack; -describe('Idempotency e2e test, default settings', () => { - const ddb = new DynamoDBClient({ region: 'eu-west-1' }); - stack = new Stack(app, stackName); +const ddb = new DynamoDBClient({ region: 'eu-west-1' }); +const stack = new Stack(app, stackName); - const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'default'); - const ddbTableNameDefault = stackName + '-default-table'; - createIdempotencyResources(stack, runtime, ddbTableNameDefault, makeFunctionIdepmpotentFile, functionNameDefault, 'handler'); +const functionNameDefault = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'default'); +const ddbTableNameDefault = stackName + '-default-table'; +createIdempotencyResources(stack, runtime, ddbTableNameDefault, makeFunctionIdepmpotentFile, functionNameDefault, 'handler'); - const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'custom'); - const ddbTableNameCustom = stackName + '-custom-table'; - createIdempotencyResources(stack, runtime, ddbTableNameCustom, makeFunctionIdepmpotentFile, functionNameCustom, 'handlerCustomized', 'customId'); +const functionNameCustom = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'custom'); +const ddbTableNameCustom = stackName + '-custom-table'; +createIdempotencyResources(stack, runtime, ddbTableNameCustom, makeFunctionIdepmpotentFile, functionNameCustom, 'handlerCustomized', 'customId'); - const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'keywordarg'); - const ddbTableNameKeywordArg = stackName + '-keywordarg-table'; - createIdempotencyResources(stack, runtime, ddbTableNameKeywordArg, makeFunctionIdepmpotentFile, functionNameKeywordArg, 'handlerWithKeywordArgument'); +const functionNameKeywordArg = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'keywordarg'); +const ddbTableNameKeywordArg = stackName + '-keywordarg-table'; +createIdempotencyResources(stack, runtime, ddbTableNameKeywordArg, makeFunctionIdepmpotentFile, functionNameKeywordArg, 'handlerWithKeywordArgument'); - beforeAll(async () => { +describe('Idempotency e2e test function wrapper, default settings', () => { + beforeAll(async () => { await deployStack(app, stack); }, SETUP_TIMEOUT); @@ -54,26 +53,22 @@ describe('Idempotency e2e test, default settings', () => { const payloadHashFirst = createHash('md5').update(JSON.stringify('bar')).digest('base64'); const payloadHashSecond = createHash('md5').update(JSON.stringify('baz')).digest('base64'); - await ddb.send(new ScanCommand({ TableName: ddbTableNameDefault })).then((result) => { - expect(result?.Items?.length).toEqual(2); - }); + const result = await ddb.send(new ScanCommand({ TableName: ddbTableNameDefault })); + expect(result?.Items?.length).toEqual(2); - await ddb.send(new GetCommand({ + const resultFirst = await ddb.send(new GetCommand({ TableName: ddbTableNameDefault, Key: { id: `${functionNameDefault}#${payloadHashFirst}` } - })).then((result) => { - expect(result?.Item?.data).toEqual('Processing done: bar'); - expect(result?.Item?.status).toEqual('COMPLETED'); - - }); + })); + expect(resultFirst?.Item?.data).toEqual('Processing done: bar'); + expect(resultFirst?.Item?.status).toEqual('COMPLETED'); - await ddb.send(new GetCommand({ + const resultSecond = await ddb.send(new GetCommand({ TableName: ddbTableNameDefault, Key: { id: `${functionNameDefault}#${payloadHashSecond}` } - })).then((result) => { - expect(result?.Item?.data).toEqual('Processing done: baz'); - expect(result?.Item?.status).toEqual('COMPLETED'); - }); + })); + expect(resultSecond?.Item?.data).toEqual('Processing done: baz'); + expect(resultSecond?.Item?.status).toEqual('COMPLETED'); }, TEST_CASE_TIMEOUT); @@ -82,16 +77,15 @@ describe('Idempotency e2e test, default settings', () => { const payloadHash = createHash('md5').update('"bar"').digest('base64'); const invocationLogsCustmozed = await invokeFunction(functionNameCustom, 2, 'SEQUENTIAL', payload, false); - await ddb.send(new GetCommand({ + const result = await ddb.send(new GetCommand({ TableName: ddbTableNameCustom, Key: { customId: `${functionNameCustom}#${payloadHash}` } - })).then((data) => { - console.log(data); - expect(data?.Item?.dataattr).toEqual('Processing done: bar'); - expect(data?.Item?.statusattr).toEqual('COMPLETED'); - expect(data?.Item?.expiryattr).toBeGreaterThan(Date.now() / 1000); - expect(invocationLogsCustmozed[0].getFunctionLogs().toString()).toContain('Got test event'); - }); + })); + console.log(result); + expect(result?.Item?.dataattr).toEqual('Processing done: bar'); + expect(result?.Item?.statusattr).toEqual('COMPLETED'); + expect(result?.Item?.expiryattr).toBeGreaterThan(Date.now() / 1000); + expect(invocationLogsCustmozed[0].getFunctionLogs().toString()).toContain('Got test event'); }, TEST_CASE_TIMEOUT); afterAll(async () => { diff --git a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts index 337967dd9f..5b35785080 100644 --- a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts +++ b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts @@ -10,7 +10,7 @@ import { IdempotencyItemAlreadyExistsError, IdempotencyPersistenceLayerError } from '../../src/Exceptions'; -import { IdempotencyRecordStatus, IdempotencyOptions, } from '../../src/types'; +import { IdempotencyFunctionOptions, IdempotencyRecordStatus, } from '../../src/types'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { IdempotencyHandler } from '../../src/IdempotencyHandler'; import { IdempotencyConfig } from '../..//src/IdempotencyConfig'; @@ -24,8 +24,9 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { const mockFunctionToMakeIdempotent = jest.fn(); const mockFunctionPayloadToBeHashed = {}; -const mockIdempotencyOptions: IdempotencyOptions = { +const mockIdempotencyOptions: IdempotencyFunctionOptions = { persistenceStore: new PersistenceLayerTestClass(), + dataKeywordArgument: 'testKeywordArgument', config: new IdempotencyConfig({}) }; const mockFullFunctionPayload = {}; @@ -33,7 +34,6 @@ const mockFullFunctionPayload = {}; const idempotentHandler = new IdempotencyHandler( mockFunctionToMakeIdempotent, mockFunctionPayloadToBeHashed, - new IdempotencyConfig({}), mockIdempotencyOptions.persistenceStore, mockFullFunctionPayload, ); diff --git a/packages/idempotency/tests/unit/idempotentDecorator.test.ts b/packages/idempotency/tests/unit/idempotentDecorator.test.ts index 78c9006d66..65644a7690 100644 --- a/packages/idempotency/tests/unit/idempotentDecorator.test.ts +++ b/packages/idempotency/tests/unit/idempotentDecorator.test.ts @@ -5,7 +5,7 @@ */ import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; -import { idempotent } from '../../src/idempotentDecorator'; +import { idempotentFunction, idempotentLambdaHandler } from '../../src/idempotentDecorator'; import type { IdempotencyRecordOptions } from '../../src/types'; import { IdempotencyRecordStatus } from '../../src/types'; import { @@ -28,8 +28,8 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { const functionalityToDecorate = jest.fn(); -class TestingClass { - @idempotent({ persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }) +class TestinClassWithLambdaHandler { + @idempotentLambdaHandler({ persistenceStore: new PersistenceLayerTestClass() }) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore public testing(record: Record): string { @@ -39,30 +39,37 @@ class TestingClass { } } -class TestingClassWithoutKeywordArgument { - @idempotent({ persistenceStore: new PersistenceLayerTestClass() }) +class TestingClassWithFunctionDecorator { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - public testing(record: Record): string { + public handler(record: Record): string { + return this.proccessRecord(record); + } + + @idempotentFunction({ persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + public proccessRecord(record: Record): string { functionalityToDecorate(record); - return 'Hi'; + return 'Processed Record'; } } -describe('Given a class with a function to decorate', (classWithFunction = new TestingClass(), - classWithFunctionNoKeywordArg = new TestingClassWithoutKeywordArgument()) => { +describe('Given a class with a function to decorate', (classWithLambdaHandler = new TestinClassWithLambdaHandler(), + classWithFunctionDecorator = new TestingClassWithFunctionDecorator()) => { const keyValueToBeSaved = 'thisWillBeSaved'; const inputRecord = { testingKey: keyValueToBeSaved, otherKey: 'thisWillNot' }; beforeEach(() => jest.clearAllMocks()); describe('When wrapping a function with no previous executions', () => { beforeEach(async () => { - await classWithFunctionNoKeywordArg.testing(inputRecord); + await classWithFunctionDecorator.handler(inputRecord); }); test('Then it will save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(inputRecord); + expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); }); test('Then it will call the function that was decorated', () => { @@ -70,17 +77,17 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }); test('Then it will save the record to COMPLETED with function return value', () => { - expect(mockSaveSuccess).toBeCalledWith(inputRecord, 'Hi'); + expect(mockSaveSuccess).toBeCalledWith(keyValueToBeSaved, 'Processed Record'); }); }); describe('When wrapping a function with no previous executions', () => { beforeEach(async () => { - await classWithFunction.testing(inputRecord); + await classWithLambdaHandler.testing(inputRecord); }); test('Then it will save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + expect(mockSaveInProgress).toBeCalledWith(inputRecord); }); test('Then it will call the function that was decorated', () => { @@ -88,7 +95,7 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }); test('Then it will save the record to COMPLETED with function return value', () => { - expect(mockSaveSuccess).toBeCalledWith(keyValueToBeSaved, 'Hi'); + expect(mockSaveSuccess).toBeCalledWith(inputRecord, 'Hi'); }); }); @@ -102,18 +109,18 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }; mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); try { - await classWithFunction.testing(inputRecord); + await classWithLambdaHandler.testing(inputRecord); } catch (e) { resultingError = e as Error; } }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + expect(mockSaveInProgress).toBeCalledWith(inputRecord); }); test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + expect(mockGetRecord).toBeCalledWith(inputRecord); }); test('Then it will not call the function that was decorated', () => { @@ -135,18 +142,18 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }; mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); try { - await classWithFunction.testing(inputRecord); + await classWithLambdaHandler.testing(inputRecord); } catch (e) { resultingError = e as Error; } }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + expect(mockSaveInProgress).toBeCalledWith(inputRecord); }); test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + expect(mockGetRecord).toBeCalledWith(inputRecord); }); test('Then it will not call the function that was decorated', () => { @@ -167,15 +174,15 @@ describe('Given a class with a function to decorate', (classWithFunction = new T }; mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); - await classWithFunction.testing(inputRecord); + await classWithLambdaHandler.testing(inputRecord); }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + expect(mockSaveInProgress).toBeCalledWith(inputRecord); }); test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(keyValueToBeSaved); + expect(mockGetRecord).toBeCalledWith(inputRecord); }); test('Then it will not call decorated functionality', () => { @@ -189,14 +196,14 @@ describe('Given a class with a function to decorate', (classWithFunction = new T beforeEach(async () => { mockSaveInProgress.mockRejectedValue(new Error('RandomError')); try { - await classWithFunction.testing(inputRecord); + await classWithLambdaHandler.testing(inputRecord); } catch (e) { resultingError = e as Error; } }); test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved); + expect(mockSaveInProgress).toBeCalledWith(inputRecord); }); test('Then an IdempotencyPersistenceLayerError is thrown', () => { diff --git a/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts index 70fd949d62..07aef22351 100644 --- a/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts +++ b/packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts @@ -3,7 +3,7 @@ * * @group unit/idempotency/makeFunctionIdempotent */ -import { IdempotencyOptions } from '../../src/types/IdempotencyOptions'; +import { IdempotencyFunctionOptions } from '../../src/types/IdempotencyOptions'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; import { makeFunctionIdempotent } from '../../src/makeFunctionIdempotent'; import type { AnyIdempotentFunction, IdempotencyRecordOptions } from '../../src/types'; @@ -27,7 +27,7 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { describe('Given a function to wrap', (functionToWrap = jest.fn()) => { beforeEach(() => jest.clearAllMocks()); - describe('Given options for idempotency', (options: IdempotencyOptions = { + describe('Given options for idempotency', (options: IdempotencyFunctionOptions = { persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }) => { From bc9874d1f4126265be1ec5e1b3f53c5c16516d07 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Fri, 12 May 2023 19:04:55 +0200 Subject: [PATCH 16/16] refactor IdempotencyHandler constructor --- .../idempotency/src/IdempotencyHandler.ts | 33 +++++++++++++++---- .../idempotency/src/idempotentDecorator.ts | 22 ++++++++----- .../idempotency/src/makeFunctionIdempotent.ts | 21 ++++++++---- .../src/types/IdempotencyOptions.ts | 19 ++++++++--- 4 files changed, 69 insertions(+), 26 deletions(-) diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index 0343a0dd38..abeadd2fe5 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -1,4 +1,4 @@ -import type { AnyFunctionWithRecord } from './types'; +import type { AnyFunctionWithRecord, IdempotencyHandlerOptions } from './types'; import { IdempotencyRecordStatus } from './types'; import { IdempotencyAlreadyInProgressError, @@ -7,14 +7,33 @@ import { IdempotencyPersistenceLayerError, } from './Exceptions'; import { BasePersistenceLayer, IdempotencyRecord } from './persistence'; +import { IdempotencyConfig } from './IdempotencyConfig'; export class IdempotencyHandler { - public constructor( - private functionToMakeIdempotent: AnyFunctionWithRecord, - private functionPayloadToBeHashed: Record, - private persistenceStore: BasePersistenceLayer, - private fullFunctionPayload: Record, - ) { + private readonly fullFunctionPayload: Record; + private readonly functionPayloadToBeHashed: Record; + private readonly functionToMakeIdempotent: AnyFunctionWithRecord; + private readonly idempotencyConfig: IdempotencyConfig; + private readonly persistenceStore: BasePersistenceLayer; + + public constructor(options: IdempotencyHandlerOptions) { + const { + functionToMakeIdempotent, + functionPayloadToBeHashed, + idempotencyConfig, + fullFunctionPayload, + persistenceStore + } = options; + this.functionToMakeIdempotent = functionToMakeIdempotent; + this.functionPayloadToBeHashed = functionPayloadToBeHashed; + this.idempotencyConfig = idempotencyConfig; + this.fullFunctionPayload = fullFunctionPayload; + + this.persistenceStore = persistenceStore; + + this.persistenceStore.configure({ + config: this.idempotencyConfig + }); } public determineResultFromIdempotencyRecord(idempotencyRecord: IdempotencyRecord): Promise | U { diff --git a/packages/idempotency/src/idempotentDecorator.ts b/packages/idempotency/src/idempotentDecorator.ts index 699b44cfdf..a4a01cf4c5 100644 --- a/packages/idempotency/src/idempotentDecorator.ts +++ b/packages/idempotency/src/idempotentDecorator.ts @@ -1,22 +1,26 @@ -import { GenericTempRecord, IdempotencyFunctionOptions, IdempotencyHandlerOptions, } from './types'; +import { GenericTempRecord, IdempotencyFunctionOptions, IdempotencyLambdaHandlerOptions, } from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; +import { IdempotencyConfig } from './IdempotencyConfig'; /** * use this function to narrow the type of options between IdempotencyHandlerOptions and IdempotencyFunctionOptions * @param options */ -const isFunctionOption = (options: IdempotencyHandlerOptions | IdempotencyFunctionOptions): boolean => (options as IdempotencyFunctionOptions).dataKeywordArgument !== undefined; +const isFunctionOption = (options: IdempotencyLambdaHandlerOptions | IdempotencyFunctionOptions): boolean => (options as IdempotencyFunctionOptions).dataKeywordArgument !== undefined; -const idempotent = function (options: IdempotencyHandlerOptions | IdempotencyFunctionOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor { +const idempotent = function (options: IdempotencyLambdaHandlerOptions | IdempotencyFunctionOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor { return function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) { const childFunction = descriptor.value; descriptor.value = function (record: GenericTempRecord) { const functionPayloadtoBeHashed = isFunctionOption(options) ? record[(options as IdempotencyFunctionOptions).dataKeywordArgument] : record; - const idempotencyHandler = new IdempotencyHandler( - childFunction, - functionPayloadtoBeHashed, - options.persistenceStore, - record); + const idempotencyConfig = options.config ? options.config : new IdempotencyConfig({}); + const idempotencyHandler = new IdempotencyHandler({ + functionToMakeIdempotent: childFunction, + functionPayloadToBeHashed: functionPayloadtoBeHashed, + persistenceStore: options.persistenceStore, + idempotencyConfig: idempotencyConfig, + fullFunctionPayload: record + }); return idempotencyHandler.handle(); }; @@ -25,7 +29,7 @@ const idempotent = function (options: IdempotencyHandlerOptions | IdempotencyFun }; }; -const idempotentLambdaHandler = function (options: IdempotencyHandlerOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor { +const idempotentLambdaHandler = function (options: IdempotencyLambdaHandlerOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor { return idempotent(options); }; const idempotentFunction = function (options: IdempotencyFunctionOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor { diff --git a/packages/idempotency/src/makeFunctionIdempotent.ts b/packages/idempotency/src/makeFunctionIdempotent.ts index 7b5916be9d..3ebefbe24b 100644 --- a/packages/idempotency/src/makeFunctionIdempotent.ts +++ b/packages/idempotency/src/makeFunctionIdempotent.ts @@ -1,5 +1,11 @@ -import type { AnyFunctionWithRecord, AnyIdempotentFunction, GenericTempRecord, IdempotencyFunctionOptions, } from './types'; +import type { + AnyFunctionWithRecord, + AnyIdempotentFunction, + GenericTempRecord, + IdempotencyFunctionOptions, +} from './types'; import { IdempotencyHandler } from './IdempotencyHandler'; +import { IdempotencyConfig } from './IdempotencyConfig'; const makeFunctionIdempotent = function ( fn: AnyFunctionWithRecord, @@ -9,11 +15,14 @@ const makeFunctionIdempotent = function ( if (options.dataKeywordArgument === undefined) { throw new Error(`Missing data keyword argument ${options.dataKeywordArgument}`); } - const idempotencyHandler: IdempotencyHandler = new IdempotencyHandler( - fn, - record[options.dataKeywordArgument], - options.persistenceStore, - record); + const idempotencyConfig = options.config ? options.config : new IdempotencyConfig({}); + const idempotencyHandler: IdempotencyHandler = new IdempotencyHandler({ + functionToMakeIdempotent: fn, + functionPayloadToBeHashed: record[options.dataKeywordArgument], + idempotencyConfig: idempotencyConfig, + persistenceStore: options.persistenceStore, + fullFunctionPayload: record + }); return idempotencyHandler.handle(); }; diff --git a/packages/idempotency/src/types/IdempotencyOptions.ts b/packages/idempotency/src/types/IdempotencyOptions.ts index 3cc77337a9..43e983b3c1 100644 --- a/packages/idempotency/src/types/IdempotencyOptions.ts +++ b/packages/idempotency/src/types/IdempotencyOptions.ts @@ -1,15 +1,25 @@ import type { Context } from 'aws-lambda'; import { BasePersistenceLayer } from '../persistence/BasePersistenceLayer'; +import { AnyFunctionWithRecord } from 'types/AnyFunction'; +import { IdempotencyConfig } from '../IdempotencyConfig'; -type IdempotencyHandlerOptions = { +type IdempotencyLambdaHandlerOptions = { persistenceStore: BasePersistenceLayer - config?: IdempotencyConfigOptions + config?: IdempotencyConfig }; -type IdempotencyFunctionOptions = IdempotencyHandlerOptions & { +type IdempotencyFunctionOptions = IdempotencyLambdaHandlerOptions & { dataKeywordArgument: string }; +type IdempotencyHandlerOptions = { + functionToMakeIdempotent: AnyFunctionWithRecord + functionPayloadToBeHashed: Record + persistenceStore: BasePersistenceLayer + idempotencyConfig: IdempotencyConfig + fullFunctionPayload: Record +}; + /** * Idempotency configuration options */ @@ -51,5 +61,6 @@ type IdempotencyConfigOptions = { export { IdempotencyConfigOptions, IdempotencyFunctionOptions, - IdempotencyHandlerOptions, + IdempotencyLambdaHandlerOptions, + IdempotencyHandlerOptions };