@natlibfi/melinda-rest-api-commons 6.1.0-alpha.0 → 6.2.0-alpha.1

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.
Files changed (92) hide show
  1. package/.github/workflows/melinda-node-tests-and-publish.yml +4 -2
  2. package/README.md +211 -14
  3. package/dist/amqp.d.ts +41 -0
  4. package/dist/amqp.d.ts.map +1 -0
  5. package/dist/amqp.js +251 -235
  6. package/dist/amqp.js.map +1 -7
  7. package/dist/constants.d.ts +49 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +38 -38
  10. package/dist/constants.js.map +1 -7
  11. package/dist/helpers/conversion.d.ts +8 -0
  12. package/dist/helpers/conversion.d.ts.map +1 -0
  13. package/dist/helpers/conversion.js +70 -64
  14. package/dist/helpers/conversion.js.map +1 -7
  15. package/dist/helpers/fix-constants.d.ts +76 -0
  16. package/dist/helpers/fix-constants.d.ts.map +1 -0
  17. package/dist/helpers/fix-constants.js +41 -41
  18. package/dist/helpers/fix-constants.js.map +1 -7
  19. package/dist/helpers/fix-handle-tempurns.d.ts +3 -0
  20. package/dist/helpers/fix-handle-tempurns.d.ts.map +1 -0
  21. package/dist/helpers/fix-handle-tempurns.js +73 -71
  22. package/dist/helpers/fix-handle-tempurns.js.map +1 -7
  23. package/dist/helpers/fix-strip-f884s.d.ts +10 -0
  24. package/dist/helpers/fix-strip-f884s.d.ts.map +1 -0
  25. package/dist/helpers/fix-strip-f884s.js +161 -163
  26. package/dist/helpers/fix-strip-f884s.js.map +1 -7
  27. package/dist/helpers/fix.d.ts +6 -0
  28. package/dist/helpers/fix.d.ts.map +1 -0
  29. package/dist/helpers/fix.js +133 -121
  30. package/dist/helpers/fix.js.map +1 -7
  31. package/dist/helpers/validation.d.ts +9 -0
  32. package/dist/helpers/validation.d.ts.map +1 -0
  33. package/dist/helpers/validation.js +14 -16
  34. package/dist/helpers/validation.js.map +1 -7
  35. package/dist/index.d.ts +13 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -7
  39. package/dist/mongo.d.ts +165 -0
  40. package/dist/mongo.d.ts.map +1 -0
  41. package/dist/mongo.js +595 -547
  42. package/dist/mongo.js.map +1 -7
  43. package/dist/mongoError.d.ts +2 -0
  44. package/dist/mongoError.d.ts.map +1 -0
  45. package/dist/mongoError.js +19 -0
  46. package/dist/mongoError.js.map +1 -0
  47. package/dist/mongoLog.d.ts +50 -0
  48. package/dist/mongoLog.d.ts.map +1 -0
  49. package/dist/mongoLog.js +274 -242
  50. package/dist/mongoLog.js.map +1 -7
  51. package/dist/utils.d.ts +36 -0
  52. package/dist/utils.d.ts.map +1 -0
  53. package/dist/utils.js +112 -110
  54. package/dist/utils.js.map +1 -7
  55. package/eslint.config.ts +66 -0
  56. package/package.json +17 -11
  57. package/{tests/amqp-error-branches.test.js → test/amqp-error-branches.test.ts} +135 -61
  58. package/{tests/amqp.healthtest.js → test/amqp.healthtest.ts} +19 -11
  59. package/{tests/amqp.tests.localtest.js → test/amqp.tests.localtest.ts} +54 -35
  60. package/{tests/helpers/conversion.test.js → test/helpers/conversion.test.ts} +26 -14
  61. package/{tests/helpers/fix-edge.test.js → test/helpers/fix-edge.test.ts} +16 -12
  62. package/{tests/helpers/fix.test.js → test/helpers/fix.test.ts} +14 -8
  63. package/{tests/helpers/validation.test.js → test/helpers/validation.test.ts} +7 -6
  64. package/{tests/mongo-add-blob-size.test.js → test/mongo-add-blob-size.test.ts} +14 -15
  65. package/{tests/mongo-check-and-check-state.test.js → test/mongo-check-and-check-state.test.ts} +6 -8
  66. package/{tests/mongo-check-and-set-import-job-state.test.js → test/mongo-check-and-set-import-job-state.test.ts} +6 -9
  67. package/{tests/mongo-check-timeout.test.js → test/mongo-check-timeout.test.ts} +6 -9
  68. package/{tests/mongo-create-bulk.test.js → test/mongo-create-bulk.test.ts} +6 -7
  69. package/{tests/mongo-create-prio.test.js → test/mongo-create-prio.test.ts} +6 -6
  70. package/{tests/mongo-error-branches.test.js → test/mongo-error-branches.test.ts} +166 -65
  71. package/{tests/mongo-get-one.test.js → test/mongo-get-one.test.ts} +6 -6
  72. package/{tests/mongo-get-stream.test.js → test/mongo-get-stream.test.ts} +6 -8
  73. package/{tests/mongo-push-ids.test.js → test/mongo-push-ids.test.ts} +6 -6
  74. package/{tests/mongo-push-messages.test.js → test/mongo-push-messages.test.ts} +6 -6
  75. package/{tests/mongo-query-by-id.test.js → test/mongo-query-by-id.test.ts} +6 -9
  76. package/{tests/mongo-query.test.js → test/mongo-query.test.ts} +6 -7
  77. package/{tests/mongo-read-content.test.js → test/mongo-read-content.test.ts} +6 -9
  78. package/{tests/mongo-remove.test.js → test/mongo-remove.test.ts} +6 -8
  79. package/{tests/mongo-set-blob-size.test.js → test/mongo-set-blob-size.test.ts} +6 -6
  80. package/{tests/mongo-set-import-job-state.test.js → test/mongo-set-import-job-state.test.ts} +6 -6
  81. package/{tests/mongo-set-operation.test.js → test/mongo-set-operation.test.ts} +6 -6
  82. package/{tests/mongo-set-operations.test.js → test/mongo-set-operations.test.ts} +6 -6
  83. package/{tests/mongo-set-state.test.js → test/mongo-set-state.test.ts} +6 -6
  84. package/{tests/mongoLog-protect.test.js → test/mongoLog-protect.test.ts} +18 -17
  85. package/{tests/mongoLog.test.js → test/mongoLog.test.ts} +8 -8
  86. package/{tests/testUtils.js → test/testUtils.ts} +49 -15
  87. package/{tests/utils.test.js → test/utils.test.ts} +5 -5
  88. package/tsconfig.json +62 -0
  89. package/tsconfig.test.json +17 -0
  90. package/dist/helpers/fix-utils.js +0 -39
  91. package/dist/helpers/fix-utils.js.map +0 -7
  92. package/eslint.config.js +0 -53
@@ -3,24 +3,47 @@ import {test} from 'node:test';
3
3
  import {execFile} from 'node:child_process';
4
4
  import {fileURLToPath} from 'node:url';
5
5
  import {Error as ApiError} from '@natlibfi/melinda-commons';
6
- import createAmqp from '../src/amqp.js';
6
+ import type {Message} from 'amqplib';
7
+ import createAmqp, {type QueueItemMessagePayload, type QueueItemRecordPayload} from '../src/amqp.ts';
7
8
 
8
- const AMQP_MODULE_PATH = fileURLToPath(new URL('../src/amqp.js', import.meta.url));
9
+ const AMQP_MODULE_PATH = fileURLToPath(new URL('../src/amqp.ts', import.meta.url));
9
10
 
10
11
  const DUMMY_URL = 'amqp://stub:5672/';
11
12
  const QUEUE = 'TESTQUEUE';
12
13
 
13
- function makeMessage(correlationId, deliveryTag) {
14
+ /**
15
+ * A minimal stub for an amqplib message. The fields beyond deliveryTag
16
+ * match the shape of a real amqplib message (the tests only exercise
17
+ * deliveryTag, correlationId and the message body).
18
+ */
19
+ function makeMessage(correlationId: string, deliveryTag: number): Message {
14
20
  const data = {leader: '00000nam a22000000i 4500', fields: [{tag: '001', value: '123'}]};
15
21
  const content = JSON.stringify({data});
16
22
  return {
17
23
  content: Buffer.from(content),
18
- properties: {correlationId, headers: {from: 'TEST'}},
19
- fields: {deliveryTag}
24
+ // only correlationId and headers are exercised by these tests; the
25
+ // remaining message properties stay undefined
26
+ properties: {
27
+ contentType: undefined,
28
+ contentEncoding: undefined,
29
+ headers: {from: 'TEST'},
30
+ deliveryMode: undefined,
31
+ priority: undefined,
32
+ correlationId,
33
+ replyTo: undefined,
34
+ expiration: undefined,
35
+ messageId: undefined,
36
+ timestamp: undefined,
37
+ type: undefined,
38
+ userId: undefined,
39
+ appId: undefined,
40
+ clusterId: undefined
41
+ },
42
+ fields: {deliveryTag, redelivered: false, exchange: '', routingKey: ''}
20
43
  };
21
44
  }
22
45
 
23
- function makeChannel(overrides = {}) {
46
+ function makeChannel(overrides: Record<string, unknown> = {}) {
24
47
  return {
25
48
  assertQueue: async () => ({messageCount: 0}),
26
49
  checkQueue: async () => ({messageCount: 0}),
@@ -30,18 +53,22 @@ function makeChannel(overrides = {}) {
30
53
  purgeQueue: async () => {},
31
54
  close: async () => {},
32
55
  sendToQueue: async () => {},
56
+ on: () => {},
33
57
  ...overrides
34
58
  };
35
59
  }
36
60
 
37
- async function makeOperator(channelOverrides) {
61
+ /**
62
+ * Create an operator with the given channel stub overrides injected via deps
63
+ */
64
+ async function makeOperator(channelOverrides?: Record<string, unknown>) {
38
65
  const channel = makeChannel(channelOverrides);
39
- const connection = {createChannel: async () => channel, close: async () => {}};
66
+ const connection = {createChannel: async () => channel, close: async () => {}, on: () => {}};
40
67
  return createAmqp(DUMMY_URL, false, {connection, channel});
41
68
  }
42
69
 
43
70
  test('consumeChunk returns false when queue drains mid-pump', async () => {
44
- // (src/amqp.js, getData/pump):
71
+ // (src/amqp.ts, getData/pump):
45
72
  // FIXED: pump now bails out (returns results) when channel.get -> null,
46
73
  // and consumeChunk maps an empty pump result to false, matching the
47
74
  // empty-queue contract of checkQueue. Previously this crashed with
@@ -67,14 +94,14 @@ test('consumeChunk returns partial chunk when queue drains after first message',
67
94
  return first;
68
95
  }
69
96
  });
70
- const {records, messages} = await amqpOperator.consumeChunk(QUEUE, true);
97
+ const {records, messages} = (await amqpOperator.consumeChunk(QUEUE, true)) as QueueItemRecordPayload;
71
98
  assert.equal(messages.length, 1);
72
99
  assert.equal(records.length, 1);
73
100
  });
74
101
 
75
102
 
76
103
  test('consumeChunk deduplicates messages with same correlationId + deliveryTag', async () => {
77
- // (src/amqp.js, getData/pump):
104
+ // (src/amqp.ts, getData/pump):
78
105
  // FIXED: dedup now compares correlationId + deliveryTag field-by-field,
79
106
  // so duplicate messages are filtered out. (Previously the includes()
80
107
  // check compared object references and never triggered.)
@@ -83,7 +110,7 @@ test('consumeChunk deduplicates messages with same correlationId + deliveryTag',
83
110
  checkQueue: async () => ({messageCount: 2}),
84
111
  get: async () => duplicate
85
112
  });
86
- const {records, messages} = await amqpOperator.consumeChunk(QUEUE, true);
113
+ const {records, messages} = (await amqpOperator.consumeChunk(QUEUE, true)) as QueueItemRecordPayload;
87
114
  assert.equal(messages.length, 1);
88
115
  assert.equal(records.length, 1);
89
116
  });
@@ -96,26 +123,25 @@ test('same deliveryTag with different correlationIds is NOT deduplicated', async
96
123
  checkQueue: async () => ({messageCount: 2}),
97
124
  get: async () => messages.shift()
98
125
  });
99
- const {messages: got} = await amqpOperator.consumeChunk(QUEUE, true);
126
+ const {messages: got} = (await amqpOperator.consumeChunk(QUEUE, true)) as QueueItemRecordPayload;
100
127
  assert.equal(got.length, 2);
101
128
  });
102
129
 
103
130
  test('removeQueue closes the temp channel even when deleteQueue throws', async () => {
104
- // (src/amqp.js, removeQueue):
131
+ // (src/amqp.ts, removeQueue):
105
132
  // FIXED: the throwaway tempChannel is now closed in a finally block, so a
106
133
  // missing queue (deleteQueue throws) no longer leaks a channel.
107
- const closedChannels = [];
134
+ const closedChannels: any[] = [];
108
135
  const tempChannel = {
109
136
  deleteQueue: async () => {
110
137
  throw new Error('NOT_FOUND - queue missing');
111
138
  },
112
139
  close: async () => {
113
- // eslint-disable-next-line functional/immutable-data
114
140
  closedChannels.push('temp');
115
141
  }
116
142
  };
117
143
  const amqpOperator = await createAmqp(DUMMY_URL, false, {
118
- connection: {createChannel: async () => tempChannel, close: async () => {}},
144
+ connection: {createChannel: async () => tempChannel, close: async () => {}, on: () => {}},
119
145
  channel: makeChannel()
120
146
  });
121
147
  await assert.rejects(() => amqpOperator.removeQueue(QUEUE));
@@ -123,17 +149,17 @@ test('removeQueue closes the temp channel even when deleteQueue throws', async (
123
149
  });
124
150
 
125
151
  test('removeQueue closes the temp channel on success too', async () => {
126
- const closedChannels = [];
152
+ const closedChannels: any[] = [];
127
153
  const amqpOperator = await createAmqp(DUMMY_URL, false, {
128
154
  connection: {
129
155
  createChannel: async () => ({
130
156
  deleteQueue: async () => {},
131
157
  close: async () => {
132
- // eslint-disable-next-line functional/immutable-data
133
158
  closedChannels.push('temp');
134
159
  }
135
160
  }),
136
- close: async () => {}
161
+ close: async () => {},
162
+ on: () => {}
137
163
  },
138
164
  channel: makeChannel()
139
165
  });
@@ -142,7 +168,7 @@ test('removeQueue closes the temp channel on success too', async () => {
142
168
  });
143
169
 
144
170
  test('healthCheck loop stops after closeChannel and does not keep firing or throw', async () => {
145
- // (src/amqp.js, healthCheck):
171
+ // (src/amqp.ts, healthCheck):
146
172
  // FIXED: closeChannel/closeConnection cancel the recursive healthCheck loop,
147
173
  // so the ~200 ms chain short-circuits instead of running until the closed
148
174
  // channel throws (which used to make graceful shutdown flaky).
@@ -151,9 +177,8 @@ test('healthCheck loop stops after closeChannel and does not keep firing or thro
151
177
  let closed = false;
152
178
  let healthCheckCalls = 0;
153
179
  const channel = {
154
- assertQueue: async (name) => {
180
+ assertQueue: async (name: string) => {
155
181
  if (name === 'HEALTHCHECK') {
156
- // eslint-disable-next-line functional/immutable-data
157
182
  healthCheckCalls += 1;
158
183
  if (closed) {
159
184
  throw new Error('channel closed');
@@ -167,12 +192,12 @@ test('healthCheck loop stops after closeChannel and does not keep firing or thro
167
192
  nack: () => {},
168
193
  purgeQueue: async () => {},
169
194
  close: async () => {
170
- // eslint-disable-next-line functional/immutable-data
171
195
  closed = true;
172
- }
196
+ },
197
+ on: () => {}
173
198
  };
174
199
  const amqpOperator = await createAmqp(DUMMY_URL, true, {
175
- connection: {createChannel: async () => channel, close: async () => {}},
200
+ connection: {createChannel: async () => channel, close: async () => {}, on: () => {}},
176
201
  channel
177
202
  });
178
203
  // let the health check run at least once, then cancel it
@@ -194,7 +219,7 @@ test('happy path: consumeChunk returns two distinct messages', async () => {
194
219
  checkQueue: async () => ({messageCount: messages.length}),
195
220
  get: async () => messages.shift()
196
221
  });
197
- const {headers, records, messages: got} = await amqpOperator.consumeChunk(QUEUE, true);
222
+ const {headers, records, messages: got} = (await amqpOperator.consumeChunk(QUEUE, true)) as QueueItemRecordPayload;
198
223
  assert.equal(got.length, 2);
199
224
  assert.equal(records.length, 2);
200
225
  assert.deepStrictEqual(headers, {from: 'TEST'});
@@ -218,7 +243,7 @@ test('checkQueue style one returns the {headers, records, messages} form when to
218
243
  assertQueue: async () => ({messageCount: 1}),
219
244
  get: async () => message
220
245
  });
221
- const {headers, records, messages} = await amqpOperator.checkQueue({queue: QUEUE, style: 'one', toRecord: true});
246
+ const {headers, records, messages} = (await amqpOperator.checkQueue({queue: QUEUE, style: 'one', toRecord: true})) as QueueItemRecordPayload;
222
247
  assert.deepStrictEqual(headers, {from: 'TEST'});
223
248
  assert.equal(records.length, 1);
224
249
  assert.strictEqual(messages.length, 1);
@@ -257,7 +282,7 @@ test('checkQueue style basic returns a chunk of records and messages when the qu
257
282
  checkQueue: async () => ({messageCount: messages.length}),
258
283
  get: async () => messages.shift()
259
284
  });
260
- const {headers, records, messages: got} = await amqpOperator.checkQueue({queue: QUEUE, style: 'basic', toRecord: true});
285
+ const {headers, records, messages: got} = (await amqpOperator.checkQueue({queue: QUEUE, style: 'basic', toRecord: true})) as QueueItemRecordPayload;
261
286
  assert.deepStrictEqual(headers, {from: 'TEST'});
262
287
  assert.equal(records.length, 2);
263
288
  assert.equal(got.length, 2);
@@ -276,17 +301,15 @@ test('checkQueue rejects with a 500 ApiError for an unknown style', async () =>
276
301
  });
277
302
 
278
303
  test('checkQueue purges the queue before returning the style result', async () => {
279
- const purgeCalls = [];
304
+ const purgeCalls: any[] = [];
280
305
  let assertCalls = 0;
281
306
  const amqpOperator = await makeOperator({
282
307
  assertQueue: async () => {
283
- // eslint-disable-next-line functional/immutable-data
284
308
  assertCalls += 1;
285
309
  // first call (before the purge) reports 3 pending, after the purge 0
286
310
  return assertCalls === 1 ? {messageCount: 3} : {messageCount: 0};
287
311
  },
288
- purgeQueue: async (name) => {
289
- // eslint-disable-next-line functional/immutable-data
312
+ purgeQueue: async (name: string) => {
290
313
  purgeCalls.push(name);
291
314
  }
292
315
  });
@@ -309,7 +332,7 @@ test('consumeOne returns the plain message when toRecord is false', async () =>
309
332
  test('consumeOne returns the {headers, records, messages} form when toRecord is true', async () => {
310
333
  const message = makeMessage('c1', 1);
311
334
  const amqpOperator = await makeOperator({get: async () => message});
312
- const {headers, records, messages} = await amqpOperator.consumeOne(QUEUE, true);
335
+ const {headers, records, messages} = (await amqpOperator.consumeOne(QUEUE, true)) as QueueItemRecordPayload;
313
336
  assert.deepStrictEqual(headers, {from: 'TEST'});
314
337
  assert.equal(records.length, 1);
315
338
  assert.strictEqual(messages.length, 1);
@@ -341,7 +364,7 @@ test('consumeChunk returns {headers, messages} without records when toRecord is
341
364
  checkQueue: async () => ({messageCount: messages.length}),
342
365
  get: async () => messages.shift()
343
366
  });
344
- const {headers, messages: got} = await amqpOperator.consumeChunk(QUEUE, false);
367
+ const {headers, messages: got} = (await amqpOperator.consumeChunk(QUEUE, false)) as QueueItemMessagePayload;
345
368
  assert.deepStrictEqual(headers, {from: 'TEST'});
346
369
  assert.equal(got.length, 2);
347
370
  });
@@ -360,11 +383,10 @@ test('consumeChunk rejects when the amqplib checkQueue throws a plain Error', as
360
383
  // ---------------------------------------------------------------------------
361
384
 
362
385
  test('ackMessages acks every message on the channel', async () => {
363
- const acked = [];
386
+ const acked: Message[] = [];
364
387
  const messages = [makeMessage('c1', 1), makeMessage('c2', 2)];
365
388
  const amqpOperator = await makeOperator({
366
- ack: (message) => {
367
- // eslint-disable-next-line functional/immutable-data
389
+ ack: (message: Message) => {
368
390
  acked.push(message);
369
391
  }
370
392
  });
@@ -373,11 +395,10 @@ test('ackMessages acks every message on the channel', async () => {
373
395
  });
374
396
 
375
397
  test('nackMessages nacks every message on the channel', async () => {
376
- const nacked = [];
398
+ const nacked: Message[] = [];
377
399
  const messages = [makeMessage('c1', 1), makeMessage('c2', 2)];
378
400
  const amqpOperator = await makeOperator({
379
- nack: (message) => {
380
- // eslint-disable-next-line functional/immutable-data
401
+ nack: (message: Message) => {
381
402
  nacked.push(message);
382
403
  }
383
404
  });
@@ -390,10 +411,9 @@ test('nackMessages nacks every message on the channel', async () => {
390
411
  // ---------------------------------------------------------------------------
391
412
 
392
413
  test('sendToQueue sends the JSON body with correlationId, persistent and headers options', async () => {
393
- const sent = [];
414
+ const sent: {queue: string, content: Buffer, options: Record<string, unknown>}[] = [];
394
415
  const amqpOperator = await makeOperator({
395
- sendToQueue: async (queue, content, options) => {
396
- // eslint-disable-next-line functional/immutable-data
416
+ sendToQueue: async (queue: string, content: Buffer, options: Record<string, unknown>) => {
397
417
  sent.push({queue, content, options});
398
418
  }
399
419
  });
@@ -405,10 +425,10 @@ test('sendToQueue sends the JSON body with correlationId, persistent and headers
405
425
  data
406
426
  });
407
427
  assert.equal(sent.length, 1);
408
- assert.equal(sent[0].queue, QUEUE);
409
- assert.ok(Buffer.isBuffer(sent[0].content));
410
- assert.deepStrictEqual(JSON.parse(sent[0].content.toString()), {data});
411
- assert.deepStrictEqual(sent[0].options, {correlationId: 'cid-1', persistent: true, headers: {h: 1}});
428
+ assert.equal(sent[0]?.queue, QUEUE);
429
+ assert.ok(sent[0] && Buffer.isBuffer(sent[0].content));
430
+ assert.deepStrictEqual(JSON.parse(sent[0]?.content.toString() ?? ''), {data});
431
+ assert.deepStrictEqual(sent[0]?.options, {correlationId: 'cid-1', persistent: true, headers: {h: 1}});
412
432
  });
413
433
 
414
434
  test('sendToQueue rejects with a 500 ApiError when the queue is undefined', async () => {
@@ -442,15 +462,15 @@ test('checkQueue rejects with a 500 ApiError when the queue is an empty string',
442
462
  // ---------------------------------------------------------------------------
443
463
 
444
464
  test('closeConnection closes the connection and the operator resolves', async () => {
445
- const closed = [];
465
+ const closed: any[] = [];
446
466
  const channel = makeChannel();
447
467
  const amqpOperator = await createAmqp(DUMMY_URL, false, {
448
468
  connection: {
449
469
  createChannel: async () => channel,
450
470
  close: async () => {
451
- // eslint-disable-next-line functional/immutable-data
452
471
  closed.push('connection');
453
- }
472
+ },
473
+ on: () => {}
454
474
  },
455
475
  channel
456
476
  });
@@ -459,16 +479,14 @@ test('closeConnection closes the connection and the operator resolves', async ()
459
479
  });
460
480
 
461
481
  test('healthCheck error arriving after cancel does not rethrow and stops the loop', async () => {
462
- let rejectAssert;
482
+ let rejectAssert: ((reason?: unknown) => void) | undefined;
463
483
  const pending = new Promise((_, reject) => {
464
- // eslint-disable-next-line functional/immutable-data
465
484
  rejectAssert = reject;
466
485
  });
467
486
  let healthCheckCalls = 0;
468
487
  const channel = makeChannel({
469
- assertQueue: async (name) => {
488
+ assertQueue: async (name: string) => {
470
489
  if (name === 'HEALTHCHECK') {
471
- // eslint-disable-next-line functional/immutable-data
472
490
  healthCheckCalls += 1;
473
491
  return pending;
474
492
  }
@@ -476,13 +494,15 @@ test('healthCheck error arriving after cancel does not rethrow and stops the loo
476
494
  }
477
495
  });
478
496
  const amqpOperator = await createAmqp(DUMMY_URL, true, {
479
- connection: {createChannel: async () => channel, close: async () => {}},
497
+ connection: {createChannel: async () => channel, close: async () => {}, on: () => {}},
480
498
  channel
481
499
  });
482
500
  // by now the loop is suspended on the pending assertQueue call;
483
501
  // cancel first, then let the error arrive so the catch sees the flag already cleared
484
502
  await amqpOperator.closeChannel();
485
- rejectAssert(new Error('amqp down after close'));
503
+ if (rejectAssert) {
504
+ rejectAssert(new Error('amqp down after close'));
505
+ }
486
506
  await new Promise((resolve) => setTimeout(resolve, 50));
487
507
  assert.equal(healthCheckCalls, 1, 'no further health checks after cancel');
488
508
  });
@@ -497,7 +517,7 @@ test('healthCheck error while running rethrows into the un-awaited loop promise'
497
517
  const script = `
498
518
  import createAmqp from '${AMQP_MODULE_PATH}';
499
519
  const channel = {
500
- assertQueue: async (name) => {
520
+ assertQueue: /** @param {*} name */ async (name) => {
501
521
  if (name === 'HEALTHCHECK') {
502
522
  throw new Error('amqp down');
503
523
  }
@@ -509,12 +529,13 @@ test('healthCheck error while running rethrows into the un-awaited loop promise'
509
529
  nack: () => {},
510
530
  purgeQueue: async () => {},
511
531
  close: async () => {},
512
- sendToQueue: async () => {}
532
+ sendToQueue: async () => {},
533
+ on: () => {}
513
534
  };
514
535
  const captured = [];
515
536
  process.on('unhandledRejection', (err) => captured.push(err));
516
537
  await createAmqp('amqp://stub:5672/', true, {
517
- connection: {createChannel: async () => channel, close: async () => {}},
538
+ connection: {createChannel: async () => channel, close: async () => {}, on: () => {}},
518
539
  channel
519
540
  });
520
541
  // the first loop iteration rejects and the error escapes the un-awaited loop promise
@@ -523,7 +544,7 @@ test('healthCheck error while running rethrows into the un-awaited loop promise'
523
544
  process.stdout.write(ok ? 'OK' : 'FAIL:' + JSON.stringify(captured.map((e) => String(e && e.message))));
524
545
  process.exit(ok ? 0 : 1);
525
546
  `;
526
- const {code, stdout} = await new Promise((resolve) => {
547
+ const {code, stdout} = await new Promise<{code: number | string, stdout: string}>((resolve) => {
527
548
  const child = execFile(process.execPath, ['--input-type=module', '-e', script], (err, out) => {
528
549
  resolve({code: err ? (err.code ?? 1) : 0, stdout: out ?? ''});
529
550
  });
@@ -533,3 +554,56 @@ test('healthCheck error while running rethrows into the un-awaited loop promise'
533
554
  // handleAmqpErrors logs the error to stdout before the escape, so only the tail matters
534
555
  assert.ok(stdout.endsWith('OK'), `expected child output to end with OK, got: ${stdout}`);
535
556
  });
557
+
558
+ test('broker loss (error + close events) logs the error and close cancels the health check', async () => {
559
+ // Failure-path listeners on the amqplib connection/channel:
560
+ // - 'error' must only log: without a handler an unhandled EventEmitter
561
+ // 'error' would become an uncaughtException, and the error alone must
562
+ // NOT stop the loop (the fast-fail contract is unchanged)
563
+ // - 'close' must cancel the health check loop so no iterations keep
564
+ // running on a dead link (matters for runHealthCheck: false consumers)
565
+ const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
566
+ const makeOn = (target: string) => (event: string, handler: (...args: unknown[]) => void) => {
567
+ const key = `${target}:${event}`;
568
+ const list = handlers[key] ?? (handlers[key] = []);
569
+ list.push(handler);
570
+ return {off: () => {}};
571
+ };
572
+ let healthCheckCalls = 0;
573
+ const channel = makeChannel({
574
+ on: makeOn('channel'),
575
+ assertQueue: async (name: string) => {
576
+ if (name === 'HEALTHCHECK') {
577
+ healthCheckCalls += 1;
578
+ }
579
+ return {messageCount: 0};
580
+ }
581
+ });
582
+ await createAmqp(DUMMY_URL, true, {
583
+ connection: {createChannel: async () => channel, close: async () => {}, on: makeOn('connection')},
584
+ channel
585
+ });
586
+ // all four listeners must be attached (connection + channel, error + close)
587
+ assert.deepStrictEqual(Object.keys(handlers).sort(), [
588
+ 'channel:close',
589
+ 'channel:error',
590
+ 'connection:close',
591
+ 'connection:error'
592
+ ]);
593
+ // let the loop run a couple of iterations, then simulate broker loss the
594
+ // way amqplib signals it: an 'error' event followed by 'close'
595
+ await new Promise((resolve) => setTimeout(resolve, 350));
596
+ for (const handler of handlers['channel:error'] ?? []) handler(new Error('socket lost'));
597
+ for (const handler of handlers['connection:error'] ?? []) handler(new Error('socket lost'));
598
+ // the 'error' events alone must not cancel the loop
599
+ const callsAfterError = healthCheckCalls;
600
+ await new Promise((resolve) => setTimeout(resolve, 500));
601
+ assert.ok(healthCheckCalls > 0, 'health check should have run before the broker loss');
602
+ assert.ok(healthCheckCalls > callsAfterError, 'health check keeps running after the error events');
603
+ for (const handler of handlers['channel:close'] ?? []) handler();
604
+ for (const handler of handlers['connection:close'] ?? []) handler();
605
+ const callsAfterClose = healthCheckCalls;
606
+ // wait longer than the 200 ms loop interval - no further health checks may fire
607
+ await new Promise((resolve) => setTimeout(resolve, 500));
608
+ assert.equal(healthCheckCalls, callsAfterClose, 'no health checks after the close events');
609
+ });
@@ -1,7 +1,8 @@
1
1
  import {describe, it} from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import createDebugLogger from 'debug';
4
- import {amqpFactory} from '../src/index.js';
4
+ import {amqpFactory} from '../src/index.ts';
5
+ import type {AmqpOperator} from '../src/amqp.ts';
5
6
 
6
7
 
7
8
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons:amqp:test');
@@ -12,10 +13,17 @@ const setTimeoutPromise = promisify(setTimeout);
12
13
 
13
14
  describe('HealthCheck: amqpOperator should error if channel or connection fails', () => {
14
15
 
15
- it('FIX: test healthCheck - this test does not really test anything, but error can be seen in debug!', async () => {
16
+ it('healthcheck loop stops silently on closeChannel without unhandled rejections', async (t) => {
16
17
  debug(`Testing amqp against a local AMQP instance - we'll need some kind of mockup for this`);
17
18
  const amqpUrl = 'amqp://127.0.0.1:5672/';
18
19
 
20
+ // pin the contract: the loop must never leak an unhandled rejection,
21
+ // both while running and after closeChannel cancels it
22
+ const unhandled: unknown[] = [];
23
+ const onUnhandledRejection = (error: unknown) => unhandled.push(error);
24
+ process.on('unhandledRejection', onUnhandledRejection);
25
+ t.after(() => process.off('unhandledRejection', onUnhandledRejection));
26
+
19
27
  const amqpOperator = await amqpFactory(amqpUrl, true);
20
28
  debug(`Connecting to ${amqpUrl}, with healthCheck running`);
21
29
  await awaitTime(amqpOperator, false);
@@ -30,20 +38,21 @@ describe('HealthCheck: amqpOperator should error if channel or connection fails'
30
38
  await awaitTime(amqpOperator, true);
31
39
  // closeChannel() sets the run flag so the loop short-circuits gracefully:
32
40
  // it must stop silently without throwing or firing further health checks
33
- assert.ok(true, 'healthCheck loop stopped without error');
41
+ assert.deepStrictEqual(unhandled, [], 'no unhandled rejections while running or after closeChannel');
34
42
 
35
43
  debug(`Closing connection`);
36
44
  await amqpOperator.closeConnection();
37
45
 
38
- async function awaitTime(amqpOperator, close) {
46
+ /**
47
+ * @param amqpOperator
48
+ * @param close
49
+ */
50
+ async function awaitTime(amqpOperator: AmqpOperator, close: boolean): Promise<void> {
39
51
  if (close) {
40
- // wrap the wait so any error thrown by the (cancelled) loop fails the test
41
52
  await amqpOperator.closeChannel();
42
- try {
43
- await setTimeoutPromise(500);
44
- } catch (error) {
45
- assert.fail(`healthCheck loop threw after closeChannel: ${error}`);
46
- }
53
+ // note: promisify(setTimeout) never rejects, so a try/catch around it
54
+ // can never catch; the unhandledRejection assertion above is the pin
55
+ await setTimeoutPromise(500);
47
56
  return;
48
57
  }
49
58
 
@@ -53,4 +62,3 @@ describe('HealthCheck: amqpOperator should error if channel or connection fails'
53
62
 
54
63
  });
55
64
  });
56
-