diff --git a/package-lock.json b/package-lock.json index b4c5f994c0..e63710ab3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,7 @@ "dependencies": { "@aws-sdk/client-dynamodb": "^3.665.0", "@aws-sdk/lib-dynamodb": "^3.665.0", - "@smithy/shared-ini-file-loader": "^3.1.8", - "aws-sdk-client-mock": "^4.0.2" + "@smithy/shared-ini-file-loader": "^3.1.8" }, "devDependencies": { "@babel/core": "^7.17.2", @@ -22,6 +21,7 @@ "@ksmithut/prettier-standard": "^0.0.10", "@types/hapi__hapi": "^20.0.10", "@types/jest": "^27.4.0", + "aws-sdk-client-mock": "^4.1.0", "aws-sdk-mock": "^6.2.0", "babel-jest": "^27.5.1", "clone-deep": "^4.0.1", @@ -7204,6 +7204,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", @@ -7215,6 +7216,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" @@ -7224,6 +7226,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -7233,6 +7236,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -7242,6 +7246,7 @@ "version": "0.7.3", "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, "license": "(Unlicense OR Apache-2.0)" }, "node_modules/@smithy/abort-controller": { @@ -8012,6 +8017,7 @@ "version": "17.0.3", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, "license": "MIT", "dependencies": { "@types/sinonjs__fake-timers": "*" @@ -8021,6 +8027,7 @@ "version": "8.1.5", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/stack-utils": { @@ -8650,9 +8657,10 @@ } }, "node_modules/aws-sdk-client-mock": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/aws-sdk-client-mock/-/aws-sdk-client-mock-4.0.2.tgz", - "integrity": "sha512-saFLXQPqHuMH0A1peNIGoAFEq9B0bpS5y5qrr+Y5F86MasVkCctggHKhHPRVjGr852Nz7cLg/PBxKs6lQoK3mg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/aws-sdk-client-mock/-/aws-sdk-client-mock-4.1.0.tgz", + "integrity": "sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==", + "dev": true, "license": "MIT", "dependencies": { "@types/sinon": "^17.0.3", @@ -15763,6 +15771,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, "license": "MIT" }, "node_modules/keyv": { @@ -16812,6 +16821,7 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true, "license": "MIT" }, "node_modules/lodash.ismatch": { @@ -17762,6 +17772,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", @@ -17775,6 +17786,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" @@ -17784,6 +17796,7 @@ "version": "13.0.2", "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.2.tgz", "integrity": "sha512-4Bb+oqXZTSTZ1q27Izly9lv8B9dlV61CROxPiVtywwzv5SnytJqhvYe6FclHYuXml4cd1VHPo1zd5PmTeJozvA==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1" @@ -19905,6 +19918,7 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=16" @@ -21914,6 +21928,7 @@ "version": "18.0.1", "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.1.tgz", "integrity": "sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", @@ -21932,6 +21947,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" @@ -21941,6 +21957,7 @@ "version": "11.2.2", "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.0" @@ -21950,6 +21967,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -21959,6 +21977,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -21968,6 +21987,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -23222,6 +23242,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, "engines": { "node": ">=4" } diff --git a/package.json b/package.json index 93e45d85e9..6e76a159f3 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@ksmithut/prettier-standard": "^0.0.10", "@types/hapi__hapi": "^20.0.10", "@types/jest": "^27.4.0", + "aws-sdk-client-mock": "^4.1.0", "aws-sdk-mock": "^6.2.0", "babel-jest": "^27.5.1", "clone-deep": "^4.0.1", @@ -111,7 +112,6 @@ "dependencies": { "@aws-sdk/client-dynamodb": "^3.665.0", "@aws-sdk/lib-dynamodb": "^3.665.0", - "@smithy/shared-ini-file-loader": "^3.1.8", - "aws-sdk-client-mock": "^4.0.2" + "@smithy/shared-ini-file-loader": "^3.1.8" } } diff --git a/packages/connectors-lib/src/__mocks__/aws-mock-helper.js b/packages/connectors-lib/src/__mocks__/aws-mock-helper.js index 3282def9ab..61118770c4 100644 --- a/packages/connectors-lib/src/__mocks__/aws-mock-helper.js +++ b/packages/connectors-lib/src/__mocks__/aws-mock-helper.js @@ -60,12 +60,6 @@ export const configureAwsSdkMock = (AwsSdk = jest.genMockFromModule('aws-sdk')) 'deleteMessage', 'deleteMessageBatch' ]) - configureMock(AwsSdk.DynamoDB, ['listTables', 'describeTable', 'getItem', 'putItem', 'query', 'scan'], {}) - configureMock( - AwsSdk.DynamoDB.DocumentClient, - ['get', 'put', 'update', 'query', 'scan', 'delete', 'createSet', 'batchGet', 'batchWrite'], - {} - ) configureMock(AwsSdk.S3, ['listObjectsV2', 'getObject', 'putObject', 'headObject', 'deleteObject', 'upload', 'listBuckets', 'headBucket']) configureMock(AwsSdk.SecretsManager, ['getSecretValue']) diff --git a/packages/connectors-lib/src/__tests__/aws.spec.js b/packages/connectors-lib/src/__tests__/aws.spec.js index 422cfe1c06..1c9e2ac53d 100644 --- a/packages/connectors-lib/src/__tests__/aws.spec.js +++ b/packages/connectors-lib/src/__tests__/aws.spec.js @@ -1,144 +1,114 @@ -import { DynamoDB } from '@aws-sdk/client-dynamodb' -import AWS from 'aws-sdk' -import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb' import Config from '../config' +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { createDocumentClient } from '../documentclient-decorator.js' +const TEST_ENDPOINT = 'http://localhost:8080' +const TEST_REGION = 'eu-west-2' -jest.mock('aws-sdk', () => { - const SQS = jest.fn().mockImplementation(config => ({ - config: { ...config, apiVersion: '2012-11-05', region: config.region || 'eu-west-2' } - })) - const S3 = jest.fn().mockImplementation(config => ({ - config: { ...config, apiVersion: '2006-03-01', region: config.region || 'eu-west-2', s3ForcePathStyle: config.s3ForcePathStyle } - })) - const SecretsManager = jest.fn().mockImplementation(config => ({ - config: { ...config, apiVersion: '2017-10-17', region: config.region || 'eu-west-2' } - })) - - return { SQS, S3, SecretsManager } -}) +jest.dontMock('aws-sdk') + +jest.mock('@aws-sdk/client-dynamodb', () => ({ + DynamoDBClient: jest.fn() +})) -jest.mock('@aws-sdk/client-dynamodb') -jest.mock('@aws-sdk/lib-dynamodb', () => ({ - DynamoDBDocument: { - from: jest.fn() - } +jest.mock('../documentclient-decorator.js', () => ({ + createDocumentClient: jest.fn() })) describe('AWS Connectors', () => { - let SQS, S3, SecretsManager + let mockDocClient beforeEach(() => { - DynamoDB.mockClear() - DynamoDBDocument.from.mockClear() + jest.resetAllMocks() - DynamoDBDocument.from.mockReturnValue({ + mockDocClient = { send: jest.fn(), queryAllPromise: jest.fn(), scanAllPromise: jest.fn(), batchWriteAllPromise: jest.fn(), createUpdateExpression: jest.fn() - }) + } - SQS = AWS.SQS - S3 = AWS.S3 - SecretsManager = AWS.SecretsManager + createDocumentClient.mockReturnValue(mockDocClient) + }) - SQS.mockClear() - S3.mockClear() - SecretsManager.mockClear() + it('checks that mockDocClient is initialised correctly for dynamodb operations', () => { + expect(mockDocClient).toBeDefined() + // expect(mockDocClient.send).toBeInstanceOf(Function) }) it('configures dynamodb with a custom endpoint if one is defined in configuration', () => { - const TEST_ENDPOINT = 'http://localhost:8080' + process.env.AWS_REGION = TEST_REGION + Config.aws.region = TEST_REGION Config.aws.dynamodb.endpoint = TEST_ENDPOINT - require('../aws.js').default() - expect(DynamoDB).toHaveBeenCalledWith( + + const awsClients = require('../aws.js').default() + + expect(DynamoDBClient).toHaveBeenCalledWith( expect.objectContaining({ + region: TEST_REGION, endpoint: TEST_ENDPOINT }) ) - expect(DynamoDBDocument.from).toHaveBeenCalledWith(expect.any(DynamoDB)) + expect(createDocumentClient).toHaveBeenCalledWith(expect.any(DynamoDBClient)) + expect(awsClients.docClient).toBe(mockDocClient) }) it('uses the default dynamodb endpoint if it is not overridden in configuration', () => { - process.env.AWS_REGION = 'eu-west-2' + const DEFAULT_REGION = 'eu-west-2' + + process.env.AWS_REGION = DEFAULT_REGION + Config.aws.region = DEFAULT_REGION delete Config.aws.dynamodb.endpoint - require('../aws.js').default() - expect(DynamoDB).toHaveBeenCalledWith( + + const awsClients = require('../aws.js').default() + + expect(DynamoDBClient).toHaveBeenCalledWith( expect.objectContaining({ - region: 'eu-west-2' + region: DEFAULT_REGION }) ) - expect(DynamoDBDocument.from).toHaveBeenCalledWith(expect.any(DynamoDB)) + expect(createDocumentClient).toHaveBeenCalledWith(expect.any(DynamoDBClient)) + expect(awsClients.docClient).toBe(mockDocClient) }) - it('configures sqs with a custom endpoint if one is defined in configuration', () => { - const TEST_ENDPOINT = 'http://localhost:8080' + it('configures sqs with a custom endpoint if one is defined in configuration', async () => { Config.aws.sqs.endpoint = TEST_ENDPOINT - require('../aws.js').default() - expect(SQS).toHaveBeenCalledWith( - expect.objectContaining({ - apiVersion: '2012-11-05', - endpoint: TEST_ENDPOINT - }) - ) + const { sqs } = require('../aws.js').default() + expect(sqs.config.endpoint).toEqual(TEST_ENDPOINT) }) - it('uses the default sqs endpoint if it is not overridden in configuration', () => { + it('uses the default sqs endpoint if it is not overridden in configuration', async () => { process.env.AWS_REGION = 'eu-west-2' delete Config.aws.sqs.endpoint - require('../aws.js').default() - expect(SQS).toHaveBeenCalledWith( - expect.objectContaining({ - apiVersion: '2012-11-05' - }) - ) + const { sqs } = require('../aws.js').default() + expect(sqs.config.endpoint).toEqual('sqs.eu-west-2.amazonaws.com') }) - it('configures s3 with a custom endpoint if one is defined in configuration', () => { - const TEST_ENDPOINT = 'http://localhost:8080' + it('configures s3 with a custom endpoint if one is defined in configuration', async () => { Config.aws.s3.endpoint = TEST_ENDPOINT - require('../aws.js').default() - expect(S3).toHaveBeenCalledWith( - expect.objectContaining({ - apiVersion: '2006-03-01', - endpoint: TEST_ENDPOINT, - s3ForcePathStyle: true - }) - ) + const { s3 } = require('../aws.js').default() + expect(s3.config.endpoint).toEqual(TEST_ENDPOINT) + expect(s3.config.s3ForcePathStyle).toBeTruthy() }) - it('uses default s3 settings if a custom endpoint is not defined', () => { + it('uses default s3 settings if a custom endpoint is not defined', async () => { process.env.AWS_REGION = 'eu-west-2' delete Config.aws.s3.endpoint - require('../aws.js').default() - expect(S3).toHaveBeenCalledWith( - expect.objectContaining({ - apiVersion: '2006-03-01' - }) - ) + const { s3 } = require('../aws.js').default() + expect(s3.config.endpoint).toEqual('s3.eu-west-2.amazonaws.com') + expect(s3.config.s3ForcePathStyle).toBeFalsy() }) - it('configures secretsmanager with a custom endpoint if one is defined in configuration', () => { - const TEST_ENDPOINT = 'http://localhost:8080' + it('configures secretsmanager with a custom endpoint if one is defined in configuration', async () => { Config.aws.secretsManager.endpoint = TEST_ENDPOINT - require('../aws.js').default() - expect(SecretsManager).toHaveBeenCalledWith( - expect.objectContaining({ - apiVersion: '2017-10-17', - endpoint: TEST_ENDPOINT - }) - ) + const { secretsManager } = require('../aws.js').default() + expect(secretsManager.config.endpoint).toEqual(TEST_ENDPOINT) }) - it('uses default secretsmanager settings if a custom endpoint is not defined', () => { + it('uses default secretsmanager settings if a custom endpoint is not defined', async () => { process.env.AWS_REGION = 'eu-west-2' delete Config.aws.secretsManager.endpoint - require('../aws.js').default() - expect(SecretsManager).toHaveBeenCalledWith( - expect.objectContaining({ - apiVersion: '2017-10-17' - }) - ) + const { secretsManager } = require('../aws.js').default() + expect(secretsManager.config.endpoint).toEqual('secretsmanager.eu-west-2.amazonaws.com') }) }) diff --git a/packages/connectors-lib/src/__tests__/document-client-decorator.spec.js b/packages/connectors-lib/src/__tests__/document-client-decorator.spec.js index 34f62c6af4..3cf116e2cc 100644 --- a/packages/connectors-lib/src/__tests__/document-client-decorator.spec.js +++ b/packages/connectors-lib/src/__tests__/document-client-decorator.spec.js @@ -1,44 +1,67 @@ import { DynamoDBDocumentClient, QueryCommand, ScanCommand, BatchWriteCommand } from '@aws-sdk/lib-dynamodb' import { mockClient } from 'aws-sdk-client-mock' -import AWS from '../aws.js' -const { docClient } = AWS() +import { createDocumentClient } from '../documentclient-decorator' describe('document client decorations', () => { const ddbMock = mockClient(DynamoDBDocumentClient) + let docClient + beforeEach(() => { ddbMock.reset() + docClient = createDocumentClient() + }) + + afterEach(() => { + jest.restoreAllMocks() }) it('deals with pagination where DynamoDB returns a LastEvaluatedKey in a query response', async () => { const testLastEvaluatedKey = { id: '16324258-85-92746491' } - // mock QueryCommand to return items with a LastEvaluatedKey - ddbMock.on(QueryCommand).resolvesOnce({ Items: [], LastEvaluatedKey: testLastEvaluatedKey }).resolvesOnce({ Items: [] }) + ddbMock + .on(QueryCommand) + .resolvesOnce({ + Items: [{ id: 'item1' }], + LastEvaluatedKey: testLastEvaluatedKey + }) + .resolvesOnce({ + Items: [{ id: 'item2' }] + }) - await docClient.queryAllPromise({ TableName: 'TEST' }) + const items = await docClient.queryAllPromise({ TableName: 'TEST' }) - // check QueryCommand was called twice & with the correct parameters - expect(ddbMock.send.callCount).toBe(2) - expect(ddbMock.send.firstCall.args[0].input.TableName).toEqual('TEST') - expect(ddbMock.send.secondCall.args[0].input.ExclusiveStartKey).toEqual(testLastEvaluatedKey) + expect(ddbMock.calls()).toHaveLength(2) + const firstCall = ddbMock.call(0).args[0].input + expect(firstCall.TableName).toEqual('TEST') + const secondCall = ddbMock.call(1).args[0].input + expect(secondCall.ExclusiveStartKey).toEqual(testLastEvaluatedKey) + expect(items).toEqual([{ id: 'item1' }, { id: 'item2' }]) }) it('deals with pagination where DynamoDB returns a LastEvaluatedKey in a scan response', async () => { const testLastEvaluatedKey = { id: '16324258-85-92746491' } - // mock ScanCommand to return items with a LastEvaluatedKey - ddbMock.on(ScanCommand).resolvesOnce({ Items: [], LastEvaluatedKey: testLastEvaluatedKey }).resolvesOnce({ Items: [] }) + ddbMock + .on(ScanCommand) + .resolvesOnce({ + Items: [{ id: 'item1' }], + LastEvaluatedKey: testLastEvaluatedKey + }) + .resolvesOnce({ + Items: [{ id: 'item2' }] + }) - await docClient.scanAllPromise({ TableName: 'TEST' }) + const items = await docClient.scanAllPromise({ TableName: 'TEST' }) - // check ScanCommand was called twicce & with the correct parameters - expect(ddbMock.send.callCount).toBe(2) - expect(ddbMock.send.firstCall.args[0].input.TableName).toEqual('TEST') - expect(ddbMock.send.secondCall.args[0].input.ExclusiveStartKey).toEqual(testLastEvaluatedKey) + expect(ddbMock.calls()).toHaveLength(2) + const firstCall = ddbMock.call(0).args[0].input + expect(firstCall.TableName).toEqual('TEST') + const secondCall = ddbMock.call(1).args[0].input + expect(secondCall.ExclusiveStartKey).toEqual(testLastEvaluatedKey) + expect(items).toEqual([{ id: 'item1' }, { id: 'item2' }]) }) it('deals with UnprocessedItems when making batchWrite requests to DynamoDB', async () => { - // mock BatchWriteCommand to return UnprocessedItems ddbMock .on(BatchWriteCommand) .resolvesOnce({ @@ -49,8 +72,11 @@ describe('document client decorations', () => { ] } }) - .resolvesOnce({ UnprocessedItems: null }) - await docClient.batchWriteAllPromise({ + .resolvesOnce({ + UnprocessedItems: {} + }) + + const request = { RequestItems: { NameOfTableToUpdate: [ { PutRequest: { Item: { key: '1', field: 'data1' } } }, @@ -58,12 +84,14 @@ describe('document client decorations', () => { { PutRequest: { Item: { key: '3', field: 'data3' } } } ] } - }) + } - // check BatchWriteCommand was called twice & with the correct parameters - expect(ddbMock.send.callCount).toBe(2) - expect(ddbMock.send.firstCall.args[0].input.RequestItems.NameOfTableToUpdate).toHaveLength(3) - expect(ddbMock.send.secondCall.args[0].input.RequestItems.NameOfTableToUpdate).toHaveLength(2) + await docClient.batchWriteAllPromise(request) + expect(ddbMock.calls()).toHaveLength(2) + const firstCall = ddbMock.call(0).args[0].input + expect(firstCall.RequestItems.NameOfTableToUpdate).toHaveLength(3) + const secondCall = ddbMock.call(1).args[0].input + expect(secondCall.RequestItems.NameOfTableToUpdate).toHaveLength(2) }) it('deals with UnprocessedItems when making batchWrite requests to DynamoDB up to the given retry limit', async () => { @@ -91,6 +119,7 @@ describe('document client decorations', () => { await expect(docClient.batchWriteAllPromise(request)).rejects.toThrow( 'Failed to write items to DynamoDB using batch write. UnprocessedItems were returned and maxRetries has been reached.' ) + expect(ddbMock.calls()).toHaveLength(11) }) it('provides a convenience method to simplify building an update expression for DynamoDB', async () => { diff --git a/packages/connectors-lib/src/aws.js b/packages/connectors-lib/src/aws.js index a3c7dd6019..466cee0112 100644 --- a/packages/connectors-lib/src/aws.js +++ b/packages/connectors-lib/src/aws.js @@ -1,27 +1,23 @@ import Config from './config.js' -import { DynamoDB } from '@aws-sdk/client-dynamodb' -import { createDocumentClient } from './documentclient-decorator.js' import AWS from 'aws-sdk' +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { createDocumentClient } from './documentclient-decorator.js' const { SQS, S3, SecretsManager } = AWS export default function () { - const dynamoDBInstance = new DynamoDB({ - apiVersion: '2012-08-10', + const dynamoDBInstance = new DynamoDBClient({ + region: Config.aws.region, ...(Config.aws.dynamodb.endpoint && { endpoint: Config.aws.dynamodb.endpoint }) }) + const docClient = createDocumentClient(dynamoDBInstance) + return { ddb: dynamoDBInstance, - docClient: createDocumentClient({ - convertEmptyValues: true, - apiVersion: '2012-08-10', - ...(Config.aws.dynamodb.endpoint && { - endpoint: Config.aws.dynamodb.endpoint - }) - }), + docClient, sqs: new SQS({ apiVersion: '2012-11-05', ...(Config.aws.sqs.endpoint && { diff --git a/packages/connectors-lib/src/documentclient-decorator.js b/packages/connectors-lib/src/documentclient-decorator.js index 3e0a845203..a2e59eb61a 100644 --- a/packages/connectors-lib/src/documentclient-decorator.js +++ b/packages/connectors-lib/src/documentclient-decorator.js @@ -1,14 +1,16 @@ import db from 'debug' -import { DynamoDB } from '@aws-sdk/client-dynamodb' -import { DynamoDBDocument, BatchWriteCommand, QueryCommand, ScanCommand } from '@aws-sdk/lib-dynamodb' +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient, BatchWriteCommand, QueryCommand, ScanCommand } from '@aws-sdk/lib-dynamodb' const debug = db('connectors:aws') -export const createDocumentClient = (options = {}) => { - const client = new DynamoDB({ - ...options, - region: options.region || process.env.AWS_REGION || 'eu-west-2' - }) - const docClient = DynamoDBDocument.from(client) +export const createDocumentClient = (dynamoDBInstance, options = {}) => { + const client = + dynamoDBInstance || + new DynamoDBClient({ + ...options, + region: options.region || process.env.AWS_REGION || 'eu-west-2' + }) + const docClient = DynamoDBDocumentClient.from(client) // Support for large query/scan operations which return results in pages const wrapPagedDocumentClientOperation = operationName => { @@ -33,9 +35,9 @@ export const createDocumentClient = (options = {}) => { docClient.scanAllPromise = wrapPagedDocumentClientOperation('scan') /** - * Handles batch writes which may return UnprocessedItems. If UnprocessedItems are returned then they will be retried with exponential backoff + * Handles batch writes which may return UnprocessedItems. If UnprocessedItems are returned then they will be retried with exponential backoff * - * @param {DocumentClient.BatchWriteItemInput} params as per DynamoDB.DocumentClient.batchWrite + * @param {BatchWriteCommand} params as per BatchWriteCommand * @returns {Promise} */ docClient.batchWriteAllPromise = async params => { diff --git a/packages/pocl-job/src/io/__tests__/db.spec.js b/packages/pocl-job/src/io/__tests__/db.spec.js index bad2e378d0..402eec99b0 100644 --- a/packages/pocl-job/src/io/__tests__/db.spec.js +++ b/packages/pocl-job/src/io/__tests__/db.spec.js @@ -1,39 +1,5 @@ import * as db from '../db.js' -import { DynamoDBDocument, GetCommand, UpdateCommand, BatchWriteCommand, QueryCommand, ScanCommand } from '@aws-sdk/lib-dynamodb' - -jest.mock('@aws-sdk/lib-dynamodb', () => { - const actualLib = jest.requireActual('@aws-sdk/lib-dynamodb') - - return { - DynamoDBDocument: { - from: jest.fn().mockReturnValue({ - send: jest.fn(command => { - if (command instanceof actualLib.GetCommand) { - return Promise.resolve({ Item: { id: 'testfile.xml' } }) - } - - if (command instanceof actualLib.UpdateCommand) { - return Promise.resolve({ Attributes: { id: 'testfile.xml', param1: 'test1', param2: 'test2' } }) - } - - if (command instanceof actualLib.BatchWriteCommand) { - return Promise.resolve({ UnprocessedItems: {} }) - } - - if (command instanceof actualLib.QueryCommand || command instanceof actualLib.ScanCommand) { - return Promise.resolve({ Items: [] }) - } - return Promise.resolve({}) - }) - }) - }, - GetCommand: actualLib.GetCommand, - UpdateCommand: actualLib.UpdateCommand, - BatchWriteCommand: actualLib.BatchWriteCommand, - QueryCommand: actualLib.QueryCommand, - ScanCommand: actualLib.ScanCommand - } -}) +import { docClient } from '../../../../connectors-lib/src/aws.js' jest.mock('../../config.js', () => ({ db: { @@ -43,6 +9,30 @@ jest.mock('../../config.js', () => ({ } })) +jest.mock('../../../../connectors-lib/src/aws.js', () => ({ + docClient: { + send: jest.fn(), + scanAllPromise: jest.fn(), + queryAllPromise: jest.fn(), + batchWriteAllPromise: jest.fn(), + createUpdateExpression: jest.fn() + } +})) + +jest.mock('@aws-sdk/lib-dynamodb', () => { + const originalModule = jest.requireActual('@aws-sdk/lib-dynamodb') + return { + ...originalModule, + GetCommand: jest.fn(), + UpdateCommand: jest.fn(), + ScanCommand: jest.fn(), + QueryCommand: jest.fn(), + BatchWriteCommand: jest.fn() + } +}) + +const { GetCommand: MockGetCommand, UpdateCommand: MockUpdateCommand } = require('@aws-sdk/lib-dynamodb') + describe('database operations', () => { const TEST_FILENAME = 'testfile.xml' @@ -51,141 +41,160 @@ describe('database operations', () => { }) describe('getFileRecord', () => { - it('calls a get operation on dynamodb', async () => { - await db.getFileRecord(TEST_FILENAME) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith(expect.any(GetCommand)) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - TableName: 'TestFileTable', - Key: { filename: TEST_FILENAME }, - ConsistentRead: true - } - }) - ) + it('calls GetCommand on dynamodb', async () => { + const mockItem = { id: 'testfile.xml' } + docClient.send.mockResolvedValueOnce({ Item: mockItem }) + + const result = await db.getFileRecord(TEST_FILENAME) + + expect(MockGetCommand).toHaveBeenCalledWith({ + TableName: 'TestFileTable', + Key: { filename: TEST_FILENAME }, + ConsistentRead: true + }) + expect(docClient.send).toHaveBeenCalledWith(expect.any(MockGetCommand)) + expect(result).toEqual(mockItem) }) }) describe('getFileRecords', () => { it('retrieves all records for the given file if no stages are provided', async () => { - await db.getFileRecords() - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith(expect.any(ScanCommand)) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - TableName: 'TestFileTable', - ConsistentRead: true, - ExpressionAttributeValues: {} - } - }) - ) + const mockItems = [] + docClient.scanAllPromise.mockResolvedValueOnce(mockItems) + + const result = await db.getFileRecords() + + expect(docClient.scanAllPromise).toHaveBeenCalledWith({ + TableName: 'TestFileTable', + ConsistentRead: true, + ExpressionAttributeValues: {} + }) + expect(result).toEqual(mockItems) }) it('retrieves all records for a given set of stages', async () => { - await db.getFileRecords('STAGE 1', 'STAGE 2') - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith(expect.any(ScanCommand)) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - TableName: 'TestFileTable', - FilterExpression: 'stage IN (:stage0,:stage1)', - ExpressionAttributeValues: { ':stage0': 'STAGE 1', ':stage1': 'STAGE 2' }, - ConsistentRead: true - } - }) - ) + const mockItems = [] + docClient.scanAllPromise.mockResolvedValueOnce(mockItems) + + const result = await db.getFileRecords('STAGE 1', 'STAGE 2') + + expect(docClient.scanAllPromise).toHaveBeenCalledWith({ + TableName: 'TestFileTable', + FilterExpression: 'stage IN (:stage0,:stage1)', + ExpressionAttributeValues: { ':stage0': 'STAGE 1', ':stage1': 'STAGE 2' }, + ConsistentRead: true + }) + expect(result).toEqual(mockItems) }) }) describe('updateFileStagingTable', () => { - it('calls update on dynamodb including all necessary parameters', async () => { - await db.updateFileStagingTable({ filename: TEST_FILENAME, param1: 'test1', param2: 'test2' }) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith(expect.any(UpdateCommand)) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - TableName: 'TestFileTable', - Key: { filename: TEST_FILENAME }, - UpdateExpression: 'SET #expires = :expires,#param1 = :param1,#param2 = :param2', - ExpressionAttributeNames: { - '#expires': 'expires', - '#param1': 'param1', - '#param2': 'param2' - }, - ExpressionAttributeValues: { - ':expires': expect.any(Number), - ':param1': 'test1', - ':param2': 'test2' - } - } - }) - ) + it('calls UpdateCommand on dynamodb', async () => { + const entries = { param1: 'test1', param2: 'test2' } + const mockAttributes = { id: 'testfile.xml', param1: 'test1', param2: 'test2' } + const mockUpdateExpression = { + UpdateExpression: 'SET #expires = :expires,#param1 = :param1,#param2 = :param2', + ExpressionAttributeNames: { + '#expires': 'expires', + '#param1': 'param1', + '#param2': 'param2' + }, + ExpressionAttributeValues: { + ':expires': 1234567890, + ':param1': 'test1', + ':param2': 'test2' + } + } + docClient.createUpdateExpression.mockReturnValue(mockUpdateExpression) + docClient.send.mockResolvedValueOnce({ Attributes: mockAttributes }) + + await db.updateFileStagingTable({ filename: TEST_FILENAME, ...entries }) + + expect(docClient.createUpdateExpression).toHaveBeenCalledWith({ + expires: expect.any(Number), + ...entries + }) + expect(MockUpdateCommand).toHaveBeenCalledWith({ + TableName: 'TestFileTable', + Key: { filename: TEST_FILENAME }, + UpdateExpression: 'SET #expires = :expires,#param1 = :param1,#param2 = :param2', + ExpressionAttributeNames: { + '#expires': 'expires', + '#param1': 'param1', + '#param2': 'param2' + }, + ExpressionAttributeValues: { + ':expires': expect.any(Number), + ':param1': 'test1', + ':param2': 'test2' + } + }) + expect(docClient.send).toHaveBeenCalledWith(expect.any(MockUpdateCommand)) }) }) describe('updateRecordStagingTable', () => { - it('calls batchWrite on dynamodb including all necessary parameters', async () => { + it('calls batchWriteAllPromise on dynamodb', async () => { const records = [{ id: 'test1' }, { id: 'test2' }] + docClient.batchWriteAllPromise.mockResolvedValueOnce({ UnprocessedItems: {} }) + await db.updateRecordStagingTable(TEST_FILENAME, records) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith(expect.any(BatchWriteCommand)) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - RequestItems: { - TestRecordTable: [ - { - PutRequest: { - Item: { filename: TEST_FILENAME, id: 'test1', expires: expect.any(Number) } - } - }, - { - PutRequest: { - Item: { filename: TEST_FILENAME, id: 'test2', expires: expect.any(Number) } - } - } - ] + + expect(docClient.batchWriteAllPromise).toHaveBeenCalledWith({ + RequestItems: { + TestRecordTable: [ + { + PutRequest: { + Item: { filename: TEST_FILENAME, id: 'test1', expires: expect.any(Number) } + } + }, + { + PutRequest: { + Item: { filename: TEST_FILENAME, id: 'test2', expires: expect.any(Number) } + } } - } - }) - ) + ] + } + }) }) - it('is a no-op if records are empty', async () => { + it('is a no-op if records is empty', async () => { await db.updateRecordStagingTable(TEST_FILENAME, []) - expect(DynamoDBDocument.from().send).not.toHaveBeenCalled() + + expect(docClient.batchWriteAllPromise).not.toHaveBeenCalled() }) }) describe('getProcessedRecords', () => { it('retrieves all records for the given file if no stages are provided', async () => { - await db.getProcessedRecords(TEST_FILENAME) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith(expect.any(QueryCommand)) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - TableName: 'TestRecordTable', - KeyConditionExpression: 'filename = :filename', - ExpressionAttributeValues: { ':filename': TEST_FILENAME }, - ConsistentRead: true - } - }) - ) + const mockItems = [] + docClient.queryAllPromise.mockResolvedValueOnce(mockItems) + + const result = await db.getProcessedRecords(TEST_FILENAME) + + expect(docClient.queryAllPromise).toHaveBeenCalledWith({ + TableName: 'TestRecordTable', + KeyConditionExpression: 'filename = :filename', + ExpressionAttributeValues: { ':filename': TEST_FILENAME }, + ConsistentRead: true + }) + expect(result).toEqual(mockItems) }) it('retrieves all records for a given set of stages', async () => { - await db.getProcessedRecords(TEST_FILENAME, 'STAGE 1', 'STAGE 2') - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith(expect.any(QueryCommand)) - expect(DynamoDBDocument.from().send).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - TableName: 'TestRecordTable', - KeyConditionExpression: 'filename = :filename', - FilterExpression: 'stage IN (:stage0,:stage1)', - ExpressionAttributeValues: { ':filename': TEST_FILENAME, ':stage0': 'STAGE 1', ':stage1': 'STAGE 2' }, - ConsistentRead: true - } - }) - ) + const mockItems = [] + docClient.queryAllPromise.mockResolvedValueOnce(mockItems) + + const result = await db.getProcessedRecords(TEST_FILENAME, 'STAGE 1', 'STAGE 2') + + expect(docClient.queryAllPromise).toHaveBeenCalledWith({ + TableName: 'TestRecordTable', + KeyConditionExpression: 'filename = :filename', + FilterExpression: 'stage IN (:stage0,:stage1)', + ExpressionAttributeValues: { ':filename': TEST_FILENAME, ':stage0': 'STAGE 1', ':stage1': 'STAGE 2' }, + ConsistentRead: true + }) + expect(result).toEqual(mockItems) }) }) }) diff --git a/packages/pocl-job/src/io/db.js b/packages/pocl-job/src/io/db.js index 41ec143388..1c3280bbab 100644 --- a/packages/pocl-job/src/io/db.js +++ b/packages/pocl-job/src/io/db.js @@ -1,7 +1,6 @@ -import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb' import config from '../config.js' -import { AWS } from '@defra-fish/connectors-lib' -const { docClient } = AWS() +import { docClient } from '../../../connectors-lib/src/aws.js' +import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb' /** * Update the POCL file staging table to add or update the entry for the provided filename @@ -11,11 +10,15 @@ const { docClient } = AWS() * @returns {Promise} */ export const updateFileStagingTable = async ({ filename, ...entries }) => { + const updateExpression = docClient.createUpdateExpression({ + expires: Math.floor(Date.now() / 1000) + config.db.stagingTtlDelta, + ...entries + }) await docClient.send( new UpdateCommand({ TableName: config.db.fileStagingTable, Key: { filename }, - ...docClient.createUpdateExpression({ expires: Math.floor(Date.now() / 1000) + config.db.stagingTtlDelta, ...entries }) + ...updateExpression }) ) } @@ -30,7 +33,7 @@ export const getFileRecords = async (...stages) => { const stageValues = stages.reduce((acc, s, i) => ({ ...acc, [`:stage${i}`]: s }), {}) return docClient.scanAllPromise({ TableName: config.db.fileStagingTable, - ...(stages.length && { FilterExpression: `stage IN (${Object.keys(stageValues)})` }), + ...(stages.length && { FilterExpression: `stage IN (${Object.keys(stageValues).join(',')})` }), ExpressionAttributeValues: stageValues, ConsistentRead: true }) @@ -85,6 +88,7 @@ export const getProcessedRecords = async (filename, ...stages) => { return docClient.queryAllPromise({ TableName: config.db.recordStagingTable, KeyConditionExpression: 'filename = :filename', + // ...(stages.length && { FilterExpression: `stage IN (${Object.keys(stageValues).join(',')})` }), ...(stages.length && { FilterExpression: `stage IN (${Object.keys(stageValues)})` }), ExpressionAttributeValues: { ':filename': filename, ...stageValues }, ConsistentRead: true diff --git a/packages/sales-api-service/src/server/plugins/__tests__/health.spec.js b/packages/sales-api-service/src/server/plugins/__tests__/health.spec.js index d0ccbf90f2..f040c6e7b7 100644 --- a/packages/sales-api-service/src/server/plugins/__tests__/health.spec.js +++ b/packages/sales-api-service/src/server/plugins/__tests__/health.spec.js @@ -1,26 +1,21 @@ import initialiseServer from '../../server.js' import { dynamicsClient } from '@defra-fish/dynamics-lib' -import AwsMock from 'aws-sdk' -import { DynamoDBClient, ListTablesCommand } from '@aws-sdk/client-dynamodb' -import { mockClient } from 'aws-sdk-client-mock' +import { docClient, sqs } from '../../../../../connectors-lib/src/aws.js' -let server = null +jest.mock('../../../../../connectors-lib/src/aws.js', () => ({ + docClient: { + send: jest.fn() + }, + sqs: { + listQueues: jest.fn() + } +})) -const dynamoDbMock = mockClient(DynamoDBClient) +let server = null describe('hapi healthcheck', () => { beforeAll(async () => { server = await initialiseServer({ port: null }) - - AwsMock.SQS.__init({ - expectedResponses: { - listQueues: { QueueUrls: ['TestQueue'] } - } - }) - - dynamoDbMock.on(ListTablesCommand).resolves({ - TableNames: ['TestTable'] - }) }) beforeEach(() => { @@ -40,6 +35,12 @@ describe('hapi healthcheck', () => { }) it('exposes a service status endpoint providing additional detailed information', async () => { + sqs.listQueues.mockReturnValue({ + promise: jest.fn().mockResolvedValue({ QueueUrls: ['TestQueue'] }) + }) + + docClient.send.mockResolvedValueOnce({ TableNames: ['TestTable'] }) + const result = await server.inject({ method: 'GET', url: '/service-status?v&h' diff --git a/packages/sales-api-service/src/server/plugins/health.js b/packages/sales-api-service/src/server/plugins/health.js index 6feee8aa16..8c0753cadb 100644 --- a/packages/sales-api-service/src/server/plugins/health.js +++ b/packages/sales-api-service/src/server/plugins/health.js @@ -1,8 +1,8 @@ import HapiAndHealthy from 'hapi-and-healthy' import { dynamicsClient } from '@defra-fish/dynamics-lib' import Project from '../../project.cjs' -import { AWS } from '@defra-fish/connectors-lib' -const { ddb, sqs } = AWS() +import { docClient, sqs } from '../../../../connectors-lib/src/aws.js' +import { ListTablesCommand } from '@aws-sdk/client-dynamodb' export default { plugin: HapiAndHealthy, @@ -22,10 +22,20 @@ export default { return { connection: 'dynamics', status: 'ok', ...(await dynamicsClient.executeUnboundFunction('RetrieveVersion')) } }, async () => { - return { connection: 'dynamodb', status: 'ok', ...(await ddb.listTables()) } + try { + const tables = await docClient.send(new ListTablesCommand({})) + return { connection: 'dynamodb', status: 'ok', TableNames: tables.TableNames } + } catch (error) { + return { connection: 'dynamodb', status: 'error', message: error.message } + } }, async () => { - return { connection: 'sqs', status: 'ok', ...(await sqs.listQueues().promise()) } + try { + const queues = await sqs.listQueues().promise() + return { connection: 'sqs', status: 'ok', QueueUrls: queues.QueueUrls } + } catch (error) { + return { connection: 'sqs', status: 'error', message: error.message } + } } ] } diff --git a/packages/sales-api-service/src/services/paymentjournals/__tests__/payment-journals.service.spec.js b/packages/sales-api-service/src/services/paymentjournals/__tests__/payment-journals.service.spec.js index cbba6b1bc8..c099203892 100644 --- a/packages/sales-api-service/src/services/paymentjournals/__tests__/payment-journals.service.spec.js +++ b/packages/sales-api-service/src/services/paymentjournals/__tests__/payment-journals.service.spec.js @@ -1,15 +1,11 @@ import { PAYMENTS_TABLE } from '../../../config.js' import { createPaymentJournal, updatePaymentJournal, getPaymentJournal, queryJournalsByTimestamp } from '../payment-journals.service.js' -import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb' +import { docClient } from '../../../../../connectors-lib/src/aws.js' +import { PutCommand, UpdateCommand, GetCommand, QueryCommand } from '@aws-sdk/lib-dynamodb' -jest.mock('@aws-sdk/lib-dynamodb', () => ({ - DynamoDBDocument: { - from: jest.fn().mockReturnValue({ - put: jest.fn().mockResolvedValue({}), - update: jest.fn().mockResolvedValue({ Attributes: { some: 'data' } }), - get: jest.fn().mockResolvedValue({ Item: { id: 'test-id', some: 'data' } }), - query: jest.fn().mockResolvedValue({ Items: [] }) - }) +jest.mock('../../../../../connectors-lib/src/aws.js', () => ({ + docClient: { + send: jest.fn() } })) @@ -18,21 +14,29 @@ describe('payment-journals service', () => { PAYMENTS_TABLE.TableName = 'TestTable' }) + beforeEach(() => { + jest.clearAllMocks() + }) + describe('createPaymentJournal', () => { - it('calls put on dynamodb', async () => { - await createPaymentJournal('test-id', { some: 'data' }) - expect(DynamoDBDocument.from().put).toHaveBeenCalledWith({ + it('calls put (PutCommand) on dynamodb', async () => { + const expectedInput = { TableName: PAYMENTS_TABLE.TableName, Item: { id: 'test-id', some: 'data', expires: expect.any(Number) }, ConditionExpression: 'attribute_not_exists(id)' - }) + } + const mockSend = docClient.send + mockSend.mockResolvedValueOnce({}) + await createPaymentJournal('test-id', { some: 'data' }) + expect(mockSend).toHaveBeenCalledWith(expect.any(PutCommand)) + const calledCommand = mockSend.mock.calls[0][0] + expect(calledCommand.input).toEqual(expectedInput) }) }) describe('updatePaymentJournal', () => { - it('calls update on dynamodb', async () => { - await updatePaymentJournal('test-id', { some: 'data' }) - expect(DynamoDBDocument.from().update).toHaveBeenCalledWith({ + it('calls update (UpdateCommand) on dynamodb', async () => { + const expectedInput = { TableName: PAYMENTS_TABLE.TableName, Key: { id: 'test-id' }, UpdateExpression: 'SET #expires = :expires, #some = :some', @@ -46,34 +50,51 @@ describe('payment-journals service', () => { }, ConditionExpression: 'attribute_exists(id)', ReturnValues: 'ALL_NEW' - }) + } + const mockSend = docClient.send + mockSend.mockResolvedValueOnce({ Attributes: { id: 'test-id', some: 'data', expires: 1234567890 } }) + await updatePaymentJournal('test-id', { some: 'data' }) + expect(mockSend).toHaveBeenCalledWith(expect.any(UpdateCommand)) + const calledCommand = mockSend.mock.calls[0][0] + expect(calledCommand.input).toEqual(expectedInput) }) }) describe('getPaymentJournal', () => { - it('calls get on dynamodb', async () => { - await getPaymentJournal('test-id') - expect(DynamoDBDocument.from().get).toHaveBeenCalledWith({ + it('calls get (GetCommand) on dynamodb', async () => { + const expectedInput = { TableName: PAYMENTS_TABLE.TableName, Key: { id: 'test-id' }, ConsistentRead: true - }) + } + const mockSend = docClient.send + mockSend.mockResolvedValueOnce({ Item: { id: 'test-id', some: 'data', expires: 1234567890 } }) + await getPaymentJournal('test-id') + expect(mockSend).toHaveBeenCalledWith(expect.any(GetCommand)) + const calledCommand = mockSend.mock.calls[0][0] + expect(calledCommand.input).toEqual(expectedInput) }) }) describe('queryJournalsByTimestamp', () => { - it('calls query on dynamodb', async () => { - await queryJournalsByTimestamp({ paymentStatus: 'In Progress', from: '2020-05-29T11:44:45.875Z', to: '2020-05-29T11:44:45.875Z' }) - expect(DynamoDBDocument.from().query).toHaveBeenCalledWith({ + it('calls query (QueryCommand) on dynamodb', async () => { + const params = { paymentStatus: 'In Progress', from: '2020-05-29T11:44:45.875Z', to: '2020-05-29T11:44:45.875Z' } + const expectedInput = { TableName: PAYMENTS_TABLE.TableName, IndexName: 'PaymentJournalsByStatusAndTimestamp', KeyConditionExpression: 'paymentStatus = :paymentStatus AND paymentTimestamp BETWEEN :from AND :to', ExpressionAttributeValues: { - ':from': '2020-05-29T11:44:45.875Z', ':paymentStatus': 'In Progress', + ':from': '2020-05-29T11:44:45.875Z', ':to': '2020-05-29T11:44:45.875Z' } - }) + } + const mockSend = docClient.send + mockSend.mockResolvedValueOnce({ Items: [{ id: 'test-id', some: 'data', expires: 1234567890 }] }) + await queryJournalsByTimestamp(params) + expect(mockSend).toHaveBeenCalledWith(expect.any(QueryCommand)) + const calledCommand = mockSend.mock.calls[0][0] + expect(calledCommand.input).toEqual(expectedInput) }) }) }) diff --git a/packages/sales-api-service/src/services/paymentjournals/payment-journals.service.js b/packages/sales-api-service/src/services/paymentjournals/payment-journals.service.js index fa4f22027f..770f78cd60 100644 --- a/packages/sales-api-service/src/services/paymentjournals/payment-journals.service.js +++ b/packages/sales-api-service/src/services/paymentjournals/payment-journals.service.js @@ -1,7 +1,8 @@ -import { AWS } from '@defra-fish/connectors-lib' +import { PutCommand, UpdateCommand, GetCommand, QueryCommand } from '@aws-sdk/lib-dynamodb' import { PAYMENTS_TABLE } from '../../config.js' import db from 'debug' -const { docClient } = AWS() +import { docClient } from '../../../../connectors-lib/src/aws.js' + const debug = db('sales:paymentjournals') /** @@ -9,13 +10,15 @@ const debug = db('sales:paymentjournals') * @param {*} payload * @returns {Promise<*>} */ -export async function createPaymentJournal (id, payload) { - const record = { id, expires: Math.floor(Date.now() / 1000) + PAYMENTS_TABLE.Ttl, ...payload } - await docClient.put({ - TableName: PAYMENTS_TABLE.TableName, - Item: record, - ConditionExpression: 'attribute_not_exists(id)' - }) +export async function createPaymentJournal (id, payload, expires = Math.floor(Date.now() / 1000) + PAYMENTS_TABLE.Ttl) { + const record = { id, expires, ...payload } + await docClient.send( + new PutCommand({ + TableName: PAYMENTS_TABLE.TableName, + Item: record, + ConditionExpression: 'attribute_not_exists(id)' + }) + ) debug('Payment journal stored with payload %o', record) return record } @@ -25,35 +28,43 @@ export async function createPaymentJournal (id, payload) { * @param {*} payload * @returns {Promise<*>} */ -export async function updatePaymentJournal (id, payload) { - const updates = { expires: Math.floor(Date.now() / 1000) + PAYMENTS_TABLE.Ttl, ...payload } - const result = await docClient.update({ - TableName: PAYMENTS_TABLE.TableName, - Key: { id }, - UpdateExpression: - 'SET ' + - Object.keys(updates) - .map(key => `#${key} = :${key}`) - .join(', '), - ExpressionAttributeNames: Object.keys(updates).reduce((acc, key) => ({ ...acc, [`#${key}`]: key }), {}), - ExpressionAttributeValues: Object.keys(updates).reduce((acc, key) => ({ ...acc, [`:${key}`]: updates[key] }), {}), - ConditionExpression: 'attribute_exists(id)', - ReturnValues: 'ALL_NEW' - }) +export async function updatePaymentJournal (id, payload, expires = Math.floor(Date.now() / 1000) + PAYMENTS_TABLE.Ttl) { + const updates = { expires, ...payload } + const updateExpression = + 'SET ' + + Object.keys(updates) + .map(key => `#${key} = :${key}`) + .join(', ') + const expressionAttributeNames = Object.keys(updates).reduce((acc, key) => ({ ...acc, [`#${key}`]: key }), {}) + const expressionAttributeValues = Object.keys(updates).reduce((acc, key) => ({ ...acc, [`:${key}`]: updates[key] }), {}) + + const result = await docClient.send( + new UpdateCommand({ + TableName: PAYMENTS_TABLE.TableName, + Key: { id }, + UpdateExpression: updateExpression, + ExpressionAttributeNames: expressionAttributeNames, + ExpressionAttributeValues: expressionAttributeValues, + ConditionExpression: 'attribute_exists(id)', + ReturnValues: 'ALL_NEW' + }) + ) return result.Attributes } /** * Get an existing payment journal - * @param {*} id + * @param {*} payload * @returns {Promise<*>} */ export async function getPaymentJournal (id) { - const result = await docClient.get({ - TableName: PAYMENTS_TABLE.TableName, - Key: { id }, - ConsistentRead: true - }) + const result = await docClient.send( + new GetCommand({ + TableName: PAYMENTS_TABLE.TableName, + Key: { id }, + ConsistentRead: true + }) + ) return result.Item } @@ -63,10 +74,17 @@ export async function getPaymentJournal (id) { * @returns {Promise<*>} */ export async function queryJournalsByTimestamp ({ paymentStatus, from, to }) { - return docClient.query({ - TableName: PAYMENTS_TABLE.TableName, - IndexName: 'PaymentJournalsByStatusAndTimestamp', - KeyConditionExpression: 'paymentStatus = :paymentStatus AND paymentTimestamp BETWEEN :from AND :to', - ExpressionAttributeValues: { ':paymentStatus': paymentStatus, ':from': from, ':to': to } - }) + const result = await docClient.send( + new QueryCommand({ + TableName: PAYMENTS_TABLE.TableName, + IndexName: 'PaymentJournalsByStatusAndTimestamp', + KeyConditionExpression: 'paymentStatus = :paymentStatus AND paymentTimestamp BETWEEN :from AND :to', + ExpressionAttributeValues: { + ':paymentStatus': paymentStatus, + ':from': from, + ':to': to + } + }) + ) + return result.Items }