@natlibfi/melinda-rest-api-commons 6.2.0-alpha.1 → 6.2.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +7 -5
  2. package/dist/amqp.d.ts.map +1 -1
  3. package/dist/amqp.js +10 -3
  4. package/dist/amqp.js.map +1 -1
  5. package/dist/helpers/fix-strip-f884s.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/mongo.d.ts +1 -0
  9. package/dist/mongo.d.ts.map +1 -1
  10. package/dist/mongo.js +25 -2
  11. package/dist/mongo.js.map +1 -1
  12. package/dist/mongoLog.d.ts +14 -1
  13. package/dist/mongoLog.d.ts.map +1 -1
  14. package/dist/mongoLog.js +11 -2
  15. package/dist/mongoLog.js.map +1 -1
  16. package/package.json +2 -2
  17. package/test/amqp-error-branches.test.ts +122 -33
  18. package/test/mongo-add-blob-size.test.ts +6 -0
  19. package/test/mongo-check-and-check-state.test.ts +6 -0
  20. package/test/mongo-check-and-set-import-job-state.test.ts +6 -0
  21. package/test/mongo-check-timeout.test.ts +6 -0
  22. package/test/mongo-create-bulk.test.ts +6 -0
  23. package/test/mongo-create-prio.test.ts +6 -0
  24. package/test/mongo-error-branches.test.ts +129 -3
  25. package/test/mongo-get-one.test.ts +6 -0
  26. package/test/mongo-get-stream.test.ts +6 -0
  27. package/test/mongo-push-ids.test.ts +6 -0
  28. package/test/mongo-push-messages.test.ts +6 -0
  29. package/test/mongo-query-by-id.test.ts +6 -0
  30. package/test/mongo-query.test.ts +6 -0
  31. package/test/mongo-read-content.test.ts +6 -0
  32. package/test/mongo-remove.test.ts +6 -0
  33. package/test/mongo-set-blob-size.test.ts +6 -0
  34. package/test/mongo-set-import-job-state.test.ts +6 -0
  35. package/test/mongo-set-operation.test.ts +6 -0
  36. package/test/mongo-set-operations.test.ts +6 -0
  37. package/test/mongo-set-state.test.ts +6 -0
  38. package/test/mongoLog-protect.test.ts +1 -0
  39. package/test/mongoLog.test.ts +6 -0
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "https://github.com/NatLibFi/melinda-rest-api-commons"
14
14
  },
15
15
  "license": "MIT",
16
- "version": "6.2.0-alpha.1",
16
+ "version": "6.2.0-alpha.3",
17
17
  "main": "./dist/index.js",
18
18
  "type": "module",
19
19
  "engines": {
@@ -42,6 +42,7 @@
42
42
  "@natlibfi/marc-record-validate": "^9.0.2",
43
43
  "@natlibfi/marc-record-validators-melinda": "^13.0.6",
44
44
  "@natlibfi/melinda-commons": "^16.0.0",
45
+ "@types/node": "^25.9.9",
45
46
  "amqplib": "^2.2.0",
46
47
  "debug": "^4.4.3",
47
48
  "http-status": "^2.1.0",
@@ -53,7 +54,6 @@
53
54
  "@natlibfi/fixura-mongo": "^4.0.2",
54
55
  "@types/debug": "^4.1.13",
55
56
  "@types/http-status": "^0.2.30",
56
- "@types/node": "^25.9.9",
57
57
  "cross-env": "^10.1.0",
58
58
  "eslint": "^10.12.0",
59
59
  "jiti": "^2.7.0",
@@ -555,13 +555,11 @@ test('healthCheck error while running rethrows into the un-awaited loop promise'
555
555
  assert.ok(stdout.endsWith('OK'), `expected child output to end with OK, got: ${stdout}`);
556
556
  });
557
557
 
558
- test('broker loss (error + close events) logs the error and close cancels the health check', async () => {
559
- // Failure-path listeners on the amqplib connection/channel:
560
- // - 'error' must only log: without a handler an unhandled EventEmitter
561
- // 'error' would become an uncaughtException, and the error alone must
562
- // NOT stop the loop (the fast-fail contract is unchanged)
563
- // - 'close' must cancel the health check loop so no iterations keep
564
- // running on a dead link (matters for runHealthCheck: false consumers)
558
+ test('graceful shutdown: expected close events cancel the loop without a fatal signal', async () => {
559
+ // closeChannel/closeConnection set shuttingDown before the intentional
560
+ // close, so the 'close' events amqplib emits for the expected close cancel
561
+ // the health check loop instead of leaving it to fail on a closed channel.
562
+ // The whole shutdown must not leak the fatal rejection.
565
563
  const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
566
564
  const makeOn = (target: string) => (event: string, handler: (...args: unknown[]) => void) => {
567
565
  const key = `${target}:${event}`;
@@ -569,41 +567,132 @@ test('broker loss (error + close events) logs the error and close cancels the he
569
567
  list.push(handler);
570
568
  return {off: () => {}};
571
569
  };
570
+ let closed = false;
572
571
  let healthCheckCalls = 0;
573
572
  const channel = makeChannel({
574
573
  on: makeOn('channel'),
575
574
  assertQueue: async (name: string) => {
576
575
  if (name === 'HEALTHCHECK') {
577
576
  healthCheckCalls += 1;
577
+ if (closed) {
578
+ throw new Error('channel closed');
579
+ }
578
580
  }
579
581
  return {messageCount: 0};
582
+ },
583
+ close: async () => {
584
+ closed = true;
580
585
  }
581
586
  });
582
- await createAmqp(DUMMY_URL, true, {
583
- connection: {createChannel: async () => channel, close: async () => {}, on: makeOn('connection')},
584
- channel
587
+ const unhandled: unknown[] = [];
588
+ const onUnhandledRejection = (error: unknown) => unhandled.push(error);
589
+ process.on('unhandledRejection', onUnhandledRejection);
590
+ try {
591
+ const amqpOperator = await createAmqp(DUMMY_URL, true, {
592
+ connection: {createChannel: async () => channel, close: async () => {}, on: makeOn('connection')},
593
+ channel
594
+ });
595
+ await new Promise((resolve) => setTimeout(resolve, 350));
596
+ assert.ok(healthCheckCalls > 0, 'health check should have run before close');
597
+ await amqpOperator.closeChannel();
598
+ // the expected close events: they must not change the already-cancelled
599
+ // state into a fatal one
600
+ for (const handler of handlers['channel:close'] ?? []) handler();
601
+ for (const handler of handlers['connection:close'] ?? []) handler();
602
+ // wait longer than the 200 ms loop interval
603
+ await new Promise((resolve) => setTimeout(resolve, 500));
604
+ } finally {
605
+ process.off('unhandledRejection', onUnhandledRejection);
606
+ }
607
+ assert.deepStrictEqual(unhandled, [], 'no unhandled rejections on graceful shutdown');
608
+ });
609
+
610
+ test('broker loss (error + close events) preserves the healthCheck fast-fail', async () => {
611
+ // Failure-path listeners on the amqplib connection/channel:
612
+ // - 'error' must only log: without a handler an unhandled EventEmitter
613
+ // 'error' would become an uncaughtException
614
+ // - an UNEXPECTED 'close' (link death / broker loss, shuttingDown false)
615
+ // must NOT cancel the health check loop: the loop keeps failing on the
616
+ // dead broker and the error escapes as an unhandled rejection - the
617
+ // documented fast-fail (exit(1)). Only an intentional
618
+ // closeChannel/closeConnection cancels the loop and suppresses the
619
+ // rethrow.
620
+ // The escape is verified in a child node process that installs its own
621
+ // unhandledRejection handler before triggering the loss (the node test
622
+ // runner fails a test on any in-process unhandled rejection).
623
+ const script = `
624
+ import createAmqp from '${AMQP_MODULE_PATH}';
625
+ const handlers = {};
626
+ const makeOn = (target) => (event, handler) => {
627
+ const key = target + ':' + event;
628
+ const list = handlers[key] ?? (handlers[key] = []);
629
+ list.push(handler);
630
+ return {off: () => {}};
631
+ };
632
+ let brokerLost = false;
633
+ let healthCheckCalls = 0;
634
+ const channel = {
635
+ assertQueue: /** @param {*} name */ async (name) => {
636
+ if (name === 'HEALTHCHECK') {
637
+ healthCheckCalls += 1;
638
+ if (brokerLost) {
639
+ throw new Error('broker lost');
640
+ }
641
+ }
642
+ return {messageCount: 0};
643
+ },
644
+ checkQueue: async () => ({messageCount: 0}),
645
+ get: async () => false,
646
+ ack: () => {},
647
+ nack: () => {},
648
+ purgeQueue: async () => {},
649
+ close: async () => {},
650
+ sendToQueue: async () => {},
651
+ on: makeOn('channel')
652
+ };
653
+ const captured = [];
654
+ process.on('unhandledRejection', (err) => captured.push(err));
655
+ await createAmqp('amqp://stub:5672/', true, {
656
+ connection: {createChannel: async () => channel, close: async () => {}, on: makeOn('connection')},
657
+ channel
658
+ });
659
+ // all four listeners must be attached (connection + channel, error + close)
660
+ const attached = Object.keys(handlers).sort();
661
+ const listenersOk = JSON.stringify(attached) === JSON.stringify(['channel:close', 'channel:error', 'connection:close', 'connection:error']);
662
+ // let the loop run a couple of iterations, then simulate broker loss the
663
+ // way amqplib signals it: an 'error' event followed by an unexpected
664
+ // 'close' (no closeChannel/closeConnection was called)
665
+ await new Promise((resolve) => setTimeout(resolve, 350));
666
+ for (const handler of handlers['channel:error'] ?? []) handler(new Error('socket lost'));
667
+ for (const handler of handlers['connection:error'] ?? []) handler(new Error('socket lost'));
668
+ for (const handler of handlers['channel:close'] ?? []) handler();
669
+ for (const handler of handlers['connection:close'] ?? []) handler();
670
+ // the unexpected close must NOT cancel the loop: it keeps running on the
671
+ // still-healthy stub (wait longer than the 200 ms loop interval)
672
+ const callsAfterClose = healthCheckCalls;
673
+ await new Promise((resolve) => setTimeout(resolve, 500));
674
+ const keptRunning = healthCheckCalls > callsAfterClose;
675
+ // now the broker is actually gone: the next failed assert must escape as
676
+ // the unhandled rejection (the documented fast-fail)
677
+ brokerLost = true;
678
+ await new Promise((resolve) => setTimeout(resolve, 500));
679
+ const ok =
680
+ listenersOk &&
681
+ healthCheckCalls > 0 &&
682
+ keptRunning &&
683
+ captured.length === 1 &&
684
+ /broker lost/.test(String(captured[0] && captured[0].message));
685
+ process.stdout.write(ok ? 'OK' : 'FAIL:' + JSON.stringify({attached, healthCheckCalls, rejections: captured.map((e) => String(e && e.message))}));
686
+ process.exit(ok ? 0 : 1);
687
+ `;
688
+ const {code, stdout} = await new Promise<{code: number | string, stdout: string}>((resolve) => {
689
+ const child = execFile(process.execPath, ['--input-type=module', '-e', script], (err, out) => {
690
+ resolve({code: err ? (err.code ?? 1) : 0, stdout: out ?? ''});
691
+ });
692
+ void child;
585
693
  });
586
- // all four listeners must be attached (connection + channel, error + close)
587
- assert.deepStrictEqual(Object.keys(handlers).sort(), [
588
- 'channel:close',
589
- 'channel:error',
590
- 'connection:close',
591
- 'connection:error'
592
- ]);
593
- // let the loop run a couple of iterations, then simulate broker loss the
594
- // way amqplib signals it: an 'error' event followed by 'close'
595
- await new Promise((resolve) => setTimeout(resolve, 350));
596
- for (const handler of handlers['channel:error'] ?? []) handler(new Error('socket lost'));
597
- for (const handler of handlers['connection:error'] ?? []) handler(new Error('socket lost'));
598
- // the 'error' events alone must not cancel the loop
599
- const callsAfterError = healthCheckCalls;
600
- await new Promise((resolve) => setTimeout(resolve, 500));
601
- assert.ok(healthCheckCalls > 0, 'health check should have run before the broker loss');
602
- assert.ok(healthCheckCalls > callsAfterError, 'health check keeps running after the error events');
603
- for (const handler of handlers['channel:close'] ?? []) handler();
604
- for (const handler of handlers['connection:close'] ?? []) handler();
605
- const callsAfterClose = healthCheckCalls;
606
- // wait longer than the 200 ms loop interval - no further health checks may fire
607
- await new Promise((resolve) => setTimeout(resolve, 500));
608
- assert.equal(healthCheckCalls, callsAfterClose, 'no health checks after the close events');
694
+ assert.equal(code, 0, `child process output: ${stdout}`);
695
+ // the error listeners and handleAmqpErrors log to stdout before the escape,
696
+ // so only the tail matters
697
+ assert.ok(stdout.endsWith('OK'), `expected child output to end with OK, got: ${stdout}`);
609
698
  });
@@ -8,6 +8,7 @@ import {getMongoOperator, handleError, compareToFirstDbEntry} from './testUtils.
8
8
 
9
9
 
10
10
  let mongoFixtures: any;
11
+ const openedOperators: any[] = [];
11
12
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:add-blob-size:test');
12
13
 
13
14
  generateTests({
@@ -34,6 +35,10 @@ generateTests({
34
35
  },
35
36
  after: async () => {
36
37
  //debug(`<< After`);
38
+ // close the operators the tests opened (one per test) before the server
39
+ for (const op of openedOperators) {
40
+ await op.close();
41
+ }
37
42
  await mongoFixtures.close();
38
43
  }
39
44
  }
@@ -61,6 +66,7 @@ async function callback(callbackParams: Record<string, any>) {
61
66
  } = callbackParams;
62
67
 
63
68
  const mongoOperator = await getMongoOperator(mongoFixtures);
69
+ openedOperators.push(mongoOperator);
64
70
  const expectedResult = await getFixture('expectedResult.json');
65
71
 
66
72
  await doPreFillDb(preFillDb);
@@ -8,6 +8,7 @@ import {getMongoOperator, handleError, compareToFirstDbEntry} from './testUtils.
8
8
 
9
9
 
10
10
  let mongoFixtures: any;
11
+ const openedOperators: any[] = [];
11
12
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:test');
12
13
 
13
14
  generateTests({
@@ -34,6 +35,10 @@ generateTests({
34
35
  },
35
36
  after: async () => {
36
37
  //debug(`<< After`);
38
+ // close the operators the tests opened (one per test) before the server
39
+ for (const op of openedOperators) {
40
+ await op.close();
41
+ }
37
42
  await mongoFixtures.close();
38
43
  }
39
44
  }
@@ -62,6 +67,7 @@ async function callback(callbackParams: Record<string, any>) {
62
67
  } = callbackParams;
63
68
 
64
69
  const mongoOperator = await getMongoOperator(mongoFixtures);
70
+ openedOperators.push(mongoOperator);
65
71
  const expectedResult = await getFixture('expectedResult.json');
66
72
 
67
73
  await doPreFillDb(preFillDb);
@@ -7,6 +7,7 @@ import createDebugLogger from 'debug';
7
7
  import {getMongoOperator, handleError, compareToFirstDbEntry} from './testUtils.ts';
8
8
 
9
9
  let mongoFixtures: any;
10
+ const openedOperators: any[] = [];
10
11
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:check-and-set-import-job-state-test');
11
12
 
12
13
  generateTests({
@@ -33,6 +34,10 @@ generateTests({
33
34
  },
34
35
  after: async () => {
35
36
  //debug(`<< After`);
37
+ // close the operators the tests opened (one per test) before the server
38
+ for (const op of openedOperators) {
39
+ await op.close();
40
+ }
36
41
  await mongoFixtures.close();
37
42
  }
38
43
  }
@@ -61,6 +66,7 @@ async function callback(callbackParams: Record<string, any>) {
61
66
  } = callbackParams;
62
67
 
63
68
  const mongoOperator = await getMongoOperator(mongoFixtures);
69
+ openedOperators.push(mongoOperator);
64
70
  const expectedResult = await getFixture('expectedResult.json');
65
71
 
66
72
  await doPreFillDb(preFillDb);
@@ -8,6 +8,7 @@ import {getMongoOperator, handleError, compareToFirstDbEntry} from './testUtils.
8
8
 
9
9
 
10
10
  let mongoFixtures: any;
11
+ const openedOperators: any[] = [];
11
12
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:check-timeout:test');
12
13
 
13
14
  generateTests({
@@ -34,6 +35,10 @@ generateTests({
34
35
  },
35
36
  after: async () => {
36
37
  //debug(`<< After`);
38
+ // close the operators the tests opened (one per test) before the server
39
+ for (const op of openedOperators) {
40
+ await op.close();
41
+ }
37
42
  await mongoFixtures.close();
38
43
  }
39
44
  }
@@ -62,6 +67,7 @@ async function callback(callbackParams: Record<string, any>) {
62
67
  } = callbackParams;
63
68
 
64
69
  const mongoOperator = await getMongoOperator(mongoFixtures);
70
+ openedOperators.push(mongoOperator);
65
71
  const expectedResult = await getFixture('expectedResult.json');
66
72
 
67
73
  await doPreFillDb(preFillDb);
@@ -6,6 +6,7 @@ import createDebugLogger from 'debug';
6
6
  import {getMongoOperator, handleError, compareToFirstDbEntry} from './testUtils.ts';
7
7
 
8
8
  let mongoFixtures: any;
9
+ const openedOperators: any[] = [];
9
10
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:test:create-bulk');
10
11
 
11
12
  generateTests({
@@ -32,6 +33,10 @@ generateTests({
32
33
  },
33
34
  after: async () => {
34
35
  //debug(`<< After`);
36
+ // close the operators the tests opened (one per test) before the server
37
+ for (const op of openedOperators) {
38
+ await op.close();
39
+ }
35
40
  await mongoFixtures.close();
36
41
  }
37
42
  }
@@ -59,6 +64,7 @@ async function callback(callbackParams: Record<string, any>) {
59
64
  } = callbackParams;
60
65
 
61
66
  const mongoOperator = await getMongoOperator(mongoFixtures);
67
+ openedOperators.push(mongoOperator);
62
68
  const expectedResult = await getFixture('expectedResult.json');
63
69
 
64
70
  await doPreFillDb(preFillDb);
@@ -7,6 +7,7 @@ import {getMongoOperator, handleError, compareToFirstDbEntry} from './testUtils.
7
7
  // import {handleError, compareToFirstDbEntry, compareToDbEntry, formatQueueItem, streamToString} from './testUtils.js';
8
8
 
9
9
  let mongoFixtures: any;
10
+ const openedOperators: any[] = [];
10
11
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:create-prio-test');
11
12
 
12
13
  generateTests({
@@ -33,6 +34,10 @@ generateTests({
33
34
  },
34
35
  after: async () => {
35
36
  //debug(`<< After`);
37
+ // close the operators the tests opened (one per test) before the server
38
+ for (const op of openedOperators) {
39
+ await op.close();
40
+ }
36
41
  await mongoFixtures.close();
37
42
  }
38
43
  }
@@ -58,6 +63,7 @@ async function callback(callbackParams: Record<string, any>) {
58
63
  } = callbackParams;
59
64
 
60
65
  const mongoOperator = await getMongoOperator(mongoFixtures);
66
+ openedOperators.push(mongoOperator);
61
67
  const expectedResult = await getFixture('expectedResult.json');
62
68
 
63
69
  await doPreFillDb(preFillDb);
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert';
2
- import {test} from 'node:test';
2
+ import {test, after} from 'node:test';
3
3
  import {Readable, PassThrough} from 'node:stream';
4
4
  import httpStatus from 'http-status';
5
5
  import {MongoDriverError} from 'mongodb';
@@ -9,6 +9,9 @@ import createMongoLog from '../src/mongoLog.ts';
9
9
 
10
10
  const DUMMY_URI = 'mongodb://stub:27017/rest-api';
11
11
  const COLLECTION = 'foobar';
12
+ // every test creates its own (stubbed) operators; close them all at the
13
+ // end - close is a no-op for stubbed factories, which is the point
14
+ const stubOperators: any[] = [];
12
15
 
13
16
  // the deps seam takes unknown-typed dependencies, so plain stub objects
14
17
  // are fine to inject (the driver types are never exercised here)
@@ -35,7 +38,9 @@ function makeGridFSBucket(overrides: Record<string, any> = {}) {
35
38
  * @param deps the injected stub operator/gridFSBucket
36
39
  */
37
40
  async function makeMongo(deps: Record<string, any>) {
38
- return createMongo(DUMMY_URI, COLLECTION, 'rest-api', deps);
41
+ const mongo = await createMongo(DUMMY_URI, COLLECTION, 'rest-api', deps);
42
+ stubOperators.push(mongo);
43
+ return mongo;
39
44
  }
40
45
 
41
46
  // The 400-guard tests pass intentionally malformed inputs (non-string
@@ -214,6 +219,119 @@ test('checkTimeOut returns false (no crash) when the queueItem does not exist',
214
219
  assert.equal(result, false);
215
220
  });
216
221
 
222
+ // checkAndSetState / checkAndSetImportJobState must report a missing item
223
+ // as 'unchanged' (with a null queueItem) — not as a timeout
224
+
225
+ test('checkAndSetState returns {status: "unchanged", queueItem: null} when the queueItem does not exist', async () => {
226
+ const mongo = await makeMongo({
227
+ operator: makeOperator({findOne: async () => null}),
228
+ gridFSBucket: makeGridFSBucket()
229
+ });
230
+ const result = await mongo.checkAndSetState({correlationId: 'NOPE', state: 'VALIDATING'});
231
+ assert.deepStrictEqual(result, {status: 'unchanged', queueItem: null});
232
+ });
233
+
234
+ test('checkAndSetImportJobState returns {status: "unchanged", queueItem: null} when the queueItem does not exist', async () => {
235
+ const mongo = await makeMongo({
236
+ operator: makeOperator({findOne: async () => null}),
237
+ gridFSBucket: makeGridFSBucket()
238
+ });
239
+ const result = await mongo.checkAndSetImportJobState({correlationId: 'NOPE', operation: 'FIX', importJobState: 'DONE'});
240
+ assert.deepStrictEqual(result, {status: 'unchanged', queueItem: null});
241
+ });
242
+
243
+ // a pre-existing ABORT item must be reported as 'unchanged' (not
244
+ // 'timeout', which is reserved for items aborted by checkTimeOut in this
245
+ // call) and the document must be left untouched
246
+
247
+ test('checkAndSetState reports a pre-existing ABORT as "unchanged" without writing', async () => {
248
+ const doc = {correlationId: 'FOO', queueItemState: 'ABORT', errorStatus: 408, errorMessage: 'Timeout in VALIDATING'};
249
+ const writes: unknown[] = [];
250
+ const mongo = await makeMongo({
251
+ operator: makeOperator({
252
+ findOne: async () => doc,
253
+ findOneAndUpdate: async (filter: unknown, update: unknown) => {
254
+ writes.push({filter, update});
255
+ return doc;
256
+ }
257
+ }),
258
+ gridFSBucket: makeGridFSBucket()
259
+ });
260
+ const result = await mongo.checkAndSetState({correlationId: 'FOO', state: 'VALIDATING'});
261
+ assert.deepStrictEqual(result, {status: 'unchanged', queueItem: doc});
262
+ assert.deepStrictEqual(writes, []);
263
+ });
264
+
265
+ test('checkAndSetImportJobState reports a pre-existing ABORT as "unchanged" without writing', async () => {
266
+ const doc = {correlationId: 'FOO', queueItemState: 'ABORT', errorStatus: 408, errorMessage: 'Timeout in VALIDATING'};
267
+ const writes: unknown[] = [];
268
+ const mongo = await makeMongo({
269
+ operator: makeOperator({
270
+ findOne: async () => doc,
271
+ findOneAndUpdate: async (filter: unknown, update: unknown) => {
272
+ writes.push({filter, update});
273
+ return doc;
274
+ }
275
+ }),
276
+ gridFSBucket: makeGridFSBucket()
277
+ });
278
+ const result = await mongo.checkAndSetImportJobState({correlationId: 'FOO', operation: 'FIX', importJobState: 'DONE'});
279
+ assert.deepStrictEqual(result, {status: 'unchanged', queueItem: doc});
280
+ assert.deepStrictEqual(writes, []);
281
+ });
282
+
283
+ // Known pre-existing concurrency outcome, pinned here as a regression test
284
+ // (a future redesign intentionally changes it): the document is VALIDATING,
285
+ // a concurrent ABORT request comes in and aborts the item, then a second
286
+ // ABORT request comes in. If that second request's pre-read still sees
287
+ // VALIDATING (the first request's abort lands between the pre-read and the
288
+ // internal timeout check), checkTimeOut finds the already-ABORTED document,
289
+ // declines to act, and the post-hoc read still sees ABORT — the second
290
+ // ABORT is reported as 'timeout' (not 'unchanged')
291
+ test('checkAndSetState reports a second concurrent ABORT as "timeout" when the first ABORT lands after its pre-read', async () => {
292
+ const validDoc = {correlationId: 'FOO', queueItemState: 'VALIDATING'};
293
+ const abortDoc = {correlationId: 'FOO', queueItemState: 'ABORT', errorStatus: 408, errorMessage: 'Timeout in VALIDATING'};
294
+ // read order for the second ABORT request: the pre-read still sees
295
+ // VALIDATING, then the first request's abort lands, so both the timeout
296
+ // check read and the post-hoc read see ABORT
297
+ const reads = [validDoc, abortDoc, abortDoc];
298
+ const writes: unknown[] = [];
299
+ const mongo = await makeMongo({
300
+ operator: makeOperator({
301
+ findOne: async () => reads.shift() ?? abortDoc,
302
+ findOneAndUpdate: async (filter: unknown, update: unknown) => {
303
+ writes.push({filter, update});
304
+ return abortDoc;
305
+ }
306
+ }),
307
+ gridFSBucket: makeGridFSBucket()
308
+ });
309
+ const result = await mongo.checkAndSetState({correlationId: 'FOO', state: 'ABORT'});
310
+ assert.deepStrictEqual(result, {status: 'timeout', queueItem: abortDoc});
311
+ assert.deepStrictEqual(writes, []);
312
+ });
313
+
314
+ // a pre-existing DONE item is terminal too (like ABORT/ERROR) and must be
315
+ // reported as 'unchanged' without touching the document
316
+
317
+ test('checkAndSetState reports a pre-existing DONE as "unchanged" without writing', async () => {
318
+ const doc = {correlationId: 'FOO', queueItemState: 'DONE'};
319
+ const writes: unknown[] = [];
320
+ const mongo = await makeMongo({
321
+ operator: makeOperator({
322
+ findOne: async () => doc,
323
+ findOneAndUpdate: async (filter: unknown, update: unknown) => {
324
+ writes.push({filter, update});
325
+ return doc;
326
+ }
327
+ }),
328
+ gridFSBucket: makeGridFSBucket()
329
+ });
330
+ const result = await mongo.checkAndSetState({correlationId: 'FOO', state: 'VALIDATING'});
331
+ assert.deepStrictEqual(result, {status: 'unchanged', queueItem: doc});
332
+ assert.deepStrictEqual(writes, []);
333
+ });
334
+
217
335
  test('setOperation rejects with ApiError 404 for a non-matching correlationId', async () => {
218
336
  const mongo = await makeMongo({
219
337
  operator: makeOperator({findOne: async () => null}),
@@ -750,9 +868,17 @@ function makeLogCollection(overrides: Record<string, any> = {}) {
750
868
  * @param deps the injected stub collection
751
869
  */
752
870
  async function makeMongoLog(deps: Record<string, any>) {
753
- return createMongoLog(DUMMY_URI, 'rest-api', deps);
871
+ const mongoLog = await createMongoLog(DUMMY_URI, 'rest-api', deps);
872
+ stubOperators.push(mongoLog);
873
+ return mongoLog;
754
874
  }
755
875
 
876
+ after(async () => {
877
+ for (const op of stubOperators) {
878
+ await op.close();
879
+ }
880
+ });
881
+
756
882
  function mongoErrored500(text: string) {
757
883
  return (error: unknown) => {
758
884
  assert.ok(error instanceof ApiError);
@@ -8,6 +8,7 @@ import {getMongoOperator, handleError} from './testUtils.ts';
8
8
 
9
9
 
10
10
  let mongoFixtures: any;
11
+ const openedOperators: any[] = [];
11
12
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:get-one:test');
12
13
 
13
14
  generateTests({
@@ -34,6 +35,10 @@ generateTests({
34
35
  },
35
36
  after: async () => {
36
37
  //debug(`<< After`);
38
+ // close the operators the tests opened (one per test) before the server
39
+ for (const op of openedOperators) {
40
+ await op.close();
41
+ }
37
42
  await mongoFixtures.close();
38
43
  }
39
44
  }
@@ -60,6 +65,7 @@ async function callback(callbackParams: Record<string, any>) {
60
65
  } = callbackParams;
61
66
 
62
67
  const mongoOperator = await getMongoOperator(mongoFixtures);
68
+ openedOperators.push(mongoOperator);
63
69
  //const expectedResult = await getFixture('expectedResult.json');
64
70
 
65
71
  await doPreFillDb(preFillDb);
@@ -7,6 +7,7 @@ import createDebugLogger from 'debug';
7
7
  import {getMongoOperator, handleError, streamToString} from './testUtils.ts';
8
8
 
9
9
  let mongoFixtures: any;
10
+ const openedOperators: any[] = [];
10
11
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:get-stream:test');
11
12
 
12
13
  generateTests({
@@ -33,6 +34,10 @@ generateTests({
33
34
  },
34
35
  after: async () => {
35
36
  //debug(`<< After`);
37
+ // close the operators the tests opened (one per test) before the server
38
+ for (const op of openedOperators) {
39
+ await op.close();
40
+ }
36
41
  await mongoFixtures.close();
37
42
  }
38
43
  }
@@ -60,6 +65,7 @@ async function callback(callbackParams: Record<string, any>) {
60
65
  } = callbackParams;
61
66
 
62
67
  const mongoOperator = await getMongoOperator(mongoFixtures);
68
+ openedOperators.push(mongoOperator);
63
69
  //const expectedResult = await getFixture('expectedResult.json');
64
70
 
65
71
  await doPreFillDb(preFillDb);
@@ -7,6 +7,7 @@ import {getMongoOperator, handleError, compareToFirstDbEntry} from './testUtils.
7
7
 
8
8
 
9
9
  let mongoFixtures: any;
10
+ const openedOperators: any[] = [];
10
11
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:push-ids:test');
11
12
 
12
13
  generateTests({
@@ -33,6 +34,10 @@ generateTests({
33
34
  },
34
35
  after: async () => {
35
36
  //debug(`<< After`);
37
+ // close the operators the tests opened (one per test) before the server
38
+ for (const op of openedOperators) {
39
+ await op.close();
40
+ }
36
41
  await mongoFixtures.close();
37
42
  }
38
43
  }
@@ -59,6 +64,7 @@ async function callback(callbackParams: Record<string, any>) {
59
64
  } = callbackParams;
60
65
 
61
66
  const mongoOperator = await getMongoOperator(mongoFixtures);
67
+ openedOperators.push(mongoOperator);
62
68
  const expectedResult = await getFixture('expectedResult.json');
63
69
 
64
70
  await doPreFillDb(preFillDb);
@@ -7,6 +7,7 @@ import createDebugLogger from 'debug';
7
7
  import {getMongoOperator, handleError, compareToFirstDbEntry} from './testUtils.ts';
8
8
 
9
9
  let mongoFixtures: any;
10
+ const openedOperators: any[] = [];
10
11
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:push-messages:test');
11
12
 
12
13
  generateTests({
@@ -33,6 +34,10 @@ generateTests({
33
34
  },
34
35
  after: async () => {
35
36
  //debug(`<< After`);
37
+ // close the operators the tests opened (one per test) before the server
38
+ for (const op of openedOperators) {
39
+ await op.close();
40
+ }
36
41
  await mongoFixtures.close();
37
42
  }
38
43
  }
@@ -59,6 +64,7 @@ async function callback(callbackParams: Record<string, any>) {
59
64
  } = callbackParams;
60
65
 
61
66
  const mongoOperator = await getMongoOperator(mongoFixtures);
67
+ openedOperators.push(mongoOperator);
62
68
  const expectedResult = await getFixture('expectedResult.json');
63
69
 
64
70
  await doPreFillDb(preFillDb);
@@ -8,6 +8,7 @@ import {getMongoOperator, handleError, compareToDbEntry, formatQueueItem} from '
8
8
 
9
9
 
10
10
  let mongoFixtures: any;
11
+ const openedOperators: any[] = [];
11
12
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons/mongo:query-by-id:test');
12
13
 
13
14
  generateTests({
@@ -34,6 +35,10 @@ generateTests({
34
35
  },
35
36
  after: async () => {
36
37
  //debug(`<< After`);
38
+ // close the operators the tests opened (one per test) before the server
39
+ for (const op of openedOperators) {
40
+ await op.close();
41
+ }
37
42
  await mongoFixtures.close();
38
43
  }
39
44
  }
@@ -63,6 +68,7 @@ async function callback(callbackParams: Record<string, any>) {
63
68
  } = callbackParams;
64
69
 
65
70
  const mongoOperator = await getMongoOperator(mongoFixtures);
71
+ openedOperators.push(mongoOperator);
66
72
  const expectedResult = await getFixture('expectedResult.json');
67
73
 
68
74
  await doPreFillDb(preFillDb);