diff --git a/packages/commons/tests/utils/e2eUtils.ts b/packages/commons/tests/utils/e2eUtils.ts index 6531c78db7..328f7241cc 100644 --- a/packages/commons/tests/utils/e2eUtils.ts +++ b/packages/commons/tests/utils/e2eUtils.ts @@ -9,7 +9,7 @@ import { } 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'; @@ -38,7 +38,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 @@ -82,27 +82,26 @@ export const generateUniqueName = ( export const invokeFunction = async ( functionName: string, - times = 1, + times: number = 1, invocationMode: 'PARALLEL' | 'SEQUENTIAL' = 'PARALLEL', - payload: FunctionPayload = {} + 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) { invocationLogs.push(new InvocationLogs(response?.LogResult)); @@ -117,9 +116,10 @@ export const invokeFunction = async ( }; 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; diff --git a/packages/idempotency/package.json b/packages/idempotency/package.json index f8a47fb2d4..aa675ee4ff 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 --detectOpenHandles", "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/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index 651301bbba..abeadd2fe5 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, IdempotencyHandlerOptions } from './types'; import { IdempotencyRecordStatus } from './types'; import { IdempotencyAlreadyInProgressError, @@ -6,20 +6,37 @@ 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 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 { + 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.' @@ -40,10 +57,31 @@ 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 { + 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; } /** @@ -70,13 +108,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 +124,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..a4a01cf4c5 100644 --- a/packages/idempotency/src/idempotentDecorator.ts +++ b/packages/idempotency/src/idempotentDecorator.ts @@ -1,15 +1,26 @@ -import { - GenericTempRecord, - IdempotencyOptions, -} from './types'; +import { GenericTempRecord, IdempotencyFunctionOptions, IdempotencyLambdaHandlerOptions, } 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: IdempotencyLambdaHandlerOptions | IdempotencyFunctionOptions): boolean => (options as IdempotencyFunctionOptions).dataKeywordArgument !== undefined; + +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; - // 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 functionPayloadtoBeHashed = isFunctionOption(options) ? record[(options as IdempotencyFunctionOptions).dataKeywordArgument] : 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(); }; @@ -18,4 +29,11 @@ const idempotent = function (options: IdempotencyOptions) { }; }; -export { idempotent }; +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 { + return idempotent(options); +}; + +export { idempotentLambdaHandler, idempotentFunction }; diff --git a/packages/idempotency/src/makeFunctionIdempotent.ts b/packages/idempotency/src/makeFunctionIdempotent.ts index 77e0a9e994..3ebefbe24b 100644 --- a/packages/idempotency/src/makeFunctionIdempotent.ts +++ b/packages/idempotency/src/makeFunctionIdempotent.ts @@ -1,17 +1,28 @@ import type { - GenericTempRecord, - IdempotencyOptions, 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 { - 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 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 6f53f8efcd..43e983b3c1 100644 --- a/packages/idempotency/src/types/IdempotencyOptions.ts +++ b/packages/idempotency/src/types/IdempotencyOptions.ts @@ -1,9 +1,23 @@ import type { Context } from 'aws-lambda'; import { BasePersistenceLayer } from '../persistence/BasePersistenceLayer'; +import { AnyFunctionWithRecord } from 'types/AnyFunction'; +import { IdempotencyConfig } from '../IdempotencyConfig'; -type IdempotencyOptions = { +type IdempotencyLambdaHandlerOptions = { + persistenceStore: BasePersistenceLayer + config?: IdempotencyConfig +}; + +type IdempotencyFunctionOptions = IdempotencyLambdaHandlerOptions & { dataKeywordArgument: string +}; + +type IdempotencyHandlerOptions = { + functionToMakeIdempotent: AnyFunctionWithRecord + functionPayloadToBeHashed: Record persistenceStore: BasePersistenceLayer + idempotencyConfig: IdempotencyConfig + fullFunctionPayload: Record }; /** @@ -45,6 +59,8 @@ type IdempotencyConfigOptions = { }; export { - IdempotencyOptions, - IdempotencyConfigOptions + IdempotencyConfigOptions, + IdempotencyFunctionOptions, + IdempotencyLambdaHandlerOptions, + IdempotencyHandlerOptions }; 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..1dda1d338c --- /dev/null +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts @@ -0,0 +1,95 @@ +import { LambdaInterface } from '@aws-lambda-powertools/commons'; +import { DynamoDBPersistenceLayer } from '../../src/persistence'; +import { idempotentFunction, idempotentLambdaHandler } from '../../src/idempotentDecorator'; +import { Context } from 'aws-lambda'; +import { Logger } from '../../../logger'; + +const IDEMPOTENCY_TABLE_NAME = process.env.IDEMPOTENCY_TABLE_NAME || 'table_name'; +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 TestEvent { + [key: string]: string +} + +interface EventRecords { + records: Record[] +} + +class DefaultLambda implements LambdaInterface { + + @idempotentLambdaHandler({ persistenceStore: dynamoDBPersistenceLayer }) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + public async handler(_event: Record, _context: Context): Promise { + logger.info(`Got test event: ${JSON.stringify(_event)}`); + // sleep to enforce error with parallel execution + await new Promise((resolve) => setTimeout(resolve, 3000)); + + return 'Hello World'; + } + + @idempotentLambdaHandler({ 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'; + } + + @idempotentLambdaHandler({ 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 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 { + 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'; + } + + @idempotentFunction({ 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 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 new file mode 100644 index 0000000000..af87c0d270 --- /dev/null +++ b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts @@ -0,0 +1,146 @@ +/** + * Test idempotency decorator + * + * @group e2e/idempotency + */ +import { v4 } from 'uuid'; +import { App, Stack } from 'aws-cdk-lib'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { generateUniqueName, invokeFunction, isValidRuntimeKey } from '../../../commons/tests/utils/e2eUtils'; +import { RESOURCE_NAME_PREFIX, SETUP_TIMEOUT, TEARDOWN_TIMEOUT, TEST_CASE_TIMEOUT } from './constants'; +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'; + +if (!isValidRuntimeKey(runtime)) { + throw new Error(`Invalid runtime key value: ${runtime}`); +} + +const uuid = v4(); +const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'Idempotency'); +const decoratorFunctionFile = 'idempotencyDecorator.test.FunctionCode.ts'; + +const app = new App(); + +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, 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 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'); +describe('Idempotency e2e test decorator, default settings', () => { + + beforeAll(async () => { + await deployStack(app, stack); + + }, SETUP_TIMEOUT); + + 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 result = await ddb.send(new GetCommand({ + TableName: ddbTableNameDefault, + Key: { id: `${functionNameDefault}#${payloadHash}` } + })); + 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); + + 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); + + const result = await ddb.send(new GetCommand({ + TableName: ddbTableNameDefault, + Key: { id: `${functionNameDefault}#${payloadHash}` } + })); + 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 () => { + const payload = { foo: 'baz' }; + const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); + + const invocationLogsCustmozed = await invokeFunction(functionNameCustom, 1, 'PARALLEL', payload, false); + const result = await ddb.send(new GetCommand({ + TableName: ddbTableNameCustom, + Key: { customId: `${functionNameCustom}#${payloadHash}` } + })); + 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 () => { + const payload = { foo: 'baz' }; + const payloadHash = createHash('md5').update(JSON.stringify(payload)).digest('base64'); + + await invokeFunction(functionNameFails, 1, 'PARALLEL', payload, false); + const result = await ddb.send(new GetCommand({ + TableName: ddbTableNameFails, + Key: { id: `${functionNameFails}#${payloadHash}` } + })); + 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 () => { + 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); + const resultFirst = await ddb.send(new GetCommand({ + TableName: ddbTableNameKeywordArg, + Key: { id: `${functionNameKeywordArg}#${payloadHashFirst}` } + })); + 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'); + const resultSecond = await ddb.send(new GetCommand({ + TableName: ddbTableNameKeywordArg, + Key: { id: `${functionNameKeywordArg}#${payloadHashSecond}` } + })); + 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 () => { + if (!process.env.DISABLE_TEARDOWN) { + await destroyStack(app, stack); + } + }, TEARDOWN_TIMEOUT); +}); \ No newline at end of file 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..06fbaae9bc --- /dev/null +++ b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts @@ -0,0 +1,67 @@ +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 || 'table_name'; +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[] +} + +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, + { + persistenceStore: dynamoDBPersistenceLayer, + dataKeywordArgument: '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(); +}; + +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 new file mode 100644 index 0000000000..4e631258d9 --- /dev/null +++ b/packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts @@ -0,0 +1,96 @@ +/** + * Test makeFunctionIdempotent + * + * @group e2e/idempotency + */ +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, Stack } from 'aws-cdk-lib'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { createHash } from 'node:crypto'; +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'; + +if (!isValidRuntimeKey(runtime)) { + throw new Error(`Invalid runtime key value: ${runtime}`); +} +const uuid = v4(); +const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'makeFnIdempotent'); +const makeFunctionIdepmpotentFile = 'makeFunctionIdempotent.test.FunctionCode.ts'; + +const app = new App(); + +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 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'); + +describe('Idempotency e2e test function wrapper, default settings', () => { + + beforeAll(async () => { + 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 result = await ddb.send(new ScanCommand({ TableName: ddbTableNameDefault })); + expect(result?.Items?.length).toEqual(2); + + const resultFirst = await ddb.send(new GetCommand({ + TableName: ddbTableNameDefault, + Key: { id: `${functionNameDefault}#${payloadHashFirst}` } + })); + expect(resultFirst?.Item?.data).toEqual('Processing done: bar'); + expect(resultFirst?.Item?.status).toEqual('COMPLETED'); + + const resultSecond = await ddb.send(new GetCommand({ + TableName: ddbTableNameDefault, + Key: { id: `${functionNameDefault}#${payloadHashSecond}` } + })); + expect(resultSecond?.Item?.data).toEqual('Processing done: baz'); + expect(resultSecond?.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); + const result = await ddb.send(new GetCommand({ + TableName: ddbTableNameCustom, + Key: { customId: `${functionNameCustom}#${payloadHash}` } + })); + 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 () => { + 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 diff --git a/packages/idempotency/tests/unit/IdempotencyHandler.test.ts b/packages/idempotency/tests/unit/IdempotencyHandler.test.ts index aeff922501..5b35785080 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 { IdempotencyFunctionOptions, IdempotencyRecordStatus, } 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: IdempotencyFunctionOptions = { persistenceStore: new PersistenceLayerTestClass(), - dataKeywordArgument: 'testingKey' + dataKeywordArgument: 'testKeywordArgument', + config: new IdempotencyConfig({}) }; const mockFullFunctionPayload = {}; const idempotentHandler = new IdempotencyHandler( mockFunctionToMakeIdempotent, mockFunctionPayloadToBeHashed, - mockIdempotencyOptions, + 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..65644a7690 100644 --- a/packages/idempotency/tests/unit/idempotentDecorator.test.ts +++ b/packages/idempotency/tests/unit/idempotentDecorator.test.ts @@ -4,14 +4,19 @@ * @group unit/idempotency/decorator */ -import { IdempotencyOptions } from '../../src/types/IdempotencyOptions'; import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; -import { idempotent } from '../../src/idempotentDecorator'; -import { IdempotencyRecordStatus } from '../../src/types'; +import { idempotentFunction, idempotentLambdaHandler } from '../../src/idempotentDecorator'; 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,11 +26,10 @@ class PersistenceLayerTestClass extends BasePersistenceLayer { protected _updateRecord = jest.fn(); } -const options: IdempotencyOptions = { persistenceStore: new PersistenceLayerTestClass(), dataKeywordArgument: 'testingKey' }; const functionalityToDecorate = jest.fn(); -class TestingClass { - @idempotent(options) +class TestinClassWithLambdaHandler { + @idempotentLambdaHandler({ persistenceStore: new PersistenceLayerTestClass() }) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore public testing(record: Record): string { @@ -35,13 +39,33 @@ class TestingClass { } } -describe('Given a class with a function to decorate', (classWithFunction = new TestingClass()) => { +class TestingClassWithFunctionDecorator { + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + 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 'Processed Record'; + } +} + +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()); + beforeEach(() => jest.clearAllMocks()); + describe('When wrapping a function with no previous executions', () => { beforeEach(async () => { - classWithFunction.testing(inputRecord); + await classWithFunctionDecorator.handler(inputRecord); }); test('Then it will save the record to INPROGRESS', () => { @@ -51,6 +75,28 @@ describe('Given a class with a function to decorate', (classWithFunction = new T 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, 'Processed Record'); + }); + + }); + describe('When wrapping a function with no previous executions', () => { + beforeEach(async () => { + await classWithLambdaHandler.testing(inputRecord); + }); + + test('Then it will save the record to INPROGRESS', () => { + 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,20 +107,20 @@ 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); + 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', () => { @@ -94,20 +140,20 @@ 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); + 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', () => { @@ -124,24 +170,25 @@ 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); + + mockGetRecord.mockResolvedValue(new IdempotencyRecord(idempotencyOptions)); + 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); }); - //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); + test('Then it will not call decorated functionality', () => { + expect(functionalityToDecorate).not.toBeCalledWith(inputRecord); }); + }); describe('When wrapping a function with issues saving the record', () => { @@ -149,18 +196,20 @@ 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', () => { 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 c8d90b251b..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' }) => { @@ -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', () => {