@vida-global/core 2.3.5 → 2.4.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.
@@ -66,9 +66,32 @@ describe('Worker', () => {
66
66
 
67
67
  expect(worker.concurrency).toEqual(value);
68
68
  });
69
+
70
+ it ('takes the constructor option over the environment', () => {
71
+ process.env.WORKER_CONCURRENCY = '2';
72
+ const value = TestHelpers.Faker.Math.randomNumber(50);
73
+ const worker = new Worker('q', { concurrency: value });
74
+
75
+ expect(worker.concurrency).toEqual(value);
76
+ });
77
+ });
78
+
79
+
80
+ describe('#rateLimit', () => {
81
+ it ('is null when the option is absent', () => {
82
+ expect(new Worker('q').rateLimit).toBeNull();
83
+ });
84
+
85
+ it ('returns the configured limit', () => {
86
+ const rateLimit = { max: 100, duration: 60000 };
87
+
88
+ expect(new Worker('q', { rateLimit }).rateLimit).toBe(rateLimit);
89
+ });
69
90
  });
70
91
 
71
92
 
93
+
94
+
72
95
  describe('#fullQueueName', () => {
73
96
  it ('appends the current NODE_ENV', () => {
74
97
  const name = TestHelpers.Faker.Text.randomString();
@@ -113,6 +136,17 @@ describe('Worker', () => {
113
136
  },
114
137
  });
115
138
  });
139
+
140
+ it ('passes a configured rate limit to BullMQ as its limiter', () => {
141
+ const rateLimit = { max: 100, duration: 60000 };
142
+ const worker = new Worker('q', { rateLimit });
143
+
144
+ expect(worker.workerOptions.limiter).toBe(rateLimit);
145
+ });
146
+
147
+ it ('omits the limiter when no rate limit is configured', () => {
148
+ expect(new Worker('q').workerOptions).not.toHaveProperty('limiter');
149
+ });
116
150
  });
117
151
 
118
152
 
@@ -147,6 +181,52 @@ describe('Worker', () => {
147
181
  const events = bullWorker.on.mock.calls.map(([event]) => event);
148
182
  expect(events).toEqual(expect.arrayContaining(['completed', 'progress', 'failed', 'error']));
149
183
  });
184
+
185
+ it ('applies a configured maximum concurrency to the queue', async () => {
186
+ const maximumConcurrency = TestHelpers.Faker.Math.randomNumber(20);
187
+ const worker = buildWorker(Worker, 'queueX', { maximumConcurrency });
188
+
189
+ await worker.listen();
190
+
191
+ expect(worker.queue.setMaximumConcurrency).toHaveBeenCalledWith(maximumConcurrency);
192
+ expect(worker.queue.removeMaximumConcurrency).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it ('removes the queue maximum concurrency when none is configured', async () => {
196
+ const worker = buildWorker(Worker, 'queueX');
197
+
198
+ await worker.listen();
199
+
200
+ expect(worker.queue.removeMaximumConcurrency).toHaveBeenCalled();
201
+ expect(worker.queue.setMaximumConcurrency).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it ('logs the maximum concurrency and the rate limit it applied', async () => {
205
+ const worker = buildWorker(Worker, 'queueX', {
206
+ maximumConcurrency: 8,
207
+ rateLimit: { max: 100, duration: 60000 },
208
+ });
209
+
210
+ await worker.listen();
211
+
212
+ const info = worker.loggerLibrary.worker.info.mock.calls.map(([message]) => message);
213
+ expect(info).toEqual(expect.arrayContaining([
214
+ 'Queue queueX-test maximum concurrency is 8',
215
+ 'Queue queueX-test rate limit is 100 per 60000ms',
216
+ ]));
217
+ });
218
+ });
219
+
220
+
221
+ describe('#close', () => {
222
+ it ('closes the BullMQ worker', async () => {
223
+ const worker = buildWorker(Worker);
224
+ await worker.listen();
225
+
226
+ await worker.close();
227
+
228
+ expect(bullmqMock.instances.workers[0].close).toHaveBeenCalled();
229
+ });
150
230
  });
151
231
 
152
232
 
@@ -177,6 +257,46 @@ describe('Worker', () => {
177
257
  expect(afterCount).toEqual(beforeCount);
178
258
  });
179
259
 
260
+
261
+ it ('falls back to the job that opted in as default for its queue', async () => {
262
+ const worker = buildWorker(Worker, 'beta');
263
+ await worker.listen();
264
+ const processor = bullmqMock.instances.workers[0].processor;
265
+
266
+ // The name bull writes for an unnamed job, which matches no registered job class.
267
+ const legacy = buildFakeBullJob({ name: '__default__', data: 'raw payload' });
268
+
269
+ await expect(processor(legacy)).resolves.toBeGreaterThanOrEqual(0);
270
+ });
271
+
272
+
273
+ // The job scan is memoized for the process, but the default job is per worker. Assigning it
274
+ // inside the memo left every worker after the first without one, so only the first queue in
275
+ // a process could consume a legacy payload.
276
+ it ('gives a second worker in the process its own default job', async () => {
277
+ const first = buildWorker(Worker, 'alpha');
278
+ await first.listen();
279
+
280
+ const second = buildWorker(Worker, 'beta');
281
+ await second.listen();
282
+ const processor = bullmqMock.instances.workers[1].processor;
283
+
284
+ const legacy = buildFakeBullJob({ name: '__default__', data: 'raw payload' });
285
+
286
+ await expect(processor(legacy)).resolves.toBeGreaterThanOrEqual(0);
287
+ });
288
+
289
+
290
+ it ('leaves a worker whose queue has no default job rejecting unknown names', async () => {
291
+ const worker = buildWorker(Worker, 'alpha');
292
+ await worker.listen();
293
+ const processor = bullmqMock.instances.workers[0].processor;
294
+
295
+ const legacy = buildFakeBullJob({ name: '__default__', data: 'raw payload' });
296
+
297
+ await expect(processor(legacy)).rejects.toEqual('Unknown Job: __default__');
298
+ });
299
+
180
300
  it ('logs each registered job via verbose when NODE_ENV is not test', async () => {
181
301
  process.env.NODE_ENV = 'development';
182
302
  const worker = buildWorker(Worker);
@@ -215,12 +335,29 @@ describe('Worker', () => {
215
335
  const args = [TestHelpers.Faker.Text.randomString()];
216
336
  const id = TestHelpers.Faker.Text.randomString();
217
337
  const updateProgress = jest.fn();
218
- const fakeBull = buildFakeBullJob({ name: 'TestJobA', data: { id, args }, updateProgress });
338
+ const fakeBull = buildFakeBullJob({ name: 'TestJobA', id, data: { args }, updateProgress });
219
339
 
220
340
  await processor(fakeBull);
221
341
 
222
342
  expect(runSpy).toHaveBeenCalledTimes(1);
223
- expect(runSpy).toHaveBeenCalledWith(id, args, expect.any(Function));
343
+ expect(runSpy).toHaveBeenCalledWith(id, args, expect.any(Function), expect.any(Object));
344
+ runSpy.mockRestore();
345
+ });
346
+
347
+ it ('tells the job which attempt it is on', async () => {
348
+ const worker = buildWorker(Worker);
349
+ const processor = await listenAndGetProcessor(worker);
350
+ const runSpy = jest.spyOn(TestJobA.prototype, '_run').mockResolvedValue(undefined);
351
+ const fakeBull = buildFakeBullJob({ attemptsMade: 2, opts: { attempts: 3 } });
352
+
353
+ await processor(fakeBull);
354
+
355
+ expect(runSpy).toHaveBeenCalledWith(
356
+ expect.anything(),
357
+ expect.anything(),
358
+ expect.any(Function),
359
+ { attempt: 3, maxAttempts: 3 },
360
+ );
224
361
  runSpy.mockRestore();
225
362
  });
226
363
 
@@ -296,7 +433,8 @@ describe('Worker', () => {
296
433
  const id = TestHelpers.Faker.Text.randomString();
297
434
  const fakeBull = buildFakeBullJob({
298
435
  name: 'TestJobA',
299
- data: { id, args: [] },
436
+ id,
437
+ data: { args: [] },
300
438
  attemptsMade: 1,
301
439
  opts: { attempts: 5 },
302
440
  });
@@ -345,7 +483,8 @@ describe('Worker', () => {
345
483
  const id = TestHelpers.Faker.Text.randomString();
346
484
  const fakeBull = buildFakeBullJob({
347
485
  name: 'TestJobA',
348
- data: { id, args: [] },
486
+ id,
487
+ data: { args: [] },
349
488
  attemptsMade: 0,
350
489
  opts: { attempts: 3 },
351
490
  });
@@ -401,19 +540,6 @@ describe('Worker', () => {
401
540
  expect(worker.apm.recordCustomEvent).not.toHaveBeenCalledWith('JobCompleted', expect.anything());
402
541
  expect(worker.apm.recordMetric).not.toHaveBeenCalled();
403
542
  });
404
-
405
- /*
406
- it ('registers a new logger scope for the job name', async () => {
407
- const worker = buildWorker(Worker);
408
- const processor = await listenAndGetProcessor(worker);
409
- jest.spyOn(TestJobA.prototype, '_run').mockResolvedValue(undefined);
410
- const fakeBull = buildFakeBullJob({ name: 'TestJobA' });
411
-
412
- await processor(fakeBull);
413
-
414
- expect(logger.addScope).toHaveBeenCalledWith('TestJobA');
415
- });
416
- */
417
543
  });
418
544
 
419
545
 
@@ -431,7 +557,7 @@ describe('Worker', () => {
431
557
  const id = TestHelpers.Faker.Text.randomString();
432
558
  const duration = TestHelpers.Faker.Math.randomNumber(10000);
433
559
 
434
- handler({ name: 'TestJobA', data: { id } }, duration);
560
+ handler({ name: 'TestJobA', id }, duration);
435
561
 
436
562
  const child = worker.loggerLibrary.worker.child.mock.results[0].value;
437
563
  expect(child.debug).toHaveBeenCalledWith(`Completed in ${duration}ms`);
@@ -444,7 +570,7 @@ describe('Worker', () => {
444
570
  const id = TestHelpers.Faker.Text.randomString();
445
571
  const payload = { step: TestHelpers.Faker.Text.randomString() };
446
572
 
447
- handler({ name: 'TestJobA', data: { id } }, payload);
573
+ handler({ name: 'TestJobA', id }, payload);
448
574
 
449
575
  const child = worker.loggerLibrary.worker.child.mock.results[0].value;
450
576
  expect(child.debug).toHaveBeenCalledWith(payload);
@@ -456,7 +582,7 @@ describe('Worker', () => {
456
582
  const handler = bullmqMock.listenerFor(bullWorker, 'failed');
457
583
  const job = {
458
584
  name: 'TestJobA',
459
- data: { id: 't1' },
585
+ id: 't1',
460
586
  attemptsMade: 1,
461
587
  opts: { attempts: 3 },
462
588
  };
@@ -473,7 +599,7 @@ describe('Worker', () => {
473
599
  const handler = bullmqMock.listenerFor(bullWorker, 'failed');
474
600
  const job = {
475
601
  name: 'TestJobA',
476
- data: { id: 't1' },
602
+ id: 't1',
477
603
  attemptsMade: 3,
478
604
  opts: { attempts: 3 },
479
605
  };
@@ -43,6 +43,69 @@ describe('Logger', () => {
43
43
 
44
44
  expect(root[childScope].id).toEqual(id);
45
45
  });
46
+
47
+ it ('returns the scoped logger it registered', () => {
48
+ const childScope = randomScope();
49
+ const root = new Logger(randomScope());
50
+
51
+ expect(root.addScope(childScope)).toBe(root[childScope]);
52
+ });
53
+
54
+ it ('returns the same logger when the scope is already registered', () => {
55
+ const childScope = randomScope();
56
+ const root = new Logger(randomScope());
57
+
58
+ expect(root.addScope(childScope)).toBe(root.addScope(childScope));
59
+ });
60
+
61
+ it ('throws when the name is already taken by something that is not a logger', () => {
62
+ const root = new Logger(randomScope());
63
+
64
+ expect(() => root.addScope('child')).toThrow(/child/);
65
+ });
66
+ });
67
+
68
+
69
+ describe('#scope', () => {
70
+ it ('registers and returns a scope whose name is not lowercase', () => {
71
+ const root = new Logger(randomScope());
72
+ const name = `S_${TestHelpers.Faker.Text.randomString()}`;
73
+
74
+ const scoped = root.scope(name);
75
+
76
+ expect(scoped.scopes).toContain(name.toLowerCase());
77
+ });
78
+
79
+ it ('reaches the scope by the name it was given and by the lowercased one', () => {
80
+ const root = new Logger(randomScope());
81
+ const name = `S_${TestHelpers.Faker.Text.randomString()}`;
82
+
83
+ const scoped = root.scope(name);
84
+
85
+ expect(root[name]).toBe(scoped);
86
+ expect(root[name.toLowerCase()]).toBe(scoped);
87
+ });
88
+
89
+ it ('returns the same logger on every call', () => {
90
+ const root = new Logger(randomScope());
91
+ const name = `S_${TestHelpers.Faker.Text.randomString()}`;
92
+
93
+ expect(root.scope(name)).toBe(root.scope(name));
94
+ });
95
+
96
+ it ('returns the existing scope when the same name arrives in another casing', () => {
97
+ const root = new Logger(randomScope());
98
+ const name = `S_${TestHelpers.Faker.Text.randomString()}`;
99
+
100
+ expect(root.scope(name.toUpperCase())).toBe(root.scope(name));
101
+ });
102
+
103
+ it ('tags the scope lowercased whichever spelling registered it', () => {
104
+ const name = `S_${TestHelpers.Faker.Text.randomString()}`;
105
+ const root = new Logger(randomScope());
106
+
107
+ expect(root.scope(name).scopes).not.toContain(name);
108
+ });
46
109
  });
47
110
 
48
111
 
@@ -83,5 +146,55 @@ describe('Logger', () => {
83
146
 
84
147
  expect(result.message).toEqual(`[TS][INFO][${scopeA.toUpperCase()}][${scopeB.toUpperCase()}] hello`);
85
148
  });
149
+
150
+
151
+ // Winston hands a logged Error through as `info`, so assigning onto it rewrote the caller's
152
+ // error with the whole formatted line.
153
+ it ('leaves the object it was given untouched', () => {
154
+ const logger = new Logger(randomScope());
155
+ const info = { level: 'info', timestamp: 'TS', message: 'hello' };
156
+
157
+ logger.messageFormatter(info);
158
+
159
+ expect(info.message).toEqual('hello');
160
+ });
161
+
162
+ it ('does not rewrite the message of an error it formats', () => {
163
+ const logger = new Logger(randomScope());
164
+ const error = new Error('boom');
165
+ error.level = 'error';
166
+ error.timestamp = 'TS';
167
+
168
+ const result = logger.messageFormatter(error);
169
+
170
+ expect(error.message).toEqual('boom');
171
+ expect(result.message).toContain('boom');
172
+ });
173
+
174
+ // Formatting the same error twice used to compound the prefix, because the first call had
175
+ // already written its output back onto the error.
176
+ it ('formats the same error the same way every time', () => {
177
+ const logger = new Logger(randomScope());
178
+ const error = new Error('boom');
179
+ error.level = 'error';
180
+ error.timestamp = 'TS';
181
+
182
+ const first = logger.messageFormatter(error);
183
+ const second = logger.messageFormatter(error);
184
+
185
+ expect(second.message).toEqual(first.message);
186
+ });
187
+
188
+ // The transports read these, so a copy that dropped them would stop formatting output.
189
+ it ("carries winston's own symbol properties onto the copy", () => {
190
+ const logger = new Logger(randomScope());
191
+ const marker = Symbol.for('level');
192
+ const info = { level: 'info', timestamp: 'TS', message: 'hello' };
193
+ info[marker] = 'info';
194
+
195
+ const result = logger.messageFormatter(info);
196
+
197
+ expect(result[marker]).toEqual('info');
198
+ });
86
199
  });
87
200
  });
@@ -70,6 +70,15 @@ describe('ApiDocGenerator', () => {
70
70
  expect(generator.endpoint).toEqual(formattedUrl);
71
71
  });
72
72
 
73
+ it ('removes the Express optional marker from a path parameter', () => {
74
+ const requiredName = Faker.Text.randomString();
75
+ const optionalName = Faker.Text.randomString();
76
+ const action = { path: `/things/:${requiredName}/:${optionalName}?` };
77
+ const generator = new ApiDocGenerator(action, ControllerClass);
78
+
79
+ expect(generator.endpoint).toEqual(`/things/{${requiredName}}/{${optionalName}}`);
80
+ });
81
+
73
82
  it ('returns the path unchanged when there are no params', () => {
74
83
  const path = `/${Faker.Text.randomString()}/${Faker.Text.randomString()}`;
75
84
  const action = { path };
@@ -157,6 +166,25 @@ describe('ApiDocGenerator', () => {
157
166
  expect(generator.pathParams.id.required).toBe(true);
158
167
  });
159
168
 
169
+ it ('uses the validator definition for an optional Express path parameter', () => {
170
+ const parameterName = Faker.Text.randomString();
171
+ const description = Faker.Text.randomString();
172
+ const action = { action: 'getRecord', method: 'GET', path: `/things/:${parameterName}?` };
173
+ ControllerClass.parametersForAction.mockReturnValue({
174
+ [parameterName]: { isString: true, optional: true, description },
175
+ });
176
+ const generator = new ApiDocGenerator(action, ControllerClass);
177
+
178
+ expect(generator.pathParams[parameterName]).toEqual({
179
+ name: parameterName,
180
+ in: 'path',
181
+ required: true,
182
+ description,
183
+ schema: { type: 'string' },
184
+ });
185
+ expect(generator.pathParams[`${parameterName}?`]).toBeUndefined();
186
+ });
187
+
160
188
  it ('excludes parameters that are not in the path', () => {
161
189
  const action = { action: 'getRecord', method: 'GET', path: '/user/:id' };
162
190
  ControllerClass.parametersForAction.mockReturnValue({
@@ -214,6 +242,17 @@ describe('ApiDocGenerator', () => {
214
242
  expect(Object.keys(generator.queryParams)).toEqual(['detail']);
215
243
  });
216
244
 
245
+ it ('does not duplicate an optional Express path parameter as a query parameter', () => {
246
+ const parameterName = Faker.Text.randomString();
247
+ const action = { action: 'getRecord', method: 'GET', path: `/things/:${parameterName}?` };
248
+ ControllerClass.parametersForAction.mockReturnValue({
249
+ [parameterName]: { isString: true, optional: true },
250
+ });
251
+ const generator = new ApiDocGenerator(action, ControllerClass);
252
+
253
+ expect(generator.queryParams[parameterName]).toBeUndefined();
254
+ });
255
+
217
256
  });
218
257
 
219
258