From 366bdaab7d729749681c71b3add5b354f97080d5 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 16 Dec 2025 12:38:35 -0500 Subject: [PATCH 1/2] Bump version to 0.2.0-alpha in package.json and package-lock.json --- infrastructure/package-lock.json | 14 ++++---------- infrastructure/package.json | 2 +- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index 352546a..9347a7a 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -1,12 +1,12 @@ { "name": "lambda-starter-infrastructure", - "version": "0.1.0", + "version": "0.2.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lambda-starter-infrastructure", - "version": "0.1.0", + "version": "0.2.0-alpha", "dependencies": { "aws-cdk-lib": "2.232.2", "constructs": "10.4.4", @@ -105,7 +105,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1723,7 +1722,6 @@ "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2655,7 +2653,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -2917,8 +2914,7 @@ "version": "10.4.4", "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.4.tgz", "integrity": "sha512-lP0qC1oViYf1cutHo9/KQ8QL637f/W29tDmv/6sy35F5zs+MD9f66nbAAIjicwc7fwyuF3rkg6PhZh4sfvWIpA==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -3618,7 +3614,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -4907,6 +4902,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5366,7 +5362,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -5442,7 +5437,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/infrastructure/package.json b/infrastructure/package.json index f9502b2..4df1a84 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "lambda-starter-infrastructure", - "version": "0.1.0", + "version": "0.2.0-alpha", "description": "AWS CDK infrastructure for lambda-starter", "private": true, "type": "commonjs", From b627ce7b66586779ffd2f59979bdd92f44a1ea8f Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 16 Dec 2025 12:38:53 -0500 Subject: [PATCH 2/2] Update Lambda client implementation and tests; bump version to 0.2.0-alpha --- package-lock.json | 144 ++++++- package.json | 3 +- src/utils/lambda-client.test.ts | 672 ++++++++++++++++++++++++++++++++ src/utils/lambda-client.ts | 111 ++++++ 4 files changed, 927 insertions(+), 3 deletions(-) create mode 100644 src/utils/lambda-client.test.ts create mode 100644 src/utils/lambda-client.ts diff --git a/package-lock.json b/package-lock.json index fda63f8..4c0e2f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "lambda-starter", - "version": "0.1.0", + "version": "0.2.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lambda-starter", - "version": "0.1.0", + "version": "0.2.0-alpha", "license": "MIT", "dependencies": { "@aws-sdk/client-dynamodb": "3.952.0", + "@aws-sdk/client-lambda": "3.952.0", "@aws-sdk/client-sns": "3.952.0", "@aws-sdk/lib-dynamodb": "3.952.0", "pino": "10.1.0", @@ -35,6 +36,20 @@ "typescript-eslint": "8.50.0" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -214,6 +229,61 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-lambda": { + "version": "3.952.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.952.0.tgz", + "integrity": "sha512-F0cK/RX1qs9NLZctGokrKe6AlK4YgiHqYaYBo32d+xT+EZ4EpNuOIWNZ4Rgq+78tG+Y9DT98Nwwl9lejYQtrog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.947.0", + "@aws-sdk/credential-provider-node": "3.952.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.948.0", + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.947.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/eventstream-serde-browser": "^4.2.5", + "@smithy/eventstream-serde-config-resolver": "^4.3.5", + "@smithy/eventstream-serde-node": "^4.2.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-stream": "^4.5.6", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-sns": { "version": "3.952.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.952.0.tgz", @@ -2342,6 +2412,76 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.6.tgz", + "integrity": "sha512-OZfsI+YRG26XZik/jKMMg37acnBSbUiK/8nETW3uM3mLj+0tMmFXdHQw1e5WEd/IHN8BGOh3te91SNDe2o4RHg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.10.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.6.tgz", + "integrity": "sha512-6OiaAaEbLB6dEkRbQyNzFSJv5HDvly3Mc6q/qcPd2uS/g3szR8wAIkh7UndAFKfMypNSTuZ6eCBmgCLR5LacTg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.6", + "@smithy/types": "^4.10.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.6.tgz", + "integrity": "sha512-xP5YXbOVRVN8A4pDnSUkEUsL9fYFU6VNhxo8tgr13YnMbf3Pn4xVr+hSyLVjS1Frfi1Uk03ET5Bwml4+0CeYEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.10.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.6.tgz", + "integrity": "sha512-jhH7nJuaOpnTFcuZpWK9dqb6Ge2yGi1okTo0W6wkJrfwAm2vwmO74tF1v07JmrSyHBcKLQATEexclJw9K1Vj7w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.6", + "@smithy/types": "^4.10.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.6.tgz", + "integrity": "sha512-olIfZ230B64TvPD6b0tPvrEp2eB0FkyL3KvDlqF4RVmIc/kn3orzXnV6DTQdOOW5UU+M5zKY3/BU47X420/oPw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.6", + "@smithy/types": "^4.10.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/fetch-http-handler": { "version": "5.3.7", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.7.tgz", diff --git a/package.json b/package.json index a47da56..e23b27c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lambda-starter", - "version": "0.1.0", + "version": "0.2.0-alpha", "description": "A starter project for Lambda functions using Node.js and TypeScript.", "keywords": [ "aws", @@ -55,6 +55,7 @@ }, "dependencies": { "@aws-sdk/client-dynamodb": "3.952.0", + "@aws-sdk/client-lambda": "3.952.0", "@aws-sdk/client-sns": "3.952.0", "@aws-sdk/lib-dynamodb": "3.952.0", "pino": "10.1.0", diff --git a/src/utils/lambda-client.test.ts b/src/utils/lambda-client.test.ts new file mode 100644 index 0000000..5563c4f --- /dev/null +++ b/src/utils/lambda-client.test.ts @@ -0,0 +1,672 @@ +// Mock the logger +const mockLoggerInfo = jest.fn(); +const mockLoggerDebug = jest.fn(); +const mockLoggerError = jest.fn(); + +jest.mock('./logger.js', () => ({ + logger: { + info: mockLoggerInfo, + debug: mockLoggerDebug, + error: mockLoggerError, + }, +})); + +// Mock the config module +jest.mock('./config.js', () => ({ + config: { + AWS_REGION: 'us-east-1', + }, +})); + +// Mock the Lambda client +let mockSend: jest.Mock; +jest.mock('@aws-sdk/client-lambda', () => { + mockSend = jest.fn(); + return { + InvokeCommand: jest.fn(), + LambdaClient: jest.fn().mockImplementation(() => ({ + send: mockSend, + })), + }; +}); + +import { invokeLambdaSync, invokeLambdaAsync } from './lambda-client.js'; + +/** + * Test suite for Lambda client utility + */ +describe('lambda-client', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('invokeLambdaSync', () => { + it('should successfully invoke a Lambda function with RequestResponse invocation type', async () => { + // Arrange + const functionName = 'test-function'; + const payload = { test: 'data' }; + const mockResponse = { result: 'success' }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify(mockResponse)), + }); + + // Act + const result = await invokeLambdaSync(functionName, payload); + + // Assert + expect(result).toEqual(mockResponse); + expect(mockLoggerInfo).toHaveBeenCalledWith('[LambdaClient] > invokeLambdaSync', { functionName }); + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaSync - successfully invoked Lambda function', + { + functionName, + statusCode: 200, + }, + ); + }); + + it('should serialize payload to JSON string', async () => { + // Arrange + const functionName = 'test-function'; + const payload = { httpMethod: 'GET', path: '/tasks', body: null }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify([])), + }); + + // Act + await invokeLambdaSync(functionName, payload); + + // Assert + expect(mockSend).toHaveBeenCalled(); + }); + + it('should deserialize response payload from Uint8Array', async () => { + // Arrange + const functionName = 'test-function'; + const payload = {}; + const mockResponse = { + statusCode: 200, + body: JSON.stringify({ data: 'test' }), + headers: { 'Content-Type': 'application/json' }, + }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify(mockResponse)), + }); + + // Act + const result = (await invokeLambdaSync(functionName, payload)) as Record; + + // Assert + expect(result).toEqual(mockResponse); + expect(typeof result).toBe('object'); + expect(result.statusCode).toBe(200); + }); + + it('should handle empty Payload from Lambda response', async () => { + // Arrange + const functionName = 'test-function'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: undefined, + Payload: undefined, + }); + + // Act + const result = await invokeLambdaSync(functionName, payload); + + // Assert + expect(result).toBeNull(); + }); + + it('should throw error when Lambda function returns FunctionError', async () => { + // Arrange + const functionName = 'test-function'; + const payload = {}; + const errorResponse = { message: 'Internal error' }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: 'Unhandled', + Payload: new TextEncoder().encode(JSON.stringify(errorResponse)), + }); + + // Act & Assert + await expect(invokeLambdaSync(functionName, payload)).rejects.toThrow('Lambda function error: Unhandled'); + expect(mockLoggerError).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaSync - Lambda function returned an error', + expect.any(Error), + expect.objectContaining({ + functionName, + FunctionError: 'Unhandled', + }), + ); + }); + + it('should handle Lambda SDK invocation errors', async () => { + // Arrange + const functionName = 'non-existent-function'; + const payload = {}; + const error = new Error('Function not found'); + + mockSend.mockRejectedValueOnce(error); + + // Act & Assert + await expect(invokeLambdaSync(functionName, payload)).rejects.toThrow('Function not found'); + expect(mockLoggerError).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaSync - failed to invoke Lambda function', + error, + { functionName }, + ); + }); + + it('should support generic type parameter for response', async () => { + // Arrange + interface TaskResponse { + tasks: { id: string; title: string }[]; + } + + const functionName = 'list-tasks'; + const payload = {}; + const mockResponse: TaskResponse = { + tasks: [{ id: '1', title: 'Test Task' }], + }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify(mockResponse)), + }); + + // Act + const result = await invokeLambdaSync(functionName, payload); + + // Assert + expect(result).toEqual(mockResponse); + const typedResult = result as TaskResponse | null; + expect(typedResult?.tasks).toBeDefined(); + }); + + it('should log debug information during invocation', async () => { + // Arrange + const functionName = 'test-function'; + const payload = { test: 'data' }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify({})), + }); + + // Act + await invokeLambdaSync(functionName, payload); + + // Assert + expect(mockLoggerDebug).toHaveBeenCalledWith( + '[LambdaClient] invokeLambdaSync - InvokeCommand', + expect.any(Object), + ); + }); + + it('should handle complex nested payloads', async () => { + // Arrange + const functionName = 'process-data'; + const complexPayload = { + event: { + httpMethod: 'POST', + path: '/api/v1/tasks', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'New Task', due: '2024-12-25' }), + requestContext: { + requestId: 'req-123', + accountId: '123456789012', + }, + }, + context: { + functionName: 'processor', + memoryLimit: 256, + }, + }; + + const mockResponse = { success: true, id: 'task-123' }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify(mockResponse)), + }); + + // Act + const result = await invokeLambdaSync(functionName, complexPayload); + + // Assert + expect(result).toEqual(mockResponse); + }); + + it('should use RequestResponse invocation type', async () => { + // Arrange + const functionName = 'sync-function'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify({ status: 'ok' })), + }); + + // Act + await invokeLambdaSync(functionName, payload); + + // Assert + expect(mockSend).toHaveBeenCalled(); + }); + + it('should handle Lambda response with non-JSON payload', async () => { + // Arrange + const functionName = 'text-function'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: undefined, + Payload: new TextEncoder().encode('plain text response'), + }); + + // Act & Assert + await expect(invokeLambdaSync(functionName, payload)).rejects.toThrow(); + }); + + it('should handle successful invocation with HTTP status codes other than 200', async () => { + // Arrange + const functionName = 'api-function'; + const payload = {}; + const mockResponse = { + statusCode: 404, + body: JSON.stringify({ message: 'Not Found' }), + }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify(mockResponse)), + }); + + // Act + const result = (await invokeLambdaSync(functionName, payload)) as Record; + + // Assert + expect(result).toEqual(mockResponse); + expect(result.statusCode).toBe(404); + }); + + it('should include function name in log messages', async () => { + // Arrange + const functionName = 'my-special-function'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 200, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify({})), + }); + + // Act + await invokeLambdaSync(functionName, payload); + + // Assert + expect(mockLoggerInfo).toHaveBeenCalledWith('[LambdaClient] > invokeLambdaSync', { functionName }); + expect(mockLoggerDebug).toHaveBeenCalledWith( + '[LambdaClient] invokeLambdaSync - InvokeCommand', + expect.any(Object), + ); + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaSync - successfully invoked Lambda function', + { + functionName, + statusCode: 200, + }, + ); + }); + }); + + describe('invokeLambdaAsync', () => { + it('should successfully invoke a Lambda function with Event invocation type', async () => { + // Arrange + const functionName = 'async-test-function'; + const payload = { test: 'data' }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify({ result: 'accepted' })), + }); + + // Act + await invokeLambdaAsync(functionName, payload); + + // Assert + expect(mockLoggerInfo).toHaveBeenCalledWith('[LambdaClient] > invokeLambdaAsync', { functionName }); + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaAsync - successfully invoked Lambda function', + { + functionName, + statusCode: 202, + }, + ); + }); + + it('should serialize payload to JSON string for async invocation', async () => { + // Arrange + const functionName = 'async-test-function'; + const payload = { httpMethod: 'POST', path: '/tasks', body: { title: 'New Task' } }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify({})), + }); + + // Act + await invokeLambdaAsync(functionName, payload); + + // Assert + expect(mockSend).toHaveBeenCalled(); + }); + + it('should deserialize response payload from Uint8Array for async invocation', async () => { + // Arrange + const functionName = 'async-test-function'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode( + JSON.stringify({ + statusCode: 202, + body: JSON.stringify({ queued: true }), + headers: { 'Content-Type': 'application/json' }, + }), + ), + }); + + // Act + await invokeLambdaAsync(functionName, payload); + + // Assert + expect(mockSend).toHaveBeenCalled(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaAsync - successfully invoked Lambda function', + expect.objectContaining({ + functionName, + statusCode: 202, + }), + ); + }); + + it('should handle empty Payload from async Lambda response', async () => { + // Arrange + const functionName = 'async-test-function'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: undefined, + }); + + // Act + await invokeLambdaAsync(functionName, payload); + + // Assert + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaAsync - successfully invoked Lambda function', + expect.objectContaining({ + functionName, + statusCode: 202, + }), + ); + }); + + it('should throw error when async Lambda function returns FunctionError', async () => { + // Arrange + const functionName = 'async-test-function'; + const payload = {}; + const errorResponse = { message: 'Internal error' }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: 'Unhandled', + Payload: new TextEncoder().encode(JSON.stringify(errorResponse)), + }); + + // Act & Assert + await expect(invokeLambdaAsync(functionName, payload)).rejects.toThrow('Lambda function error: Unhandled'); + expect(mockLoggerError).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaAsync - Lambda function returned an error', + expect.any(Error), + expect.objectContaining({ + functionName, + response: expect.objectContaining({ + FunctionError: 'Unhandled', + }), + }), + ); + }); + + it('should handle Lambda SDK invocation errors for async invocation', async () => { + // Arrange + const functionName = 'non-existent-async-function'; + const payload = {}; + const error = new Error('Function not found'); + + mockSend.mockRejectedValueOnce(error); + + // Act & Assert + await expect(invokeLambdaAsync(functionName, payload)).rejects.toThrow('Function not found'); + expect(mockLoggerError).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaAsync - failed to invoke Lambda function', + error, + { functionName }, + ); + }); + + it('should support generic type parameter for async response', async () => { + // Arrange + const functionName = 'create-task-async'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode( + JSON.stringify({ + jobId: 'job-123', + status: 'queued', + }), + ), + }); + + // Act + await invokeLambdaAsync(functionName, payload); + + // Assert - invokeLambdaAsync returns void + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaAsync - successfully invoked Lambda function', + expect.objectContaining({ + functionName, + statusCode: 202, + }), + ); + }); + + it('should log debug information during async invocation', async () => { + // Arrange + const functionName = 'async-test-function'; + const payload = { test: 'data' }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify({})), + }); + + // Act + await invokeLambdaAsync(functionName, payload); + + // Assert + expect(mockLoggerDebug).toHaveBeenCalledWith( + '[LambdaClient] invokeLambdaAsync - InvokeCommand', + expect.any(Object), + ); + }); + + it('should handle complex nested payloads for async invocation', async () => { + // Arrange + const functionName = 'process-data-async'; + const complexPayload = { + event: { + httpMethod: 'POST', + path: '/api/v1/tasks', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Background Task', priority: 'high' }), + requestContext: { + requestId: 'req-456', + accountId: '123456789012', + }, + }, + context: { + functionName: 'async-processor', + memoryLimit: 512, + }, + }; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify({ jobId: 'async-job-456', queued: true })), + }); + + // Act + await invokeLambdaAsync(functionName, complexPayload); + + // Assert + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaAsync - successfully invoked Lambda function', + expect.objectContaining({ + functionName, + statusCode: 202, + }), + ); + }); + + it('should use Event invocation type for async invocation', async () => { + // Arrange + const functionName = 'event-function'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify({ status: 'queued' })), + }); + + // Act + await invokeLambdaAsync(functionName, payload); + + // Assert + expect(mockSend).toHaveBeenCalled(); + }); + + it('should handle Lambda async response with non-JSON payload', async () => { + // Arrange + const functionName = 'text-async-function'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode('plain text response'), + }); + + // Act & Assert - invokeLambdaAsync doesn't parse payloads for async + await invokeLambdaAsync(functionName, payload); + + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaAsync - successfully invoked Lambda function', + expect.objectContaining({ + functionName, + statusCode: 202, + }), + ); + }); + + it('should handle successful async invocation with various HTTP status codes', async () => { + // Arrange + const functionName = 'api-async-function'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode( + JSON.stringify({ + statusCode: 202, + body: JSON.stringify({ message: 'Accepted' }), + }), + ), + }); + + // Act + await invokeLambdaAsync(functionName, payload); + + // Assert + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaAsync - successfully invoked Lambda function', + expect.objectContaining({ + functionName, + statusCode: 202, + }), + ); + }); + + it('should include function name in async log messages', async () => { + // Arrange + const functionName = 'my-async-special-function'; + const payload = {}; + + mockSend.mockResolvedValueOnce({ + StatusCode: 202, + FunctionError: undefined, + Payload: new TextEncoder().encode(JSON.stringify({})), + }); + + // Act + await invokeLambdaAsync(functionName, payload); + + // Assert + expect(mockLoggerInfo).toHaveBeenCalledWith('[LambdaClient] > invokeLambdaAsync', { functionName }); + expect(mockLoggerDebug).toHaveBeenCalledWith( + '[LambdaClient] invokeLambdaAsync - InvokeCommand', + expect.any(Object), + ); + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[LambdaClient] < invokeLambdaAsync - successfully invoked Lambda function', + { + functionName, + statusCode: 202, + }, + ); + }); + }); +}); diff --git a/src/utils/lambda-client.ts b/src/utils/lambda-client.ts new file mode 100644 index 0000000..0ae55fa --- /dev/null +++ b/src/utils/lambda-client.ts @@ -0,0 +1,111 @@ +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; + +import { config } from './config'; +import { logger } from './logger'; + +/** + * AWS Lambda service client + */ +const _lambdaClient = new LambdaClient({ region: config.AWS_REGION }); + +/** + * Type representing a JSON payload for Lambda invocation + */ +export type JsonPayload = Record | Array; + +/** + * Invokes a Lambda function synchronously (RequestResponse) + * @param functionName - The name or ARN of the Lambda function to invoke + * @param payload - The JSON payload to pass to the Lambda function + * @returns The response payload from the Lambda function + * @throws Error if the Lambda invocation fails + */ +export const invokeLambdaSync = async (functionName: string, payload: JsonPayload): Promise => { + logger.info('[LambdaClient] > invokeLambdaSync', { functionName }); + + try { + const command = new InvokeCommand({ + FunctionName: functionName, + InvocationType: 'RequestResponse', + Payload: JSON.stringify(payload), + }); + + logger.debug('[LambdaClient] invokeLambdaSync - InvokeCommand', { command }); + + const response = await _lambdaClient.send(command); + + // Parse the response payload + const responsePayload = response.Payload ? JSON.parse(new TextDecoder().decode(response.Payload)) : null; + + logger.info('[LambdaClient] < invokeLambdaSync - successfully invoked Lambda function', { + functionName, + statusCode: response.StatusCode, + }); + + // Check for function errors + if (response.FunctionError) { + logger.error( + '[LambdaClient] < invokeLambdaSync - Lambda function returned an error', + new Error(response.FunctionError), + { + functionName, + FunctionError: response.FunctionError, + responsePayload, + }, + ); + throw new Error(`Lambda function error: ${response.FunctionError}`); + } + + return responsePayload as T; + } catch (error) { + logger.error('[LambdaClient] < invokeLambdaSync - failed to invoke Lambda function', error as Error, { + functionName, + }); + throw error; + } +}; + +/** + * Invokes a Lambda function asynchronously (Event) + * @param functionName - The name or ARN of the Lambda function to invoke + * @param payload - The JSON payload to pass to the Lambda function + * @throws Error if the Lambda invocation fails + */ +export const invokeLambdaAsync = async (functionName: string, payload: JsonPayload): Promise => { + logger.info('[LambdaClient] > invokeLambdaAsync', { functionName }); + + try { + const command = new InvokeCommand({ + FunctionName: functionName, + InvocationType: 'Event', + Payload: JSON.stringify(payload), + }); + + logger.debug('[LambdaClient] invokeLambdaAsync - InvokeCommand', { command }); + + const response = await _lambdaClient.send(command); + + // Check for function errors + if (response.FunctionError) { + logger.error( + '[LambdaClient] < invokeLambdaAsync - Lambda function returned an error', + new Error(response.FunctionError), + { + functionName, + response, + }, + ); + throw new Error(`Lambda function error: ${response.FunctionError}`); + } + + logger.info('[LambdaClient] < invokeLambdaAsync - successfully invoked Lambda function', { + functionName, + statusCode: response.StatusCode, + }); + } catch (error) { + logger.error('[LambdaClient] < invokeLambdaAsync - failed to invoke Lambda function', error as Error, { + functionName, + }); + throw error; + } +};