@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.
@@ -0,0 +1,787 @@
1
+
2
+ import assert from 'node:assert';
3
+ import {test} from 'node:test';
4
+ import {Readable, PassThrough} from 'node:stream';
5
+ import httpStatus from 'http-status';
6
+ import {MongoDriverError} from 'mongodb';
7
+ import {Error as ApiError} from '@natlibfi/melinda-commons';
8
+ import createMongo from '../src/mongo.js';
9
+ import createMongoLog from '../src/mongoLog.js';
10
+
11
+ const DUMMY_URI = 'mongodb://stub:27017/rest-api';
12
+ const COLLECTION = 'foobar';
13
+
14
+ function makeOperator(overrides = {}) {
15
+ return {
16
+ insertOne: async () => ({acknowledged: true, insertedId: 1}),
17
+ deleteOne: async () => ({acknowledged: true, deletedCount: 1}),
18
+ ...overrides
19
+ };
20
+ }
21
+
22
+ function makeGridFSBucket(overrides = {}) {
23
+ return {
24
+ find: () => ({toArray: async () => []}),
25
+ openDownloadStreamByName: () => {
26
+ throw new Error('not implemented in stub');
27
+ },
28
+ ...overrides
29
+ };
30
+ }
31
+
32
+ async function makeMongo(deps) {
33
+ return createMongo(DUMMY_URI, COLLECTION, 'rest-api', deps);
34
+ }
35
+
36
+ test('createPrio: insertOne with acknowledged=false throws ApiError 500', async () => {
37
+ const mongo = await makeMongo({
38
+ operator: makeOperator({insertOne: async () => ({acknowledged: false})}),
39
+ gridFSBucket: makeGridFSBucket()
40
+ });
41
+ await assert.rejects(
42
+ () => mongo.createPrio({correlationId: 'FOO', cataloger: 'xxx', oCatalogerIn: 'xxx', operation: 'CREATE'}),
43
+ (error) => {
44
+ assert.ok(error instanceof ApiError);
45
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
46
+ return true;
47
+ }
48
+ );
49
+ });
50
+
51
+ test('createPrio: insertOne rejecting throws ApiError 500 with "Mongo errored" message', async () => {
52
+ const mongo = await makeMongo({
53
+ operator: makeOperator({insertOne: async () => {
54
+ throw new Error('boom');
55
+ }}),
56
+ gridFSBucket: makeGridFSBucket()
57
+ });
58
+ await assert.rejects(
59
+ () => mongo.createPrio({correlationId: 'FOO', cataloger: 'xxx', oCatalogerIn: 'xxx', operation: 'CREATE'}),
60
+ (error) => {
61
+ assert.ok(error instanceof ApiError);
62
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
63
+ assert.ok(error.payload.includes('Mongo errored'));
64
+ return true;
65
+ }
66
+ );
67
+ });
68
+
69
+ test('createBulk (no stream): insertOne with acknowledged=false throws ApiError 500', async () => {
70
+ const mongo = await makeMongo({
71
+ operator: makeOperator({insertOne: async () => ({acknowledged: false})}),
72
+ gridFSBucket: makeGridFSBucket()
73
+ });
74
+ await assert.rejects(
75
+ () => mongo.createBulk({
76
+ correlationId: 'FOO',
77
+ cataloger: 'xxx',
78
+ oCatalogerIn: 'xxx',
79
+ operation: 'CREATE',
80
+ contentType: 'application/json',
81
+ recordLoadParams: {}
82
+ }),
83
+ (error) => {
84
+ assert.ok(error instanceof ApiError);
85
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
86
+ return true;
87
+ }
88
+ );
89
+ });
90
+
91
+ test('remove: MongoDriverError "File not found" deletes queueItem and reports what was removed', async () => {
92
+ const deleteOne = async () => ({acknowledged: true, deletedCount: 1});
93
+ const mongo = await makeMongo({
94
+ operator: makeOperator({deleteOne}),
95
+ gridFSBucket: makeGridFSBucket({
96
+ find: () => ({
97
+ toArray: async () => {
98
+ throw new MongoDriverError('File not found for id (ObjectId("abc"))');
99
+ }
100
+ })
101
+ })
102
+ });
103
+ const result = await mongo.remove({correlationId: 'FOO'});
104
+ assert.deepStrictEqual(result, {removed: true, deletedContent: false});
105
+ });
106
+
107
+ test('remove: returns {removed: true, deletedContent: true} when the queueItem and its GridFS file both exist', async () => {
108
+ const deletedFiles = [];
109
+ const mongo = await makeMongo({
110
+ operator: makeOperator({deleteOne: async () => ({acknowledged: true, deletedCount: 1})}),
111
+ gridFSBucket: makeGridFSBucket({
112
+ find: () => ({toArray: async () => [{_id: 'file-1', filename: 'FOO'}]}),
113
+ delete: async (id) => {
114
+ // eslint-disable-next-line functional/immutable-data
115
+ deletedFiles.push(id);
116
+ return true;
117
+ }
118
+ })
119
+ });
120
+ const result = await mongo.remove({correlationId: 'FOO'});
121
+ assert.deepStrictEqual(result, {removed: true, deletedContent: true});
122
+ assert.deepStrictEqual(deletedFiles, ['file-1']);
123
+ });
124
+
125
+ test('remove: returns removed: false when the queueItem no longer exists in the collection', async () => {
126
+ const mongo = await makeMongo({
127
+ operator: makeOperator({deleteOne: async () => ({acknowledged: true, deletedCount: 0})}),
128
+ gridFSBucket: makeGridFSBucket()
129
+ });
130
+ const result = await mongo.remove({correlationId: 'FOO'});
131
+ assert.deepStrictEqual(result, {removed: false, deletedContent: false});
132
+ });
133
+
134
+ test('remove: MongoDriverError with other message throws ApiError 500', async () => {
135
+ const mongo = await makeMongo({
136
+ operator: makeOperator(),
137
+ gridFSBucket: makeGridFSBucket({
138
+ find: () => ({
139
+ toArray: async () => {
140
+ throw new MongoDriverError('some other driver error');
141
+ }
142
+ })
143
+ })
144
+ });
145
+ await assert.rejects(
146
+ () => mongo.remove({correlationId: 'FOO'}),
147
+ (error) => {
148
+ assert.ok(error instanceof ApiError);
149
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
150
+ assert.ok(error.payload.includes('some other driver error'));
151
+ return true;
152
+ }
153
+ );
154
+ });
155
+
156
+ // getStream is now async - it pre-checks file existence (throws 404
157
+ // for a missing file) and wraps bucket errors into ApiError 500 rejections.
158
+ // (Previously it was synchronous and a missing file surfaced as an unhandled
159
+ // 'error' event on the returned stream.)
160
+
161
+ test('getStream rejects with ApiError 404 when the GridFS file does not exist', async () => {
162
+ const mongo = await makeMongo({
163
+ operator: makeOperator(),
164
+ gridFSBucket: makeGridFSBucket({
165
+ find: () => ({toArray: async () => []})
166
+ })
167
+ });
168
+ await assert.rejects(
169
+ () => mongo.getStream('FOO'),
170
+ (error) => {
171
+ assert.ok(error instanceof ApiError);
172
+ assert.equal(error.status, httpStatus.NOT_FOUND);
173
+ return true;
174
+ }
175
+ );
176
+ });
177
+
178
+ test('getStream rejects with ApiError 500 when openDownloadStreamByName throws', async () => {
179
+ const mongo = await makeMongo({
180
+ operator: makeOperator(),
181
+ gridFSBucket: makeGridFSBucket({
182
+ find: () => ({toArray: async () => [{_id: '1', filename: 'FOO'}]}),
183
+ openDownloadStreamByName: () => {
184
+ throw new Error('sync bucket failure');
185
+ }
186
+ })
187
+ });
188
+ await assert.rejects(
189
+ () => mongo.getStream('FOO'),
190
+ (error) => {
191
+ assert.ok(error instanceof ApiError);
192
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
193
+ assert.ok(error.payload.includes('Mongo errored'));
194
+ return true;
195
+ }
196
+ );
197
+ });
198
+
199
+ test('checkTimeOut returns false (no crash) when the queueItem does not exist', async () => {
200
+ const mongo = await makeMongo({
201
+ operator: makeOperator({findOne: async () => null}),
202
+ gridFSBucket: makeGridFSBucket()
203
+ });
204
+ const result = await mongo.checkTimeOut({correlationId: 'NOPE'});
205
+ assert.equal(result, false);
206
+ });
207
+
208
+ test('setOperation rejects with ApiError 404 for a non-matching correlationId', async () => {
209
+ const mongo = await makeMongo({
210
+ operator: makeOperator({findOne: async () => null}),
211
+ gridFSBucket: makeGridFSBucket()
212
+ });
213
+ await assert.rejects(
214
+ () => mongo.setOperation({correlationId: 'NOPE', operation: 'UPDATE'}),
215
+ (error) => {
216
+ assert.ok(error instanceof ApiError);
217
+ assert.equal(error.status, httpStatus.NOT_FOUND);
218
+ return true;
219
+ }
220
+ );
221
+ });
222
+
223
+ test('setOperations rejects with ApiError 404 for a non-matching correlationId', async () => {
224
+ const mongo = await makeMongo({
225
+ operator: makeOperator({findOne: async () => null}),
226
+ gridFSBucket: makeGridFSBucket()
227
+ });
228
+ await assert.rejects(
229
+ () => mongo.setOperations({correlationId: 'NOPE', addOperation: 'UPDATE'}),
230
+ (error) => {
231
+ assert.ok(error instanceof ApiError);
232
+ assert.equal(error.status, httpStatus.NOT_FOUND);
233
+ return true;
234
+ }
235
+ );
236
+ });
237
+
238
+ test('setOperation returns the resulting document (v6 driver shape, no {ok, value})', async () => {
239
+ const doc = {correlationId: 'FOO', operation: 'UPDATE'};
240
+ const mongo = await makeMongo({
241
+ operator: makeOperator({
242
+ findOne: async () => ({correlationId: 'FOO', operation: 'CREATE'}),
243
+ findOneAndUpdate: async () => doc
244
+ }),
245
+ gridFSBucket: makeGridFSBucket()
246
+ });
247
+ const result = await mongo.setOperation({correlationId: 'FOO', operation: 'UPDATE'});
248
+ assert.deepStrictEqual(result, doc);
249
+ });
250
+
251
+ test('createBulk (stream) returns the normalized {correlationId, acknowledged, queueItemState} shape', async () => {
252
+ const input = new Readable({
253
+ read() {
254
+ this.push(Buffer.from('chunk'));
255
+ this.push(null);
256
+ }
257
+ });
258
+ const mongo = await makeMongo({
259
+ operator: makeOperator({insertOne: async () => ({acknowledged: true, insertedId: 1})}),
260
+ gridFSBucket: makeGridFSBucket({openUploadStream: () => new PassThrough()})
261
+ });
262
+ const result = await mongo.createBulk({
263
+ correlationId: 'FOO',
264
+ cataloger: 'xxx',
265
+ oCatalogerIn: 'xxx',
266
+ operation: 'CREATE',
267
+ contentType: 'application/json',
268
+ recordLoadParams: {},
269
+ stream: input
270
+ });
271
+ assert.deepStrictEqual(result, {
272
+ correlationId: 'FOO',
273
+ acknowledged: true,
274
+ queueItemState: 'UPLOADING'
275
+ });
276
+ });
277
+
278
+ test('checkTimeOut honours MONGO_TIMEOUT_MINUTES (older item than the window is aborted)', async () => {
279
+ process.env.MONGO_TIMEOUT_MINUTES = '5';
280
+ const oldDate = new Date(Date.now() - 4 * 60 * 1000); // 4 min old: fine at 5m, not at 1m
281
+ const mongo = await makeMongo({
282
+ operator: makeOperator({
283
+ findOne: async () => ({
284
+ correlationId: 'FOO',
285
+ queueItemState: 'VALIDATING',
286
+ modificationTime: oldDate,
287
+ importJobState: {CREATE: 'DONE', UPDATE: 'DONE', FIX: 'DONE'}
288
+ })
289
+ }),
290
+ gridFSBucket: makeGridFSBucket()
291
+ });
292
+ const result = await mongo.checkTimeOut({correlationId: 'FOO'});
293
+ delete process.env.MONGO_TIMEOUT_MINUTES;
294
+ assert.equal(result, true);
295
+ });
296
+
297
+ // createBulk stream path used to have no error handling on the GridFS
298
+ // upload stream (hangs forever) and never aborted it on input errors
299
+ // (orphan partial chunks). Both are now wired to reject + cleanup.
300
+
301
+ test('createBulk rejects with ApiError 500 when input stream errors mid-upload, and cleans up the partial file', async () => {
302
+ const deletedFiles = [];
303
+ // errors itself once flowing mode starts
304
+ const input = new Readable({
305
+ read() {
306
+ setImmediate(() => this.emit('error', new Error('input stream boom')));
307
+ }
308
+ });
309
+ const mongo = await makeMongo({
310
+ operator: makeOperator(),
311
+ gridFSBucket: makeGridFSBucket({
312
+ openUploadStream: () => new PassThrough(),
313
+ find: () => ({toArray: async () => [{_id: 'partial-1', filename: 'FOO'}]}),
314
+ delete: async (id) => {
315
+ // eslint-disable-next-line functional/immutable-data
316
+ deletedFiles.push(id);
317
+ return true;
318
+ }
319
+ })
320
+ });
321
+ await assert.rejects(
322
+ () => mongo.createBulk({
323
+ correlationId: 'FOO',
324
+ cataloger: 'xxx',
325
+ oCatalogerIn: 'xxx',
326
+ operation: 'CREATE',
327
+ contentType: 'application/json',
328
+ recordLoadParams: {},
329
+ stream: input
330
+ }),
331
+ (error) => {
332
+ assert.ok(error instanceof ApiError);
333
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
334
+ assert.ok(error.payload.includes('input stream boom'));
335
+ return true;
336
+ }
337
+ );
338
+ assert.deepStrictEqual(deletedFiles, ['partial-1']);
339
+ });
340
+
341
+ test('createBulk rejects with ApiError 500 when the GridFS output stream errors (used to hang forever)', async () => {
342
+ const input = new Readable({read() {}});
343
+ const mongo = await makeMongo({
344
+ operator: makeOperator(),
345
+ gridFSBucket: makeGridFSBucket({
346
+ openUploadStream: () => {
347
+ const output = new PassThrough();
348
+ setImmediate(() => output.emit('error', new Error('disk on fire')));
349
+ return output;
350
+ }
351
+ })
352
+ });
353
+ await assert.rejects(
354
+ () => mongo.createBulk({
355
+ correlationId: 'FOO',
356
+ cataloger: 'xxx',
357
+ oCatalogerIn: 'xxx',
358
+ operation: 'CREATE',
359
+ contentType: 'application/json',
360
+ recordLoadParams: {},
361
+ stream: input
362
+ }),
363
+ (error) => {
364
+ assert.ok(error instanceof ApiError);
365
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
366
+ assert.ok(error.payload.includes('disk on fire'));
367
+ return true;
368
+ }
369
+ );
370
+ });
371
+
372
+ // pushIds / pushMessages validate their array inputs (400, outside the
373
+ // try so the 400 is not re-wrapped) and wrap driver errors in ApiError 500.
374
+
375
+ test('pushIds rejects with ApiError 400 when handledIds is not an array', async () => {
376
+ const mongo = await makeMongo({operator: makeOperator(), gridFSBucket: makeGridFSBucket()});
377
+ await assert.rejects(
378
+ () => mongo.pushIds({correlationId: 'FOO', handledIds: 'nope', rejectedIds: []}),
379
+ (error) => {
380
+ assert.ok(error instanceof ApiError);
381
+ assert.equal(error.status, httpStatus.BAD_REQUEST);
382
+ return true;
383
+ }
384
+ );
385
+ });
386
+
387
+ test('pushIds rejects with ApiError 400 when rejectedIds is not an array', async () => {
388
+ const mongo = await makeMongo({operator: makeOperator(), gridFSBucket: makeGridFSBucket()});
389
+ await assert.rejects(
390
+ () => mongo.pushIds({correlationId: 'FOO', handledIds: [], rejectedIds: undefined}),
391
+ (error) => {
392
+ assert.ok(error instanceof ApiError);
393
+ assert.equal(error.status, httpStatus.BAD_REQUEST);
394
+ return true;
395
+ }
396
+ );
397
+ });
398
+
399
+ test('pushIds rejects with ApiError 500 "Mongo errored" when updateOne throws', async () => {
400
+ const mongo = await makeMongo({
401
+ operator: makeOperator({updateOne: async () => {
402
+ throw new Error('pushIds boom');
403
+ }}),
404
+ gridFSBucket: makeGridFSBucket()
405
+ });
406
+ await assert.rejects(
407
+ () => mongo.pushIds({correlationId: 'FOO', handledIds: ['1'], rejectedIds: []}),
408
+ (error) => {
409
+ assert.ok(error instanceof ApiError);
410
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
411
+ assert.ok(error.payload.includes('Mongo errored'));
412
+ assert.ok(error.payload.includes('pushIds boom'));
413
+ return true;
414
+ }
415
+ );
416
+ });
417
+
418
+ test('pushMessages rejects with ApiError 400 when messages is not an array', async () => {
419
+ const mongo = await makeMongo({operator: makeOperator(), gridFSBucket: makeGridFSBucket()});
420
+ await assert.rejects(
421
+ () => mongo.pushMessages({correlationId: 'FOO', messages: 'nope'}),
422
+ (error) => {
423
+ assert.ok(error instanceof ApiError);
424
+ assert.equal(error.status, httpStatus.BAD_REQUEST);
425
+ return true;
426
+ }
427
+ );
428
+ });
429
+
430
+ test('pushMessages rejects with ApiError 500 "Mongo errored" when updateOne throws', async () => {
431
+ const mongo = await makeMongo({
432
+ operator: makeOperator({updateOne: async () => {
433
+ throw new Error('pushMessages boom');
434
+ }}),
435
+ gridFSBucket: makeGridFSBucket()
436
+ });
437
+ await assert.rejects(
438
+ () => mongo.pushMessages({correlationId: 'FOO', messages: ['m1']}),
439
+ (error) => {
440
+ assert.ok(error instanceof ApiError);
441
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
442
+ assert.ok(error.payload.includes('Mongo errored'));
443
+ assert.ok(error.payload.includes('pushMessages boom'));
444
+ return true;
445
+ }
446
+ );
447
+ });
448
+
449
+ // Entry-point type guards: a non-scalar (e.g. object) correlationId must be
450
+ // rejected with 400 before it can be spliced into a query as a $-operator.
451
+
452
+ test('checkAndSetState rejects with ApiError 400 when correlationId is not a string', async () => {
453
+ const mongo = await makeMongo({operator: makeOperator(), gridFSBucket: makeGridFSBucket()});
454
+ await assert.rejects(
455
+ () => mongo.checkAndSetState({correlationId: {'$ne': ''}, state: 'VALIDATING'}),
456
+ (error) => {
457
+ assert.ok(error instanceof ApiError);
458
+ assert.equal(error.status, httpStatus.BAD_REQUEST);
459
+ return true;
460
+ }
461
+ );
462
+ });
463
+
464
+ test('getOne rejects with ApiError 400 when queueItemState is not a string', async () => {
465
+ const mongo = await makeMongo({operator: makeOperator({findOne: async () => null}), gridFSBucket: makeGridFSBucket()});
466
+ await assert.rejects(
467
+ () => mongo.getOne({queueItemState: {'$ne': ''}}),
468
+ (error) => {
469
+ assert.ok(error instanceof ApiError);
470
+ assert.equal(error.status, httpStatus.BAD_REQUEST);
471
+ return true;
472
+ }
473
+ );
474
+ });
475
+
476
+ test('getOne rejects with ApiError 400 when importJobState is not an object', async () => {
477
+ const mongo = await makeMongo({operator: makeOperator({findOne: async () => null}), gridFSBucket: makeGridFSBucket()});
478
+ await assert.rejects(
479
+ () => mongo.getOne({importJobState: 'IN_QUEUE'}),
480
+ (error) => {
481
+ assert.ok(error instanceof ApiError);
482
+ assert.equal(error.status, httpStatus.BAD_REQUEST);
483
+ return true;
484
+ }
485
+ );
486
+ });
487
+
488
+ test('getOne rejects with ApiError 400 when importJobState has a $-operator key', async () => {
489
+ const mongo = await makeMongo({operator: makeOperator({findOne: async () => null}), gridFSBucket: makeGridFSBucket()});
490
+ await assert.rejects(
491
+ () => mongo.getOne({importJobState: {'$ne': ''}}),
492
+ (error) => {
493
+ assert.ok(error instanceof ApiError);
494
+ assert.equal(error.status, httpStatus.BAD_REQUEST);
495
+ return true;
496
+ }
497
+ );
498
+ });
499
+
500
+ test('getOne rejects with ApiError 400 when an importJobState value is not a string', async () => {
501
+ const mongo = await makeMongo({operator: makeOperator({findOne: async () => null}), gridFSBucket: makeGridFSBucket()});
502
+ await assert.rejects(
503
+ () => mongo.getOne({importJobState: {'importJobState.UPDATE': {'$gt': ''}}}),
504
+ (error) => {
505
+ assert.ok(error instanceof ApiError);
506
+ assert.equal(error.status, httpStatus.BAD_REQUEST);
507
+ return true;
508
+ }
509
+ );
510
+ });
511
+
512
+ test('getOne queries findOne with a valid importJobState filter', async () => {
513
+ const filters = [];
514
+ const mongo = await makeMongo({
515
+ operator: makeOperator({findOne: async (filter) => {
516
+ // eslint-disable-next-line functional/immutable-data
517
+ filters.push(filter);
518
+ return null;
519
+ }}),
520
+ gridFSBucket: makeGridFSBucket()
521
+ });
522
+ const result = await mongo.getOne({importJobState: {'importJobState.UPDATE': 'IN_QUEUE'}});
523
+ assert.strictEqual(result, null);
524
+ assert.deepStrictEqual(filters, [{'importJobState.UPDATE': 'IN_QUEUE'}]);
525
+ });
526
+
527
+ test('setBlobSize rejects with ApiError 400 when blobSize is not a number', async () => {
528
+ const mongo = await makeMongo({operator: makeOperator(), gridFSBucket: makeGridFSBucket()});
529
+ await assert.rejects(
530
+ () => mongo.setBlobSize({correlationId: 'FOO', blobSize: '7'}),
531
+ (error) => {
532
+ assert.ok(error instanceof ApiError);
533
+ assert.equal(error.status, httpStatus.BAD_REQUEST);
534
+ return true;
535
+ }
536
+ );
537
+ });
538
+
539
+ test('setBlobSize stores a real number when blobSize is a number', async () => {
540
+ const updates = [];
541
+ const mongo = await makeMongo({
542
+ operator: makeOperator({findOneAndUpdate: async (filter, update) => {
543
+ // eslint-disable-next-line functional/immutable-data
544
+ updates.push({filter, update});
545
+ return {correlationId: 'FOO', blobSize: 7};
546
+ }}),
547
+ gridFSBucket: makeGridFSBucket()
548
+ });
549
+ await mongo.setBlobSize({correlationId: 'FOO', blobSize: 7});
550
+ assert.strictEqual(updates.length, 1);
551
+ assert.strictEqual(updates[0].update.$set.blobSize, 7);
552
+ assert.strictEqual(typeof updates[0].update.$set.blobSize, 'number');
553
+ });
554
+
555
+ test('pushMessages rejects with ApiError 400 when messageField is not a string', async () => {
556
+ const mongo = await makeMongo({operator: makeOperator(), gridFSBucket: makeGridFSBucket()});
557
+ await assert.rejects(
558
+ () => mongo.pushMessages({correlationId: 'FOO', messages: ['m1'], messageField: 42}),
559
+ (error) => {
560
+ assert.ok(error instanceof ApiError);
561
+ assert.equal(error.status, httpStatus.BAD_REQUEST);
562
+ return true;
563
+ }
564
+ );
565
+ });
566
+
567
+ test('createBulk (stream): insertOne with acknowledged=false throws ApiError 500', async () => {
568
+ const input = new Readable({
569
+ read() {
570
+ this.push(Buffer.from('chunk'));
571
+ this.push(null);
572
+ }
573
+ });
574
+ const mongo = await makeMongo({
575
+ operator: makeOperator({insertOne: async () => ({acknowledged: false})}),
576
+ gridFSBucket: makeGridFSBucket({openUploadStream: () => new PassThrough()})
577
+ });
578
+ await assert.rejects(
579
+ () => mongo.createBulk({
580
+ correlationId: 'FOO',
581
+ cataloger: 'xxx',
582
+ oCatalogerIn: 'xxx',
583
+ operation: 'CREATE',
584
+ contentType: 'application/json',
585
+ recordLoadParams: {},
586
+ stream: input
587
+ }),
588
+ (error) => {
589
+ assert.ok(error instanceof ApiError);
590
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
591
+ assert.ok(error.payload.includes('Mongo errored'));
592
+ return true;
593
+ }
594
+ );
595
+ });
596
+
597
+ test('createBulk (stream): a failing GridFS cleanup does not mask the original upload error', async () => {
598
+ // input errors mid-upload AND the partial-file cleanup (gridFSBucket.find)
599
+ // throws too: the cleanup error is only logged, the original error must
600
+ // still propagate
601
+ const input = new Readable({
602
+ read() {
603
+ setImmediate(() => this.emit('error', new Error('input stream boom')));
604
+ }
605
+ });
606
+ const mongo = await makeMongo({
607
+ operator: makeOperator(),
608
+ gridFSBucket: makeGridFSBucket({
609
+ openUploadStream: () => new PassThrough(),
610
+ find: () => {
611
+ throw new Error('gridfs cleanup boom');
612
+ }
613
+ })
614
+ });
615
+ await assert.rejects(
616
+ () => mongo.createBulk({
617
+ correlationId: 'FOO',
618
+ cataloger: 'xxx',
619
+ oCatalogerIn: 'xxx',
620
+ operation: 'CREATE',
621
+ contentType: 'application/json',
622
+ recordLoadParams: {},
623
+ stream: input
624
+ }),
625
+ (error) => {
626
+ assert.ok(error instanceof ApiError);
627
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
628
+ assert.ok(error.payload.includes('input stream boom'));
629
+ assert.ok(!error.payload.includes('gridfs cleanup boom'));
630
+ return true;
631
+ }
632
+ );
633
+ });
634
+
635
+ test('remove: non-MongoDriverError from deleteOne throws ApiError 500 with the error message', async () => {
636
+ const mongo = await makeMongo({
637
+ operator: makeOperator({deleteOne: async () => {
638
+ throw new Error('plain delete boom');
639
+ }}),
640
+ gridFSBucket: makeGridFSBucket()
641
+ });
642
+ await assert.rejects(
643
+ () => mongo.remove({correlationId: 'FOO'}),
644
+ (error) => {
645
+ assert.ok(error instanceof ApiError);
646
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
647
+ assert.ok(error.payload.includes('plain delete boom'));
648
+ return true;
649
+ }
650
+ );
651
+ });
652
+
653
+ test('setOperation rejects with ApiError 404 when findOneAndUpdate returns null', async () => {
654
+ const mongo = await makeMongo({
655
+ operator: makeOperator({
656
+ findOne: async () => ({correlationId: 'FOO', operation: 'CREATE'}),
657
+ findOneAndUpdate: async () => null
658
+ }),
659
+ gridFSBucket: makeGridFSBucket()
660
+ });
661
+ await assert.rejects(
662
+ () => mongo.setOperation({correlationId: 'FOO', operation: 'UPDATE'}),
663
+ (error) => {
664
+ assert.ok(error instanceof ApiError);
665
+ assert.equal(error.status, httpStatus.NOT_FOUND);
666
+ assert.ok(error.payload.message.includes('not found'));
667
+ return true;
668
+ }
669
+ );
670
+ });
671
+
672
+ // mongoLog: same deps-seam convention as mongo (factory last param). When
673
+ // deps.collection is provided no real connection is made and every
674
+ // db.collection('logs') call routes to the injected object. These tests
675
+ // cover the catch blocks that re-wrap driver failures as ApiError 500.
676
+
677
+ function makeLogCollection(overrides = {}) {
678
+ return {
679
+ insertOne: async () => ({acknowledged: true, insertedId: 1}),
680
+ updateMany: async () => ({acknowledged: true, matchedCount: 0, modifiedCount: 0}),
681
+ deleteMany: async () => ({acknowledged: true, deletedCount: 0}),
682
+ ...overrides
683
+ };
684
+ }
685
+
686
+ async function makeMongoLog(deps) {
687
+ return createMongoLog(DUMMY_URI, 'rest-api', deps);
688
+ }
689
+
690
+ function mongoErrored500(text) {
691
+ return (error) => {
692
+ assert.ok(error instanceof ApiError);
693
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
694
+ assert.ok(error.payload.includes('Mongo errored'));
695
+ assert.ok(error.payload.includes(text));
696
+ return true;
697
+ };
698
+ }
699
+
700
+ test('mongoLog addLogItem: insertOne with acknowledged=false throws ApiError 500', async () => {
701
+ const mongoLog = await makeMongoLog({
702
+ collection: makeLogCollection({insertOne: async () => ({acknowledged: false})})
703
+ });
704
+ await assert.rejects(
705
+ () => mongoLog.addLogItem({correlationId: 'FOO', logItemType: 'MERGE_LOG', blobSequence: 1}),
706
+ (error) => {
707
+ assert.ok(error instanceof ApiError);
708
+ assert.equal(error.status, httpStatus.INTERNAL_SERVER_ERROR);
709
+ return true;
710
+ }
711
+ );
712
+ });
713
+
714
+ test('mongoLog addLogItem: insertOne rejecting throws ApiError 500 with "Mongo errored" message', async () => {
715
+ const mongoLog = await makeMongoLog({
716
+ collection: makeLogCollection({insertOne: async () => {
717
+ throw new Error('addLogItem boom');
718
+ }})
719
+ });
720
+ await assert.rejects(
721
+ () => mongoLog.addLogItem({correlationId: 'FOO', logItemType: 'MERGE_LOG', blobSequence: 1}),
722
+ mongoErrored500('addLogItem boom')
723
+ );
724
+ });
725
+
726
+ test('mongoLog protect: updateMany rejecting throws ApiError 500 with "Mongo errored" message', async () => {
727
+ const mongoLog = await makeMongoLog({
728
+ collection: makeLogCollection({updateMany: async () => {
729
+ throw new Error('protect boom');
730
+ }})
731
+ });
732
+ await assert.rejects(
733
+ () => mongoLog.protect('FOO'),
734
+ mongoErrored500('protect boom')
735
+ );
736
+ });
737
+
738
+ test('mongoLog unprotect: updateMany rejecting throws ApiError 500 with "Mongo errored" message', async () => {
739
+ const mongoLog = await makeMongoLog({
740
+ collection: makeLogCollection({updateMany: async () => {
741
+ throw new Error('unprotect boom');
742
+ }})
743
+ });
744
+ await assert.rejects(
745
+ () => mongoLog.unprotect('FOO'),
746
+ mongoErrored500('unprotect boom')
747
+ );
748
+ });
749
+
750
+ test('mongoLog remove: deleteMany rejecting throws ApiError 500 with "Mongo errored" message', async () => {
751
+ const mongoLog = await makeMongoLog({
752
+ collection: makeLogCollection({deleteMany: async () => {
753
+ throw new Error('remove boom');
754
+ }})
755
+ });
756
+ await assert.rejects(
757
+ () => mongoLog.remove('FOO'),
758
+ mongoErrored500('remove boom')
759
+ );
760
+ });
761
+
762
+ test('mongoLog removeBySequences: deleteMany rejecting throws ApiError 500 with "Mongo errored" message', async () => {
763
+ const mongoLog = await makeMongoLog({
764
+ collection: makeLogCollection({deleteMany: async () => {
765
+ throw new Error('removeBySequences boom');
766
+ }})
767
+ });
768
+ await assert.rejects(
769
+ () => mongoLog.removeBySequences('FOO', [1, 2]),
770
+ mongoErrored500('removeBySequences boom')
771
+ );
772
+ });
773
+
774
+ test('mongoLog protect: injected collection routes the update and returns its result', async () => {
775
+ const filters = [];
776
+ const result = {acknowledged: true, matchedCount: 1, modifiedCount: 1};
777
+ const mongoLog = await makeMongoLog({
778
+ collection: makeLogCollection({updateMany: async (filter) => {
779
+ // eslint-disable-next-line functional/immutable-data
780
+ filters.push(filter);
781
+ return result;
782
+ }})
783
+ });
784
+ const mongoLogResult = await mongoLog.protect('FOO');
785
+ assert.deepStrictEqual(mongoLogResult, result);
786
+ assert.deepStrictEqual(filters, [{correlationId: 'FOO'}]);
787
+ });