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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.github/workflows/melinda-node-tests-and-publish.yml +4 -2
  2. package/README.md +82 -0
  3. package/dist/amqp.d.ts +41 -0
  4. package/dist/amqp.d.ts.map +1 -0
  5. package/dist/amqp.js +242 -218
  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 -65
  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 +163 -151
  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 -117
  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 +603 -464
  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 +282 -172
  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 +118 -87
  54. package/dist/utils.js.map +1 -7
  55. package/eslint.config.ts +66 -0
  56. package/package.json +23 -25
  57. package/test/amqp-error-branches.test.ts +551 -0
  58. package/{tests/amqp.healthtest.js → test/amqp.healthtest.ts} +19 -7
  59. package/{tests/amqp.tests.localtest.js → test/amqp.tests.localtest.ts} +66 -52
  60. package/{tests/helpers/conversion.test.js → test/helpers/conversion.test.ts} +39 -11
  61. package/test/helpers/fix-edge.test.ts +201 -0
  62. package/test/helpers/fix.test.ts +65 -0
  63. package/{tests/helpers/validation.test.js → test/helpers/validation.test.ts} +13 -8
  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} +9 -9
  66. package/{tests/mongo-check-and-set-import-job-state.test.js → test/mongo-check-and-set-import-job-state.test.ts} +9 -10
  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/test/mongo-error-branches.test.ts +883 -0
  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} +10 -9
  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} +16 -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/test/mongoLog-protect.test.ts +182 -0
  85. package/test/mongoLog.test.ts +191 -0
  86. package/{tests/testUtils.js → test/testUtils.ts} +49 -15
  87. package/test/utils.test.ts +360 -0
  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
  93. package/tests/helpers/fix.test.js +0 -41
  94. package/tests/mongoLog.test.js +0 -84
@@ -0,0 +1,551 @@
1
+ import assert from 'node:assert';
2
+ import {test} from 'node:test';
3
+ import {execFile} from 'node:child_process';
4
+ import {fileURLToPath} from 'node:url';
5
+ import {Error as ApiError} from '@natlibfi/melinda-commons';
6
+ import type {Message} from 'amqplib';
7
+ import createAmqp, {type QueueItemMessagePayload, type QueueItemRecordPayload} from '../src/amqp.ts';
8
+
9
+ const AMQP_MODULE_PATH = fileURLToPath(new URL('../src/amqp.ts', import.meta.url));
10
+
11
+ const DUMMY_URL = 'amqp://stub:5672/';
12
+ const QUEUE = 'TESTQUEUE';
13
+
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 {
20
+ const data = {leader: '00000nam a22000000i 4500', fields: [{tag: '001', value: '123'}]};
21
+ const content = JSON.stringify({data});
22
+ return {
23
+ content: Buffer.from(content),
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: ''}
43
+ };
44
+ }
45
+
46
+ function makeChannel(overrides: Record<string, unknown> = {}) {
47
+ return {
48
+ assertQueue: async () => ({messageCount: 0}),
49
+ checkQueue: async () => ({messageCount: 0}),
50
+ get: async () => false,
51
+ ack: () => {},
52
+ nack: () => {},
53
+ purgeQueue: async () => {},
54
+ close: async () => {},
55
+ sendToQueue: async () => {},
56
+ ...overrides
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Create an operator with the given channel stub overrides injected via deps
62
+ */
63
+ async function makeOperator(channelOverrides?: Record<string, unknown>) {
64
+ const channel = makeChannel(channelOverrides);
65
+ const connection = {createChannel: async () => channel, close: async () => {}};
66
+ return createAmqp(DUMMY_URL, false, {connection, channel});
67
+ }
68
+
69
+ test('consumeChunk returns false when queue drains mid-pump', async () => {
70
+ // (src/amqp.ts, getData/pump):
71
+ // FIXED: pump now bails out (returns results) when channel.get -> null,
72
+ // and consumeChunk maps an empty pump result to false, matching the
73
+ // empty-queue contract of checkQueue. Previously this crashed with
74
+ // TypeError: Cannot read properties of null (reading 'properties').
75
+ const amqpOperator = await makeOperator({
76
+ checkQueue: async () => ({messageCount: 1}),
77
+ get: async () => null
78
+ });
79
+ const result = await amqpOperator.consumeChunk(QUEUE, false);
80
+ assert.strictEqual(result, false);
81
+ });
82
+
83
+ test('consumeChunk returns partial chunk when queue drains after first message', async () => {
84
+ // Drains after one of two messages: pump must stop gracefully and
85
+ // consumeChunk must return the one message it got.
86
+ const first = makeMessage('c1', 1);
87
+ let drained = false;
88
+ const amqpOperator = await makeOperator({
89
+ checkQueue: async () => ({messageCount: 2}),
90
+ get: async () => {
91
+ if (drained) return null;
92
+ drained = true;
93
+ return first;
94
+ }
95
+ });
96
+ const {records, messages} = (await amqpOperator.consumeChunk(QUEUE, true)) as QueueItemRecordPayload;
97
+ assert.equal(messages.length, 1);
98
+ assert.equal(records.length, 1);
99
+ });
100
+
101
+
102
+ test('consumeChunk deduplicates messages with same correlationId + deliveryTag', async () => {
103
+ // (src/amqp.ts, getData/pump):
104
+ // FIXED: dedup now compares correlationId + deliveryTag field-by-field,
105
+ // so duplicate messages are filtered out. (Previously the includes()
106
+ // check compared object references and never triggered.)
107
+ const duplicate = makeMessage('c1', 1);
108
+ const amqpOperator = await makeOperator({
109
+ checkQueue: async () => ({messageCount: 2}),
110
+ get: async () => duplicate
111
+ });
112
+ const {records, messages} = (await amqpOperator.consumeChunk(QUEUE, true)) as QueueItemRecordPayload;
113
+ assert.equal(messages.length, 1);
114
+ assert.equal(records.length, 1);
115
+ });
116
+
117
+ test('same deliveryTag with different correlationIds is NOT deduplicated', async () => {
118
+ // Dedup key is correlationId + deliveryTag; a redelivery of a message
119
+ // with a new correlationId must still come through.
120
+ const messages = [makeMessage('c1', 1), makeMessage('c2', 1)];
121
+ const amqpOperator = await makeOperator({
122
+ checkQueue: async () => ({messageCount: 2}),
123
+ get: async () => messages.shift()
124
+ });
125
+ const {messages: got} = (await amqpOperator.consumeChunk(QUEUE, true)) as QueueItemRecordPayload;
126
+ assert.equal(got.length, 2);
127
+ });
128
+
129
+ test('removeQueue closes the temp channel even when deleteQueue throws', async () => {
130
+ // (src/amqp.ts, removeQueue):
131
+ // FIXED: the throwaway tempChannel is now closed in a finally block, so a
132
+ // missing queue (deleteQueue throws) no longer leaks a channel.
133
+ const closedChannels: any[] = [];
134
+ const tempChannel = {
135
+ deleteQueue: async () => {
136
+ throw new Error('NOT_FOUND - queue missing');
137
+ },
138
+ close: async () => {
139
+ closedChannels.push('temp');
140
+ }
141
+ };
142
+ const amqpOperator = await createAmqp(DUMMY_URL, false, {
143
+ connection: {createChannel: async () => tempChannel, close: async () => {}},
144
+ channel: makeChannel()
145
+ });
146
+ await assert.rejects(() => amqpOperator.removeQueue(QUEUE));
147
+ assert.deepStrictEqual(closedChannels, ['temp']);
148
+ });
149
+
150
+ test('removeQueue closes the temp channel on success too', async () => {
151
+ const closedChannels: any[] = [];
152
+ const amqpOperator = await createAmqp(DUMMY_URL, false, {
153
+ connection: {
154
+ createChannel: async () => ({
155
+ deleteQueue: async () => {},
156
+ close: async () => {
157
+ closedChannels.push('temp');
158
+ }
159
+ }),
160
+ close: async () => {}
161
+ },
162
+ channel: makeChannel()
163
+ });
164
+ await amqpOperator.removeQueue(QUEUE);
165
+ assert.deepStrictEqual(closedChannels, ['temp']);
166
+ });
167
+
168
+ test('healthCheck loop stops after closeChannel and does not keep firing or throw', async () => {
169
+ // (src/amqp.ts, healthCheck):
170
+ // FIXED: closeChannel/closeConnection cancel the recursive healthCheck loop,
171
+ // so the ~200 ms chain short-circuits instead of running until the closed
172
+ // channel throws (which used to make graceful shutdown flaky).
173
+ // The stub only starts throwing for the HEALTHCHECK queue once it is
174
+ // closed, so the un-cancelled iterations before the close succeed.
175
+ let closed = false;
176
+ let healthCheckCalls = 0;
177
+ const channel = {
178
+ assertQueue: async (name: string) => {
179
+ if (name === 'HEALTHCHECK') {
180
+ healthCheckCalls += 1;
181
+ if (closed) {
182
+ throw new Error('channel closed');
183
+ }
184
+ }
185
+ return {messageCount: 0};
186
+ },
187
+ checkQueue: async () => ({messageCount: 0}),
188
+ get: async () => false,
189
+ ack: () => {},
190
+ nack: () => {},
191
+ purgeQueue: async () => {},
192
+ close: async () => {
193
+ closed = true;
194
+ }
195
+ };
196
+ const amqpOperator = await createAmqp(DUMMY_URL, true, {
197
+ connection: {createChannel: async () => channel, close: async () => {}},
198
+ channel
199
+ });
200
+ // let the health check run at least once, then cancel it
201
+ await new Promise((resolve) => setTimeout(resolve, 350));
202
+ await amqpOperator.closeChannel();
203
+ const callsAfterClose = healthCheckCalls;
204
+ // wait longer than the 200 ms loop interval - no further health checks may fire
205
+ await new Promise((resolve) => setTimeout(resolve, 500));
206
+ assert.ok(healthCheckCalls > 0, 'health check should have run before close');
207
+ assert.equal(healthCheckCalls, callsAfterClose, 'no health checks after closeChannel');
208
+ });
209
+
210
+ test('happy path: consumeChunk returns two distinct messages', async () => {
211
+ const messages = [
212
+ makeMessage('c1', 1),
213
+ makeMessage('c2', 2)
214
+ ];
215
+ const amqpOperator = await makeOperator({
216
+ checkQueue: async () => ({messageCount: messages.length}),
217
+ get: async () => messages.shift()
218
+ });
219
+ const {headers, records, messages: got} = (await amqpOperator.consumeChunk(QUEUE, true)) as QueueItemRecordPayload;
220
+ assert.equal(got.length, 2);
221
+ assert.equal(records.length, 2);
222
+ assert.deepStrictEqual(headers, {from: 'TEST'});
223
+ });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // checkQueue styles
227
+ // ---------------------------------------------------------------------------
228
+
229
+ test('checkQueue style messages returns the message count', async () => {
230
+ for (const count of [0, 3, 42]) {
231
+ const amqpOperator = await makeOperator({assertQueue: async () => ({messageCount: count})});
232
+ const result = await amqpOperator.checkQueue({queue: QUEUE, style: 'messages'});
233
+ assert.strictEqual(result, count);
234
+ }
235
+ });
236
+
237
+ test('checkQueue style one returns the {headers, records, messages} form when toRecord is true', async () => {
238
+ const message = makeMessage('c1', 1);
239
+ const amqpOperator = await makeOperator({
240
+ assertQueue: async () => ({messageCount: 1}),
241
+ get: async () => message
242
+ });
243
+ const {headers, records, messages} = (await amqpOperator.checkQueue({queue: QUEUE, style: 'one', toRecord: true})) as QueueItemRecordPayload;
244
+ assert.deepStrictEqual(headers, {from: 'TEST'});
245
+ assert.equal(records.length, 1);
246
+ assert.strictEqual(messages.length, 1);
247
+ assert.strictEqual(messages[0], message);
248
+ });
249
+
250
+ test('checkQueue style one returns the plain message when toRecord is false', async () => {
251
+ const message = makeMessage('c1', 1);
252
+ const amqpOperator = await makeOperator({
253
+ assertQueue: async () => ({messageCount: 1}),
254
+ get: async () => message
255
+ });
256
+ const result = await amqpOperator.checkQueue({queue: QUEUE, style: 'one', toRecord: false});
257
+ assert.strictEqual(result, message);
258
+ });
259
+
260
+ test('checkQueue style one returns false for an empty queue', async () => {
261
+ const amqpOperator = await makeOperator({
262
+ assertQueue: async () => ({messageCount: 0}),
263
+ get: async () => false
264
+ });
265
+ const result = await amqpOperator.checkQueue({queue: QUEUE, style: 'one'});
266
+ assert.strictEqual(result, false);
267
+ });
268
+
269
+ test('checkQueue style basic returns false for an empty queue', async () => {
270
+ const amqpOperator = await makeOperator({assertQueue: async () => ({messageCount: 0})});
271
+ const result = await amqpOperator.checkQueue({queue: QUEUE, style: 'basic'});
272
+ assert.strictEqual(result, false);
273
+ });
274
+
275
+ test('checkQueue style basic returns a chunk of records and messages when the queue is not empty', async () => {
276
+ const messages = [makeMessage('c1', 1), makeMessage('c2', 2)];
277
+ const amqpOperator = await makeOperator({
278
+ assertQueue: async () => ({messageCount: messages.length}),
279
+ checkQueue: async () => ({messageCount: messages.length}),
280
+ get: async () => messages.shift()
281
+ });
282
+ const {headers, records, messages: got} = (await amqpOperator.checkQueue({queue: QUEUE, style: 'basic', toRecord: true})) as QueueItemRecordPayload;
283
+ assert.deepStrictEqual(headers, {from: 'TEST'});
284
+ assert.equal(records.length, 2);
285
+ assert.equal(got.length, 2);
286
+ });
287
+
288
+ test('checkQueue rejects with a 500 ApiError for an unknown style', async () => {
289
+ const amqpOperator = await makeOperator({assertQueue: async () => ({messageCount: 0})});
290
+ await assert.rejects(
291
+ async () => amqpOperator.checkQueue({queue: QUEUE, style: 'unknown-style'}),
292
+ (err) => {
293
+ assert.ok(err instanceof ApiError);
294
+ assert.equal(err.status, 500);
295
+ return true;
296
+ }
297
+ );
298
+ });
299
+
300
+ test('checkQueue purges the queue before returning the style result', async () => {
301
+ const purgeCalls: any[] = [];
302
+ let assertCalls = 0;
303
+ const amqpOperator = await makeOperator({
304
+ assertQueue: async () => {
305
+ assertCalls += 1;
306
+ // first call (before the purge) reports 3 pending, after the purge 0
307
+ return assertCalls === 1 ? {messageCount: 3} : {messageCount: 0};
308
+ },
309
+ purgeQueue: async (name: string) => {
310
+ purgeCalls.push(name);
311
+ }
312
+ });
313
+ const result = await amqpOperator.checkQueue({queue: QUEUE, style: 'messages', purge: true});
314
+ assert.deepStrictEqual(purgeCalls, [QUEUE]);
315
+ assert.strictEqual(result, 0);
316
+ });
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // consumeOne
320
+ // ---------------------------------------------------------------------------
321
+
322
+ test('consumeOne returns the plain message when toRecord is false', async () => {
323
+ const message = makeMessage('c1', 1);
324
+ const amqpOperator = await makeOperator({get: async () => message});
325
+ const result = await amqpOperator.consumeOne(QUEUE, false);
326
+ assert.strictEqual(result, message);
327
+ });
328
+
329
+ test('consumeOne returns the {headers, records, messages} form when toRecord is true', async () => {
330
+ const message = makeMessage('c1', 1);
331
+ const amqpOperator = await makeOperator({get: async () => message});
332
+ const {headers, records, messages} = (await amqpOperator.consumeOne(QUEUE, true)) as QueueItemRecordPayload;
333
+ assert.deepStrictEqual(headers, {from: 'TEST'});
334
+ assert.equal(records.length, 1);
335
+ assert.strictEqual(messages.length, 1);
336
+ assert.strictEqual(messages[0], message);
337
+ });
338
+
339
+ test('consumeOne returns false when the queue is empty', async () => {
340
+ const amqpOperator = await makeOperator({get: async () => false});
341
+ const result = await amqpOperator.consumeOne(QUEUE, true);
342
+ assert.strictEqual(result, false);
343
+ });
344
+
345
+ test('consumeOne rejects when channel.get throws a plain Error', async () => {
346
+ const amqpOperator = await makeOperator({
347
+ get: async () => {
348
+ throw new Error('get exploded');
349
+ }
350
+ });
351
+ await assert.rejects(async () => amqpOperator.consumeOne(QUEUE, true), /get exploded/);
352
+ });
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // consumeChunk extra branches
356
+ // ---------------------------------------------------------------------------
357
+
358
+ test('consumeChunk returns {headers, messages} without records when toRecord is false', async () => {
359
+ const messages = [makeMessage('c1', 1), makeMessage('c2', 2)];
360
+ const amqpOperator = await makeOperator({
361
+ checkQueue: async () => ({messageCount: messages.length}),
362
+ get: async () => messages.shift()
363
+ });
364
+ const {headers, messages: got} = (await amqpOperator.consumeChunk(QUEUE, false)) as QueueItemMessagePayload;
365
+ assert.deepStrictEqual(headers, {from: 'TEST'});
366
+ assert.equal(got.length, 2);
367
+ });
368
+
369
+ test('consumeChunk rejects when the amqplib checkQueue throws a plain Error', async () => {
370
+ const amqpOperator = await makeOperator({
371
+ checkQueue: async () => {
372
+ throw new Error('count exploded');
373
+ }
374
+ });
375
+ await assert.rejects(async () => amqpOperator.consumeChunk(QUEUE, true), /count exploded/);
376
+ });
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // ackMessages / nackMessages
380
+ // ---------------------------------------------------------------------------
381
+
382
+ test('ackMessages acks every message on the channel', async () => {
383
+ const acked: Message[] = [];
384
+ const messages = [makeMessage('c1', 1), makeMessage('c2', 2)];
385
+ const amqpOperator = await makeOperator({
386
+ ack: (message: Message) => {
387
+ acked.push(message);
388
+ }
389
+ });
390
+ amqpOperator.ackMessages(messages);
391
+ assert.deepStrictEqual(acked, messages);
392
+ });
393
+
394
+ test('nackMessages nacks every message on the channel', async () => {
395
+ const nacked: Message[] = [];
396
+ const messages = [makeMessage('c1', 1), makeMessage('c2', 2)];
397
+ const amqpOperator = await makeOperator({
398
+ nack: (message: Message) => {
399
+ nacked.push(message);
400
+ }
401
+ });
402
+ amqpOperator.nackMessages(messages);
403
+ assert.deepStrictEqual(nacked, messages);
404
+ });
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // sendToQueue
408
+ // ---------------------------------------------------------------------------
409
+
410
+ test('sendToQueue sends the JSON body with correlationId, persistent and headers options', async () => {
411
+ const sent: {queue: string, content: Buffer, options: Record<string, unknown>}[] = [];
412
+ const amqpOperator = await makeOperator({
413
+ sendToQueue: async (queue: string, content: Buffer, options: Record<string, unknown>) => {
414
+ sent.push({queue, content, options});
415
+ }
416
+ });
417
+ const data = {title: 'Test record', tags: ['a', 'b']};
418
+ await amqpOperator.sendToQueue({
419
+ queue: QUEUE,
420
+ correlationId: 'cid-1',
421
+ headers: {h: 1},
422
+ data
423
+ });
424
+ assert.equal(sent.length, 1);
425
+ assert.equal(sent[0]?.queue, QUEUE);
426
+ assert.ok(sent[0] && Buffer.isBuffer(sent[0].content));
427
+ assert.deepStrictEqual(JSON.parse(sent[0]?.content.toString() ?? ''), {data});
428
+ assert.deepStrictEqual(sent[0]?.options, {correlationId: 'cid-1', persistent: true, headers: {h: 1}});
429
+ });
430
+
431
+ test('sendToQueue rejects with a 500 ApiError when the queue is undefined', async () => {
432
+ const amqpOperator = await makeOperator();
433
+ await assert.rejects(
434
+ async () => amqpOperator.sendToQueue({queue: undefined, correlationId: 'cid', data: {}}),
435
+ (err) => {
436
+ assert.ok(err instanceof ApiError);
437
+ assert.equal(err.status, 500);
438
+ assert.equal(err.payload, 'Undefined queue!');
439
+ return true;
440
+ }
441
+ );
442
+ });
443
+
444
+ test('checkQueue rejects with a 500 ApiError when the queue is an empty string', async () => {
445
+ const amqpOperator = await makeOperator();
446
+ await assert.rejects(
447
+ async () => amqpOperator.checkQueue({queue: '', style: 'basic'}),
448
+ (err) => {
449
+ assert.ok(err instanceof ApiError);
450
+ assert.equal(err.status, 500);
451
+ assert.equal(err.payload, 'Undefined queue!');
452
+ return true;
453
+ }
454
+ );
455
+ });
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // closeConnection / healthCheck error handling
459
+ // ---------------------------------------------------------------------------
460
+
461
+ test('closeConnection closes the connection and the operator resolves', async () => {
462
+ const closed: any[] = [];
463
+ const channel = makeChannel();
464
+ const amqpOperator = await createAmqp(DUMMY_URL, false, {
465
+ connection: {
466
+ createChannel: async () => channel,
467
+ close: async () => {
468
+ closed.push('connection');
469
+ }
470
+ },
471
+ channel
472
+ });
473
+ await amqpOperator.closeConnection();
474
+ assert.deepStrictEqual(closed, ['connection']);
475
+ });
476
+
477
+ test('healthCheck error arriving after cancel does not rethrow and stops the loop', async () => {
478
+ let rejectAssert: ((reason?: unknown) => void) | undefined;
479
+ const pending = new Promise((_, reject) => {
480
+ rejectAssert = reject;
481
+ });
482
+ let healthCheckCalls = 0;
483
+ const channel = makeChannel({
484
+ assertQueue: async (name: string) => {
485
+ if (name === 'HEALTHCHECK') {
486
+ healthCheckCalls += 1;
487
+ return pending;
488
+ }
489
+ return {messageCount: 0};
490
+ }
491
+ });
492
+ const amqpOperator = await createAmqp(DUMMY_URL, true, {
493
+ connection: {createChannel: async () => channel, close: async () => {}},
494
+ channel
495
+ });
496
+ // by now the loop is suspended on the pending assertQueue call;
497
+ // cancel first, then let the error arrive so the catch sees the flag already cleared
498
+ await amqpOperator.closeChannel();
499
+ if (rejectAssert) {
500
+ rejectAssert(new Error('amqp down after close'));
501
+ }
502
+ await new Promise((resolve) => setTimeout(resolve, 50));
503
+ assert.equal(healthCheckCalls, 1, 'no further health checks after cancel');
504
+ });
505
+
506
+ test('healthCheck error while running rethrows into the un-awaited loop promise', async () => {
507
+ // When assertQueue rejects while healthCheckRunning is still true, the catch
508
+ // rethrows via handleAmqpErrors into the loop promise, which is never
509
+ // awaited -> the rejection escapes as an unhandled rejection.
510
+ // The node test runner fails a test on any in-process unhandled rejection,
511
+ // so the escape is verified in a child node process that installs its own
512
+ // unhandledRejection handler before triggering the error.
513
+ const script = `
514
+ import createAmqp from '${AMQP_MODULE_PATH}';
515
+ const channel = {
516
+ assertQueue: /** @param {*} name */ async (name) => {
517
+ if (name === 'HEALTHCHECK') {
518
+ throw new Error('amqp down');
519
+ }
520
+ return {messageCount: 0};
521
+ },
522
+ checkQueue: async () => ({messageCount: 0}),
523
+ get: async () => false,
524
+ ack: () => {},
525
+ nack: () => {},
526
+ purgeQueue: async () => {},
527
+ close: async () => {},
528
+ sendToQueue: async () => {}
529
+ };
530
+ const captured = [];
531
+ process.on('unhandledRejection', (err) => captured.push(err));
532
+ await createAmqp('amqp://stub:5672/', true, {
533
+ connection: {createChannel: async () => channel, close: async () => {}},
534
+ channel
535
+ });
536
+ // the first loop iteration rejects and the error escapes the un-awaited loop promise
537
+ await new Promise((resolve) => setTimeout(resolve, 50));
538
+ const ok = captured.length === 1 && /amqp down/.test(String(captured[0] && captured[0].message));
539
+ process.stdout.write(ok ? 'OK' : 'FAIL:' + JSON.stringify(captured.map((e) => String(e && e.message))));
540
+ process.exit(ok ? 0 : 1);
541
+ `;
542
+ const {code, stdout} = await new Promise<{code: number | string, stdout: string}>((resolve) => {
543
+ const child = execFile(process.execPath, ['--input-type=module', '-e', script], (err, out) => {
544
+ resolve({code: err ? (err.code ?? 1) : 0, stdout: out ?? ''});
545
+ });
546
+ void child;
547
+ });
548
+ assert.equal(code, 0, `child process output: ${stdout}`);
549
+ // handleAmqpErrors logs the error to stdout before the escape, so only the tail matters
550
+ assert.ok(stdout.endsWith('OK'), `expected child output to end with OK, got: ${stdout}`);
551
+ });
@@ -1,6 +1,8 @@
1
- //import assert from 'node:assert';
1
+ import {describe, it} from 'node:test';
2
+ import assert from 'node:assert';
2
3
  import createDebugLogger from 'debug';
3
- import {amqpFactory} from '../src/index.js';
4
+ import {amqpFactory} from '../src/index.ts';
5
+ import type {AmqpOperator} from '../src/amqp.ts';
4
6
 
5
7
 
6
8
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons:amqp:test');
@@ -25,17 +27,28 @@ describe('HealthCheck: amqpOperator should error if channel or connection fails'
25
27
  debug(`Amqp Operator should have been alive.`);
26
28
 
27
29
  debug(`Testing healthCheckLoop with closed channel`);
28
- debug(`Waiting 0.5s and expecting healthCheckLoop to throw error`);
30
+ debug(`Waiting 0.5s and expecting the healthCheckLoop to stop silently (no error)`);
29
31
  await awaitTime(amqpOperator, true);
30
- debug(`Amqp Operator should have been errored.`);
32
+ // closeChannel() sets the run flag so the loop short-circuits gracefully:
33
+ // it must stop silently without throwing or firing further health checks
34
+ assert.ok(true, 'healthCheck loop stopped without error');
31
35
 
32
36
  debug(`Closing connection`);
33
37
  await amqpOperator.closeConnection();
34
38
 
35
- async function awaitTime(amqpOperator, close) {
39
+ /**
40
+ * @param amqpOperator
41
+ * @param close
42
+ */
43
+ async function awaitTime(amqpOperator: AmqpOperator, close: boolean): Promise<void> {
36
44
  if (close) {
45
+ // wrap the wait so any error thrown by the (cancelled) loop fails the test
37
46
  await amqpOperator.closeChannel();
38
- await setTimeoutPromise(500);
47
+ try {
48
+ await setTimeoutPromise(500);
49
+ } catch (error) {
50
+ assert.fail(`healthCheck loop threw after closeChannel: ${error}`);
51
+ }
39
52
  return;
40
53
  }
41
54
 
@@ -45,4 +58,3 @@ describe('HealthCheck: amqpOperator should error if channel or connection fails'
45
58
 
46
59
  });
47
60
  });
48
-