@defra-fish/connectors-lib 1.61.0-rc.8 → 1.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -5
- package/src/__tests__/__snapshots__/document-client-decorator.spec.js.snap +36 -0
- package/src/__tests__/aws.spec.js +95 -41
- package/src/__tests__/document-client-decorator.spec.js +196 -139
- package/src/__tests__/sales-api-connector.spec.js +0 -48
- package/src/aws.js +9 -5
- package/src/documentclient-decorator.js +14 -8
- package/src/sales-api-connector.js +0 -12
- package/src/__mocks__/aws-mock-helper.js +0 -73
- package/src/__mocks__/aws-sdk.js +0 -3
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra-fish/connectors-lib",
|
|
3
|
-
"version": "1.61.0
|
|
3
|
+
"version": "1.61.0",
|
|
4
4
|
"description": "Shared connectors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
|
-
"node": ">=
|
|
7
|
+
"node": ">=20"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [
|
|
10
10
|
"rod",
|
|
@@ -35,12 +35,16 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@airbrake/node": "^2.1.7",
|
|
38
|
-
"aws-sdk": "^
|
|
38
|
+
"@aws-sdk/client-dynamodb": "^3.0.0",
|
|
39
|
+
"@aws-sdk/client-s3": "^3.0.0",
|
|
40
|
+
"@aws-sdk/client-secrets-manager": "^3.0.0",
|
|
41
|
+
"@aws-sdk/client-sqs": "^3.0.0",
|
|
42
|
+
"@aws-sdk/lib-dynamodb": "^3.0.0",
|
|
39
43
|
"debug": "^4.3.3",
|
|
40
44
|
"http-status-codes": "^2.3.0",
|
|
41
45
|
"ioredis": "^4.28.5",
|
|
42
|
-
"node-fetch": "^2.
|
|
46
|
+
"node-fetch": "^2.7.0",
|
|
43
47
|
"redlock": "^4.2.0"
|
|
44
48
|
},
|
|
45
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "8d1841265a40178f285288e5323a36e887483434"
|
|
46
50
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
2
|
+
|
|
3
|
+
exports[`document client decorations createUpdateExpression transforms object to an update expression, with provided attribute names and values 1`] = `
|
|
4
|
+
Object {
|
|
5
|
+
"ExpressionAttributeNames": Object {
|
|
6
|
+
"#payload": "payload",
|
|
7
|
+
"#payment": "payment",
|
|
8
|
+
"#permissions": "permissions",
|
|
9
|
+
"#status": "status",
|
|
10
|
+
},
|
|
11
|
+
"ExpressionAttributeValues": Object {
|
|
12
|
+
":payload": Object {
|
|
13
|
+
"id": "abc-123",
|
|
14
|
+
"name": "ABCDE-123JJ-ABK12",
|
|
15
|
+
"type": "ddd-111-ggg-888",
|
|
16
|
+
},
|
|
17
|
+
":payment": Object {
|
|
18
|
+
"amount": 16.32,
|
|
19
|
+
"method": "barter",
|
|
20
|
+
"source": "credit",
|
|
21
|
+
"timestamp": "2025-04-09T11:53:17.854Z",
|
|
22
|
+
},
|
|
23
|
+
":permissions": Array [
|
|
24
|
+
Object {
|
|
25
|
+
"id": "abc-123",
|
|
26
|
+
"name": "ABCDE-123JJ-ABK12",
|
|
27
|
+
"type": "ddd-111-ggg-888",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
":status": Object {
|
|
31
|
+
"id": "finalised",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
"UpdateExpression": "SET #payload = :payload,#permissions = :permissions,#status = :status,#payment = :payment",
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
@@ -1,58 +1,112 @@
|
|
|
1
1
|
import Config from '../config.js'
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import AWS from '../aws.js'
|
|
3
|
+
import { DynamoDB } from '@aws-sdk/client-dynamodb'
|
|
4
|
+
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'
|
|
5
|
+
import { createDocumentClient } from '../documentclient-decorator.js'
|
|
6
|
+
|
|
7
|
+
jest.mock('../documentclient-decorator.js')
|
|
8
|
+
|
|
9
|
+
describe('AWS Special cases', () => {
|
|
10
|
+
it('document client passes convertEmptyValues flag', () => {
|
|
11
|
+
let spiedOptions
|
|
12
|
+
createDocumentClient.mockImplementation(options => {
|
|
13
|
+
spiedOptions = options
|
|
14
|
+
const client = new DynamoDB(options)
|
|
15
|
+
return DynamoDBDocument.from(client)
|
|
16
|
+
})
|
|
17
|
+
AWS()
|
|
18
|
+
expect(spiedOptions).toEqual(
|
|
19
|
+
expect.objectContaining({
|
|
20
|
+
convertEmptyValues: true
|
|
21
|
+
})
|
|
22
|
+
)
|
|
9
23
|
})
|
|
10
24
|
|
|
11
|
-
it('
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
expect(sqs.config.endpoint).toEqual(TEST_ENDPOINT)
|
|
25
|
+
it('exports ListObjectsV2Command from S3 SDK', () => {
|
|
26
|
+
const { ListObjectsV2Command } = AWS()
|
|
27
|
+
expect(ListObjectsV2Command).toBeDefined()
|
|
15
28
|
})
|
|
16
29
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
describe('AWS connectors for S3Client', () => {
|
|
31
|
+
it('has region set to eu-west-2', async () => {
|
|
32
|
+
const { s3 } = AWS()
|
|
33
|
+
const region = await s3.config.region()
|
|
34
|
+
expect(region).toBe('eu-west-2')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('sets forcePathStyle to true when endpoint is defined', () => {
|
|
38
|
+
Config.aws.s3.endpoint = 'http://localhost:8080'
|
|
39
|
+
const { s3 } = AWS()
|
|
40
|
+
expect(s3.config.forcePathStyle).toBe(true)
|
|
41
|
+
delete Config.aws.s3.endpoint
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does not set forcePathStyle when no endpoint is defined', () => {
|
|
45
|
+
const { s3 } = AWS()
|
|
46
|
+
expect(s3.config.forcePathStyle).not.toBe(true)
|
|
47
|
+
})
|
|
22
48
|
})
|
|
49
|
+
})
|
|
23
50
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
51
|
+
describe.each`
|
|
52
|
+
name | clientName | configName | expectedAPIVersion
|
|
53
|
+
${'ddb'} | ${'DynamoDB'} | ${''} | ${'2012-08-10'}
|
|
54
|
+
${'sqs'} | ${'SQS'} | ${''} | ${'2012-11-05'}
|
|
55
|
+
${'s3'} | ${'S3Client'} | ${'s3'} | ${'2006-03-01'}
|
|
56
|
+
${'secretsManager'} | ${'SecretsManager'} | ${'secretsManager'} | ${'2017-10-17'}
|
|
57
|
+
${'docClient'} | ${'DynamoDBDocument'} | ${'dynamodb'} | ${'2012-08-10'}
|
|
58
|
+
`('AWS connectors for $clientName', ({ name, clientName, configName, expectedAPIVersion }) => {
|
|
59
|
+
beforeAll(() => {
|
|
60
|
+
createDocumentClient.mockImplementation(options => {
|
|
61
|
+
const client = new DynamoDB(options)
|
|
62
|
+
return DynamoDBDocument.from(client)
|
|
63
|
+
})
|
|
29
64
|
})
|
|
30
65
|
|
|
31
|
-
it(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
expect(s3.config.endpoint).toEqual(TEST_ENDPOINT)
|
|
35
|
-
expect(s3.config.s3ForcePathStyle).toBeTruthy()
|
|
66
|
+
it(`exposes a ${clientName} client`, () => {
|
|
67
|
+
const { [name]: client } = AWS()
|
|
68
|
+
expect(client).toBeDefined()
|
|
36
69
|
})
|
|
37
70
|
|
|
38
|
-
it(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const { s3 } = require('../aws.js').default()
|
|
42
|
-
expect(s3.config.endpoint).toEqual('s3.eu-west-2.amazonaws.com')
|
|
43
|
-
expect(s3.config.s3ForcePathStyle).toBeFalsy()
|
|
71
|
+
it(`${name} client has type ${clientName}`, () => {
|
|
72
|
+
const { [name]: client } = AWS()
|
|
73
|
+
expect(client.constructor.name).toEqual(clientName)
|
|
44
74
|
})
|
|
45
75
|
|
|
46
|
-
it(
|
|
47
|
-
Config.aws.
|
|
48
|
-
|
|
49
|
-
|
|
76
|
+
it(`configures ${name} with a custom endpoint if one is defined in configuration`, async () => {
|
|
77
|
+
Config.aws[configName || clientName.toLowerCase()].endpoint = 'http://localhost:8080'
|
|
78
|
+
|
|
79
|
+
const { [name]: client } = AWS()
|
|
80
|
+
const endpoint = await client.config.endpoint()
|
|
81
|
+
|
|
82
|
+
expect(endpoint).toEqual(
|
|
83
|
+
expect.objectContaining({
|
|
84
|
+
hostname: 'localhost',
|
|
85
|
+
port: 8080,
|
|
86
|
+
protocol: 'http:',
|
|
87
|
+
path: '/'
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it(`leaves endpoint undefined for ${clientName}, reverting to the internal handling for the default endpoint if it is not set in config`, async () => {
|
|
93
|
+
Config.aws[configName || clientName.toLowerCase()].endpoint = 'http://localhost:8080'
|
|
94
|
+
|
|
95
|
+
const { [name]: client } = AWS()
|
|
96
|
+
const endpoint = await client.config.endpoint()
|
|
97
|
+
|
|
98
|
+
expect(endpoint).toEqual(
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
hostname: 'localhost',
|
|
101
|
+
port: 8080,
|
|
102
|
+
protocol: 'http:',
|
|
103
|
+
path: '/'
|
|
104
|
+
})
|
|
105
|
+
)
|
|
50
106
|
})
|
|
51
107
|
|
|
52
|
-
it('uses
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const { secretsManager } = require('../aws.js').default()
|
|
56
|
-
expect(secretsManager.config.endpoint).toEqual('secretsmanager.eu-west-2.amazonaws.com')
|
|
108
|
+
it('uses expected apiVersion', () => {
|
|
109
|
+
const { [name]: client } = AWS()
|
|
110
|
+
expect(client.config.apiVersion).toEqual(expectedAPIVersion)
|
|
57
111
|
})
|
|
58
112
|
})
|
|
@@ -1,161 +1,218 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
|
|
1
|
+
import { createDocumentClient } from '../documentclient-decorator'
|
|
2
|
+
import { DynamoDB } from '@aws-sdk/client-dynamodb'
|
|
3
|
+
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'
|
|
4
|
+
|
|
5
|
+
jest.mock('@aws-sdk/client-dynamodb')
|
|
6
|
+
jest.mock('@aws-sdk/lib-dynamodb')
|
|
4
7
|
|
|
5
8
|
describe('document client decorations', () => {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
Items: [],
|
|
13
|
-
LastEvaluatedKey: testLastEvaluatedKey
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
Items: []
|
|
17
|
-
}
|
|
18
|
-
)
|
|
19
|
-
await docClient.queryAllPromise({
|
|
20
|
-
TableName: 'TEST'
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
jest.spyOn(global, 'setTimeout').mockImplementation(cb => cb())
|
|
11
|
+
DynamoDBDocument.from.mockReturnValue({
|
|
12
|
+
query: jest.fn().mockResolvedValue({ Items: [], lastEvaluatedKey: false }),
|
|
13
|
+
scan: jest.fn().mockResolvedValue({ Items: [], lastEvaluatedKey: false }),
|
|
14
|
+
batchWrite: jest.fn().mockResolvedValue({ UnprocessedItems: {} })
|
|
21
15
|
})
|
|
22
|
-
expect(AWSSdk.DynamoDB.DocumentClient.mockedMethods.query).toHaveBeenNthCalledWith(
|
|
23
|
-
1,
|
|
24
|
-
expect.objectContaining({
|
|
25
|
-
TableName: 'TEST'
|
|
26
|
-
})
|
|
27
|
-
)
|
|
28
|
-
expect(AWSSdk.DynamoDB.DocumentClient.mockedMethods.query).toHaveBeenNthCalledWith(
|
|
29
|
-
2,
|
|
30
|
-
expect.objectContaining({
|
|
31
|
-
TableName: 'TEST',
|
|
32
|
-
ExclusiveStartKey: testLastEvaluatedKey
|
|
33
|
-
})
|
|
34
|
-
)
|
|
35
16
|
})
|
|
17
|
+
afterEach(jest.clearAllMocks)
|
|
36
18
|
|
|
37
|
-
it('
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
Items: []
|
|
48
|
-
}
|
|
49
|
-
)
|
|
50
|
-
await docClient.scanAllPromise({
|
|
51
|
-
TableName: 'TEST'
|
|
52
|
-
})
|
|
53
|
-
expect(AWSSdk.DynamoDB.DocumentClient.mockedMethods.scan).toHaveBeenNthCalledWith(
|
|
54
|
-
1,
|
|
55
|
-
expect.objectContaining({
|
|
56
|
-
TableName: 'TEST'
|
|
57
|
-
})
|
|
58
|
-
)
|
|
59
|
-
expect(AWSSdk.DynamoDB.DocumentClient.mockedMethods.scan).toHaveBeenNthCalledWith(
|
|
60
|
-
2,
|
|
61
|
-
expect.objectContaining({
|
|
62
|
-
TableName: 'TEST',
|
|
63
|
-
ExclusiveStartKey: testLastEvaluatedKey
|
|
64
|
-
})
|
|
65
|
-
)
|
|
19
|
+
it('passes options to DynamoDB constructor', () => {
|
|
20
|
+
const options = {
|
|
21
|
+
option1: '1',
|
|
22
|
+
option2: 2,
|
|
23
|
+
option3: Symbol('option3')
|
|
24
|
+
}
|
|
25
|
+
createDocumentClient(options)
|
|
26
|
+
expect(DynamoDB).toHaveBeenCalledWith(options)
|
|
66
27
|
})
|
|
67
28
|
|
|
68
|
-
it('
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
UnprocessedItems: null
|
|
81
|
-
}
|
|
82
|
-
)
|
|
83
|
-
await docClient.batchWriteAllPromise({
|
|
84
|
-
RequestItems: {
|
|
85
|
-
NameOfTableToUpdate: [
|
|
86
|
-
{ PutRequest: { Item: { key: '1', field: 'data1' } } },
|
|
87
|
-
{ PutRequest: { Item: { key: '2', field: 'data2' } } },
|
|
88
|
-
{ PutRequest: { Item: { key: '3', field: 'data3' } } }
|
|
89
|
-
]
|
|
90
|
-
}
|
|
91
|
-
})
|
|
92
|
-
expect(AWSSdk.DynamoDB.DocumentClient.mockedMethods.batchWrite).toHaveBeenCalledTimes(2)
|
|
93
|
-
expect(AWSSdk.DynamoDB.DocumentClient.mockedMethods.batchWrite).toHaveBeenNthCalledWith(
|
|
94
|
-
1,
|
|
95
|
-
expect.objectContaining({
|
|
96
|
-
RequestItems: {
|
|
97
|
-
NameOfTableToUpdate: [
|
|
98
|
-
{ PutRequest: { Item: { key: '1', field: 'data1' } } },
|
|
99
|
-
{ PutRequest: { Item: { key: '2', field: 'data2' } } },
|
|
100
|
-
{ PutRequest: { Item: { key: '3', field: 'data3' } } }
|
|
101
|
-
]
|
|
102
|
-
}
|
|
103
|
-
})
|
|
104
|
-
)
|
|
105
|
-
expect(AWSSdk.DynamoDB.DocumentClient.mockedMethods.batchWrite).toHaveBeenNthCalledWith(
|
|
106
|
-
2,
|
|
29
|
+
it('creates DynamoDBDocument using client', () => {
|
|
30
|
+
createDocumentClient()
|
|
31
|
+
const [mockClient] = DynamoDB.mock.instances
|
|
32
|
+
expect(DynamoDBDocument.from).toHaveBeenCalledWith(mockClient, expect.any(Object))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('Sets options to strip empty and undefined values when marshalling to DynamoDB lists, sets and values', () => {
|
|
36
|
+
createDocumentClient()
|
|
37
|
+
expect(DynamoDBDocument.from).toHaveBeenCalledWith(
|
|
38
|
+
expect.any(Object),
|
|
107
39
|
expect.objectContaining({
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
{ PutRequest: { Item: { key: '2', field: 'data2' } } }
|
|
112
|
-
]
|
|
40
|
+
marshallOptions: {
|
|
41
|
+
convertEmptyValues: true,
|
|
42
|
+
removeUndefinedValues: true
|
|
113
43
|
}
|
|
114
44
|
})
|
|
115
45
|
)
|
|
116
46
|
})
|
|
117
47
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
48
|
+
describe.each`
|
|
49
|
+
aggregateMethod | baseMethod
|
|
50
|
+
${'queryAllPromise'} | ${'query'}
|
|
51
|
+
${'scanAllPromise'} | ${'scan'}
|
|
52
|
+
`('$aggregateMethod', ({ aggregateMethod, baseMethod }) => {
|
|
53
|
+
it('is added to document client', () => {
|
|
54
|
+
const docClient = createDocumentClient()
|
|
55
|
+
expect(docClient[aggregateMethod]).toBeDefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it(`passes arguments provided for ${aggregateMethod} to ${baseMethod}`, async () => {
|
|
59
|
+
const params = { TableName: 'TEST', KeyConditionExpression: 'id = :id', ExpressionAttributeValues: { ':id': 1 } }
|
|
60
|
+
const docClient = createDocumentClient()
|
|
61
|
+
await docClient[aggregateMethod](params)
|
|
62
|
+
expect(docClient[baseMethod]).toHaveBeenCalledWith(params)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it(`calls ${baseMethod} repeatedly until LastEvaluatedKey evaluates to false, concatenating all returned items`, async () => {
|
|
66
|
+
const expectedItems = [
|
|
67
|
+
{ id: 1, data: Symbol('data1') },
|
|
68
|
+
{ id: 2, data: Symbol('data2') },
|
|
69
|
+
{ id: 3, data: Symbol('data3') },
|
|
70
|
+
{ id: 4, data: Symbol('data4') },
|
|
71
|
+
{ id: 5, data: Symbol('data5') }
|
|
72
|
+
]
|
|
73
|
+
const docClient = createDocumentClient()
|
|
74
|
+
docClient[baseMethod]
|
|
75
|
+
.mockResolvedValueOnce({ Items: expectedItems.slice(0, 2), LastEvaluatedKey: true })
|
|
76
|
+
.mockResolvedValueOnce({ Items: expectedItems.slice(2, 4), LastEvaluatedKey: true })
|
|
77
|
+
.mockResolvedValueOnce({ Items: expectedItems.slice(4), LastEvaluatedKey: false })
|
|
78
|
+
const actualItems = await docClient[aggregateMethod]()
|
|
79
|
+
expect(actualItems).toEqual(expectedItems)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it(`whilst concatenating ${baseMethod} results, passes ExclusiveStartKey param`, async () => {
|
|
83
|
+
const expectedKey = Symbol('🔑')
|
|
84
|
+
const docClient = createDocumentClient()
|
|
85
|
+
docClient[baseMethod].mockResolvedValueOnce({ Items: [], LastEvaluatedKey: expectedKey }).mockResolvedValueOnce({ Items: [] })
|
|
86
|
+
await docClient[aggregateMethod]()
|
|
87
|
+
expect(docClient[baseMethod]).toHaveBeenNthCalledWith(
|
|
88
|
+
2,
|
|
89
|
+
expect.objectContaining({
|
|
90
|
+
ExclusiveStartKey: expectedKey
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("omits ExclusiveStartKey if previous LastEvaluatedKey isn't available", async () => {
|
|
96
|
+
const docClient = createDocumentClient()
|
|
97
|
+
await docClient[aggregateMethod]()
|
|
98
|
+
expect(docClient[baseMethod]).toHaveBeenNthCalledWith(
|
|
99
|
+
1,
|
|
100
|
+
expect.not.objectContaining({
|
|
101
|
+
ExclusiveStartKey: expect.anything()
|
|
102
|
+
})
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('batchWriteAllPromise', () => {
|
|
108
|
+
it('is added to document client', () => {
|
|
109
|
+
const docClient = createDocumentClient()
|
|
110
|
+
expect(docClient.batchWriteAllPromise).toBeDefined()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('passes arguments provided for batchWriteAllPromise to batchWrite', async () => {
|
|
114
|
+
const params = { RequestItems: { TEST: [{ PutRequest: { Item: { id: 1, data: Symbol('data1') } } }] } }
|
|
115
|
+
const docClient = createDocumentClient()
|
|
116
|
+
await docClient.batchWriteAllPromise(params)
|
|
117
|
+
expect(docClient.batchWrite).toHaveBeenCalledWith(params)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('calls batchWrite repeatedly until UnprocessedItems is empty', async () => {
|
|
121
|
+
const docClient = createDocumentClient()
|
|
122
|
+
docClient.batchWrite
|
|
123
|
+
.mockResolvedValueOnce({ UnprocessedItems: { key: true } })
|
|
124
|
+
.mockResolvedValueOnce({ UnprocessedItems: { key: true } })
|
|
125
|
+
.mockResolvedValueOnce({ UnprocessedItems: { key: true } })
|
|
126
|
+
.mockResolvedValueOnce({ UnprocessedItems: {} })
|
|
127
|
+
await docClient.batchWriteAllPromise({ RequestItems: { key: true } })
|
|
128
|
+
expect(docClient.batchWrite).toHaveBeenCalledTimes(4)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it.each([
|
|
132
|
+
[1, 500],
|
|
133
|
+
[2, 750],
|
|
134
|
+
[3, 1125],
|
|
135
|
+
[4, 1687.5],
|
|
136
|
+
[5, 2500],
|
|
137
|
+
[6, 2500],
|
|
138
|
+
[7, 2500],
|
|
139
|
+
[8, 2500],
|
|
140
|
+
[9, 2500],
|
|
141
|
+
[10, 2500]
|
|
142
|
+
])('retries %i times with %i ms delay on final retry', async (retries, delay) => {
|
|
143
|
+
const docClient = createDocumentClient()
|
|
144
|
+
for (let i = 0; i < retries; i++) {
|
|
145
|
+
docClient.batchWrite.mockResolvedValueOnce({ UnprocessedItems: { key: true } })
|
|
125
146
|
}
|
|
147
|
+
await docClient.batchWriteAllPromise({ RequestItems: { key: true } })
|
|
148
|
+
expect(setTimeout).toHaveBeenNthCalledWith(retries, expect.any(Function), delay)
|
|
126
149
|
})
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
{ PutRequest: { Item: { key: '2', field: 'data2' } } },
|
|
133
|
-
{ PutRequest: { Item: { key: '3', field: 'data3' } } }
|
|
134
|
-
]
|
|
150
|
+
|
|
151
|
+
it('throws an error on the eleventh retry', async () => {
|
|
152
|
+
const docClient = createDocumentClient()
|
|
153
|
+
for (let i = 0; i < 11; i++) {
|
|
154
|
+
docClient.batchWrite.mockResolvedValueOnce({ UnprocessedItems: { key: true } })
|
|
135
155
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
156
|
+
await expect(() => docClient.batchWriteAllPromise({ RequestItems: { key: true } })).rejects.toThrow()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('adds unprocessed items to batchWrite request', async () => {
|
|
160
|
+
const token = Symbol('token')
|
|
161
|
+
const firstCallSymbol = Symbol('first call')
|
|
162
|
+
const secondCallSymbol = Symbol('second call')
|
|
163
|
+
const docClient = createDocumentClient()
|
|
164
|
+
docClient.batchWrite.mockResolvedValueOnce({ UnprocessedItems: { secondCallSymbol } })
|
|
165
|
+
await docClient.batchWriteAllPromise({ token, RequestItems: { firstCallSymbol } })
|
|
166
|
+
expect(docClient.batchWrite).toHaveBeenNthCalledWith(
|
|
167
|
+
2,
|
|
168
|
+
expect.objectContaining({
|
|
169
|
+
token,
|
|
170
|
+
RequestItems: { secondCallSymbol }
|
|
171
|
+
})
|
|
172
|
+
)
|
|
173
|
+
})
|
|
142
174
|
})
|
|
143
175
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
176
|
+
describe('createUpdateExpression', () => {
|
|
177
|
+
it('is added to document client', () => {
|
|
178
|
+
const docClient = createDocumentClient()
|
|
179
|
+
expect(docClient.createUpdateExpression).toBeDefined()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('returns an object with UpdateExpression, ExpressionAttributeNames and ExpressionAttributeValues', () => {
|
|
183
|
+
const docClient = createDocumentClient()
|
|
184
|
+
const actual = docClient.createUpdateExpression({ id: 1, data: Symbol('data1') })
|
|
185
|
+
expect(actual).toEqual(
|
|
186
|
+
expect.objectContaining({
|
|
187
|
+
UpdateExpression: expect.any(String),
|
|
188
|
+
ExpressionAttributeNames: expect.any(Object),
|
|
189
|
+
ExpressionAttributeValues: expect.any(Object)
|
|
190
|
+
})
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('transforms object to an update expression, with provided attribute names and values', () => {
|
|
195
|
+
const permission = {
|
|
196
|
+
id: 'abc-123',
|
|
197
|
+
name: 'ABCDE-123JJ-ABK12',
|
|
198
|
+
type: 'ddd-111-ggg-888'
|
|
158
199
|
}
|
|
200
|
+
const transaction = {
|
|
201
|
+
payload: permission,
|
|
202
|
+
permissions: [permission],
|
|
203
|
+
status: { id: 'finalised' },
|
|
204
|
+
payment: {
|
|
205
|
+
amount: 16.32,
|
|
206
|
+
method: 'barter',
|
|
207
|
+
source: 'credit',
|
|
208
|
+
timestamp: '2025-04-09T11:53:17.854Z'
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const docClient = createDocumentClient()
|
|
212
|
+
|
|
213
|
+
const actual = docClient.createUpdateExpression(transaction)
|
|
214
|
+
|
|
215
|
+
expect(actual).toMatchSnapshot()
|
|
159
216
|
})
|
|
160
217
|
})
|
|
161
218
|
})
|
|
@@ -706,52 +706,4 @@ describe('sales-api-connector', () => {
|
|
|
706
706
|
})
|
|
707
707
|
})
|
|
708
708
|
})
|
|
709
|
-
|
|
710
|
-
describe('linkRecurringPayments', () => {
|
|
711
|
-
describe.each([
|
|
712
|
-
['existing-recurring-payment-id', 'agreement-id'],
|
|
713
|
-
['abc-123', 'def-456']
|
|
714
|
-
])("Processing payment for transaction id '%s'", (existingRecurringPaymentId, agreementId) => {
|
|
715
|
-
beforeEach(() => {
|
|
716
|
-
fetch.mockReturnValue({
|
|
717
|
-
ok: true,
|
|
718
|
-
status: 200,
|
|
719
|
-
statusText: 'OK',
|
|
720
|
-
text: async () => JSON.stringify({ existingRecurringPaymentId, agreementId })
|
|
721
|
-
})
|
|
722
|
-
})
|
|
723
|
-
|
|
724
|
-
it('calls the endpoint with the correct parameters', async () => {
|
|
725
|
-
await salesApi.linkRecurringPayments(existingRecurringPaymentId, agreementId)
|
|
726
|
-
|
|
727
|
-
expect(fetch).toHaveBeenCalledWith(`http://0.0.0.0:4000/linkRecurringPayments/${existingRecurringPaymentId}/${agreementId}`, {
|
|
728
|
-
method: 'get',
|
|
729
|
-
headers: expect.any(Object),
|
|
730
|
-
timeout: 20000
|
|
731
|
-
})
|
|
732
|
-
})
|
|
733
|
-
|
|
734
|
-
it('returns the expected response data', async () => {
|
|
735
|
-
const processedResult = await salesApi.linkRecurringPayments(existingRecurringPaymentId, agreementId)
|
|
736
|
-
|
|
737
|
-
expect(processedResult).toEqual({ existingRecurringPaymentId, agreementId })
|
|
738
|
-
})
|
|
739
|
-
})
|
|
740
|
-
|
|
741
|
-
it('throws an error on non-2xx response', async () => {
|
|
742
|
-
fetch.mockReturnValue({
|
|
743
|
-
ok: false,
|
|
744
|
-
status: 500,
|
|
745
|
-
statusText: 'Internal Server Error',
|
|
746
|
-
text: async () => 'Server Error'
|
|
747
|
-
})
|
|
748
|
-
|
|
749
|
-
await expect(salesApi.linkRecurringPayments('existing-recurring-payment-id', 'agreement-id')).rejects.toThrow('Internal Server Error')
|
|
750
|
-
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0:4000/linkRecurringPayments/existing-recurring-payment-id/agreement-id', {
|
|
751
|
-
method: 'get',
|
|
752
|
-
headers: expect.any(Object),
|
|
753
|
-
timeout: 20000
|
|
754
|
-
})
|
|
755
|
-
})
|
|
756
|
-
})
|
|
757
709
|
})
|
package/src/aws.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import Config from './config.js'
|
|
2
2
|
import { createDocumentClient } from './documentclient-decorator.js'
|
|
3
|
-
import
|
|
4
|
-
|
|
3
|
+
import { DynamoDB } from '@aws-sdk/client-dynamodb'
|
|
4
|
+
import { SQS } from '@aws-sdk/client-sqs'
|
|
5
|
+
import { S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3'
|
|
6
|
+
import { SecretsManager } from '@aws-sdk/client-secrets-manager'
|
|
5
7
|
|
|
6
8
|
export default function () {
|
|
7
9
|
return {
|
|
@@ -24,11 +26,12 @@ export default function () {
|
|
|
24
26
|
endpoint: Config.aws.sqs.endpoint
|
|
25
27
|
})
|
|
26
28
|
}),
|
|
27
|
-
s3: new
|
|
29
|
+
s3: new S3Client({
|
|
30
|
+
region: 'eu-west-2',
|
|
28
31
|
apiVersion: '2006-03-01',
|
|
29
32
|
...(Config.aws.s3.endpoint && {
|
|
30
33
|
endpoint: Config.aws.s3.endpoint,
|
|
31
|
-
|
|
34
|
+
forcePathStyle: true
|
|
32
35
|
})
|
|
33
36
|
}),
|
|
34
37
|
secretsManager: new SecretsManager({
|
|
@@ -36,6 +39,7 @@ export default function () {
|
|
|
36
39
|
...(Config.aws.secretsManager.endpoint && {
|
|
37
40
|
endpoint: Config.aws.secretsManager.endpoint
|
|
38
41
|
})
|
|
39
|
-
})
|
|
42
|
+
}),
|
|
43
|
+
ListObjectsV2Command
|
|
40
44
|
}
|
|
41
45
|
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import db from 'debug'
|
|
2
|
-
import
|
|
3
|
-
|
|
2
|
+
import { DynamoDB } from '@aws-sdk/client-dynamodb'
|
|
3
|
+
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'
|
|
4
4
|
const debug = db('connectors:aws')
|
|
5
5
|
|
|
6
6
|
export const createDocumentClient = options => {
|
|
7
|
-
const
|
|
7
|
+
const client = new DynamoDB(options)
|
|
8
|
+
const docClient = DynamoDBDocument.from(client, {
|
|
9
|
+
marshallOptions: {
|
|
10
|
+
convertEmptyValues: true,
|
|
11
|
+
removeUndefinedValues: true
|
|
12
|
+
}
|
|
13
|
+
})
|
|
8
14
|
|
|
9
15
|
// Support for large query/scan operations which return results in pages
|
|
10
16
|
const wrapPagedDocumentClientOperation = operationName => {
|
|
@@ -15,7 +21,7 @@ export const createDocumentClient = options => {
|
|
|
15
21
|
const response = await docClient[operationName]({
|
|
16
22
|
...params,
|
|
17
23
|
...(lastEvaluatedKey && { ExclusiveStartKey: lastEvaluatedKey })
|
|
18
|
-
})
|
|
24
|
+
})
|
|
19
25
|
lastEvaluatedKey = response.LastEvaluatedKey
|
|
20
26
|
response.Items && items.push(...response.Items)
|
|
21
27
|
} while (lastEvaluatedKey)
|
|
@@ -26,9 +32,9 @@ export const createDocumentClient = options => {
|
|
|
26
32
|
docClient.scanAllPromise = wrapPagedDocumentClientOperation('scan')
|
|
27
33
|
|
|
28
34
|
/**
|
|
29
|
-
* Handles batch writes which may return UnprocessedItems.
|
|
35
|
+
* Handles batch writes which may return UnprocessedItems. If UnprocessedItems are returned then they will be retried with exponential backoff
|
|
30
36
|
*
|
|
31
|
-
* @param {DocumentClient.
|
|
37
|
+
* @param {DocumentClient.BatchWriteCommandInput} params as per DynamoDB.DocumentClient.batchWrite
|
|
32
38
|
* @returns {Promise<void>}
|
|
33
39
|
*/
|
|
34
40
|
docClient.batchWriteAllPromise = async params => {
|
|
@@ -37,13 +43,13 @@ export const createDocumentClient = options => {
|
|
|
37
43
|
let unprocessedItemsDelay = 500
|
|
38
44
|
let maxRetries = 10
|
|
39
45
|
while (hasUnprocessedItems) {
|
|
40
|
-
const result = await docClient.batchWrite(request)
|
|
46
|
+
const result = await docClient.batchWrite(request)
|
|
41
47
|
hasUnprocessedItems = !!Object.keys(result.UnprocessedItems ?? {}).length
|
|
42
48
|
if (hasUnprocessedItems) {
|
|
43
49
|
request = { ...params, RequestItems: result.UnprocessedItems }
|
|
44
50
|
if (maxRetries-- === 0) {
|
|
45
51
|
throw new Error(
|
|
46
|
-
'Failed to write items to DynamoDB using batch write.
|
|
52
|
+
'Failed to write items to DynamoDB using batch write. UnprocessedItems were returned and maxRetries has been reached.'
|
|
47
53
|
)
|
|
48
54
|
}
|
|
49
55
|
await new Promise(resolve => setTimeout(resolve, unprocessedItemsDelay))
|
|
@@ -308,15 +308,3 @@ export const preparePermissionDataForRenewal = async referenceNumber =>
|
|
|
308
308
|
export const processRPResult = async (transactionId, paymentId, createdDate) => {
|
|
309
309
|
return exec2xxOrThrow(call(new URL(`/processRPResult/${transactionId}/${paymentId}/${createdDate}`, urlBase), 'get'))
|
|
310
310
|
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Link an old RecurringPayment to its replacement
|
|
314
|
-
*
|
|
315
|
-
* @param existingRecurringPaymentId
|
|
316
|
-
* @param agreementId
|
|
317
|
-
* @returns {Promise<*>}
|
|
318
|
-
* @throws on a non-2xx response
|
|
319
|
-
*/
|
|
320
|
-
export const linkRecurringPayments = async (existingRecurringPaymentId, agreementId) => {
|
|
321
|
-
return exec2xxOrThrow(call(new URL(`/linkRecurringPayments/${existingRecurringPaymentId}/${agreementId}`, urlBase), 'get'))
|
|
322
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import cloneDeep from 'clone-deep'
|
|
2
|
-
export const configureAwsSdkMock = (AwsSdk = jest.genMockFromModule('aws-sdk')) => {
|
|
3
|
-
const configuredMocks = []
|
|
4
|
-
AwsSdk.__resetAll = () => configuredMocks.forEach(c => c.__init())
|
|
5
|
-
|
|
6
|
-
const configureMock = (awsClass, methodNames, defaultResponse) => {
|
|
7
|
-
configuredMocks.push(awsClass)
|
|
8
|
-
awsClass.mockedMethods = createHandlers(awsClass, methodNames, defaultResponse)
|
|
9
|
-
|
|
10
|
-
awsClass.mockImplementation(() => ({ ...awsClass.mockedMethods }))
|
|
11
|
-
awsClass.expectedResponses = {}
|
|
12
|
-
awsClass.nextResponses = {}
|
|
13
|
-
awsClass.expectedErrors = {}
|
|
14
|
-
awsClass.__init = (
|
|
15
|
-
{ expectedResponses = {}, nextResponses = [], expectedErrors = {} } = {
|
|
16
|
-
expectedResponses: {},
|
|
17
|
-
nextResponses: {},
|
|
18
|
-
expectedErrors: {}
|
|
19
|
-
}
|
|
20
|
-
) => {
|
|
21
|
-
awsClass.expectedResponses = expectedResponses
|
|
22
|
-
awsClass.nextResponses = nextResponses
|
|
23
|
-
awsClass.expectedErrors = expectedErrors
|
|
24
|
-
}
|
|
25
|
-
awsClass.__setResponse = (methodName, response) => {
|
|
26
|
-
awsClass.expectedResponses[methodName] = cloneDeep(response)
|
|
27
|
-
}
|
|
28
|
-
awsClass.__setNextResponses = (methodName, ...responses) => {
|
|
29
|
-
awsClass.nextResponses[methodName] = cloneDeep(responses)
|
|
30
|
-
}
|
|
31
|
-
awsClass.__throwWithErrorOn = (methodName, error = new Error('Test error')) => {
|
|
32
|
-
awsClass.expectedErrors[methodName] = error
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const createHandlers = (awsClass, names, defaultResponse) => {
|
|
37
|
-
return names.reduce((acc, name) => {
|
|
38
|
-
acc[name] = jest.fn(() => ({
|
|
39
|
-
promise: jest.fn(async () => {
|
|
40
|
-
if (awsClass.expectedErrors[name]) {
|
|
41
|
-
throw awsClass.expectedErrors[name]
|
|
42
|
-
}
|
|
43
|
-
if (awsClass.nextResponses[name] && awsClass.nextResponses[name].length) {
|
|
44
|
-
return awsClass.nextResponses[name].shift()
|
|
45
|
-
}
|
|
46
|
-
return awsClass.expectedResponses[name] || defaultResponse
|
|
47
|
-
})
|
|
48
|
-
}))
|
|
49
|
-
return acc
|
|
50
|
-
}, {})
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
configureMock(AwsSdk.SQS, [
|
|
54
|
-
'listQueues',
|
|
55
|
-
'createQueue',
|
|
56
|
-
'deleteQueue',
|
|
57
|
-
'purgeQueue',
|
|
58
|
-
'sendMessage',
|
|
59
|
-
'receiveMessage',
|
|
60
|
-
'deleteMessage',
|
|
61
|
-
'deleteMessageBatch'
|
|
62
|
-
])
|
|
63
|
-
configureMock(AwsSdk.DynamoDB, ['listTables', 'describeTable', 'getItem', 'putItem', 'query', 'scan'], {})
|
|
64
|
-
configureMock(
|
|
65
|
-
AwsSdk.DynamoDB.DocumentClient,
|
|
66
|
-
['get', 'put', 'update', 'query', 'scan', 'delete', 'createSet', 'batchGet', 'batchWrite'],
|
|
67
|
-
{}
|
|
68
|
-
)
|
|
69
|
-
configureMock(AwsSdk.S3, ['listObjectsV2', 'getObject', 'putObject', 'headObject', 'deleteObject', 'upload', 'listBuckets', 'headBucket'])
|
|
70
|
-
configureMock(AwsSdk.SecretsManager, ['getSecretValue'])
|
|
71
|
-
|
|
72
|
-
return AwsSdk
|
|
73
|
-
}
|
package/src/__mocks__/aws-sdk.js
DELETED