@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.
Files changed (94) hide show
  1. package/.github/workflows/melinda-node-tests-and-publish.yml +4 -2
  2. package/README.md +82 -0
  3. package/dist/amqp.d.ts +41 -0
  4. package/dist/amqp.d.ts.map +1 -0
  5. package/dist/amqp.js +242 -218
  6. package/dist/amqp.js.map +1 -7
  7. package/dist/constants.d.ts +49 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +38 -38
  10. package/dist/constants.js.map +1 -7
  11. package/dist/helpers/conversion.d.ts +8 -0
  12. package/dist/helpers/conversion.d.ts.map +1 -0
  13. package/dist/helpers/conversion.js +70 -65
  14. package/dist/helpers/conversion.js.map +1 -7
  15. package/dist/helpers/fix-constants.d.ts +76 -0
  16. package/dist/helpers/fix-constants.d.ts.map +1 -0
  17. package/dist/helpers/fix-constants.js +41 -41
  18. package/dist/helpers/fix-constants.js.map +1 -7
  19. package/dist/helpers/fix-handle-tempurns.d.ts +3 -0
  20. package/dist/helpers/fix-handle-tempurns.d.ts.map +1 -0
  21. package/dist/helpers/fix-handle-tempurns.js +73 -71
  22. package/dist/helpers/fix-handle-tempurns.js.map +1 -7
  23. package/dist/helpers/fix-strip-f884s.d.ts +10 -0
  24. package/dist/helpers/fix-strip-f884s.d.ts.map +1 -0
  25. package/dist/helpers/fix-strip-f884s.js +163 -151
  26. package/dist/helpers/fix-strip-f884s.js.map +1 -7
  27. package/dist/helpers/fix.d.ts +6 -0
  28. package/dist/helpers/fix.d.ts.map +1 -0
  29. package/dist/helpers/fix.js +133 -117
  30. package/dist/helpers/fix.js.map +1 -7
  31. package/dist/helpers/validation.d.ts +9 -0
  32. package/dist/helpers/validation.d.ts.map +1 -0
  33. package/dist/helpers/validation.js +14 -16
  34. package/dist/helpers/validation.js.map +1 -7
  35. package/dist/index.d.ts +13 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -7
  39. package/dist/mongo.d.ts +165 -0
  40. package/dist/mongo.d.ts.map +1 -0
  41. package/dist/mongo.js +603 -464
  42. package/dist/mongo.js.map +1 -7
  43. package/dist/mongoError.d.ts +2 -0
  44. package/dist/mongoError.d.ts.map +1 -0
  45. package/dist/mongoError.js +19 -0
  46. package/dist/mongoError.js.map +1 -0
  47. package/dist/mongoLog.d.ts +50 -0
  48. package/dist/mongoLog.d.ts.map +1 -0
  49. package/dist/mongoLog.js +282 -172
  50. package/dist/mongoLog.js.map +1 -7
  51. package/dist/utils.d.ts +36 -0
  52. package/dist/utils.d.ts.map +1 -0
  53. package/dist/utils.js +118 -87
  54. package/dist/utils.js.map +1 -7
  55. package/eslint.config.ts +66 -0
  56. package/package.json +23 -25
  57. package/test/amqp-error-branches.test.ts +551 -0
  58. package/{tests/amqp.healthtest.js → test/amqp.healthtest.ts} +19 -7
  59. package/{tests/amqp.tests.localtest.js → test/amqp.tests.localtest.ts} +66 -52
  60. package/{tests/helpers/conversion.test.js → test/helpers/conversion.test.ts} +39 -11
  61. package/test/helpers/fix-edge.test.ts +201 -0
  62. package/test/helpers/fix.test.ts +65 -0
  63. package/{tests/helpers/validation.test.js → test/helpers/validation.test.ts} +13 -8
  64. package/{tests/mongo-add-blob-size.test.js → test/mongo-add-blob-size.test.ts} +14 -15
  65. package/{tests/mongo-check-and-check-state.test.js → test/mongo-check-and-check-state.test.ts} +9 -9
  66. package/{tests/mongo-check-and-set-import-job-state.test.js → test/mongo-check-and-set-import-job-state.test.ts} +9 -10
  67. package/{tests/mongo-check-timeout.test.js → test/mongo-check-timeout.test.ts} +6 -9
  68. package/{tests/mongo-create-bulk.test.js → test/mongo-create-bulk.test.ts} +6 -7
  69. package/{tests/mongo-create-prio.test.js → test/mongo-create-prio.test.ts} +6 -6
  70. package/test/mongo-error-branches.test.ts +883 -0
  71. package/{tests/mongo-get-one.test.js → test/mongo-get-one.test.ts} +6 -6
  72. package/{tests/mongo-get-stream.test.js → test/mongo-get-stream.test.ts} +10 -9
  73. package/{tests/mongo-push-ids.test.js → test/mongo-push-ids.test.ts} +6 -6
  74. package/{tests/mongo-push-messages.test.js → test/mongo-push-messages.test.ts} +6 -6
  75. package/{tests/mongo-query-by-id.test.js → test/mongo-query-by-id.test.ts} +6 -9
  76. package/{tests/mongo-query.test.js → test/mongo-query.test.ts} +6 -7
  77. package/{tests/mongo-read-content.test.js → test/mongo-read-content.test.ts} +16 -9
  78. package/{tests/mongo-remove.test.js → test/mongo-remove.test.ts} +6 -8
  79. package/{tests/mongo-set-blob-size.test.js → test/mongo-set-blob-size.test.ts} +6 -6
  80. package/{tests/mongo-set-import-job-state.test.js → test/mongo-set-import-job-state.test.ts} +6 -6
  81. package/{tests/mongo-set-operation.test.js → test/mongo-set-operation.test.ts} +6 -6
  82. package/{tests/mongo-set-operations.test.js → test/mongo-set-operations.test.ts} +6 -6
  83. package/{tests/mongo-set-state.test.js → test/mongo-set-state.test.ts} +6 -6
  84. package/test/mongoLog-protect.test.ts +182 -0
  85. package/test/mongoLog.test.ts +191 -0
  86. package/{tests/testUtils.js → test/testUtils.ts} +49 -15
  87. package/test/utils.test.ts +360 -0
  88. package/tsconfig.json +62 -0
  89. package/tsconfig.test.json +17 -0
  90. package/dist/helpers/fix-utils.js +0 -39
  91. package/dist/helpers/fix-utils.js.map +0 -7
  92. package/eslint.config.js +0 -53
  93. package/tests/helpers/fix.test.js +0 -41
  94. package/tests/mongoLog.test.js +0 -84
@@ -3,8 +3,11 @@ import createDebugLogger from 'debug';
3
3
  import {promisify} from 'util';
4
4
  import {READERS} from '@natlibfi/fixura';
5
5
  import generateTests from '@natlibfi/fixugen';
6
+ import type {CallbackArgs} from '@natlibfi/fixugen';
6
7
  import {Error as ApiError} from '@natlibfi/melinda-commons';
7
- import {amqpFactory} from '../src/index.js';
8
+ import type {GetMessage, Message} from 'amqplib';
9
+ import {amqpFactory} from '../src/index.ts';
10
+ import type {AmqpOperator, QueueItemMessagePayload, QueueItemRecordPayload, SendToQueueParams} from '../src/amqp.ts';
8
11
 
9
12
  const setTimeoutPromise = promisify(setTimeout);
10
13
  const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons:amqp:test');
@@ -18,8 +21,7 @@ const testData = {
18
21
 
19
22
  debug(`Testing amqp against a local AMQP instance - we'll need some kind of mockup for this`);
20
23
  const amqpUrl = 'amqp://127.0.0.1:5672/';
21
- // eslint-disable-next-line functional/no-let
22
- let amqpOperator;
24
+ let amqpOperator: AmqpOperator;
23
25
 
24
26
  generateTests({
25
27
  callback,
@@ -48,8 +50,15 @@ generateTests({
48
50
  }
49
51
  });
50
52
 
51
- // eslint-disable-next-line max-statements
52
- async function callback({
53
+ /**
54
+ * One fixture case. See the fixture metadata files in test-fixtures/amqp
55
+ * for the possible keys (getFixture, expectToFail, expectedErrorStatus,
56
+ * ackMessages, nackMessages, checkNextMessage, checkQueueStyle, queue,
57
+ * sentToQueue, expectedMessageCount, removeQueue, expectedErrorMessage,
58
+ * expectApiError, expectFalseCheckResult).
59
+ */
60
+ async function callback(callbackParams: CallbackArgs) {
61
+ const {
53
62
  getFixture,
54
63
  expectToFail = false,
55
64
  expectedErrorStatus = '',
@@ -57,14 +66,16 @@ async function callback({
57
66
  nackMessages = false,
58
67
  checkNextMessage = false,
59
68
  checkQueueStyle = false,
60
- queue = testData.queue,
69
+ queue: queueFromFixture = testData.queue,
61
70
  sentToQueue,
62
71
  expectedMessageCount,
63
72
  removeQueue = false,
64
73
  expectedErrorMessage = undefined,
65
74
  expectApiError = false,
66
75
  expectFalseCheckResult = false
67
- }) {
76
+ } = callbackParams;
77
+ // the fixture contract passes the queue name as a string
78
+ const queue = queueFromFixture as string;
68
79
  try {
69
80
  await sendMessagesToQueue();
70
81
 
@@ -79,10 +90,13 @@ async function callback({
79
90
  assert.equal(messageCount, expectedMessageCount);
80
91
 
81
92
  if (checkNextMessage) {
82
- const {headers, records} = await queueCheckNextMessage();
93
+ const {headers, records} = (await queueCheckNextMessage()) as QueueItemRecordPayload;
83
94
  const nextMessage = {headers, records};
84
95
  const expectedNextMessage = getFixture('expectedMessage.json');
85
- assert.equal(nextMessage, expectedNextMessage);
96
+ assert.deepStrictEqual(
97
+ JSON.parse(JSON.stringify(nextMessage)),
98
+ expectedNextMessage,
99
+ );
86
100
  return;
87
101
  }
88
102
 
@@ -94,15 +108,19 @@ async function callback({
94
108
  testErrors(error);
95
109
  }
96
110
 
97
- function testErrors(error) {
111
+ /**
112
+ * @param error
113
+ */
114
+ function testErrors(error: unknown) {
98
115
  const isApiError = error instanceof ApiError;
99
116
  if (expectApiError) {
100
117
  assert.equal(isApiError, true);
101
118
  debug(`We expected and got an ApiError`);
102
- assert.equal(error.status, expectedErrorStatus);
119
+ const apiError = error as ApiError;
120
+ assert.equal(apiError.status, expectedErrorStatus);
103
121
 
104
122
  if (expectedErrorMessage) {
105
- assert.equal(error.payload, expectedErrorMessage);
123
+ assert.equal(apiError.payload, expectedErrorMessage);
106
124
  return;
107
125
  }
108
126
 
@@ -110,26 +128,26 @@ async function callback({
110
128
  }
111
129
  assert.equal(isApiError, false);
112
130
  debug(`We expected and got an non-ApiError`);
113
- debug(`Error is a ${error.constructor.name}`);
131
+ const maybeError = error as Error;
132
+ debug(`Error is a ${maybeError.constructor.name}`);
114
133
  }
115
134
 
116
135
  function queueRemoval() {
117
136
  if (removeQueue) {
118
- return amqpOperator.removeQueue(removeQueue);
137
+ return amqpOperator.removeQueue(removeQueue as string);
119
138
  }
120
139
 
121
140
  return;
122
141
  }
123
142
 
124
- function queueCheckNextMessage() {
143
+ function queueCheckNextMessage(): Promise<QueueItemRecordPayload> | false {
125
144
  if (checkNextMessage) {
126
- return amqpOperator.consumeOne(testData.queue, true);
145
+ return amqpOperator.consumeOne(testData.queue, true) as Promise<QueueItemRecordPayload>;
127
146
  }
128
147
 
129
148
  return false;
130
149
  }
131
150
 
132
- // eslint-disable-next-line max-statements
133
151
  async function checkQueue() {
134
152
  debug(`checkQueueStyle: ${checkQueueStyle}`);
135
153
 
@@ -137,33 +155,29 @@ async function callback({
137
155
  const result = await amqpOperator.checkQueue({queue, style: 'one', toRecord: true, purge: false});
138
156
 
139
157
  if (expectFalseCheckResult) {
140
- expect(result).to.eql(false);
158
+ assert.strictEqual(result, false);
141
159
  return;
142
160
  }
143
161
 
144
- assert.equal(Object.hasOwn(result, 'headers'), true);
145
- assert.equal(Object.hasOwn(result, 'records'), true);
146
- assert.equal(Object.hasOwn(result, 'messages'), true);
147
- assert.equal(result.records.length, 1);
148
- assert.equal(result.messages.length, 1);
162
+ const recordResult = result as QueueItemRecordPayload;
163
+ assert.equal(Object.hasOwn(recordResult, 'headers'), true);
164
+ assert.equal(Object.hasOwn(recordResult, 'records'), true);
165
+ assert.equal(Object.hasOwn(recordResult, 'messages'), true);
166
+ assert.equal(recordResult.records.length, 1);
167
+ assert.equal(recordResult.messages.length, 1);
149
168
 
150
169
  return;
151
170
  }
152
171
 
153
172
  if (checkQueueStyle === 'oneNotRecord') {
154
173
  const result = await amqpOperator.checkQueue({queue, style: 'one', toRecord: false, purge: false});
155
- const {fields} = result;
174
+ const {fields} = result as GetMessage;
156
175
 
157
176
  if (expectFalseCheckResult) {
158
- expect(result).to.eql(false);
177
+ assert.strictEqual(result, false);
159
178
  return;
160
179
  }
161
180
 
162
- assert.equal(Object.hasOwn(result, 'deliveryTag'), true);
163
- assert.equal(Object.hasOwn(result, 'exchange'), true);
164
- assert.equal(Object.hasOwn(result, 'messageCount'), true);
165
- assert.equal(Object.hasOwn(result, 'redelivered'), true);
166
- assert.equal(Object.hasOwn(result, 'routingKey'), true);
167
181
  assert.equal(typeof fields.deliveryTag, 'number');
168
182
  assert.equal(typeof fields.messageCount, 'number');
169
183
  assert.equal(typeof fields.redelivered, 'boolean');
@@ -176,17 +190,21 @@ async function callback({
176
190
  const result = await amqpOperator.checkQueue({queue, style: 'basic', toRecord: true, purge: false});
177
191
 
178
192
  if (expectFalseCheckResult) {
179
- expect(result).to.eql(false);
193
+ assert.strictEqual(result, false);
180
194
  return;
181
195
  }
182
196
 
183
- assert.equal(Object.hasOwn(result, 'headers'), true);
184
- assert.equal(Object.hasOwn(result, 'records'), true);
185
- assert.equal(Object.hasOwn(result, 'messages'), true);
186
- assert.equal(result.records.length, 3);
187
- assert.equal(result.messages.length, 3);
197
+ const recordResult = result as QueueItemRecordPayload;
198
+ assert.equal(Object.hasOwn(recordResult, 'headers'), true);
199
+ assert.equal(Object.hasOwn(recordResult, 'records'), true);
200
+ assert.equal(Object.hasOwn(recordResult, 'messages'), true);
201
+ assert.equal(recordResult.records.length, 3);
202
+ assert.equal(recordResult.messages.length, 3);
188
203
  const expectedResult = getFixture('expectedResult.json');
189
- assert.deepStrictEqual(result.records, expectedResult);
204
+ assert.deepStrictEqual(
205
+ JSON.parse(JSON.stringify(recordResult.records)),
206
+ expectedResult,
207
+ );
190
208
 
191
209
  return;
192
210
  }
@@ -195,21 +213,17 @@ async function callback({
195
213
  const result = await amqpOperator.checkQueue({queue, style: 'basic', toRecord: false, purge: false});
196
214
 
197
215
  if (expectFalseCheckResult) {
198
- expect(result).to.eql(false);
216
+ assert.strictEqual(result, false);
199
217
  return;
200
218
  }
201
219
 
202
- assert.equal(Object.hasOwn(result, 'headers'), true);
203
- assert.equal(Object.hasOwn(result, 'messages'), true);
204
- assert.equal(result.messages.length, 3);
220
+ const messageResult = result as QueueItemMessagePayload;
221
+ assert.equal(Object.hasOwn(messageResult, 'headers'), true);
222
+ assert.equal(Object.hasOwn(messageResult, 'messages'), true);
223
+ assert.equal(messageResult.messages.length, 3);
205
224
 
206
- const [{fields}] = result.messages;
207
-
208
- assert.equal(Object.hasOwn(result, 'deliveryTag'), true);
209
- assert.equal(Object.hasOwn(result, 'exchange'), true);
210
- assert.equal(Object.hasOwn(result, 'messageCount'), true);
211
- assert.equal(Object.hasOwn(result, 'redelivered'), true);
212
- assert.equal(Object.hasOwn(result, 'routingKey'), true);
225
+ const [firstMessage] = messageResult.messages as [Message, ...Message[]];
226
+ const {fields} = firstMessage;
213
227
 
214
228
  assert.equal(typeof fields.deliveryTag, 'number');
215
229
  assert.equal(typeof fields.messageCount, 'number');
@@ -233,14 +247,14 @@ async function callback({
233
247
  async function ackOrNackMessages() {
234
248
  if (ackMessages) {
235
249
  const message = await amqpOperator.consumeOne(testData.queue, false);
236
- await amqpOperator.ackMessages([message]);
250
+ await amqpOperator.ackMessages([message as GetMessage]);
237
251
 
238
252
  return setTimeoutPromise(10);
239
253
  }
240
254
 
241
255
  if (nackMessages) {
242
256
  const message = await amqpOperator.consumeOne(testData.queue, false);
243
- await amqpOperator.nackMessages([message]);
257
+ await amqpOperator.nackMessages([message as GetMessage]);
244
258
 
245
259
  return setTimeoutPromise(10);
246
260
  }
@@ -250,7 +264,7 @@ async function callback({
250
264
 
251
265
  async function sendMessagesToQueue() {
252
266
  if (sentToQueue) {
253
- const messages = getFixture('input.json');
267
+ const messages = getFixture('input.json') as SendToQueueParams[];
254
268
  // {queue, correlationId, headers, data}
255
269
 
256
270
  const promises = messages.map(message => amqpOperator.sendToQueue(message));
@@ -277,7 +291,7 @@ function testHeathCheck() {
277
291
  await testHealthCheckLoopErroring(amqpOperator2);
278
292
  } catch (error) {
279
293
  debug(error);
280
- expect(error).to.eql('');
294
+ assert.strictEqual(error, '');
281
295
  } finally {
282
296
  amqpOperator2.closeConnection();
283
297
  }
@@ -1,12 +1,11 @@
1
-
2
-
3
1
  import assert from 'node:assert';
4
2
  import {describe, it} from 'node:test';
5
3
  import fixtureFactory from '@natlibfi/fixura';
6
4
  import {MarcRecord} from '@natlibfi/marc-record';
5
+ import type {MarcRecordObject} from '@natlibfi/marc-record';
7
6
  import {Error as ConversionError} from '@natlibfi/melinda-commons';
8
- import createConversionService from '../../src/helpers/conversion.js';
9
- import {CONVERSION_FORMATS} from '../../src/constants.js';
7
+ import createConversionService from '../../src/helpers/conversion.ts';
8
+ import {CONVERSION_FORMATS} from '../../src/constants.ts';
10
9
 
11
10
  describe('services/conversion', () => {
12
11
  const {getFixture} = fixtureFactory({
@@ -20,11 +19,11 @@ describe('services/conversion', () => {
20
19
  });
21
20
  const conversionService = createConversionService();
22
21
 
23
- const marcRecord = new MarcRecord(JSON.parse(getFixture({components: ['json1']})), {subfieldValues: false});
24
- const marcXml = getFixture({components: ['marcxml1']});
25
- const iso2709 = getFixture({components: ['iso2709_1']});
26
- const json = getFixture({components: ['json1']});
27
- const alephSeq = getFixture({components: ['aleph_sequential']});
22
+ const marcRecord = new MarcRecord(JSON.parse(getFixture({components: ['json1']}) as string) as MarcRecordObject, {subfieldValues: false});
23
+ const marcXml = getFixture({components: ['marcxml1']}) as string;
24
+ const iso2709 = getFixture({components: ['iso2709_1']}) as string;
25
+ const json = getFixture({components: ['json1']}) as string;
26
+ const alephSeq = getFixture({components: ['aleph_sequential']}) as string;
28
27
 
29
28
 
30
29
  describe('factory', () => {
@@ -38,11 +37,16 @@ describe('services/conversion', () => {
38
37
 
39
38
  describe('#serialize', () => {
40
39
  it('Should throw because of unsupported format', () => {
40
+ let threw = false;
41
41
  try {
42
- conversionService.serialize();
42
+ // the undefined inputs are the point of this test: serialize must
43
+ // throw a ConversionError for an unsupported format, not a TypeError
44
+ conversionService.serialize(undefined as never, undefined as never);
43
45
  } catch (error) {
46
+ threw = true;
44
47
  assert(error instanceof ConversionError);
45
48
  }
49
+ assert.equal(threw, true);
46
50
  });
47
51
 
48
52
  it('Should serialize to MARCXML', () => {
@@ -65,15 +69,36 @@ describe('services/conversion', () => {
65
69
  assert.equal(data, alephSeq);
66
70
  });
67
71
 
72
+ it('Should rewrap a plain (non-ConversionError) serializer error into a ConversionError with 400', () => {
73
+ // a plain {} makes MARCXML.to throw a TypeError, which serialize must
74
+ // rewrap into a ConversionError 400 (not propagate the raw TypeError)
75
+ let threw = false;
76
+ try {
77
+ conversionService.serialize({} as never, CONVERSION_FORMATS.MARCXML);
78
+ } catch (error) {
79
+ threw = true;
80
+ assert(error instanceof ConversionError);
81
+ assert.equal(error.status, 400);
82
+ // the payload is a string here: 'Error while serializing record. ...'
83
+ assert.ok((error.payload as string).includes('Error while serializing record.'));
84
+ }
85
+ assert.equal(threw, true);
86
+ });
87
+
68
88
  });
69
89
 
70
90
  describe('#unserialize', () => {
71
91
  it('Should throw because of unsupported format', () => {
92
+ let threw = false;
72
93
  try {
73
- conversionService.unserialize();
94
+ // the undefined inputs are the point of this test: unserialize must
95
+ // throw a ConversionError for an unsupported format, not a TypeError
96
+ conversionService.unserialize(undefined as never, undefined as never);
74
97
  } catch (error) {
98
+ threw = true;
75
99
  assert(error instanceof ConversionError);
76
100
  }
101
+ assert.equal(threw, true);
77
102
  });
78
103
 
79
104
  it('Should unserialize from MARCXML', async () => {
@@ -98,11 +123,14 @@ describe('services/conversion', () => {
98
123
 
99
124
 
100
125
  it('Should throw because the record could not be unserialized', () => {
126
+ let threw = false;
101
127
  try {
102
128
  conversionService.unserialize('', CONVERSION_FORMATS.JSON);
103
129
  } catch (error) {
130
+ threw = true;
104
131
  assert(error instanceof ConversionError);
105
132
  }
133
+ assert.equal(threw, true);
106
134
  });
107
135
  });
108
136
  });
@@ -0,0 +1,201 @@
1
+ // Edge-case tests for the MARC fix helpers.
2
+
3
+ import assert from 'node:assert';
4
+ import {test} from 'node:test';
5
+ import {MarcRecord} from '@natlibfi/marc-record';
6
+ import type {MarcField} from '@natlibfi/marc-record';
7
+ import {fixRecord} from '../../src/helpers/fix.ts';
8
+ import {BIB_STRIP_F884S_SETTINGS, BIB_F035_TO_SID} from '../../src/helpers/fix-constants.ts';
9
+
10
+ // a Melinda f884 (tag 884, $5 MELINDA, $a + $g present) that
11
+ // has NO $k subfield. findSource/findHash used to call `sfK.replace(...)` on
12
+ // undefined and crash the whole fixRecord call. Now a missing $k is treated
13
+ // as an empty string: the field gets NO_SOURCE (source) and the all-zero
14
+ // sentinel (hash), and the merged date range is applied to its $g.
15
+ test('fixRecord handles Melinda f884 without $k subfield without crashing', () => {
16
+ const record = new MarcRecord({
17
+ leader: '03085cam a22006858i 4500',
18
+ fields: [
19
+ {tag: '001', value: '000000001'},
20
+ {tag: '003', value: 'FI-MELINDA'},
21
+ {tag: '005', value: '20220818041047.0'},
22
+ {
23
+ // Melinda f884 WITHOUT $k
24
+ tag: '884',
25
+ ind1: ' ',
26
+ ind2: ' ',
27
+ subfields: [
28
+ {code: 'a', value: 'ONIX3 to MARC transformation'},
29
+ {code: 'g', value: '20220101'},
30
+ {code: '5', value: 'MELINDA'}
31
+ ]
32
+ },
33
+ {
34
+ // A second Melinda f884 with $k so that the
35
+ // "not enough Melinda f884s" early return does not trigger
36
+ tag: '884',
37
+ ind1: ' ',
38
+ ind2: ' ',
39
+ subfields: [
40
+ {code: 'a', value: 'ONIX3 to MARC transformation'},
41
+ {code: 'g', value: '20000718'},
42
+ {
43
+ code: 'k',
44
+ value: 'SRC_4:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
45
+ },
46
+ {code: '5', value: 'MELINDA'}
47
+ ]
48
+ }
49
+ ]
50
+ }, {subfieldValues: false});
51
+
52
+ // Fixed behaviour: fixRecord must not throw
53
+ const result = fixRecord(record, BIB_STRIP_F884S_SETTINGS);
54
+
55
+ // Both Melinda f884s survive (they are not identical, so uniq keeps both)
56
+ // these are data fields: mirrors the JSDoc baseline MarcField[] cast
57
+ const f884s = (result.fields as MarcField[]).filter((f) => f.tag === '884');
58
+ assert.equal(f884s.length, 2);
59
+
60
+ // The field WITHOUT $k still has its $a, $g and $5 MELINDA
61
+ const noK = f884s.find((f) => !f.subfields.some((s) => s.code === 'k'));
62
+ assert.ok(noK, 'field without $k should survive');
63
+ assert.deepEqual(
64
+ noK.subfields.map((s) => s.code).sort(),
65
+ ['5', 'a', 'g']
66
+ );
67
+ const gNoK = noK.subfields.find((s) => s.code === 'g')?.value;
68
+ const aNoK = noK.subfields.find((s) => s.code === 'a')?.value;
69
+ assert.equal(aNoK, 'ONIX3 to MARC transformation');
70
+ assert.equal(gNoK, '20220101');
71
+
72
+ // The field WITH $k keeps its $a, $g, $k and $5 MELINDA
73
+ const withK = f884s.find((f) => f.subfields.some((s) => s.code === 'k'));
74
+ assert.ok(withK, 'field with $k should survive');
75
+ assert.deepEqual(
76
+ withK.subfields.map((s) => s.code).sort(),
77
+ ['5', 'a', 'g', 'k']
78
+ );
79
+ assert.equal(withK.subfields.find((s) => s.code === 'g')?.value, '20000718');
80
+ assert.equal(withK.subfields.find((s) => s.code === 'k')?.value,
81
+ 'SRC_4:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
82
+ });
83
+
84
+ // stripF884s early return: fewer than 2 Melinda f884s (0 or 1) means the
85
+ // record is returned unchanged (the merge/uniq pipeline never runs).
86
+ test('stripF884s returns the record unchanged when it has no Melinda f884s', () => {
87
+ const record = new MarcRecord({
88
+ leader: '03085cam a22006858i 4500',
89
+ fields: [
90
+ {tag: '001', value: '000000001'},
91
+ {tag: '035', ind1: ' ', ind2: ' ', subfields: [{code: 'a', value: '(FI-BTJ)12345'}]}
92
+ ]
93
+ }, {subfieldValues: false});
94
+
95
+ const result = fixRecord(record, BIB_STRIP_F884S_SETTINGS);
96
+ assert.deepStrictEqual(result, record.toObject());
97
+ });
98
+
99
+ test('stripF884s returns the record unchanged when it has a single Melinda f884', () => {
100
+ const record = new MarcRecord({
101
+ leader: '03085cam a22006858i 4500',
102
+ fields: [
103
+ {tag: '001', value: '000000001'},
104
+ {
105
+ // a single Melinda f884: fewer than 2, so the field must survive
106
+ // untouched (no date-range merge, no removeFields/insertFields)
107
+ tag: '884',
108
+ ind1: ' ',
109
+ ind2: ' ',
110
+ subfields: [
111
+ {code: 'a', value: 'ONIX3 to MARC transformation'},
112
+ {code: 'g', value: '20220101'},
113
+ {
114
+ code: 'k',
115
+ value: 'SRC_4:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
116
+ },
117
+ {code: '5', value: 'MELINDA'}
118
+ ]
119
+ }
120
+ ]
121
+ }, {subfieldValues: false});
122
+
123
+ const result = fixRecord(record, BIB_STRIP_F884S_SETTINGS);
124
+ assert.deepStrictEqual(result, record.toObject());
125
+ });
126
+
127
+ // generateMissingSIDs: an existing SID field is only counted when its $b is
128
+ // in fSIDFilters AND it has a $c. Anything else maps to undefined and is
129
+ // filtered out, so the SID is (re)generated from the f035.
130
+ test('fixRecord ignores an existing SID field whose $b is not in fSIDFilters and generates the SID from the f035', () => {
131
+ const record = new MarcRecord({
132
+ leader: '03085cam a22006858i 4500',
133
+ fields: [
134
+ {tag: '001', value: '000000001'},
135
+ {tag: '035', ind1: ' ', ind2: ' ', subfields: [{code: 'a', value: '(FI-BTJ)12345'}]},
136
+ {
137
+ // $b 'FI-KV' is NOT in fSIDFilters -> the field must be ignored,
138
+ // so the f035 (FI-BTJ)12345 still yields a generated SID
139
+ tag: 'SID',
140
+ ind1: ' ',
141
+ ind2: ' ',
142
+ subfields: [
143
+ {code: 'c', value: '12345'},
144
+ {code: 'b', value: 'FI-KV'}
145
+ ]
146
+ }
147
+ ]
148
+ }, {subfieldValues: false});
149
+
150
+ const result = fixRecord(record, {generateMissingSIDs: BIB_F035_TO_SID});
151
+ // these are data fields: mirrors the JSDoc baseline MarcField[] cast
152
+ const sids = (result.fields as MarcField[]).filter((f) => f.tag === 'SID');
153
+ assert.equal(sids.length, 2);
154
+ const generated = sids.find((f) => f.subfields.some((s) => s.code === 'b' && s.value === 'FI-BTJ'));
155
+ assert.ok(generated, 'a new SID with $b FI-BTJ should have been generated');
156
+ assert.equal(generated.subfields.find((s) => s.code === 'c')?.value, '12345');
157
+ });
158
+
159
+ test('fixRecord ignores an existing SID field with a matching $b but missing $c and generates the SID from the f035', () => {
160
+ const record = new MarcRecord({
161
+ leader: '03085cam a22006858i 4500',
162
+ fields: [
163
+ {tag: '001', value: '000000001'},
164
+ {tag: '035', ind1: ' ', ind2: ' ', subfields: [{code: 'a', value: '(FI-BTJ)12345'}]},
165
+ {
166
+ // $b 'FI-BTJ' IS in fSIDFilters, but there is no $c -> the field
167
+ // must be ignored, so the f035 still yields a generated SID
168
+ tag: 'SID',
169
+ ind1: ' ',
170
+ ind2: ' ',
171
+ subfields: [
172
+ {code: 'b', value: 'FI-BTJ'}
173
+ ]
174
+ }
175
+ ]
176
+ }, {subfieldValues: false});
177
+
178
+ const result = fixRecord(record, {generateMissingSIDs: BIB_F035_TO_SID});
179
+ // these are data fields: mirrors the JSDoc baseline MarcField[] cast
180
+ const sids = (result.fields as MarcField[]).filter((f) => f.tag === 'SID');
181
+ assert.equal(sids.length, 2);
182
+ const generated = sids.find((f) => f.subfields.some((s) => s.code === 'c'));
183
+ assert.ok(generated, 'a generated SID with $c should exist');
184
+ assert.equal(generated.subfields.find((s) => s.code === 'b')?.value, 'FI-BTJ');
185
+ assert.equal(generated.subfields.find((s) => s.code === 'c')?.value, '12345');
186
+ });
187
+
188
+ test('fixRecord adds no SID when the f035 $a matches no f035Filter', () => {
189
+ const record = new MarcRecord({
190
+ leader: '03085cam a22006858i 4500',
191
+ fields: [
192
+ {tag: '001', value: '000000001'},
193
+ // $a does not match any f035Filter in BIB_F035_TO_SID, so
194
+ // f035ToSidInfo maps the field to undefined and nothing is generated
195
+ {tag: '035', ind1: ' ', ind2: ' ', subfields: [{code: 'a', value: '(NOT-IN-FILTERS)999'}]}
196
+ ]
197
+ }, {subfieldValues: false});
198
+
199
+ const result = fixRecord(record, {generateMissingSIDs: BIB_F035_TO_SID});
200
+ assert.equal(result.fields.filter((f) => f.tag === 'SID').length, 0);
201
+ });
@@ -0,0 +1,65 @@
1
+ import assert from 'node:assert';
2
+ import createDebugLogger from 'debug';
3
+ import {MarcRecord} from '@natlibfi/marc-record';
4
+ import type {MarcRecordObject} from '@natlibfi/marc-record';
5
+ import {READERS} from '@natlibfi/fixura';
6
+ import generateTests, {type CallbackArgs} from '@natlibfi/fixugen';
7
+ import {fixRecord} from '../../src/helpers/fix.ts';
8
+ import * as fixSettings from '../../src/helpers/fix-constants.ts';
9
+ import type {FixSettings} from '../../src/helpers/fix-constants.ts';
10
+ // import inspect from 'util';
11
+
12
+ const debug = createDebugLogger('@natlibfi/melinda-rest-api-commons:fix:test');
13
+ const debugData = debug.extend('data');
14
+
15
+ // the settings names usable via metadata.json
16
+ type SettingsKey = 'BIB_FORMAT_SETTINGS' | 'BIB_PREVALIDATION_FIX_SETTINGS' | 'BIB_POSTMERGE_FIX_SETTINGS' | 'BIB_PREIMPORT_FIX_SETTINGS' | 'AUTNAME_PREIMPORT_FIX_SETTINGS' | 'BIB_HANDLE_TEMP_URNS_SETTINGS' | 'BIB_STRIP_F884S_SETTINGS' | 'BIB_STRIP_F984S_SETTINGS' | 'BIB_STRIP_MELINDA_TEMP_SUBFIELDS_SETTINGS';
17
+
18
+ generateTests({
19
+ callback,
20
+ path: [import.meta.dirname, '..', '..', 'test-fixtures', 'fix'],
21
+ recurse: true,
22
+ useMetadataFile: true,
23
+ fixura: {
24
+ failWhenNotFound: false,
25
+ reader: READERS.JSON
26
+ }
27
+ });
28
+
29
+ function callback(callbackParams: CallbackArgs) {
30
+ const {getFixture, settings = undefined} = callbackParams;
31
+ if (
32
+ settings !== undefined
33
+ && typeof settings !== 'object'
34
+ && (fixSettings[settings as SettingsKey]) === undefined
35
+ ) {
36
+ throw new Error(`There are no settings ${settings} defined!`);
37
+ }
38
+ let runSettings: FixSettings | undefined = (fixSettings[settings as SettingsKey]) || (settings as FixSettings);
39
+ // metadata.json is plain JSON, so f035Filters may contain string
40
+ // patterns which fix.js cannot use as-is; convert them to RegExp
41
+ if (runSettings && runSettings.generateMissingSIDs) {
42
+ runSettings = {
43
+ ...runSettings,
44
+ generateMissingSIDs: {
45
+ ...runSettings.generateMissingSIDs,
46
+ f035Filters: (runSettings.generateMissingSIDs.f035Filters || []).map(
47
+ (filter): RegExp => (typeof filter === 'string' ? new RegExp(filter) : filter)
48
+ ),
49
+ fSIDFilters: runSettings.generateMissingSIDs.fSIDFilters
50
+ }
51
+ };
52
+ }
53
+
54
+ const record = new MarcRecord(getFixture('record.json') as MarcRecordObject, {subfieldValues: false});
55
+ const expectedRecord = getFixture('expectedRecord.json');
56
+
57
+ debug(`Running fixer with settings ${settings}.`);
58
+ debugData(JSON.stringify(runSettings, null, 2));
59
+
60
+ const resultRecord = fixRecord(record, runSettings);
61
+ debugData(`Result: ${JSON.stringify(resultRecord)}`);
62
+ debugData(`Expected: ${JSON.stringify(expectedRecord)}`);
63
+ assert.deepStrictEqual(resultRecord, expectedRecord);
64
+
65
+ }