@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.
- package/.github/workflows/melinda-node-tests-and-publish.yml +4 -2
- package/README.md +82 -0
- package/dist/amqp.d.ts +41 -0
- package/dist/amqp.d.ts.map +1 -0
- package/dist/amqp.js +242 -218
- 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 -65
- 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 +163 -151
- 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 -117
- 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 +603 -464
- 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 +282 -172
- 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 +118 -87
- package/dist/utils.js.map +1 -7
- package/eslint.config.ts +66 -0
- package/package.json +23 -25
- package/test/amqp-error-branches.test.ts +551 -0
- package/{tests/amqp.healthtest.js → test/amqp.healthtest.ts} +19 -7
- package/{tests/amqp.tests.localtest.js → test/amqp.tests.localtest.ts} +66 -52
- package/{tests/helpers/conversion.test.js → test/helpers/conversion.test.ts} +39 -11
- package/test/helpers/fix-edge.test.ts +201 -0
- package/test/helpers/fix.test.ts +65 -0
- package/{tests/helpers/validation.test.js → test/helpers/validation.test.ts} +13 -8
- 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} +9 -9
- package/{tests/mongo-check-and-set-import-job-state.test.js → test/mongo-check-and-set-import-job-state.test.ts} +9 -10
- 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/test/mongo-error-branches.test.ts +883 -0
- 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} +10 -9
- 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} +16 -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/test/mongoLog-protect.test.ts +182 -0
- package/test/mongoLog.test.ts +191 -0
- package/{tests/testUtils.js → test/testUtils.ts} +49 -15
- package/test/utils.test.ts +360 -0
- 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
- package/tests/helpers/fix.test.js +0 -41
- 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
|
-
|
|
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.
|
|
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
|
|
30
|
+
debug(`Waiting 0.5s and expecting the healthCheckLoop to stop silently (no error)`);
|
|
29
31
|
await awaitTime(amqpOperator, true);
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|