@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.
- package/.github/workflows/melinda-node-tests-and-publish.yml +4 -2
- package/README.md +211 -14
- package/dist/amqp.d.ts +41 -0
- package/dist/amqp.d.ts.map +1 -0
- package/dist/amqp.js +251 -235
- package/dist/amqp.js.map +1 -7
- package/dist/constants.d.ts +49 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +38 -38
- package/dist/constants.js.map +1 -7
- package/dist/helpers/conversion.d.ts +8 -0
- package/dist/helpers/conversion.d.ts.map +1 -0
- package/dist/helpers/conversion.js +70 -64
- package/dist/helpers/conversion.js.map +1 -7
- package/dist/helpers/fix-constants.d.ts +76 -0
- package/dist/helpers/fix-constants.d.ts.map +1 -0
- package/dist/helpers/fix-constants.js +41 -41
- package/dist/helpers/fix-constants.js.map +1 -7
- package/dist/helpers/fix-handle-tempurns.d.ts +3 -0
- package/dist/helpers/fix-handle-tempurns.d.ts.map +1 -0
- package/dist/helpers/fix-handle-tempurns.js +73 -71
- package/dist/helpers/fix-handle-tempurns.js.map +1 -7
- package/dist/helpers/fix-strip-f884s.d.ts +10 -0
- package/dist/helpers/fix-strip-f884s.d.ts.map +1 -0
- package/dist/helpers/fix-strip-f884s.js +161 -163
- package/dist/helpers/fix-strip-f884s.js.map +1 -7
- package/dist/helpers/fix.d.ts +6 -0
- package/dist/helpers/fix.d.ts.map +1 -0
- package/dist/helpers/fix.js +133 -121
- package/dist/helpers/fix.js.map +1 -7
- package/dist/helpers/validation.d.ts +9 -0
- package/dist/helpers/validation.d.ts.map +1 -0
- package/dist/helpers/validation.js +14 -16
- package/dist/helpers/validation.js.map +1 -7
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -7
- package/dist/mongo.d.ts +165 -0
- package/dist/mongo.d.ts.map +1 -0
- package/dist/mongo.js +595 -547
- package/dist/mongo.js.map +1 -7
- package/dist/mongoError.d.ts +2 -0
- package/dist/mongoError.d.ts.map +1 -0
- package/dist/mongoError.js +19 -0
- package/dist/mongoError.js.map +1 -0
- package/dist/mongoLog.d.ts +50 -0
- package/dist/mongoLog.d.ts.map +1 -0
- package/dist/mongoLog.js +274 -242
- package/dist/mongoLog.js.map +1 -7
- package/dist/utils.d.ts +36 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +112 -110
- package/dist/utils.js.map +1 -7
- package/eslint.config.ts +66 -0
- package/package.json +17 -11
- package/{tests/amqp-error-branches.test.js → test/amqp-error-branches.test.ts} +135 -61
- package/{tests/amqp.healthtest.js → test/amqp.healthtest.ts} +19 -11
- package/{tests/amqp.tests.localtest.js → test/amqp.tests.localtest.ts} +54 -35
- package/{tests/helpers/conversion.test.js → test/helpers/conversion.test.ts} +26 -14
- package/{tests/helpers/fix-edge.test.js → test/helpers/fix-edge.test.ts} +16 -12
- package/{tests/helpers/fix.test.js → test/helpers/fix.test.ts} +14 -8
- package/{tests/helpers/validation.test.js → test/helpers/validation.test.ts} +7 -6
- package/{tests/mongo-add-blob-size.test.js → test/mongo-add-blob-size.test.ts} +14 -15
- package/{tests/mongo-check-and-check-state.test.js → test/mongo-check-and-check-state.test.ts} +6 -8
- package/{tests/mongo-check-and-set-import-job-state.test.js → test/mongo-check-and-set-import-job-state.test.ts} +6 -9
- package/{tests/mongo-check-timeout.test.js → test/mongo-check-timeout.test.ts} +6 -9
- package/{tests/mongo-create-bulk.test.js → test/mongo-create-bulk.test.ts} +6 -7
- package/{tests/mongo-create-prio.test.js → test/mongo-create-prio.test.ts} +6 -6
- package/{tests/mongo-error-branches.test.js → test/mongo-error-branches.test.ts} +166 -65
- package/{tests/mongo-get-one.test.js → test/mongo-get-one.test.ts} +6 -6
- package/{tests/mongo-get-stream.test.js → test/mongo-get-stream.test.ts} +6 -8
- package/{tests/mongo-push-ids.test.js → test/mongo-push-ids.test.ts} +6 -6
- package/{tests/mongo-push-messages.test.js → test/mongo-push-messages.test.ts} +6 -6
- package/{tests/mongo-query-by-id.test.js → test/mongo-query-by-id.test.ts} +6 -9
- package/{tests/mongo-query.test.js → test/mongo-query.test.ts} +6 -7
- package/{tests/mongo-read-content.test.js → test/mongo-read-content.test.ts} +6 -9
- package/{tests/mongo-remove.test.js → test/mongo-remove.test.ts} +6 -8
- package/{tests/mongo-set-blob-size.test.js → test/mongo-set-blob-size.test.ts} +6 -6
- package/{tests/mongo-set-import-job-state.test.js → test/mongo-set-import-job-state.test.ts} +6 -6
- package/{tests/mongo-set-operation.test.js → test/mongo-set-operation.test.ts} +6 -6
- package/{tests/mongo-set-operations.test.js → test/mongo-set-operations.test.ts} +6 -6
- package/{tests/mongo-set-state.test.js → test/mongo-set-state.test.ts} +6 -6
- package/{tests/mongoLog-protect.test.js → test/mongoLog-protect.test.ts} +18 -17
- package/{tests/mongoLog.test.js → test/mongoLog.test.ts} +8 -8
- package/{tests/testUtils.js → test/testUtils.ts} +49 -15
- package/{tests/utils.test.js → test/utils.test.ts} +5 -5
- package/tsconfig.json +62 -0
- package/tsconfig.test.json +17 -0
- package/dist/helpers/fix-utils.js +0 -39
- package/dist/helpers/fix-utils.js.map +0 -7
- 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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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]
|
|
409
|
-
assert.ok(Buffer.isBuffer(sent[0].content));
|
|
410
|
-
assert.deepStrictEqual(JSON.parse(sent[0]
|
|
411
|
-
assert.deepStrictEqual(sent[0]
|
|
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
|
-
|
|
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.
|
|
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('
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|