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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "core-services-sdk",
3
- "version": "1.3.87",
3
+ "version": "1.3.89",
4
4
  "main": "src/index.js",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",
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
- typeof body === 'string' ||
208
- body instanceof URLSearchParams ||
209
- body instanceof FormData ||
210
- body instanceof ArrayBuffer ||
211
- body instanceof Blob
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
- body: JSON.stringify(body),
218
- headers: {
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: { ...JSON_HEADER, ...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: { ...JSON_HEADER, ...preparedHeaders },
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: { ...JSON_HEADER, ...preparedHeaders },
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: { ...JSON_HEADER, ...preparedHeaders },
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: { ...JSON_HEADER, ...preparedHeaders },
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: { ...JSON_HEADER, ...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
- if (!queue || !queue.trim()) {
195
- const message = 'Cannot subscribe to RabbitMQ with an empty queue name'
196
- logger.error({ error: message })
197
- throw new Error(message)
198
- }
206
+ try {
207
+ assertValidQueueName(queue)
199
208
 
200
- if (typeof onReceive !== 'function') {
201
- const message = `Cannot subscribe to queue "${queue}" because onReceive is not a function`
202
- logger.error({ error: message })
203
- throw new Error(message)
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
- try {
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
- waitForRabbitHealthy(containerName)
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 -f ${containerName}`, { stdio: 'ignore' })
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
- * Polls the container health status using `docker inspect` and retries
92
- * for a fixed amount of time before failing.
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 = 60
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 output = execSync(
111
- `docker inspect --format='{{.State.Health.Status}}' ${containerName}`,
121
+ const status = execSync(
122
+ `docker inspect --format='{{.State.Status}}' ${containerName}`,
112
123
  { encoding: 'utf8' },
113
124
  ).trim()
114
125
 
115
- if (output === 'healthy') {
116
- console.log(`[RabbitTest] RabbitMQ is ready.`)
117
- return
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] Still waiting... Status: ${output} (${retries}/${maxRetries})`,
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] Container not ready yet (${retries}/${maxRetries})`,
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 collectBody(req) {
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
- resolve(Buffer.concat(chunks).toString())
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 body = await collectBody(req)
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 when expectedType is not json', async () => {
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
- receivedMessages = []
75
+ const queue = uniqueQueue('integration')
76
+ const receivedMessages = []
84
77
 
85
- // @ts-ignore
86
- unsubscribe = await rabbit.subscribe({
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
- // @ts-ignore
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
- // @ts-ignore
104
- await rabbit.publish(QUEUE, { step: 2 })
92
+ await rabbit.publish(queue, { step: 2 })
105
93
 
106
- await sleep(1000)
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 -f ${containerName}`, { stdio: 'ignore' })
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(`mongosh --port ${port} --eval "db.runCommand({ ping: 1 })"`, {
70
- stdio: 'ignore',
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`)