@defra-fish/connectors-lib 1.73.0 → 1.74.0-rc.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra-fish/connectors-lib",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.74.0-rc.0",
|
|
4
4
|
"description": "Shared connectors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -47,5 +47,5 @@
|
|
|
47
47
|
"node-fetch": "2.7.0",
|
|
48
48
|
"redlock": "4.2.0"
|
|
49
49
|
},
|
|
50
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "4528014dc6eea3407641b04f1733bf01ed297a5e"
|
|
51
51
|
}
|
|
@@ -2,25 +2,33 @@ import * as govUkPayApi from '../govuk-pay-api.js'
|
|
|
2
2
|
jest.mock('node-fetch')
|
|
3
3
|
const fetch = require('node-fetch')
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
const envVars = Object.freeze({
|
|
6
|
+
GOV_PAY_API_URL: 'http://0.0.0.0/payment',
|
|
7
|
+
GOV_PAY_RCP_API_URL: 'http://0.0.0.0/agreement',
|
|
8
|
+
GOV_PAY_APIKEY: 'key',
|
|
9
|
+
GOV_PAY_RECURRING_APIKEY: 'recurringkey'
|
|
10
|
+
})
|
|
9
11
|
|
|
10
|
-
const headers = {
|
|
12
|
+
const headers = () => ({
|
|
11
13
|
accept: 'application/json',
|
|
12
14
|
authorization: `Bearer ${process.env.GOV_PAY_APIKEY}`,
|
|
13
15
|
'content-type': 'application/json'
|
|
14
|
-
}
|
|
16
|
+
})
|
|
15
17
|
|
|
16
|
-
const recurringHeaders = {
|
|
18
|
+
const recurringHeaders = () => ({
|
|
17
19
|
accept: 'application/json',
|
|
18
20
|
authorization: `Bearer ${process.env.GOV_PAY_RECURRING_APIKEY}`,
|
|
19
21
|
'content-type': 'application/json'
|
|
20
|
-
}
|
|
22
|
+
})
|
|
21
23
|
|
|
22
24
|
describe('govuk-pay-api-connector', () => {
|
|
23
|
-
beforeEach(
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
jest.clearAllMocks()
|
|
27
|
+
for (const [key, value] of Object.entries(envVars)) {
|
|
28
|
+
process.env[key] = value
|
|
29
|
+
}
|
|
30
|
+
delete process.env.GOV_PAY_REQUEST_TIMEOUT_MS
|
|
31
|
+
})
|
|
24
32
|
|
|
25
33
|
describe('createPayment', () => {
|
|
26
34
|
it('creates new payments', async () => {
|
|
@@ -28,7 +36,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
28
36
|
await expect(govUkPayApi.createPayment({ cost: 0 })).resolves.toEqual({ ok: true, status: 200 })
|
|
29
37
|
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment', {
|
|
30
38
|
body: JSON.stringify({ cost: 0 }),
|
|
31
|
-
headers,
|
|
39
|
+
headers: headers(),
|
|
32
40
|
method: 'post',
|
|
33
41
|
timeout: 10000
|
|
34
42
|
})
|
|
@@ -42,7 +50,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
42
50
|
expect(govUkPayApi.createPayment({ cost: 0 })).rejects.toEqual(Error(''))
|
|
43
51
|
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment', {
|
|
44
52
|
body: JSON.stringify({ cost: 0 }),
|
|
45
|
-
headers,
|
|
53
|
+
headers: headers(),
|
|
46
54
|
method: 'post',
|
|
47
55
|
timeout: 10000
|
|
48
56
|
})
|
|
@@ -54,7 +62,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
54
62
|
await expect(govUkPayApi.createPayment({ cost: 0 }, true)).resolves.toEqual({ ok: true, status: 200 })
|
|
55
63
|
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment', {
|
|
56
64
|
body: JSON.stringify({ cost: 0 }),
|
|
57
|
-
headers: recurringHeaders,
|
|
65
|
+
headers: recurringHeaders(),
|
|
58
66
|
method: 'post',
|
|
59
67
|
timeout: 10000
|
|
60
68
|
})
|
|
@@ -66,7 +74,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
66
74
|
fetch.mockReturnValueOnce({ ok: true, status: 200, json: () => {} })
|
|
67
75
|
await expect(govUkPayApi.fetchPaymentStatus(123)).resolves.toEqual(expect.objectContaining({ ok: true, status: 200 }))
|
|
68
76
|
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123', {
|
|
69
|
-
headers,
|
|
77
|
+
headers: headers(),
|
|
70
78
|
method: 'get',
|
|
71
79
|
timeout: 10000
|
|
72
80
|
})
|
|
@@ -78,7 +86,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
78
86
|
throw new Error('')
|
|
79
87
|
})
|
|
80
88
|
await expect(govUkPayApi.fetchPaymentStatus(123)).rejects.toEqual(Error(''))
|
|
81
|
-
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123', { headers, method: 'get', timeout: 10000 })
|
|
89
|
+
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123', { headers: headers(), method: 'get', timeout: 10000 })
|
|
82
90
|
expect(consoleErrorSpy).toHaveBeenCalled()
|
|
83
91
|
})
|
|
84
92
|
|
|
@@ -86,7 +94,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
86
94
|
fetch.mockReturnValueOnce({ ok: true, status: 200, json: () => {} })
|
|
87
95
|
await expect(govUkPayApi.fetchPaymentStatus(123, true)).resolves.toEqual(expect.objectContaining({ ok: true, status: 200 }))
|
|
88
96
|
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123', {
|
|
89
|
-
headers: recurringHeaders,
|
|
97
|
+
headers: recurringHeaders(),
|
|
90
98
|
method: 'get',
|
|
91
99
|
timeout: 10000
|
|
92
100
|
})
|
|
@@ -97,7 +105,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
97
105
|
it('retrieves payment events', async () => {
|
|
98
106
|
fetch.mockReturnValueOnce({ ok: true, status: 200, json: () => {} })
|
|
99
107
|
await expect(govUkPayApi.fetchPaymentEvents(123)).resolves.toEqual(expect.objectContaining({ ok: true, status: 200 }))
|
|
100
|
-
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123/events', { headers, method: 'get', timeout: 10000 })
|
|
108
|
+
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123/events', { headers: headers(), method: 'get', timeout: 10000 })
|
|
101
109
|
})
|
|
102
110
|
|
|
103
111
|
it('logs and throws errors', async () => {
|
|
@@ -113,7 +121,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
113
121
|
fetch.mockReturnValueOnce({ ok: true, status: 200, json: () => {} })
|
|
114
122
|
await expect(govUkPayApi.fetchPaymentEvents(123, true)).resolves.toEqual(expect.objectContaining({ ok: true, status: 200 }))
|
|
115
123
|
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123/events', {
|
|
116
|
-
headers: recurringHeaders,
|
|
124
|
+
headers: recurringHeaders(),
|
|
117
125
|
method: 'get',
|
|
118
126
|
timeout: 10000
|
|
119
127
|
})
|
|
@@ -126,7 +134,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
126
134
|
await expect(govUkPayApi.createRecurringPaymentAgreement({ cost: 0 })).resolves.toEqual({ ok: true, status: 200 })
|
|
127
135
|
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/agreement', {
|
|
128
136
|
body: JSON.stringify({ cost: 0 }),
|
|
129
|
-
headers: recurringHeaders,
|
|
137
|
+
headers: recurringHeaders(),
|
|
130
138
|
method: 'post',
|
|
131
139
|
timeout: 10000
|
|
132
140
|
})
|
|
@@ -140,7 +148,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
140
148
|
expect(govUkPayApi.createRecurringPaymentAgreement({ reference: '123' })).rejects.toEqual(Error(''))
|
|
141
149
|
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/agreement', {
|
|
142
150
|
body: JSON.stringify({ reference: '123' }),
|
|
143
|
-
headers: recurringHeaders,
|
|
151
|
+
headers: recurringHeaders(),
|
|
144
152
|
method: 'post',
|
|
145
153
|
timeout: 10000
|
|
146
154
|
})
|
|
@@ -148,6 +156,106 @@ describe('govuk-pay-api-connector', () => {
|
|
|
148
156
|
})
|
|
149
157
|
})
|
|
150
158
|
|
|
159
|
+
describe('queueRecurringPayment', () => {
|
|
160
|
+
const getSamplePreparedPayment = (overrides = {}) => ({
|
|
161
|
+
agreement_id: 'agreement_id',
|
|
162
|
+
...overrides
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('queues a recurring payment', async () => {
|
|
166
|
+
const GOV_PAY_API_URL = 'GovPay API URL'
|
|
167
|
+
const GOV_PAY_REQUEST_TIMEOUT_MS = '12345'
|
|
168
|
+
const GOV_PAY_RECURRING_APIKEY = 'GovPay Recurring API Key'
|
|
169
|
+
process.env.GOV_PAY_API_URL = GOV_PAY_API_URL
|
|
170
|
+
process.env.GOV_PAY_REQUEST_TIMEOUT_MS = GOV_PAY_REQUEST_TIMEOUT_MS
|
|
171
|
+
process.env.GOV_PAY_RECURRING_APIKEY = GOV_PAY_RECURRING_APIKEY
|
|
172
|
+
const batcher = { addRequest: jest.fn() }
|
|
173
|
+
const preparedPayment = getSamplePreparedPayment({ cost: 0 })
|
|
174
|
+
govUkPayApi.queueRecurringPayment(preparedPayment, batcher)
|
|
175
|
+
|
|
176
|
+
expect(batcher.addRequest).toHaveBeenCalledWith(
|
|
177
|
+
GOV_PAY_API_URL,
|
|
178
|
+
expect.objectContaining({
|
|
179
|
+
headers: recurringHeaders(),
|
|
180
|
+
method: 'post',
|
|
181
|
+
body: JSON.stringify(preparedPayment),
|
|
182
|
+
timeout: GOV_PAY_REQUEST_TIMEOUT_MS
|
|
183
|
+
}),
|
|
184
|
+
expect.anything()
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('adds agreement id as a reference', async () => {
|
|
189
|
+
// eslint-disable-next-line camelcase
|
|
190
|
+
const agreement_id = Symbol('agreement_id')
|
|
191
|
+
const batcher = { addRequest: jest.fn() }
|
|
192
|
+
govUkPayApi.queueRecurringPayment({ cost: 0, reference: '123', agreement_id }, batcher)
|
|
193
|
+
expect(batcher.addRequest).toHaveBeenCalledWith(expect.any(String), expect.any(Object), agreement_id)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("uses default timeout of 10000ms if GOV_PAY_REQUEST_TIMEOUT_MS isn't set", async () => {
|
|
197
|
+
delete process.env.GOV_PAY_REQUEST_TIMEOUT_MS
|
|
198
|
+
const batcher = { addRequest: jest.fn() }
|
|
199
|
+
|
|
200
|
+
govUkPayApi.queueRecurringPayment(getSamplePreparedPayment({ cost: 0 }), batcher)
|
|
201
|
+
|
|
202
|
+
expect(batcher.addRequest).toHaveBeenCalledWith(
|
|
203
|
+
expect.any(String),
|
|
204
|
+
expect.objectContaining({
|
|
205
|
+
timeout: 10000
|
|
206
|
+
}),
|
|
207
|
+
expect.anything()
|
|
208
|
+
)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('queueRecurringPaymentStatusCheck', () => {
|
|
213
|
+
it.each(['abc-123', 'def-456'])('queues a recurring payment status check with payment id %s', async paymentId => {
|
|
214
|
+
const GOV_PAY_API_URL = 'GovPay API URL'
|
|
215
|
+
const GOV_PAY_REQUEST_TIMEOUT_MS = '12345'
|
|
216
|
+
const GOV_PAY_RECURRING_APIKEY = 'GovPay Recurring API Key'
|
|
217
|
+
process.env.GOV_PAY_API_URL = GOV_PAY_API_URL
|
|
218
|
+
process.env.GOV_PAY_REQUEST_TIMEOUT_MS = GOV_PAY_REQUEST_TIMEOUT_MS
|
|
219
|
+
process.env.GOV_PAY_RECURRING_APIKEY = GOV_PAY_RECURRING_APIKEY
|
|
220
|
+
const batcher = { addRequest: jest.fn() }
|
|
221
|
+
govUkPayApi.queueRecurringPaymentStatusCheck(paymentId, batcher)
|
|
222
|
+
|
|
223
|
+
expect(batcher.addRequest).toHaveBeenCalledWith(
|
|
224
|
+
`${GOV_PAY_API_URL}/${paymentId}`,
|
|
225
|
+
expect.objectContaining({
|
|
226
|
+
headers: recurringHeaders(),
|
|
227
|
+
method: 'get',
|
|
228
|
+
timeout: GOV_PAY_REQUEST_TIMEOUT_MS
|
|
229
|
+
}),
|
|
230
|
+
expect.any(String)
|
|
231
|
+
)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it.each(['3c726d49-a1c0-42f0-88ca-78c0b9932bcd', '4122f2d9-b6ae-47ee-9590-c5d7f45fd9da'])(
|
|
235
|
+
'adds payment id %s as a reference',
|
|
236
|
+
async paymentId => {
|
|
237
|
+
const batcher = { addRequest: jest.fn() }
|
|
238
|
+
govUkPayApi.queueRecurringPaymentStatusCheck(paymentId, batcher)
|
|
239
|
+
expect(batcher.addRequest).toHaveBeenCalledWith(expect.any(String), expect.any(Object), paymentId)
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
it("uses default timeout of 10000ms if GOV_PAY_REQUEST_TIMEOUT_MS isn't set", async () => {
|
|
244
|
+
delete process.env.GOV_PAY_REQUEST_TIMEOUT_MS
|
|
245
|
+
const batcher = { addRequest: jest.fn() }
|
|
246
|
+
|
|
247
|
+
govUkPayApi.queueRecurringPaymentStatusCheck('aaa-111', batcher)
|
|
248
|
+
|
|
249
|
+
expect(batcher.addRequest).toHaveBeenCalledWith(
|
|
250
|
+
expect.any(String),
|
|
251
|
+
expect.objectContaining({
|
|
252
|
+
timeout: 10000
|
|
253
|
+
}),
|
|
254
|
+
expect.any(String)
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
151
259
|
describe('isGovPayUp', () => {
|
|
152
260
|
it.each(['http://gov.uk.pay/health/check/url', 'https://gov-uk-pay?health-check-url'])(
|
|
153
261
|
'calls healthy endpoint %s',
|
|
@@ -191,7 +299,7 @@ describe('govuk-pay-api-connector', () => {
|
|
|
191
299
|
await expect(govUkPayApi.getRecurringPaymentAgreementInformation(123)).resolves.toEqual(
|
|
192
300
|
expect.objectContaining({ ok: true, status: 200 })
|
|
193
301
|
)
|
|
194
|
-
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/agreement/123', { headers: recurringHeaders, method: 'get', timeout: 10000 })
|
|
302
|
+
expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/agreement/123', { headers: recurringHeaders(), method: 'get', timeout: 10000 })
|
|
195
303
|
})
|
|
196
304
|
|
|
197
305
|
it('logs and throws errors', async () => {
|
|
@@ -3,7 +3,7 @@ import fetch from 'node-fetch'
|
|
|
3
3
|
import db from 'debug'
|
|
4
4
|
const [{ value: debug }] = db.mock.results
|
|
5
5
|
|
|
6
|
-
jest.mock('node-fetch', () => jest.fn(() => ({ status: 200 })))
|
|
6
|
+
jest.mock('node-fetch', () => jest.fn(() => Promise.resolve({ status: 200 })))
|
|
7
7
|
jest.mock('debug', () => jest.fn(() => jest.fn()))
|
|
8
8
|
|
|
9
9
|
describe('HTTP Request Batcher', () => {
|
|
@@ -34,7 +34,7 @@ describe('HTTP Request Batcher', () => {
|
|
|
34
34
|
|
|
35
35
|
it('initialises with an empty response queue', () => {
|
|
36
36
|
const batcher = new HTTPRequestBatcher()
|
|
37
|
-
expect(batcher.
|
|
37
|
+
expect(batcher.responseDetails).toEqual([])
|
|
38
38
|
})
|
|
39
39
|
|
|
40
40
|
it('initialises with a custom batch size', () => {
|
|
@@ -50,9 +50,39 @@ describe('HTTP Request Batcher', () => {
|
|
|
50
50
|
batcher.addRequest('https://api-three.example.com', { method: 'PUT' })
|
|
51
51
|
|
|
52
52
|
expect(batcher.requestQueue).toEqual([
|
|
53
|
-
{ url: 'https://api-one.example.com', options: { method: 'GET' } },
|
|
54
|
-
{ url: 'https://api-b.example.com', options: { method: 'POST' } },
|
|
55
|
-
{ url: 'https://api-three.example.com', options: { method: 'PUT' } }
|
|
53
|
+
expect.objectContaining({ url: 'https://api-one.example.com', options: { method: 'GET' } }),
|
|
54
|
+
expect.objectContaining({ url: 'https://api-b.example.com', options: { method: 'POST' } }),
|
|
55
|
+
expect.objectContaining({ url: 'https://api-three.example.com', options: { method: 'PUT' } })
|
|
56
|
+
])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('allows an optional reference to be added to a request', () => {
|
|
60
|
+
const reference1 = Symbol('ref-1')
|
|
61
|
+
const reference2 = Symbol('ref-2')
|
|
62
|
+
const batcher = new HTTPRequestBatcher()
|
|
63
|
+
|
|
64
|
+
batcher.addRequest('https://api-one.example.com', { method: 'GET' }, reference1)
|
|
65
|
+
batcher.addRequest('https://api-b.example.com', { method: 'POST' }, reference2)
|
|
66
|
+
|
|
67
|
+
expect(batcher.requestQueue).toEqual([
|
|
68
|
+
expect.objectContaining({ url: 'https://api-one.example.com', options: { method: 'GET' }, reference: reference1 }),
|
|
69
|
+
expect.objectContaining({ url: 'https://api-b.example.com', options: { method: 'POST' }, reference: reference2 })
|
|
70
|
+
])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('persists the reference in the responseDetails array after fetch', async () => {
|
|
74
|
+
const reference1 = Symbol('ref-1')
|
|
75
|
+
const reference2 = Symbol('ref-2')
|
|
76
|
+
const batcher = new HTTPRequestBatcher()
|
|
77
|
+
|
|
78
|
+
batcher.addRequest('https://api-one.example.com', { method: 'GET' }, reference1)
|
|
79
|
+
batcher.addRequest('https://api-b.example.com', { method: 'POST' }, reference2)
|
|
80
|
+
|
|
81
|
+
await batcher.fetch()
|
|
82
|
+
|
|
83
|
+
expect(batcher.responseDetails).toEqual([
|
|
84
|
+
expect.objectContaining({ url: 'https://api-one.example.com', options: { method: 'GET' }, reference: reference1 }),
|
|
85
|
+
expect.objectContaining({ url: 'https://api-b.example.com', options: { method: 'POST' }, reference: reference2 })
|
|
56
86
|
])
|
|
57
87
|
})
|
|
58
88
|
|
|
@@ -99,13 +129,26 @@ describe('HTTP Request Batcher', () => {
|
|
|
99
129
|
}
|
|
100
130
|
await batcher.fetch()
|
|
101
131
|
|
|
102
|
-
expect(batcher.responses).toEqual([{ status: 200 }, { status: 200 }])
|
|
132
|
+
expect(batcher.responseDetails.map(rd => rd.responses[0])).toEqual([{ status: 200 }, { status: 200 }])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('pairs responses with the corresponding request', async () => {
|
|
136
|
+
const batcher = new HTTPRequestBatcher({ batchSize: 2 })
|
|
137
|
+
batcher.addRequest('https://api-one.example.com', { method: 'GET' })
|
|
138
|
+
batcher.addRequest('https://api-two.example.com', { method: 'POST' })
|
|
139
|
+
await batcher.fetch()
|
|
140
|
+
|
|
141
|
+
expect(batcher.responseDetails).toEqual([
|
|
142
|
+
expect.objectContaining({ responses: [{ status: 200 }], url: 'https://api-one.example.com', options: { method: 'GET' } }),
|
|
143
|
+
expect.objectContaining({ responses: [{ status: 200 }], url: 'https://api-two.example.com', options: { method: 'POST' } })
|
|
144
|
+
])
|
|
103
145
|
})
|
|
104
146
|
|
|
105
147
|
describe('multiple batches', () => {
|
|
106
148
|
beforeEach(() => {
|
|
107
149
|
jest.useFakeTimers()
|
|
108
150
|
jest.spyOn(global, 'setTimeout')
|
|
151
|
+
global.setTimeout.mockImplementation(cb => cb())
|
|
109
152
|
})
|
|
110
153
|
|
|
111
154
|
afterEach(() => {
|
|
@@ -166,20 +209,57 @@ describe('HTTP Request Batcher', () => {
|
|
|
166
209
|
const batcher = new HTTPRequestBatcher({ batchSize: 1 })
|
|
167
210
|
batcher.addRequest('https://api.example.com')
|
|
168
211
|
batcher.addRequest('https://alt-api.example.com')
|
|
169
|
-
global.setTimeout.mockImplementationOnce(cb => cb())
|
|
170
212
|
await batcher.fetch()
|
|
171
|
-
expect(batcher.responses).toEqual([{ status: 200 }, { status: 200 }])
|
|
213
|
+
expect(batcher.responseDetails.map(rd => rd.responses[0])).toEqual([{ status: 200 }, { status: 200 }])
|
|
172
214
|
})
|
|
173
215
|
|
|
174
216
|
it('retries requests that received a 429 response', async () => {
|
|
175
217
|
const batcher = new HTTPRequestBatcher({ batchSize: 1 })
|
|
176
218
|
fetch.mockImplementationOnce(() => ({ status: 429 }))
|
|
177
219
|
batcher.addRequest('https://api.example.com')
|
|
178
|
-
global.setTimeout.mockImplementationOnce(cb => cb())
|
|
179
220
|
await batcher.fetch()
|
|
180
221
|
expect(fetch).toHaveBeenCalledTimes(2)
|
|
181
222
|
})
|
|
182
223
|
|
|
224
|
+
it.each([10, 7])('retries requests that received a 429 response up to configured maxRequestAttempts(%i)', async maxRequestAttempts => {
|
|
225
|
+
const batcher = new HTTPRequestBatcher({ batchSize: 1 })
|
|
226
|
+
batcher.maxRequestAttempts = maxRequestAttempts
|
|
227
|
+
for (let x = 0; x < maxRequestAttempts; x++) {
|
|
228
|
+
fetch.mockImplementationOnce(() => ({ status: 429 }))
|
|
229
|
+
}
|
|
230
|
+
batcher.addRequest('https://api.example.com')
|
|
231
|
+
await batcher.fetch()
|
|
232
|
+
expect(fetch).toHaveBeenCalledTimes(maxRequestAttempts)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it.each([10, 7])(
|
|
236
|
+
'only retries until a successful response is received when maxRequestAttempts is set to %i',
|
|
237
|
+
async maxRequestAttempts => {
|
|
238
|
+
const batcher = new HTTPRequestBatcher({ batchSize: 1 })
|
|
239
|
+
batcher.maxRequestAttempts = maxRequestAttempts
|
|
240
|
+
for (let x = 0; x < maxRequestAttempts - 3; x++) {
|
|
241
|
+
fetch.mockImplementationOnce(() => ({ status: 429 }))
|
|
242
|
+
}
|
|
243
|
+
fetch.mockResolvedValueOnce({ status: 200 })
|
|
244
|
+
batcher.addRequest('https://api.example.com')
|
|
245
|
+
|
|
246
|
+
await batcher.fetch()
|
|
247
|
+
|
|
248
|
+
expect(fetch).toHaveBeenCalledTimes(maxRequestAttempts - 3 + 1)
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
it('stops retrying requests that received a 429 response once a successful response is received', async () => {
|
|
253
|
+
const batcher = new HTTPRequestBatcher({ batchSize: 1 })
|
|
254
|
+
batcher.maxRequestAttempts = 5
|
|
255
|
+
for (let x = 0; x < 4; x++) {
|
|
256
|
+
fetch.mockImplementationOnce(() => ({ status: 429 }))
|
|
257
|
+
}
|
|
258
|
+
batcher.addRequest('https://api.example.com')
|
|
259
|
+
await batcher.fetch()
|
|
260
|
+
expect(fetch).toHaveBeenCalledTimes(5)
|
|
261
|
+
})
|
|
262
|
+
|
|
183
263
|
it('retries requests with the same options as the original request', async () => {
|
|
184
264
|
const batcher = new HTTPRequestBatcher({ batchSize: 3 })
|
|
185
265
|
fetch.mockResolvedValueOnce({ status: 200 }).mockResolvedValueOnce({ status: 429 })
|
|
@@ -188,7 +268,6 @@ describe('HTTP Request Batcher', () => {
|
|
|
188
268
|
batcher.addRequest('https://alt-api.example.com', sampleOptions)
|
|
189
269
|
batcher.addRequest('https://api-three.example.com')
|
|
190
270
|
batcher.addRequest('https://api-four.example.com')
|
|
191
|
-
global.setTimeout.mockImplementationOnce(cb => cb())
|
|
192
271
|
await batcher.fetch()
|
|
193
272
|
expect(fetch).toHaveBeenNthCalledWith(5, 'https://alt-api.example.com', sampleOptions)
|
|
194
273
|
})
|
|
@@ -199,7 +278,6 @@ describe('HTTP Request Batcher', () => {
|
|
|
199
278
|
batcher.addRequest('https://api.example.com')
|
|
200
279
|
batcher.addRequest('https://alt-api.example.com')
|
|
201
280
|
batcher.addRequest('https://api-three.example.com')
|
|
202
|
-
global.setTimeout.mockImplementationOnce(cb => cb())
|
|
203
281
|
await batcher.fetch()
|
|
204
282
|
expect(batcher.batchSize).toBe(2)
|
|
205
283
|
})
|
|
@@ -210,7 +288,6 @@ describe('HTTP Request Batcher', () => {
|
|
|
210
288
|
batcher.addRequest('https://api.example.com')
|
|
211
289
|
batcher.addRequest('https://alt-api.example.com')
|
|
212
290
|
batcher.addRequest('https://api-three.example.com')
|
|
213
|
-
global.setTimeout.mockImplementationOnce(cb => cb())
|
|
214
291
|
await batcher.fetch()
|
|
215
292
|
expect(debug).toHaveBeenCalledWith('429 response received for https://api.example.com, reducing batch size to 2')
|
|
216
293
|
})
|
|
@@ -221,7 +298,6 @@ describe('HTTP Request Batcher', () => {
|
|
|
221
298
|
batcher.addRequest('https://api.example.com')
|
|
222
299
|
batcher.addRequest('https://api.example.com')
|
|
223
300
|
batcher.addRequest('https://api.example.com')
|
|
224
|
-
global.setTimeout.mockImplementationOnce(cb => cb())
|
|
225
301
|
await batcher.fetch()
|
|
226
302
|
expect(debug).toHaveBeenCalledWith(
|
|
227
303
|
'Beginning batched fetch of 4 requests with initial batch size of 3 and delay between batches of 1000ms'
|
|
@@ -232,18 +308,54 @@ describe('HTTP Request Batcher', () => {
|
|
|
232
308
|
const batcher = new HTTPRequestBatcher({ batchSize: 1 })
|
|
233
309
|
fetch.mockImplementationOnce(() => ({ status: 429 }))
|
|
234
310
|
batcher.addRequest('https://api.example.com')
|
|
235
|
-
global.setTimeout.mockImplementationOnce(cb => cb())
|
|
236
311
|
await batcher.fetch()
|
|
237
312
|
expect(batcher.batchSize).toBe(1)
|
|
238
313
|
})
|
|
239
314
|
|
|
240
315
|
it('only retry once if a 429 response is received again', async () => {
|
|
241
316
|
const batcher = new HTTPRequestBatcher({ batchSize: 1 })
|
|
242
|
-
fetch.mockResolvedValueOnce({ status: 429 }).mockResolvedValueOnce({ status: 429 })
|
|
243
317
|
batcher.addRequest('https://api.example.com')
|
|
244
|
-
|
|
318
|
+
fetch.mockResolvedValueOnce({ status: 429 }).mockResolvedValueOnce({ status: 429 })
|
|
245
319
|
await batcher.fetch()
|
|
246
320
|
expect(fetch).toHaveBeenCalledTimes(2)
|
|
247
321
|
})
|
|
322
|
+
|
|
323
|
+
it('sends all requests whether or not they fail', async () => {
|
|
324
|
+
fetch
|
|
325
|
+
.mockResolvedValueOnce({ ok: true, status: 200, json: () => {} })
|
|
326
|
+
.mockResolvedValueOnce({ ok: false, status: 500, json: () => {} })
|
|
327
|
+
.mockRejectedValueOnce(new Error('test event error'))
|
|
328
|
+
.mockResolvedValueOnce({ ok: true, status: 200, json: () => {} })
|
|
329
|
+
.mockResolvedValueOnce({ ok: true, status: 200, json: () => {} })
|
|
330
|
+
const batcher = new HTTPRequestBatcher()
|
|
331
|
+
batcher.addRequest('https://api.example.com/endpoint-1', { method: 'GET' })
|
|
332
|
+
batcher.addRequest('https://api.example.com/endpoint-3', { method: 'GET' })
|
|
333
|
+
batcher.addRequest('https://api.example.com/endpoint-2', { method: 'GET' })
|
|
334
|
+
batcher.addRequest('https://api.example.com/endpoint-gamma', { method: 'GET' })
|
|
335
|
+
batcher.addRequest('https://api.example.com/endpoint-alpha', { method: 'GET' })
|
|
336
|
+
|
|
337
|
+
await batcher.fetch()
|
|
338
|
+
|
|
339
|
+
expect(fetch).toHaveBeenCalledTimes(5)
|
|
340
|
+
expect(fetch).toHaveBeenNthCalledWith(1, 'https://api.example.com/endpoint-1', { method: 'GET' })
|
|
341
|
+
expect(fetch).toHaveBeenNthCalledWith(2, 'https://api.example.com/endpoint-3', { method: 'GET' })
|
|
342
|
+
expect(fetch).toHaveBeenNthCalledWith(3, 'https://api.example.com/endpoint-2', { method: 'GET' })
|
|
343
|
+
expect(fetch).toHaveBeenNthCalledWith(4, 'https://api.example.com/endpoint-gamma', { method: 'GET' })
|
|
344
|
+
expect(fetch).toHaveBeenNthCalledWith(5, 'https://api.example.com/endpoint-alpha', { method: 'GET' })
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('does not add more than one entry in responseDetails array for the same request when retrying a request', async () => {
|
|
348
|
+
fetch
|
|
349
|
+
.mockResolvedValueOnce({ ok: true, status: 200, json: () => {} })
|
|
350
|
+
.mockResolvedValueOnce({ ok: false, status: 429, json: () => {} })
|
|
351
|
+
const batcher = new HTTPRequestBatcher()
|
|
352
|
+
batcher.addRequest('https://api.example.com/endpoint-1', { method: 'GET' })
|
|
353
|
+
batcher.addRequest('https://api.example.com/endpoint-2', { method: 'GET' })
|
|
354
|
+
|
|
355
|
+
await batcher.fetch()
|
|
356
|
+
|
|
357
|
+
expect(batcher.responseDetails.length).toBe(2)
|
|
358
|
+
expect(batcher.responseDetails[0]).not.toBe(batcher.responseDetails[1])
|
|
359
|
+
})
|
|
248
360
|
})
|
|
249
361
|
})
|
package/src/govuk-pay-api.js
CHANGED
|
@@ -32,6 +32,31 @@ export const createRecurringPaymentAgreement = async preparedPayment => {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export const queueRecurringPayment = (preparedPayment, batcher) => {
|
|
36
|
+
batcher.addRequest(
|
|
37
|
+
process.env.GOV_PAY_API_URL,
|
|
38
|
+
{
|
|
39
|
+
headers: headers(true),
|
|
40
|
+
method: 'post',
|
|
41
|
+
body: JSON.stringify(preparedPayment),
|
|
42
|
+
timeout: process.env.GOV_PAY_REQUEST_TIMEOUT_MS || GOV_PAY_REQUEST_TIMEOUT_MS_DEFAULT
|
|
43
|
+
},
|
|
44
|
+
preparedPayment.agreement_id
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const queueRecurringPaymentStatusCheck = (paymentId, batcher) => {
|
|
49
|
+
batcher.addRequest(
|
|
50
|
+
`${process.env.GOV_PAY_API_URL}/${paymentId}`,
|
|
51
|
+
{
|
|
52
|
+
headers: headers(true),
|
|
53
|
+
method: 'get',
|
|
54
|
+
timeout: process.env.GOV_PAY_REQUEST_TIMEOUT_MS || GOV_PAY_REQUEST_TIMEOUT_MS_DEFAULT
|
|
55
|
+
},
|
|
56
|
+
paymentId
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
35
60
|
/**
|
|
36
61
|
* Create a new payment
|
|
37
62
|
* @param preparedPayment - see the GOV.UK pay API reference for details
|
|
@@ -4,10 +4,11 @@ import { StatusCodes } from 'http-status-codes'
|
|
|
4
4
|
|
|
5
5
|
const debug = db('connectors:http-request-batcher')
|
|
6
6
|
export default class HTTPRequestBatcher {
|
|
7
|
+
maxRequestAttempts = 2
|
|
7
8
|
#batchSize
|
|
8
9
|
#delay
|
|
9
10
|
#requests = []
|
|
10
|
-
#
|
|
11
|
+
#responseDetails = []
|
|
11
12
|
|
|
12
13
|
constructor ({ batchSize = 50, delay = 1000 } = {}) {
|
|
13
14
|
this.#batchSize = batchSize
|
|
@@ -22,34 +23,49 @@ export default class HTTPRequestBatcher {
|
|
|
22
23
|
return this.#requests
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
get
|
|
26
|
-
return this.#
|
|
26
|
+
get responseDetails () {
|
|
27
|
+
return this.#responseDetails
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
get delay () {
|
|
30
31
|
return this.#delay
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
addRequest (url, options) {
|
|
34
|
+
addRequest (url, options, reference = null) {
|
|
34
35
|
if (!url) {
|
|
35
36
|
throw new Error('URL is required')
|
|
36
37
|
}
|
|
37
|
-
this.#requests.push({
|
|
38
|
+
this.#requests.push({
|
|
39
|
+
url,
|
|
40
|
+
options,
|
|
41
|
+
reference,
|
|
42
|
+
responses: []
|
|
43
|
+
})
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
async
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
async #processBatch (fetchRequests, requestQueue) {
|
|
47
|
+
for (const fetchRequest of fetchRequests) {
|
|
48
|
+
const response = await (async () => {
|
|
49
|
+
try {
|
|
50
|
+
return await fetchRequest.responsePromise
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return e
|
|
53
|
+
}
|
|
54
|
+
})()
|
|
55
|
+
fetchRequest.responses.push(response)
|
|
56
|
+
if (
|
|
57
|
+
fetchRequest.responses.at(-1).status === StatusCodes.TOO_MANY_REQUESTS &&
|
|
58
|
+
fetchRequest.responses.length < this.maxRequestAttempts
|
|
59
|
+
) {
|
|
60
|
+
requestQueue.push(fetchRequest)
|
|
47
61
|
this.#batchSize = Math.max(this.#batchSize - 1, 1)
|
|
48
|
-
debug(
|
|
62
|
+
debug(`${StatusCodes.TOO_MANY_REQUESTS} response received for ${fetchRequest.url}, reducing batch size to ${this.#batchSize}`)
|
|
63
|
+
}
|
|
64
|
+
if (!this.#responseDetails.includes(fetchRequest)) {
|
|
65
|
+
this.#responseDetails.push(fetchRequest)
|
|
49
66
|
}
|
|
50
67
|
}
|
|
51
68
|
fetchRequests.length = 0
|
|
52
|
-
sentRequests.length = 0
|
|
53
69
|
if (requestQueue.length) {
|
|
54
70
|
// don't wait if this is the last batch
|
|
55
71
|
await new Promise(resolve => setTimeout(resolve, this.#delay))
|
|
@@ -63,14 +79,13 @@ export default class HTTPRequestBatcher {
|
|
|
63
79
|
} and delay between batches of ${this.#delay}ms`
|
|
64
80
|
)
|
|
65
81
|
const requestQueue = [...this.#requests]
|
|
66
|
-
const sentRequests = []
|
|
67
82
|
const fetchRequests = []
|
|
68
83
|
while (requestQueue.length) {
|
|
69
84
|
const request = requestQueue.shift()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (fetchRequests.length === this.#batchSize) {
|
|
73
|
-
await this
|
|
85
|
+
request.responsePromise = fetch(request.url, request.options)
|
|
86
|
+
fetchRequests.push(request)
|
|
87
|
+
if (fetchRequests.length === this.#batchSize || requestQueue.length === 0) {
|
|
88
|
+
await this.#processBatch(fetchRequests, requestQueue)
|
|
74
89
|
}
|
|
75
90
|
}
|
|
76
91
|
debug('Batched fetch complete')
|