core-services-sdk 1.3.87 → 1.3.89
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 +1 -1
- package/src/http/http.js +111 -18
- package/src/rabbit-mq/rabbit-mq.js +22 -11
- package/src/rabbit-mq/start-stop-rabbitmq.js +62 -38
- package/tests/http/http.int.test.js +173 -4
- package/tests/http/http.unit.test.js +101 -2
- package/tests/rabbit-mq/rabbit-mq.test.js +60 -34
- package/tests/resources/docker-mongo-test.js +10 -9
package/package.json
CHANGED
package/src/http/http.js
CHANGED
|
@@ -183,6 +183,26 @@ const getResponsePayload = async (response, responseType) => {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Checks if value is a Node.js Readable stream.
|
|
188
|
+
*
|
|
189
|
+
* @param {any} value
|
|
190
|
+
* @returns {boolean}
|
|
191
|
+
*/
|
|
192
|
+
const isReadableStream = (value) => {
|
|
193
|
+
return value && typeof value === 'object' && typeof value.pipe === 'function'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Checks if value is a TypedArray (Uint8Array, etc).
|
|
198
|
+
*
|
|
199
|
+
* @param {any} value
|
|
200
|
+
* @returns {boolean}
|
|
201
|
+
*/
|
|
202
|
+
const isTypedArray = (value) => {
|
|
203
|
+
return ArrayBuffer.isView(value)
|
|
204
|
+
}
|
|
205
|
+
|
|
186
206
|
/**
|
|
187
207
|
* Normalizes the request body before sending it via fetch.
|
|
188
208
|
*
|
|
@@ -192,6 +212,9 @@ const getResponsePayload = async (response, responseType) => {
|
|
|
192
212
|
* - URLSearchParams → sent as-is (fetch serializes automatically)
|
|
193
213
|
* - FormData → sent as-is (fetch sets multipart boundary automatically)
|
|
194
214
|
* - ArrayBuffer / Blob → sent as-is
|
|
215
|
+
* - Buffer → sent as-is
|
|
216
|
+
* - TypedArray → sent as-is
|
|
217
|
+
* - Readable stream → sent as-is
|
|
195
218
|
* - object / array → JSON.stringify applied and Content-Type ensured
|
|
196
219
|
*
|
|
197
220
|
* @param {any} body
|
|
@@ -203,22 +226,89 @@ const prepareRequestBody = (body, headers = {}) => {
|
|
|
203
226
|
return { body: undefined, headers }
|
|
204
227
|
}
|
|
205
228
|
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
body
|
|
212
|
-
|
|
229
|
+
if (typeof body === 'string') {
|
|
230
|
+
return { body, headers }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (body instanceof URLSearchParams) {
|
|
234
|
+
return { body, headers }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (body instanceof FormData) {
|
|
238
|
+
return { body, headers }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (Buffer.isBuffer(body)) {
|
|
242
|
+
return { body, headers }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (body instanceof ArrayBuffer) {
|
|
246
|
+
return { body, headers }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (isTypedArray(body)) {
|
|
250
|
+
return { body, headers }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
213
254
|
return { body, headers }
|
|
214
255
|
}
|
|
215
256
|
|
|
257
|
+
if (isReadableStream(body)) {
|
|
258
|
+
return { body, headers }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (typeof body === 'object') {
|
|
262
|
+
return {
|
|
263
|
+
body: JSON.stringify(body),
|
|
264
|
+
headers: {
|
|
265
|
+
'Content-Type': 'application/json',
|
|
266
|
+
...headers,
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { body, headers }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolves headers safely based on body type.
|
|
276
|
+
*
|
|
277
|
+
* Adds default headers only when no Content-Type is provided
|
|
278
|
+
* AND the body is a plain JSON object or array.
|
|
279
|
+
*
|
|
280
|
+
* @param {Record<string,string>} preparedHeaders
|
|
281
|
+
* @param {any} body
|
|
282
|
+
* @param {Record<string,string>} defaultHeaders
|
|
283
|
+
* @returns {Record<string,string>}
|
|
284
|
+
*/
|
|
285
|
+
const resolveHeaders = (
|
|
286
|
+
preparedHeaders = {},
|
|
287
|
+
body,
|
|
288
|
+
defaultHeaders = JSON_HEADER,
|
|
289
|
+
) => {
|
|
290
|
+
const hasContentType = Object.keys(preparedHeaders).some(
|
|
291
|
+
(key) => key.toLowerCase() === 'content-type',
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if (hasContentType) {
|
|
295
|
+
return preparedHeaders
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const isPlainObject =
|
|
299
|
+
body !== null && typeof body === 'object' && body.constructor === Object
|
|
300
|
+
|
|
301
|
+
const isArray = Array.isArray(body)
|
|
302
|
+
|
|
303
|
+
const isJsonCandidate = isPlainObject || isArray
|
|
304
|
+
|
|
305
|
+
if (!isJsonCandidate) {
|
|
306
|
+
return preparedHeaders
|
|
307
|
+
}
|
|
308
|
+
|
|
216
309
|
return {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
'Content-Type': 'application/json',
|
|
220
|
-
...headers,
|
|
221
|
-
},
|
|
310
|
+
...defaultHeaders,
|
|
311
|
+
...preparedHeaders,
|
|
222
312
|
}
|
|
223
313
|
}
|
|
224
314
|
|
|
@@ -238,7 +328,10 @@ export const get = async ({
|
|
|
238
328
|
const response = await fetch(url, {
|
|
239
329
|
...extraParams,
|
|
240
330
|
method: HTTP_METHODS.GET,
|
|
241
|
-
headers: {
|
|
331
|
+
headers: {
|
|
332
|
+
Accept: 'application/json',
|
|
333
|
+
...headers,
|
|
334
|
+
},
|
|
242
335
|
})
|
|
243
336
|
await checkStatus(response)
|
|
244
337
|
return getResponsePayload(response, expectedType)
|
|
@@ -265,7 +358,7 @@ export const post = async ({
|
|
|
265
358
|
const response = await fetch(url, {
|
|
266
359
|
...extraParams,
|
|
267
360
|
method: HTTP_METHODS.POST,
|
|
268
|
-
headers:
|
|
361
|
+
headers: resolveHeaders(preparedHeaders, body),
|
|
269
362
|
body: preparedBody,
|
|
270
363
|
})
|
|
271
364
|
await checkStatus(response)
|
|
@@ -293,7 +386,7 @@ export const put = async ({
|
|
|
293
386
|
const response = await fetch(url, {
|
|
294
387
|
...extraParams,
|
|
295
388
|
method: HTTP_METHODS.PUT,
|
|
296
|
-
headers:
|
|
389
|
+
headers: resolveHeaders(preparedHeaders, body),
|
|
297
390
|
body: preparedBody,
|
|
298
391
|
})
|
|
299
392
|
await checkStatus(response)
|
|
@@ -321,7 +414,7 @@ export const patch = async ({
|
|
|
321
414
|
const response = await fetch(url, {
|
|
322
415
|
...extraParams,
|
|
323
416
|
method: HTTP_METHODS.PATCH,
|
|
324
|
-
headers:
|
|
417
|
+
headers: resolveHeaders(preparedHeaders, body),
|
|
325
418
|
body: preparedBody,
|
|
326
419
|
})
|
|
327
420
|
await checkStatus(response)
|
|
@@ -349,7 +442,7 @@ export const deleteApi = async ({
|
|
|
349
442
|
const response = await fetch(url, {
|
|
350
443
|
...extraParams,
|
|
351
444
|
method: HTTP_METHODS.DELETE,
|
|
352
|
-
headers:
|
|
445
|
+
headers: resolveHeaders(preparedHeaders, body),
|
|
353
446
|
...(preparedBody ? { body: preparedBody } : {}),
|
|
354
447
|
})
|
|
355
448
|
await checkStatus(response)
|
|
@@ -367,7 +460,7 @@ export const head = async ({ url, headers = {}, extraParams = {} }) => {
|
|
|
367
460
|
const response = await fetch(url, {
|
|
368
461
|
...extraParams,
|
|
369
462
|
method: HTTP_METHODS.HEAD,
|
|
370
|
-
headers
|
|
463
|
+
headers,
|
|
371
464
|
})
|
|
372
465
|
|
|
373
466
|
await checkStatus(response)
|
|
@@ -13,6 +13,18 @@ import { mask } from '../util/mask-sensitive.js'
|
|
|
13
13
|
|
|
14
14
|
const generateMsgId = () => `rbt_${ulid()}`
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Validates a RabbitMQ queue name.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} queue
|
|
20
|
+
* @throws {Error} If queue name is invalid
|
|
21
|
+
*/
|
|
22
|
+
const assertValidQueueName = (queue) => {
|
|
23
|
+
if (!queue || typeof queue !== 'string' || !queue.trim()) {
|
|
24
|
+
throw new Error(`Invalid queue name: "${queue}"`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* Connects to a RabbitMQ server.
|
|
18
30
|
*
|
|
@@ -191,19 +203,15 @@ export const subscribeToQueue = async ({
|
|
|
191
203
|
}) => {
|
|
192
204
|
const logger = log.child({ op: 'subscribeToQueue', queue })
|
|
193
205
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
logger.error({ error: message })
|
|
197
|
-
throw new Error(message)
|
|
198
|
-
}
|
|
206
|
+
try {
|
|
207
|
+
assertValidQueueName(queue)
|
|
199
208
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
if (typeof onReceive !== 'function') {
|
|
210
|
+
const message = `Cannot subscribe to queue "${queue}" because onReceive is not a function`
|
|
211
|
+
logger.error({ error: message })
|
|
212
|
+
throw new Error(message)
|
|
213
|
+
}
|
|
205
214
|
|
|
206
|
-
try {
|
|
207
215
|
await channel.assertQueue(queue, { durable: true })
|
|
208
216
|
|
|
209
217
|
if (prefetch) {
|
|
@@ -282,6 +290,8 @@ export const initializeQueue = async ({ host, log }) => {
|
|
|
282
290
|
* @returns {Promise<boolean>} True if the message was sent successfully
|
|
283
291
|
*/
|
|
284
292
|
const publish = async (queue, data, correlationId) => {
|
|
293
|
+
assertValidQueueName(queue)
|
|
294
|
+
|
|
285
295
|
const msgId = generateMsgId()
|
|
286
296
|
const t0 = Date.now()
|
|
287
297
|
const logChild = logger.child({
|
|
@@ -296,6 +306,7 @@ export const initializeQueue = async ({ host, log }) => {
|
|
|
296
306
|
|
|
297
307
|
await channel.assertQueue(queue, { durable: true })
|
|
298
308
|
const payload = { msgId, data, correlationId }
|
|
309
|
+
|
|
299
310
|
const sent = channel.sendToQueue(
|
|
300
311
|
queue,
|
|
301
312
|
Buffer.from(JSON.stringify(payload)),
|
|
@@ -31,39 +31,24 @@ import { execSync } from 'node:child_process'
|
|
|
31
31
|
export function startRabbit({ containerName, ...rest }) {
|
|
32
32
|
console.log(`[RabbitTest] Starting RabbitMQ...`)
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' })
|
|
36
|
-
} catch {}
|
|
37
|
-
|
|
38
|
-
// Kill any containers that might still be holding the ports
|
|
39
|
-
for (const port of [rest.amqpPort, rest.uiPort]) {
|
|
40
|
-
try {
|
|
41
|
-
const id = execSync(`docker ps -q --filter "publish=${port}"`, {
|
|
42
|
-
encoding: 'utf8',
|
|
43
|
-
}).trim()
|
|
44
|
-
if (id) {
|
|
45
|
-
execSync(`docker rm -f ${id}`, { stdio: 'ignore' })
|
|
46
|
-
}
|
|
47
|
-
} catch {}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
execSync(
|
|
51
|
-
`docker run -d \
|
|
34
|
+
const dockerRunCmd = `docker run -d \
|
|
52
35
|
--name ${containerName} \
|
|
53
36
|
-e RABBITMQ_DEFAULT_USER=${rest.user} \
|
|
54
37
|
-e RABBITMQ_DEFAULT_PASS=${rest.pass} \
|
|
55
38
|
-p ${rest.amqpPort}:5672 \
|
|
56
39
|
-p ${rest.uiPort}:15672 \
|
|
57
|
-
--tmpfs /var/lib/rabbitmq \
|
|
58
40
|
--health-cmd="rabbitmq-diagnostics -q ping" \
|
|
59
41
|
--health-interval=5s \
|
|
60
42
|
--health-timeout=5s \
|
|
61
43
|
--health-retries=10 \
|
|
62
|
-
rabbitmq:3-management
|
|
63
|
-
{ stdio: 'inherit' },
|
|
64
|
-
)
|
|
44
|
+
rabbitmq:3-management`
|
|
65
45
|
|
|
66
|
-
|
|
46
|
+
cleanupContainer(containerName, [rest.amqpPort, rest.uiPort])
|
|
47
|
+
execSync(dockerRunCmd, { stdio: 'ignore' })
|
|
48
|
+
waitForRabbitHealthy(containerName, dockerRunCmd, [
|
|
49
|
+
rest.amqpPort,
|
|
50
|
+
rest.uiPort,
|
|
51
|
+
])
|
|
67
52
|
}
|
|
68
53
|
|
|
69
54
|
/**
|
|
@@ -79,53 +64,92 @@ export function startRabbit({ containerName, ...rest }) {
|
|
|
79
64
|
export function stopRabbit(containerName = 'rabbit-test') {
|
|
80
65
|
console.log(`[RabbitTest] Stopping RabbitMQ...`)
|
|
81
66
|
try {
|
|
82
|
-
execSync(`docker rm -
|
|
67
|
+
execSync(`docker rm -fv ${containerName}`, { stdio: 'ignore' })
|
|
83
68
|
} catch (error) {
|
|
84
69
|
console.error(`[RabbitTest] Failed to stop RabbitMQ: ${error}`)
|
|
85
70
|
}
|
|
86
71
|
}
|
|
87
72
|
|
|
73
|
+
function cleanupContainer(containerName, ports = []) {
|
|
74
|
+
try {
|
|
75
|
+
execSync(`docker rm -fv ${containerName}`, { stdio: 'ignore' })
|
|
76
|
+
} catch {}
|
|
77
|
+
|
|
78
|
+
for (const port of ports) {
|
|
79
|
+
try {
|
|
80
|
+
const id = execSync(`docker ps -q --filter "publish=${port}"`, {
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
}).trim()
|
|
83
|
+
if (id) {
|
|
84
|
+
execSync(`docker rm -fv ${id}`, { stdio: 'ignore' })
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
88
90
|
/**
|
|
89
91
|
* Waits until the RabbitMQ Docker container reports a healthy status.
|
|
90
92
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
+
* If the container crashes (e.g. due to Docker volume initialization race
|
|
94
|
+
* on macOS), it is automatically recreated and retried.
|
|
93
95
|
*
|
|
94
96
|
* @param {string} containerName
|
|
95
97
|
* Docker container name.
|
|
96
98
|
*
|
|
99
|
+
* @param {string} dockerRunCmd
|
|
100
|
+
* The docker run command to recreate the container if it crashes.
|
|
101
|
+
*
|
|
102
|
+
* @param {number[]} ports
|
|
103
|
+
* Host ports to clean up when recreating.
|
|
104
|
+
*
|
|
97
105
|
* @returns {void}
|
|
98
106
|
*
|
|
99
107
|
* @throws {Error}
|
|
100
108
|
* Throws if the container does not become healthy within the timeout.
|
|
101
109
|
*/
|
|
102
|
-
function waitForRabbitHealthy(containerName) {
|
|
110
|
+
function waitForRabbitHealthy(containerName, dockerRunCmd, ports) {
|
|
103
111
|
console.log(`[RabbitTest] Waiting for RabbitMQ to be healthy...`)
|
|
104
112
|
|
|
105
|
-
const maxRetries =
|
|
113
|
+
const maxRetries = 90
|
|
114
|
+
const maxRestarts = 3
|
|
106
115
|
let retries = 0
|
|
116
|
+
let restarts = 0
|
|
107
117
|
|
|
108
118
|
while (retries < maxRetries) {
|
|
119
|
+
// Check if the container has crashed
|
|
109
120
|
try {
|
|
110
|
-
const
|
|
111
|
-
`docker inspect --format='{{.State.
|
|
121
|
+
const status = execSync(
|
|
122
|
+
`docker inspect --format='{{.State.Status}}' ${containerName}`,
|
|
112
123
|
{ encoding: 'utf8' },
|
|
113
124
|
).trim()
|
|
114
125
|
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (retries % 10 === 0 && retries > 0) {
|
|
126
|
+
if (status === 'exited' || status === 'dead') {
|
|
127
|
+
if (restarts >= maxRestarts) {
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
restarts++
|
|
121
131
|
console.log(
|
|
122
|
-
`[RabbitTest]
|
|
132
|
+
`[RabbitTest] Container crashed, restarting (${restarts}/${maxRestarts})...`,
|
|
123
133
|
)
|
|
134
|
+
cleanupContainer(containerName, ports)
|
|
135
|
+
execSync(dockerRunCmd, { stdio: 'ignore' })
|
|
136
|
+
execSync('sleep 2')
|
|
137
|
+
retries++
|
|
138
|
+
continue
|
|
124
139
|
}
|
|
140
|
+
} catch {}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
execSync(
|
|
144
|
+
`docker exec ${containerName} rabbitmq-diagnostics -q ping`,
|
|
145
|
+
{ stdio: 'ignore' },
|
|
146
|
+
)
|
|
147
|
+
console.log(`[RabbitTest] RabbitMQ is ready.`)
|
|
148
|
+
return
|
|
125
149
|
} catch {
|
|
126
150
|
if (retries % 10 === 0 && retries > 0) {
|
|
127
151
|
console.log(
|
|
128
|
-
`[RabbitTest]
|
|
152
|
+
`[RabbitTest] Still waiting... (${retries}/${maxRetries})`,
|
|
129
153
|
)
|
|
130
154
|
}
|
|
131
155
|
}
|
|
@@ -6,7 +6,7 @@ import { http } from '../../src/http/http.js'
|
|
|
6
6
|
let server
|
|
7
7
|
let baseUrl
|
|
8
8
|
|
|
9
|
-
function
|
|
9
|
+
function collectRaw(req) {
|
|
10
10
|
return new Promise((resolve) => {
|
|
11
11
|
const chunks = []
|
|
12
12
|
|
|
@@ -15,14 +15,15 @@ function collectBody(req) {
|
|
|
15
15
|
})
|
|
16
16
|
|
|
17
17
|
req.on('end', () => {
|
|
18
|
-
|
|
18
|
+
const buffer = Buffer.concat(chunks)
|
|
19
|
+
resolve(buffer)
|
|
19
20
|
})
|
|
20
21
|
})
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
beforeAll(async () => {
|
|
24
25
|
server = httpServer.createServer(async (req, res) => {
|
|
25
|
-
const
|
|
26
|
+
const raw = await collectRaw(req)
|
|
26
27
|
|
|
27
28
|
res.setHeader('content-type', 'application/json')
|
|
28
29
|
|
|
@@ -30,7 +31,8 @@ beforeAll(async () => {
|
|
|
30
31
|
JSON.stringify({
|
|
31
32
|
method: req.method,
|
|
32
33
|
headers: req.headers,
|
|
33
|
-
body,
|
|
34
|
+
body: raw.toString(),
|
|
35
|
+
bodyLength: raw.length,
|
|
34
36
|
}),
|
|
35
37
|
)
|
|
36
38
|
})
|
|
@@ -59,6 +61,15 @@ describe('http util integration', () => {
|
|
|
59
61
|
expect(result.body).toBe(JSON.stringify({ hello: 'world' }))
|
|
60
62
|
})
|
|
61
63
|
|
|
64
|
+
it('should send array JSON correctly', async () => {
|
|
65
|
+
const result = await http.post({
|
|
66
|
+
url: `${baseUrl}/array`,
|
|
67
|
+
body: [1, 2, 3],
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(result.body).toBe(JSON.stringify([1, 2, 3]))
|
|
71
|
+
})
|
|
72
|
+
|
|
62
73
|
it('should send string body without stringify', async () => {
|
|
63
74
|
const result = await http.post({
|
|
64
75
|
url: `${baseUrl}/string`,
|
|
@@ -93,6 +104,43 @@ describe('http util integration', () => {
|
|
|
93
104
|
expect(result.body).toBe(params.toString())
|
|
94
105
|
})
|
|
95
106
|
|
|
107
|
+
it('should support Buffer (binary)', async () => {
|
|
108
|
+
const buffer = Buffer.from('PDF-DATA-TEST')
|
|
109
|
+
|
|
110
|
+
const result = await http.post({
|
|
111
|
+
url: `${baseUrl}/buffer`,
|
|
112
|
+
body: buffer,
|
|
113
|
+
headers: {
|
|
114
|
+
'Content-Type': 'application/pdf',
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(result.bodyLength).toBe(buffer.length)
|
|
119
|
+
expect(result.body).toBe(buffer.toString())
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('should support Uint8Array (TypedArray)', async () => {
|
|
123
|
+
const uint8 = new Uint8Array([1, 2, 3, 4])
|
|
124
|
+
|
|
125
|
+
const result = await http.post({
|
|
126
|
+
url: `${baseUrl}/typed`,
|
|
127
|
+
body: uint8,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
expect(result.bodyLength).toBe(uint8.length)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should support ArrayBuffer', async () => {
|
|
134
|
+
const buffer = new Uint8Array([5, 6, 7]).buffer
|
|
135
|
+
|
|
136
|
+
const result = await http.post({
|
|
137
|
+
url: `${baseUrl}/arraybuffer`,
|
|
138
|
+
body: buffer,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(result.bodyLength).toBe(3)
|
|
142
|
+
})
|
|
143
|
+
|
|
96
144
|
it('should support PUT with JSON', async () => {
|
|
97
145
|
const result = await http.put({
|
|
98
146
|
url: `${baseUrl}/put`,
|
|
@@ -123,6 +171,14 @@ describe('http util integration', () => {
|
|
|
123
171
|
expect(result.body).toBe(JSON.stringify({ remove: true }))
|
|
124
172
|
})
|
|
125
173
|
|
|
174
|
+
it('should not send body when undefined', async () => {
|
|
175
|
+
const result = await http.post({
|
|
176
|
+
url: `${baseUrl}/empty`,
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
expect(result.bodyLength).toBe(0)
|
|
180
|
+
})
|
|
181
|
+
|
|
126
182
|
it('should support GET request', async () => {
|
|
127
183
|
const result = await http.get({
|
|
128
184
|
url: `${baseUrl}/get`,
|
|
@@ -138,4 +194,117 @@ describe('http util integration', () => {
|
|
|
138
194
|
|
|
139
195
|
expect(response.status).toBe(200)
|
|
140
196
|
})
|
|
197
|
+
|
|
198
|
+
it('should set JSON content-type by default', async () => {
|
|
199
|
+
const result = await http.post({
|
|
200
|
+
url: `${baseUrl}/json-header`,
|
|
201
|
+
body: { a: 1 },
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(result.headers['content-type']).toContain('application/json')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('should allow overriding content-type', async () => {
|
|
208
|
+
const result = await http.post({
|
|
209
|
+
url: `${baseUrl}/override-header`,
|
|
210
|
+
body: { a: 1 },
|
|
211
|
+
headers: {
|
|
212
|
+
'Content-Type': 'application/custom+json',
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
expect(result.headers['content-type']).toContain('application/custom+json')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('should preserve binary content-type', async () => {
|
|
220
|
+
const buffer = Buffer.from('file')
|
|
221
|
+
|
|
222
|
+
const result = await http.post({
|
|
223
|
+
url: `${baseUrl}/pdf`,
|
|
224
|
+
body: buffer,
|
|
225
|
+
headers: {
|
|
226
|
+
'Content-Type': 'application/pdf',
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
expect(result.headers['content-type']).toContain('application/pdf')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('should not force JSON content-type for URLSearchParams', async () => {
|
|
234
|
+
const params = new URLSearchParams({ a: '1' })
|
|
235
|
+
|
|
236
|
+
const result = await http.post({
|
|
237
|
+
url: `${baseUrl}/urlencoded`,
|
|
238
|
+
body: params,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
expect(result.headers['content-type']).toContain(
|
|
242
|
+
'application/x-www-form-urlencoded',
|
|
243
|
+
)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('should not override multipart content-type for FormData', async () => {
|
|
247
|
+
const form = new FormData()
|
|
248
|
+
form.append('field', 'value')
|
|
249
|
+
|
|
250
|
+
const result = await http.post({
|
|
251
|
+
url: `${baseUrl}/multipart`,
|
|
252
|
+
body: form,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
expect(result.headers['content-type']).toContain('multipart/form-data')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('should respect content-type for string body', async () => {
|
|
259
|
+
const result = await http.post({
|
|
260
|
+
url: `${baseUrl}/text`,
|
|
261
|
+
body: 'hello',
|
|
262
|
+
headers: {
|
|
263
|
+
'Content-Type': 'text/plain',
|
|
264
|
+
},
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
expect(result.headers['content-type']).toContain('text/plain')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('GET should NOT send content-type header', async () => {
|
|
271
|
+
const result = await http.get({
|
|
272
|
+
url: `${baseUrl}/get-no-content-type`,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
expect(result.headers['content-type']).toBeUndefined()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('should NOT add JSON content-type for Buffer without headers', async () => {
|
|
279
|
+
const buffer = Buffer.from('binary')
|
|
280
|
+
|
|
281
|
+
const result = await http.post({
|
|
282
|
+
url: `${baseUrl}/buffer-no-header`,
|
|
283
|
+
body: buffer,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
expect(result.headers['content-type']).toBeUndefined()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should NOT add JSON content-type for TypedArray', async () => {
|
|
290
|
+
const uint8 = new Uint8Array([1, 2, 3])
|
|
291
|
+
|
|
292
|
+
const result = await http.post({
|
|
293
|
+
url: `${baseUrl}/typed-no-header`,
|
|
294
|
+
body: uint8,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
expect(result.headers['content-type']).toBeUndefined()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('should NOT add JSON content-type for ArrayBuffer', async () => {
|
|
301
|
+
const buffer = new Uint8Array([1, 2]).buffer
|
|
302
|
+
|
|
303
|
+
const result = await http.post({
|
|
304
|
+
url: `${baseUrl}/arraybuffer-no-header`,
|
|
305
|
+
body: buffer,
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
expect(result.headers['content-type']).toBeUndefined()
|
|
309
|
+
})
|
|
141
310
|
})
|
|
@@ -166,14 +166,13 @@ describe('http client (native fetch)', () => {
|
|
|
166
166
|
})
|
|
167
167
|
|
|
168
168
|
describe('PUT with non-JSON body', () => {
|
|
169
|
-
it('should not stringify body
|
|
169
|
+
it('should not stringify string body', async () => {
|
|
170
170
|
const rawBody = 'plain text body'
|
|
171
171
|
mockFetch.mockResolvedValueOnce(createMockResponse({ body: 'OK' }))
|
|
172
172
|
|
|
173
173
|
await http.put({
|
|
174
174
|
url: 'http://test.com',
|
|
175
175
|
body: rawBody,
|
|
176
|
-
expectedType: ResponseType.text,
|
|
177
176
|
})
|
|
178
177
|
|
|
179
178
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
@@ -395,4 +394,104 @@ describe('edge cases', () => {
|
|
|
395
394
|
}),
|
|
396
395
|
)
|
|
397
396
|
})
|
|
397
|
+
|
|
398
|
+
it('should NOT add JSON content-type for Buffer', async () => {
|
|
399
|
+
const buffer = Buffer.from('binary')
|
|
400
|
+
|
|
401
|
+
mockFetch.mockResolvedValueOnce(
|
|
402
|
+
createMockResponse({ body: JSON.stringify({ ok: true }) }),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
await http.post({
|
|
406
|
+
url: 'http://test.com',
|
|
407
|
+
body: buffer,
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
411
|
+
'http://test.com',
|
|
412
|
+
expect.objectContaining({
|
|
413
|
+
headers: expect.not.objectContaining({
|
|
414
|
+
'Content-Type': 'application/json',
|
|
415
|
+
}),
|
|
416
|
+
}),
|
|
417
|
+
)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('should NOT add JSON content-type for TypedArray', async () => {
|
|
421
|
+
const uint8 = new Uint8Array([1, 2, 3])
|
|
422
|
+
|
|
423
|
+
mockFetch.mockResolvedValueOnce(
|
|
424
|
+
createMockResponse({ body: JSON.stringify({ ok: true }) }),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
await http.post({
|
|
428
|
+
url: 'http://test.com',
|
|
429
|
+
body: uint8,
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
433
|
+
'http://test.com',
|
|
434
|
+
expect.objectContaining({
|
|
435
|
+
headers: expect.not.objectContaining({
|
|
436
|
+
'Content-Type': 'application/json',
|
|
437
|
+
}),
|
|
438
|
+
}),
|
|
439
|
+
)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('should NOT add JSON content-type for ArrayBuffer', async () => {
|
|
443
|
+
const buffer = new Uint8Array([1, 2]).buffer
|
|
444
|
+
|
|
445
|
+
mockFetch.mockResolvedValueOnce(
|
|
446
|
+
createMockResponse({ body: JSON.stringify({ ok: true }) }),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
await http.post({
|
|
450
|
+
url: 'http://test.com',
|
|
451
|
+
body: buffer,
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
455
|
+
'http://test.com',
|
|
456
|
+
expect.objectContaining({
|
|
457
|
+
headers: expect.not.objectContaining({
|
|
458
|
+
'Content-Type': 'application/json',
|
|
459
|
+
}),
|
|
460
|
+
}),
|
|
461
|
+
)
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('should include default Accept header in GET', async () => {
|
|
465
|
+
mockFetch.mockResolvedValueOnce(
|
|
466
|
+
createMockResponse({ body: JSON.stringify({ ok: true }) }),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
await http.get({
|
|
470
|
+
url: 'http://test.com',
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
474
|
+
'http://test.com',
|
|
475
|
+
expect.objectContaining({
|
|
476
|
+
headers: expect.objectContaining({
|
|
477
|
+
Accept: 'application/json',
|
|
478
|
+
}),
|
|
479
|
+
}),
|
|
480
|
+
)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('HEAD should NOT include content-type header', async () => {
|
|
484
|
+
mockFetch.mockResolvedValueOnce(createMockResponse())
|
|
485
|
+
|
|
486
|
+
await http.head({
|
|
487
|
+
url: 'http://test.com',
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
491
|
+
'http://test.com',
|
|
492
|
+
expect.objectContaining({
|
|
493
|
+
headers: {},
|
|
494
|
+
}),
|
|
495
|
+
)
|
|
496
|
+
})
|
|
398
497
|
})
|
|
@@ -14,11 +14,13 @@ const AMQP_PORT = 5679
|
|
|
14
14
|
const UI_PORT = 15679
|
|
15
15
|
const USER = 'test'
|
|
16
16
|
const PASS = 'test'
|
|
17
|
-
const QUEUE = 'integration-test-queue'
|
|
18
17
|
|
|
19
18
|
const log = pino({
|
|
20
19
|
level: 'silent',
|
|
21
20
|
})
|
|
21
|
+
|
|
22
|
+
const uniqueQueue = (name) => `${name}-${Date.now()}-${Math.random()}`
|
|
23
|
+
|
|
22
24
|
// @ts-ignore
|
|
23
25
|
async function waitForRabbitConnection({ uri, log, timeoutMs = 30000 }) {
|
|
24
26
|
const start = Date.now()
|
|
@@ -36,12 +38,7 @@ async function waitForRabbitConnection({ uri, log, timeoutMs = 30000 }) {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
describe('RabbitMQ integration', () => {
|
|
39
|
-
// @ts-ignore
|
|
40
41
|
let rabbit
|
|
41
|
-
// @ts-ignore
|
|
42
|
-
let unsubscribe
|
|
43
|
-
// @ts-ignore
|
|
44
|
-
let receivedMessages
|
|
45
42
|
|
|
46
43
|
beforeAll(async () => {
|
|
47
44
|
startRabbit({
|
|
@@ -66,11 +63,6 @@ describe('RabbitMQ integration', () => {
|
|
|
66
63
|
|
|
67
64
|
afterAll(async () => {
|
|
68
65
|
try {
|
|
69
|
-
// @ts-ignore
|
|
70
|
-
if (unsubscribe) {
|
|
71
|
-
await unsubscribe()
|
|
72
|
-
}
|
|
73
|
-
// @ts-ignore
|
|
74
66
|
if (rabbit) {
|
|
75
67
|
await rabbit.close()
|
|
76
68
|
}
|
|
@@ -80,42 +72,81 @@ describe('RabbitMQ integration', () => {
|
|
|
80
72
|
})
|
|
81
73
|
|
|
82
74
|
it('should publish, consume, unsubscribe, and stop consuming', async () => {
|
|
83
|
-
|
|
75
|
+
const queue = uniqueQueue('integration')
|
|
76
|
+
const receivedMessages = []
|
|
84
77
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
queue: QUEUE,
|
|
88
|
-
// @ts-ignore
|
|
78
|
+
const unsubscribe = await rabbit.subscribe({
|
|
79
|
+
queue,
|
|
89
80
|
onReceive: async (data) => {
|
|
90
81
|
receivedMessages.push(data)
|
|
91
82
|
},
|
|
92
83
|
})
|
|
93
84
|
|
|
94
|
-
|
|
95
|
-
await rabbit.publish(QUEUE, { step: 1 })
|
|
85
|
+
await rabbit.publish(queue, { step: 1 })
|
|
96
86
|
await waitFor(() => receivedMessages.length === 1)
|
|
97
87
|
|
|
98
|
-
// @ts-ignore
|
|
99
88
|
expect(receivedMessages).toEqual([{ step: 1 }])
|
|
100
89
|
|
|
101
90
|
await unsubscribe()
|
|
102
91
|
|
|
103
|
-
|
|
104
|
-
await rabbit.publish(QUEUE, { step: 2 })
|
|
92
|
+
await rabbit.publish(queue, { step: 2 })
|
|
105
93
|
|
|
106
|
-
await sleep(
|
|
94
|
+
await sleep(500)
|
|
107
95
|
|
|
108
|
-
// @ts-ignore
|
|
109
96
|
expect(receivedMessages).toEqual([{ step: 1 }])
|
|
110
97
|
})
|
|
98
|
+
|
|
99
|
+
describe('RabbitMQ queue name validation', () => {
|
|
100
|
+
it('should throw when subscribing with invalid queues', async () => {
|
|
101
|
+
const cases = [undefined, '', ' ']
|
|
102
|
+
|
|
103
|
+
for (const queue of cases) {
|
|
104
|
+
await expect(
|
|
105
|
+
rabbit.subscribe({
|
|
106
|
+
queue,
|
|
107
|
+
onReceive: async () => {},
|
|
108
|
+
}),
|
|
109
|
+
).rejects.toThrow('Invalid queue name')
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should throw when publishing with invalid queues', async () => {
|
|
114
|
+
const cases = [undefined, '', ' ']
|
|
115
|
+
|
|
116
|
+
for (const queue of cases) {
|
|
117
|
+
await expect(rabbit.publish(queue, { test: true })).rejects.toThrow(
|
|
118
|
+
'Invalid queue name',
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should not affect valid queue when invalid publish is attempted', async () => {
|
|
124
|
+
const queue = uniqueQueue('validation')
|
|
125
|
+
const messages = []
|
|
126
|
+
|
|
127
|
+
const unsub = await rabbit.subscribe({
|
|
128
|
+
queue,
|
|
129
|
+
onReceive: async (data) => {
|
|
130
|
+
messages.push(data)
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
await rabbit.publish(queue, { ok: true })
|
|
135
|
+
await waitFor(() => messages.length === 1)
|
|
136
|
+
|
|
137
|
+
expect(messages).toEqual([{ ok: true }])
|
|
138
|
+
|
|
139
|
+
await expect(rabbit.publish(undefined, { bad: true })).rejects.toThrow()
|
|
140
|
+
|
|
141
|
+
await sleep(300)
|
|
142
|
+
|
|
143
|
+
expect(messages).toEqual([{ ok: true }])
|
|
144
|
+
|
|
145
|
+
await unsub()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
111
148
|
})
|
|
112
149
|
|
|
113
|
-
/**
|
|
114
|
-
* Waits until a condition becomes true or times out.
|
|
115
|
-
*
|
|
116
|
-
* @param {() => boolean} predicate
|
|
117
|
-
* @param {number} timeoutMs
|
|
118
|
-
*/
|
|
119
150
|
async function waitFor(predicate, timeoutMs = 5000) {
|
|
120
151
|
const start = Date.now()
|
|
121
152
|
|
|
@@ -129,11 +160,6 @@ async function waitFor(predicate, timeoutMs = 5000) {
|
|
|
129
160
|
throw new Error('Condition not met within timeout')
|
|
130
161
|
}
|
|
131
162
|
|
|
132
|
-
/**
|
|
133
|
-
* Sleeps for the given number of milliseconds.
|
|
134
|
-
*
|
|
135
|
-
* @param {number} ms
|
|
136
|
-
*/
|
|
137
163
|
function sleep(ms) {
|
|
138
164
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
139
165
|
}
|
|
@@ -15,7 +15,7 @@ export function startMongo(port = 27027, containerName = 'mongo-test') {
|
|
|
15
15
|
stdio: 'inherit',
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
-
waitForMongo(port)
|
|
18
|
+
waitForMongo(port, containerName)
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -40,7 +40,7 @@ export function startMongoReplicaSet(
|
|
|
40
40
|
{ stdio: 'inherit' },
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
-
waitForMongo(port)
|
|
43
|
+
waitForMongo(port, containerName)
|
|
44
44
|
|
|
45
45
|
// Initialize replica set
|
|
46
46
|
console.log(`[MongoTest] Initializing replica set "${replSet}"...`)
|
|
@@ -60,15 +60,16 @@ export function startMongoReplicaSet(
|
|
|
60
60
|
export function stopMongo(containerName = 'mongo-test') {
|
|
61
61
|
console.log(`[MongoTest] Stopping MongoDB...`)
|
|
62
62
|
try {
|
|
63
|
-
execSync(`docker rm -
|
|
63
|
+
execSync(`docker rm -fv ${containerName}`, { stdio: 'ignore' })
|
|
64
64
|
} catch {}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function isConnected(port) {
|
|
67
|
+
function isConnected(port, containerName) {
|
|
68
68
|
try {
|
|
69
|
-
execSync(
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
execSync(
|
|
70
|
+
`docker exec ${containerName} mongosh --port 27017 --eval "db.runCommand({ ping: 1 })"`,
|
|
71
|
+
{ stdio: 'ignore' },
|
|
72
|
+
)
|
|
72
73
|
return true
|
|
73
74
|
} catch {
|
|
74
75
|
return false
|
|
@@ -78,14 +79,14 @@ function isConnected(port) {
|
|
|
78
79
|
* Wait until MongoDB is ready to accept connections
|
|
79
80
|
* @param {number} port
|
|
80
81
|
*/
|
|
81
|
-
function waitForMongo(port) {
|
|
82
|
+
function waitForMongo(port, containerName) {
|
|
82
83
|
console.log(`[MongoTest] Waiting for MongoDB to be ready...`)
|
|
83
84
|
const maxRetries = 60
|
|
84
85
|
let retries = 0
|
|
85
86
|
let connected = false
|
|
86
87
|
|
|
87
88
|
while (!connected && retries < maxRetries) {
|
|
88
|
-
connected = isConnected(port)
|
|
89
|
+
connected = isConnected(port, containerName)
|
|
89
90
|
if (!connected) {
|
|
90
91
|
retries++
|
|
91
92
|
execSync(`sleep 1`)
|