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