@vida-global/core 1.4.5 → 2.0.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 (55) hide show
  1. package/AGENTS.md +13 -0
  2. package/README.md +1 -0
  3. package/config/newrelic-config.js +118 -0
  4. package/index.js +9 -5
  5. package/lib/activeRecord/baseRecord.js +49 -3
  6. package/lib/apm/agent.js +22 -0
  7. package/lib/apm/index.js +54 -0
  8. package/lib/apm/utils.js +43 -0
  9. package/lib/http/client.js +16 -2
  10. package/lib/jobQueue/README.md +63 -0
  11. package/lib/jobQueue/abstractJob.js +103 -0
  12. package/lib/jobQueue/abstractJobComponent.js +37 -0
  13. package/lib/jobQueue/index.js +10 -0
  14. package/lib/jobQueue/jobImporter.js +19 -0
  15. package/lib/jobQueue/queue.js +161 -0
  16. package/lib/jobQueue/worker.js +273 -0
  17. package/lib/logger/README.md +33 -2
  18. package/lib/logger/index.js +117 -20
  19. package/lib/logger/serverMiddleware.js +41 -0
  20. package/lib/redis/redisClient.js +32 -24
  21. package/lib/server/controllerImporter.js +9 -47
  22. package/lib/server/server.js +34 -71
  23. package/lib/server/serverController.js +215 -23
  24. package/lib/utils/abstractAutoImporter.js +64 -0
  25. package/package.json +8 -6
  26. package/test/activeRecord/baseRecord.test.js +129 -0
  27. package/test/activeRecord/db/connection.test.js +4 -1
  28. package/test/activeRecord/db/connectionConfiguration.test.js +9 -3
  29. package/test/activeRecord/db/schema.test.js +217 -0
  30. package/test/activeRecord/helpers/baseRecordMocks.js +6 -4
  31. package/test/activeRecord/helpers/connection.js +0 -3
  32. package/test/activeRecord/helpers/connectionConfiguration.js +0 -8
  33. package/test/activeRecord/utils.test.js +72 -0
  34. package/test/apm/agent.test.js +56 -0
  35. package/test/apm/utils.test.js +121 -0
  36. package/test/helpers/env.js +33 -0
  37. package/test/http/client.test.js +3 -3
  38. package/test/jobQueue/abstractJob.test.js +307 -0
  39. package/test/jobQueue/abstractJobComponent.test.js +110 -0
  40. package/test/jobQueue/helpers/abstractJob.js +26 -0
  41. package/test/jobQueue/helpers/apmMock.js +20 -0
  42. package/test/jobQueue/helpers/bullmqMock.js +67 -0
  43. package/test/jobQueue/helpers/env.js +29 -0
  44. package/test/jobQueue/helpers/fixtureJobs/notAJob.js +8 -0
  45. package/test/jobQueue/helpers/fixtureJobs/testJobA.js +14 -0
  46. package/test/jobQueue/helpers/fixtureJobs/testJobB.js +14 -0
  47. package/test/jobQueue/helpers/loggerMock.js +43 -0
  48. package/test/jobQueue/helpers/worker.js +38 -0
  49. package/test/jobQueue/queue.test.js +320 -0
  50. package/test/jobQueue/worker.test.js +526 -0
  51. package/test/logger/index.test.js +61 -0
  52. package/test/logger/serverMiddleware.test.js +72 -0
  53. package/test/server/apiDocsGenerator.test.js +38 -0
  54. package/test/server/server.test.js +186 -4
  55. package/test/server/serverController.test.js +407 -5
@@ -0,0 +1,526 @@
1
+ const TestHelpers = require('@vida-global/test-helpers');
2
+ const { setupJobQueueEnv } = require('./helpers/env');
3
+ const { buildFakeBullJob, buildWorker } = require('./helpers/worker');
4
+
5
+
6
+ jest.mock('bullmq', () => {
7
+ const mock = require('./helpers/bullmqMock');
8
+ return { Queue: mock.Queue, Worker: mock.Worker };
9
+ });
10
+
11
+ jest.mock('../../lib/logger', () => {
12
+ const { buildLoggerMock } = require('./helpers/loggerMock');
13
+ return { logger: buildLoggerMock() };
14
+ });
15
+
16
+ jest.mock('../../lib/apm', () => {
17
+ const { buildApmMock } = require('./helpers/apmMock');
18
+ return buildApmMock();
19
+ });
20
+
21
+
22
+ let bullmqMock;
23
+ let Worker;
24
+ let logger;
25
+ let APM;
26
+ let TestJobA;
27
+ let TestJobB;
28
+ let restoreEnv;
29
+
30
+ beforeEach(() => {
31
+ jest.resetModules();
32
+ restoreEnv = setupJobQueueEnv(['WORKER_QUEUE_NAME', 'WORKER_CONCURRENCY']);
33
+
34
+ bullmqMock = require('./helpers/bullmqMock');
35
+ ({ Worker } = require('../../lib/jobQueue/worker'));
36
+ ({ logger } = require('../../lib/logger'));
37
+ APM = require('../../lib/apm');
38
+ ({ TestJobA } = require('./helpers/fixtureJobs/testJobA'));
39
+ ({ TestJobB } = require('./helpers/fixtureJobs/testJobB'));
40
+ });
41
+
42
+
43
+ afterEach(() => {
44
+ restoreEnv();
45
+ jest.clearAllMocks();
46
+ });
47
+
48
+
49
+ describe('Worker', () => {
50
+ describe('constructor', () => {
51
+ it ('uses the queueName argument when provided', () => {
52
+ const name = TestHelpers.Faker.Text.randomString();
53
+ const worker = new Worker(name);
54
+
55
+ expect(worker.queueName).toEqual(name);
56
+ });
57
+
58
+ it ('falls back to WORKER_QUEUE_NAME when argument is omitted', () => {
59
+ const name = TestHelpers.Faker.Text.randomString();
60
+ process.env.WORKER_QUEUE_NAME = name;
61
+
62
+ const worker = new Worker();
63
+
64
+ expect(worker.queueName).toEqual(name);
65
+ });
66
+
67
+ it ('leaves queueName undefined when neither the arg nor the env var is set', () => {
68
+ const worker = new Worker();
69
+
70
+ expect(worker.queueName).toBeUndefined();
71
+ });
72
+ });
73
+
74
+
75
+ describe('#concurrency', () => {
76
+ it ('defaults to 1', () => {
77
+ const worker = new Worker('q');
78
+
79
+ expect(worker.concurrency).toEqual(1);
80
+ });
81
+
82
+ it ('parses WORKER_CONCURRENCY from the environment', () => {
83
+ const value = TestHelpers.Faker.Math.randomNumber(50);
84
+ process.env.WORKER_CONCURRENCY = String(value);
85
+ const worker = new Worker('q');
86
+
87
+ expect(worker.concurrency).toEqual(value);
88
+ });
89
+ });
90
+
91
+
92
+ describe('#fullQueueName', () => {
93
+ it ('appends the current NODE_ENV', () => {
94
+ const name = TestHelpers.Faker.Text.randomString();
95
+ const worker = new Worker(name);
96
+
97
+ expect(worker.fullQueueName).toEqual(`${name}-test`);
98
+ });
99
+ });
100
+
101
+
102
+ describe('#jobDirectories', () => {
103
+ it ('returns [cwd/jobs] by default', () => {
104
+ const worker = new Worker('q');
105
+
106
+ expect(worker.jobDirectories).toEqual([`${process.cwd()}/jobs`]);
107
+ });
108
+ });
109
+
110
+
111
+ describe('#logger', () => {
112
+ it ('returns the worker scope', () => {
113
+ const worker = new Worker('q');
114
+
115
+ expect(worker.logger).toBe(logger.worker);
116
+ });
117
+ });
118
+
119
+
120
+ describe('#workerOptions', () => {
121
+ it ('returns autorun: false, concurrency, and redis connection details', () => {
122
+ const value = TestHelpers.Faker.Math.randomNumber(20);
123
+ process.env.WORKER_CONCURRENCY = String(value);
124
+ const worker = new Worker('q');
125
+
126
+ expect(worker.workerOptions).toEqual({
127
+ autorun: false,
128
+ concurrency: value,
129
+ connection: {
130
+ host: process.env.REDIS_BULL_HOST,
131
+ password: process.env.REDIS_BULL_PASSWORD,
132
+ port: process.env.REDIS_BULL_PORT,
133
+ },
134
+ });
135
+ });
136
+ });
137
+
138
+
139
+ describe('#listen', () => {
140
+ it ('constructs the BullMQ worker, and calls run()', async () => {
141
+ const worker = buildWorker(Worker, 'queueX');
142
+
143
+ await worker.listen();
144
+
145
+ expect(bullmqMock.instances.workers).toHaveLength(1);
146
+ const bullWorker = bullmqMock.instances.workers[0];
147
+ expect(bullWorker.name).toEqual('queueX-test');
148
+ expect(bullWorker.options.autorun).toBe(false);
149
+ expect(bullWorker.run).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ it ('only constructs the BullMQ worker once across multiple listen calls', async () => {
153
+ const worker = buildWorker(Worker);
154
+
155
+ await worker.listen();
156
+ await worker.listen();
157
+
158
+ expect(bullmqMock.instances.workers).toHaveLength(1);
159
+ });
160
+
161
+ it ('registers completed, progress, failed, and error event handlers', async () => {
162
+ const worker = buildWorker(Worker);
163
+
164
+ await worker.listen();
165
+
166
+ const bullWorker = bullmqMock.instances.workers[0];
167
+ const events = bullWorker.on.mock.calls.map(([event]) => event);
168
+ expect(events).toEqual(expect.arrayContaining(['completed', 'progress', 'failed', 'error']));
169
+ });
170
+ });
171
+
172
+
173
+ describe('#registerJobs', () => {
174
+ it ('imports AbstractJob subclasses from jobDirectories and ignores other classes', async () => {
175
+ const worker = buildWorker(Worker);
176
+
177
+ await worker.listen();
178
+
179
+ const bullWorker = bullmqMock.instances.workers[0];
180
+ const processor = bullWorker.processor;
181
+ const fakeBull = buildFakeBullJob({ name: 'TestJobA' });
182
+
183
+ await expect(processor(fakeBull)).resolves.toBeGreaterThanOrEqual(0);
184
+
185
+ const unknown = buildFakeBullJob({ name: 'NotAJob' });
186
+ await expect(processor(unknown)).rejects.toEqual('Unknown Job: NotAJob');
187
+ });
188
+
189
+ it ('short-circuits subsequent calls once registeredJobs is populated', async () => {
190
+ const worker = buildWorker(Worker);
191
+
192
+ await worker.listen();
193
+ const beforeCount = bullmqMock.instances.workers[0].on.mock.calls.length;
194
+ await worker.listen();
195
+ const afterCount = bullmqMock.instances.workers[0].on.mock.calls.length;
196
+
197
+ expect(afterCount).toEqual(beforeCount);
198
+ });
199
+
200
+ it ('logs each registered job via verbose when NODE_ENV is not test', async () => {
201
+ process.env.NODE_ENV = 'development';
202
+ const worker = buildWorker(Worker);
203
+
204
+ await worker.listen();
205
+
206
+ expect(logger.worker.verbose).toHaveBeenCalledWith('JOB: TestJobA');
207
+ expect(logger.worker.verbose).toHaveBeenCalledWith('JOB: TestJobB');
208
+ });
209
+
210
+ it ('skips per-job verbose logging when NODE_ENV is test', async () => {
211
+ process.env.NODE_ENV = 'test';
212
+ const worker = buildWorker(Worker);
213
+
214
+ await worker.listen();
215
+
216
+ const verboseCalls = logger.worker.verbose.mock.calls.filter(
217
+ ([msg]) => typeof msg === 'string' && msg.startsWith('JOB:')
218
+ );
219
+ expect(verboseCalls).toHaveLength(0);
220
+ });
221
+ });
222
+
223
+
224
+ describe('#processJob', () => {
225
+ async function listenAndGetProcessor(worker) {
226
+ await worker.listen();
227
+ return bullmqMock.instances.workers[0].processor;
228
+ }
229
+
230
+
231
+ it ('instantiates the registered job and calls _run with id, args, and bound updateProgress', async () => {
232
+ const worker = buildWorker(Worker);
233
+ const processor = await listenAndGetProcessor(worker);
234
+ const runSpy = jest.spyOn(TestJobA.prototype, '_run').mockResolvedValue(undefined);
235
+ const args = [TestHelpers.Faker.Text.randomString()];
236
+ const id = TestHelpers.Faker.Text.randomString();
237
+ const updateProgress = jest.fn();
238
+ const fakeBull = buildFakeBullJob({ name: 'TestJobA', data: { id, args }, updateProgress });
239
+
240
+ await processor(fakeBull);
241
+
242
+ expect(runSpy).toHaveBeenCalledTimes(1);
243
+ expect(runSpy).toHaveBeenCalledWith(id, args, expect.any(Function));
244
+ runSpy.mockRestore();
245
+ });
246
+
247
+ it ('returns the elapsed milliseconds between start and finish', async () => {
248
+ const worker = buildWorker(Worker);
249
+ const processor = await listenAndGetProcessor(worker);
250
+ jest.spyOn(TestJobA.prototype, '_run').mockResolvedValue(undefined);
251
+ const startedAt = 1000;
252
+ const finishedAt = 1750;
253
+ jest.spyOn(Date, 'now')
254
+ .mockReturnValueOnce(startedAt)
255
+ .mockReturnValueOnce(finishedAt);
256
+ const fakeBull = buildFakeBullJob({ name: 'TestJobA' });
257
+
258
+ const elapsed = await processor(fakeBull);
259
+
260
+ expect(elapsed).toEqual(finishedAt - startedAt);
261
+ });
262
+
263
+ it ('throws a string error when the job name is not registered', async () => {
264
+ const worker = buildWorker(Worker);
265
+ const processor = await listenAndGetProcessor(worker);
266
+ const fakeBull = buildFakeBullJob({ name: 'NonexistentJob' });
267
+
268
+ await expect(processor(fakeBull)).rejects.toEqual('Unknown Job: NonexistentJob');
269
+ });
270
+
271
+ it ('propagates errors thrown from the job run method', async () => {
272
+ const worker = buildWorker(Worker);
273
+ const processor = await listenAndGetProcessor(worker);
274
+ const error = new Error(TestHelpers.Faker.Text.randomString());
275
+ jest.spyOn(TestJobA.prototype, '_run').mockRejectedValue(error);
276
+ const fakeBull = buildFakeBullJob({ name: 'TestJobA' });
277
+
278
+ await expect(processor(fakeBull)).rejects.toBe(error);
279
+ });
280
+
281
+
282
+ it ('wraps the job in a background transaction named after the job class', async () => {
283
+ const queueName = TestHelpers.Faker.Text.randomString();
284
+ const worker = buildWorker(Worker, queueName);
285
+ const processor = await listenAndGetProcessor(worker);
286
+ jest.spyOn(TestJobA.prototype, '_run').mockResolvedValue(undefined);
287
+ const fakeBull = buildFakeBullJob({ name: 'TestJobA' });
288
+
289
+ await processor(fakeBull);
290
+
291
+ expect(APM.startBackgroundTransaction).toHaveBeenCalledTimes(1);
292
+ expect(APM.startBackgroundTransaction).toHaveBeenCalledWith(
293
+ 'TestJobA',
294
+ `${queueName}-test`,
295
+ expect.any(Function),
296
+ );
297
+ });
298
+
299
+ it ('wraps job._run in a segment named "job:<jobName>"', async () => {
300
+ const worker = buildWorker(Worker);
301
+ const processor = await listenAndGetProcessor(worker);
302
+ jest.spyOn(TestJobA.prototype, '_run').mockResolvedValue(undefined);
303
+ const fakeBull = buildFakeBullJob({ name: 'TestJobA' });
304
+
305
+ await processor(fakeBull);
306
+
307
+ expect(APM.startSegment).toHaveBeenCalledTimes(1);
308
+ expect(APM.startSegment).toHaveBeenCalledWith('job:TestJobA', true, expect.any(Function));
309
+ });
310
+
311
+ it ('adds jobId, jobName, queueName, attempt, and maxAttempts as custom attributes', async () => {
312
+ const queueName = TestHelpers.Faker.Text.randomString();
313
+ const worker = buildWorker(Worker, queueName);
314
+ const processor = await listenAndGetProcessor(worker);
315
+ jest.spyOn(TestJobA.prototype, '_run').mockResolvedValue(undefined);
316
+ const id = TestHelpers.Faker.Text.randomString();
317
+ const fakeBull = buildFakeBullJob({
318
+ name: 'TestJobA',
319
+ data: { id, args: [] },
320
+ attemptsMade: 1,
321
+ opts: { attempts: 5 },
322
+ });
323
+
324
+ await processor(fakeBull);
325
+
326
+ expect(APM.addCustomAttribute).toHaveBeenCalledWith('jobId', id);
327
+ expect(APM.addCustomAttribute).toHaveBeenCalledWith('jobName', 'TestJobA');
328
+ expect(APM.addCustomAttribute).toHaveBeenCalledWith('queueName', queueName);
329
+ expect(APM.addCustomAttribute).toHaveBeenCalledWith('attempt', 2);
330
+ expect(APM.addCustomAttribute).toHaveBeenCalledWith('maxAttempts', 5);
331
+ });
332
+
333
+ it ('records a JobCompleted custom event and a duration metric on success', async () => {
334
+ const queueName = TestHelpers.Faker.Text.randomString();
335
+ const worker = buildWorker(Worker, queueName);
336
+ const processor = await listenAndGetProcessor(worker);
337
+ jest.spyOn(TestJobA.prototype, '_run').mockResolvedValue(undefined);
338
+ const startedAt = 1000;
339
+ const finishedAt = 1500;
340
+ jest.spyOn(Date, 'now')
341
+ .mockReturnValueOnce(startedAt)
342
+ .mockReturnValueOnce(finishedAt);
343
+ const fakeBull = buildFakeBullJob({ name: 'TestJobA' });
344
+
345
+ await processor(fakeBull);
346
+
347
+ expect(APM.recordCustomEvent).toHaveBeenCalledWith('JobCompleted', {
348
+ jobName: 'TestJobA',
349
+ queueName,
350
+ durationMs: finishedAt - startedAt,
351
+ attempts: 1,
352
+ });
353
+ expect(APM.recordMetric).toHaveBeenCalledWith(
354
+ `Custom/Worker/${queueName}/TestJobA/duration`,
355
+ finishedAt - startedAt,
356
+ );
357
+ });
358
+
359
+ it ('reports errors to APM with job context and rethrows', async () => {
360
+ const queueName = TestHelpers.Faker.Text.randomString();
361
+ const worker = buildWorker(Worker, queueName);
362
+ const processor = await listenAndGetProcessor(worker);
363
+ const err = new Error(TestHelpers.Faker.Text.randomString());
364
+ jest.spyOn(TestJobA.prototype, '_run').mockRejectedValue(err);
365
+ const id = TestHelpers.Faker.Text.randomString();
366
+ const fakeBull = buildFakeBullJob({
367
+ name: 'TestJobA',
368
+ data: { id, args: [] },
369
+ attemptsMade: 0,
370
+ opts: { attempts: 3 },
371
+ });
372
+
373
+ await expect(processor(fakeBull)).rejects.toBe(err);
374
+
375
+ expect(APM.reportError).toHaveBeenCalledTimes(1);
376
+ expect(APM.reportError).toHaveBeenCalledWith(err, {
377
+ jobId: id,
378
+ jobName: 'TestJobA',
379
+ queueName,
380
+ attempt: 1,
381
+ maxAttempts: 3,
382
+ isFinalRetry: false,
383
+ });
384
+ expect(APM.recordCustomEvent).not.toHaveBeenCalledWith('JobFailed', expect.anything());
385
+ });
386
+
387
+ it ('records a JobFailed custom event on the final retry', async () => {
388
+ const queueName = TestHelpers.Faker.Text.randomString();
389
+ const worker = buildWorker(Worker, queueName);
390
+ const processor = await listenAndGetProcessor(worker);
391
+ const err = new Error(TestHelpers.Faker.Text.randomString());
392
+ jest.spyOn(TestJobA.prototype, '_run').mockRejectedValue(err);
393
+ const fakeBull = buildFakeBullJob({
394
+ name: 'TestJobA',
395
+ attemptsMade: 2,
396
+ opts: { attempts: 3 },
397
+ });
398
+
399
+ await expect(processor(fakeBull)).rejects.toBe(err);
400
+
401
+ expect(APM.recordCustomEvent).toHaveBeenCalledWith('JobFailed', {
402
+ jobName: 'TestJobA',
403
+ queueName,
404
+ attempts: 3,
405
+ error: err.message,
406
+ });
407
+ expect(APM.reportError).toHaveBeenCalledWith(err, expect.objectContaining({
408
+ isFinalRetry: true,
409
+ }));
410
+ });
411
+
412
+ it ('does not record JobCompleted or the duration metric when the job throws', async () => {
413
+ const worker = buildWorker(Worker);
414
+ const processor = await listenAndGetProcessor(worker);
415
+ const err = new Error(TestHelpers.Faker.Text.randomString());
416
+ jest.spyOn(TestJobA.prototype, '_run').mockRejectedValue(err);
417
+ const fakeBull = buildFakeBullJob({ name: 'TestJobA' });
418
+
419
+ await expect(processor(fakeBull)).rejects.toBe(err);
420
+
421
+ expect(APM.recordCustomEvent).not.toHaveBeenCalledWith('JobCompleted', expect.anything());
422
+ expect(APM.recordMetric).not.toHaveBeenCalled();
423
+ });
424
+
425
+ /*
426
+ it ('registers a new logger scope for the job name', async () => {
427
+ const worker = buildWorker(Worker);
428
+ const processor = await listenAndGetProcessor(worker);
429
+ jest.spyOn(TestJobA.prototype, '_run').mockResolvedValue(undefined);
430
+ const fakeBull = buildFakeBullJob({ name: 'TestJobA' });
431
+
432
+ await processor(fakeBull);
433
+
434
+ expect(logger.addScope).toHaveBeenCalledWith('TestJobA');
435
+ });
436
+ */
437
+ });
438
+
439
+
440
+ describe('event handlers', () => {
441
+ async function listenAndGetWorker(worker) {
442
+ await worker.listen();
443
+ return bullmqMock.instances.workers[0];
444
+ }
445
+
446
+
447
+ it ('"completed" handler logs duration on the job-named child logger', async () => {
448
+ const worker = buildWorker(Worker);
449
+ const bullWorker = await listenAndGetWorker(worker);
450
+ const handler = bullmqMock.listenerFor(bullWorker, 'completed');
451
+ const id = TestHelpers.Faker.Text.randomString();
452
+ const duration = TestHelpers.Faker.Math.randomNumber(10000);
453
+
454
+ handler({ name: 'TestJobA', data: { id } }, duration);
455
+
456
+ const child = logger.worker.child.mock.results[0].value;
457
+ expect(child.debug).toHaveBeenCalledWith(`Completed in ${duration}ms`);
458
+ });
459
+
460
+ it ('"progress" handler logs the progress payload on the job-named child logger', async () => {
461
+ const worker = buildWorker(Worker);
462
+ const bullWorker = await listenAndGetWorker(worker);
463
+ const handler = bullmqMock.listenerFor(bullWorker, 'progress');
464
+ const id = TestHelpers.Faker.Text.randomString();
465
+ const payload = { step: TestHelpers.Faker.Text.randomString() };
466
+
467
+ handler({ name: 'TestJobA', data: { id } }, payload);
468
+
469
+ const child = logger.worker.child.mock.results[0].value;
470
+ expect(child.debug).toHaveBeenCalledWith(payload);
471
+ });
472
+
473
+ it ('"failed" handler logs and does NOT throw when attempts remain', async () => {
474
+ const worker = buildWorker(Worker);
475
+ const bullWorker = await listenAndGetWorker(worker);
476
+ const handler = bullmqMock.listenerFor(bullWorker, 'failed');
477
+ const job = {
478
+ name: 'TestJobA',
479
+ data: { id: 't1' },
480
+ attemptsMade: 1,
481
+ opts: { attempts: 3 },
482
+ };
483
+ const err = new Error('boom');
484
+
485
+ expect(() => handler(job, err)).not.toThrow();
486
+ const child = logger.worker.child.mock.results[0].value;
487
+ expect(child.debug).toHaveBeenCalledWith('ERROR (1/3) Error: boom');
488
+ });
489
+
490
+ it ('"failed" handler re-throws on the final attempt', async () => {
491
+ const worker = buildWorker(Worker);
492
+ const bullWorker = await listenAndGetWorker(worker);
493
+ const handler = bullmqMock.listenerFor(bullWorker, 'failed');
494
+ const job = {
495
+ name: 'TestJobA',
496
+ data: { id: 't1' },
497
+ attemptsMade: 3,
498
+ opts: { attempts: 3 },
499
+ };
500
+ const err = new Error('final');
501
+
502
+ expect(() => handler(job, err)).toThrow(err);
503
+ });
504
+
505
+ it ('"error" handler logs and rethrows in development', async () => {
506
+ process.env.NODE_ENV = 'development';
507
+ const worker = buildWorker(Worker);
508
+ const bullWorker = await listenAndGetWorker(worker);
509
+ const handler = bullmqMock.listenerFor(bullWorker, 'error');
510
+ const err = new Error(TestHelpers.Faker.Text.randomString());
511
+
512
+ expect(() => handler(err)).toThrow(err);
513
+ expect(logger.worker.error).toHaveBeenCalledWith(err);
514
+ });
515
+
516
+ it ('"error" handler logs and does NOT throw outside development', async () => {
517
+ const worker = buildWorker(Worker);
518
+ const bullWorker = await listenAndGetWorker(worker);
519
+ const handler = bullmqMock.listenerFor(bullWorker, 'error');
520
+ const err = new Error(TestHelpers.Faker.Text.randomString());
521
+
522
+ expect(() => handler(err)).not.toThrow();
523
+ expect(logger.worker.error).toHaveBeenCalledWith(err);
524
+ });
525
+ });
526
+ });
@@ -0,0 +1,61 @@
1
+ const TestHelpers = require('@vida-global/test-helpers');
2
+ const { Logger } = require('../../lib/logger');
3
+
4
+
5
+ // Faker can return random strings that collide with Logger's own properties (`scopes`, `id`,
6
+ // `debug`, etc.). Prefixing keeps generated scope names in their own namespace.
7
+ function randomScope() {
8
+ return `s_${TestHelpers.Faker.Text.randomString()}`.toLowerCase();
9
+ }
10
+
11
+
12
+ describe('Logger', () => {
13
+ describe('#addScope', () => {
14
+ it ('nests the new scope under the parent logger\'s scopes', () => {
15
+ const parentScope = randomScope();
16
+ const childScope = randomScope();
17
+ const root = new Logger(parentScope);
18
+
19
+ root.addScope(childScope);
20
+
21
+ expect(root[childScope].scopes).toEqual([parentScope, childScope]);
22
+ });
23
+
24
+ it ('chains scope nesting through multiple levels', () => {
25
+ const scopeA = randomScope();
26
+ const scopeB = randomScope();
27
+ const scopeC = randomScope();
28
+ const root = new Logger(scopeA);
29
+
30
+ root.addScope(scopeB);
31
+ root[scopeB].addScope(scopeC);
32
+
33
+ expect(root[scopeB][scopeC].scopes).toEqual([scopeA, scopeB, scopeC]);
34
+ });
35
+
36
+ it ('preserves the parent\'s id on the nested logger', () => {
37
+ const id = TestHelpers.Faker.Text.randomString();
38
+ const parentScope = randomScope();
39
+ const childScope = randomScope();
40
+ const root = new Logger(parentScope, id);
41
+
42
+ root.addScope(childScope);
43
+
44
+ expect(root[childScope].id).toEqual(id);
45
+ });
46
+ });
47
+
48
+
49
+ describe('#messageFormatter', () => {
50
+ it ('renders every nested scope in the prefix in order', () => {
51
+ const scopeA = randomScope();
52
+ const scopeB = randomScope();
53
+ const logger = new Logger([scopeA, scopeB]);
54
+ const info = { level: 'info', timestamp: 'TS', message: 'hello' };
55
+
56
+ const result = logger.messageFormatter(info);
57
+
58
+ expect(result.message).toEqual(`[TS][INFO][${scopeA.toUpperCase()}][${scopeB.toUpperCase()}] hello`);
59
+ });
60
+ });
61
+ });
@@ -0,0 +1,72 @@
1
+ jest.mock('express-winston', () => {
2
+ return {
3
+ logger: jest.fn(() => jest.fn()) // returns a noop middleware
4
+ };
5
+ });
6
+
7
+ const expressWinston = require('express-winston');
8
+
9
+ // Invoking middleware() triggers expressWinston.logger(options) — capturing options gives us
10
+ // access to the otherwise-unexported msgFormatter
11
+ const { middleware } = require('../../lib/logger/serverMiddleware');
12
+ middleware({ _winstonLogger: {} });
13
+
14
+ const options = expressWinston.logger.mock.calls[0][0];
15
+ const msgFormatter = options.msg;
16
+
17
+
18
+ describe('logger/serverMiddleware', () => {
19
+ describe('msgFormatter', () => {
20
+ const baseRes = (overrides = {}) => ({
21
+ statusCode: 200,
22
+ responseTime: 12,
23
+ ...overrides
24
+ });
25
+
26
+ const baseReq = (overrides = {}) => ({
27
+ method: 'GET',
28
+ url: '/api/things',
29
+ ip: '127.0.0.1',
30
+ ...overrides
31
+ });
32
+
33
+ it ('uses Controller#action when req.controller is set', () => {
34
+ class FooController {}
35
+ const req = baseReq({ controller: new FooController(), action: 'getIndex' });
36
+ const res = baseRes();
37
+ const msg = msgFormatter(req, res);
38
+ expect(msg).toContain('FooController#getIndex');
39
+ expect(msg).toContain('req: GET /api/things for 127.0.0.1');
40
+ expect(msg).toContain('res: statusCode=200 responseTime=12ms');
41
+ });
42
+
43
+ it ('uses "request completed" when no controller and statusCode < 500', () => {
44
+ const msg = msgFormatter(baseReq(), baseRes({ statusCode: 200 }));
45
+ expect(msg).toContain('request completed');
46
+ });
47
+
48
+ it ('uses "request errored" when no controller and statusCode >= 500', () => {
49
+ const msg = msgFormatter(baseReq(), baseRes({ statusCode: 503 }));
50
+ expect(msg).toContain('request errored');
51
+ });
52
+
53
+ it ('strips the query string from the request URL', () => {
54
+ const req = baseReq({ url: '/api/things?limit=10&q=foo' });
55
+ const msg = msgFormatter(req, baseRes());
56
+ expect(msg).toContain('req: GET /api/things for 127.0.0.1');
57
+ expect(msg).not.toContain('limit=10');
58
+ });
59
+
60
+ it ('appends the error stack when res.error is present', () => {
61
+ const err = new Error('boom');
62
+ err.stack = 'Error: boom\n at somewhere';
63
+ const msg = msgFormatter(baseReq(), baseRes({ error: err }));
64
+ expect(msg).toContain('Error: boom\n at somewhere');
65
+ });
66
+
67
+ it ('does not append a stack when there is no res.error', () => {
68
+ const msg = msgFormatter(baseReq(), baseRes());
69
+ expect(msg).not.toContain('Error:');
70
+ });
71
+ });
72
+ });