@vida-global/core 2.0.0 → 2.0.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.
- package/README.md +8 -7
- package/config/newrelic-config.js +2 -0
- package/lib/activeRecord/README.md +220 -111
- package/lib/http/README.md +84 -19
- package/lib/jobQueue/README.md +139 -46
- package/lib/logger/README.md +48 -5
- package/lib/server/README.md +282 -129
- package/lib/server/controllerImporter.js +1 -1
- package/lib/server/controllerMixins/callbacks.js +132 -0
- package/lib/server/controllerMixins/documentation.js +36 -0
- package/lib/server/controllerMixins/renderer.js +315 -0
- package/lib/server/controllerMixins/requestDetails.js +82 -0
- package/lib/server/controllerMixins/routing.js +108 -0
- package/lib/server/controllerMixins/validations.js +114 -0
- package/lib/server/server.js +2 -0
- package/lib/server/serverController.js +75 -672
- package/package.json +8 -5
- package/test/server/serverController.test.js +868 -22
- package/test/apm/agent.test.js +0 -56
- package/test/apm/utils.test.js +0 -121
|
@@ -27,23 +27,37 @@ describe('VidaServerController', () => {
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
describe('.
|
|
30
|
+
describe('.autoLoadMixins', () => {
|
|
31
31
|
class AutoLoadTestController extends VidaServerController {
|
|
32
|
-
static get
|
|
32
|
+
static get mixinDirectory() {
|
|
33
33
|
return `${process.cwd()}/test/server/helpers/autoload/${this.name}.js`;
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
it ('
|
|
37
|
+
it ('calls loadMixin with an empty mixin when there is no mixin file at the expected path', () => {
|
|
38
38
|
class TmpController extends AutoLoadTestController {}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
const loadMixinSpy = jest.spyOn(TmpController, 'loadMixin');
|
|
40
|
+
|
|
41
|
+
TmpController.autoLoadMixins();
|
|
42
|
+
|
|
43
|
+
expect(loadMixinSpy).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(loadMixinSpy).toHaveBeenCalledWith({});
|
|
42
45
|
});
|
|
43
46
|
|
|
44
|
-
it ('
|
|
47
|
+
it ('does not add any own prototype or static properties when there is no mixin', () => {
|
|
48
|
+
class TmpController extends AutoLoadTestController {}
|
|
49
|
+
const protoKeysBefore = Object.getOwnPropertyNames(TmpController.prototype);
|
|
50
|
+
const staticKeysBefore = Object.getOwnPropertyNames(TmpController);
|
|
51
|
+
|
|
52
|
+
TmpController.autoLoadMixins();
|
|
53
|
+
|
|
54
|
+
expect(Object.getOwnPropertyNames(TmpController.prototype)).toEqual(protoKeysBefore);
|
|
55
|
+
expect(Object.getOwnPropertyNames(TmpController)).toEqual(staticKeysBefore);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it ('loads instance methods and accessors from the mixin', () => {
|
|
45
59
|
class TmpWithHelpersController extends AutoLoadTestController {}
|
|
46
|
-
TmpWithHelpersController.
|
|
60
|
+
TmpWithHelpersController.autoLoadMixins();
|
|
47
61
|
|
|
48
62
|
const { InstanceMethods, Accessors } = require('./helpers/autoload/TmpWithHelpersController');
|
|
49
63
|
|
|
@@ -156,6 +170,52 @@ describe('VidaServerController', () => {
|
|
|
156
170
|
});
|
|
157
171
|
|
|
158
172
|
|
|
173
|
+
describe('#coerceString', () => {
|
|
174
|
+
const controller = new FooController({}, {});
|
|
175
|
+
|
|
176
|
+
it ('returns the string when passed a non-empty string', () => {
|
|
177
|
+
const value = TestHelpers.Faker.Text.randomString();
|
|
178
|
+
expect(controller.coerceString(value)).toBe(value);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it ('trims surrounding whitespace', () => {
|
|
182
|
+
const value = TestHelpers.Faker.Text.randomString();
|
|
183
|
+
expect(controller.coerceString(` ${value} `)).toBe(value);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it ('returns the fallback for an empty string', () => {
|
|
187
|
+
const fallback = TestHelpers.Faker.Text.randomString();
|
|
188
|
+
expect(controller.coerceString('', {fallback})).toBe(fallback);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it ('returns the fallback for a whitespace-only string', () => {
|
|
192
|
+
const fallback = TestHelpers.Faker.Text.randomString();
|
|
193
|
+
expect(controller.coerceString(' ', {fallback})).toBe(fallback);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it.each([
|
|
197
|
+
[null],
|
|
198
|
+
[undefined],
|
|
199
|
+
[123],
|
|
200
|
+
[true],
|
|
201
|
+
[false],
|
|
202
|
+
[{}],
|
|
203
|
+
[[]],
|
|
204
|
+
])('returns the fallback for non-string input (%p)', (input) => {
|
|
205
|
+
const fallback = TestHelpers.Faker.Text.randomString();
|
|
206
|
+
expect(controller.coerceString(input, {fallback})).toBe(fallback);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it ('defaults the fallback to an empty string when no options are given', () => {
|
|
210
|
+
expect(controller.coerceString(null)).toBe('');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it ('defaults the fallback to an empty string for empty input when no options are given', () => {
|
|
214
|
+
expect(controller.coerceString(' ')).toBe('');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
|
|
159
219
|
describe('#coerceBoolean', () => {
|
|
160
220
|
const controller = new FooController({}, {});
|
|
161
221
|
|
|
@@ -304,7 +364,6 @@ describe('VidaServerController', () => {
|
|
|
304
364
|
const paramName2 = TestHelpers.Faker.Text.randomString();
|
|
305
365
|
const value1 = TestHelpers.Faker.Text.randomString();
|
|
306
366
|
const value2 = TestHelpers.Faker.Text.randomString();
|
|
307
|
-
const params = {[paramName1]: value1, [paramName2]: value2};
|
|
308
367
|
const validationType1 = TestHelpers.Faker.Text.randomString();
|
|
309
368
|
const validationType2 = TestHelpers.Faker.Text.randomString();
|
|
310
369
|
const options1 = [TestHelpers.Faker.Text.randomString()];
|
|
@@ -312,11 +371,13 @@ describe('VidaServerController', () => {
|
|
|
312
371
|
const error1 = TestHelpers.Faker.Text.randomString()
|
|
313
372
|
const error2 = TestHelpers.Faker.Text.randomString()
|
|
314
373
|
|
|
374
|
+
let params;
|
|
315
375
|
let controller;
|
|
316
376
|
let response;
|
|
317
377
|
let validator1;
|
|
318
378
|
let validator2;
|
|
319
379
|
beforeEach(() => {
|
|
380
|
+
params = {[paramName1]: value1, [paramName2]: value2};
|
|
320
381
|
response = {json: jest.fn(), statusCode: 200};
|
|
321
382
|
controller = new FooController({params}, response);
|
|
322
383
|
controller.render = jest.fn();
|
|
@@ -343,7 +404,14 @@ describe('VidaServerController', () => {
|
|
|
343
404
|
|
|
344
405
|
it ('throws a `ValidationError` when there\'s an error', async () => {
|
|
345
406
|
validator1.mockImplementation(() => error1);
|
|
346
|
-
await expect(controller.validateParameters(validations)).rejects.toThrow();
|
|
407
|
+
await expect(controller.validateParameters(validations)).rejects.toThrow(Errors.ValidationError);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it ('attaches the failing parameter and error message to the ValidationError fields', async () => {
|
|
411
|
+
validator1.mockImplementation(() => error1);
|
|
412
|
+
await expect(controller.validateParameters(validations)).rejects.toMatchObject({
|
|
413
|
+
fields: {[paramName1]: [error1], [paramName2]: [error1]}
|
|
414
|
+
});
|
|
347
415
|
});
|
|
348
416
|
|
|
349
417
|
it ('throws a `ValidationError` when there are multiple errors', async () => {
|
|
@@ -371,16 +439,86 @@ describe('VidaServerController', () => {
|
|
|
371
439
|
|
|
372
440
|
it ('does not error when optional parameters are not included', async () => {
|
|
373
441
|
const validations = {foo: {isString: true, optional: true}};
|
|
374
|
-
expect(controller.validateParameters(validations)).resolves.not.toThrow();
|
|
442
|
+
await expect(controller.validateParameters(validations)).resolves.not.toThrow();
|
|
375
443
|
});
|
|
376
444
|
|
|
377
|
-
it ('does
|
|
445
|
+
it ('does error when optional parameters are invalid', async () => {
|
|
378
446
|
const validations = {foo: {isString: {length: {gte: 2}}, optional: true}};
|
|
379
447
|
params.foo = '';
|
|
380
448
|
|
|
381
449
|
await expect(controller.validateParameters(validations)).rejects.toThrow(Errors.ValidationError);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
382
453
|
|
|
383
|
-
|
|
454
|
+
describe('.validateAction', () => {
|
|
455
|
+
it ('is a no-op when the controller has no parametersFor<Action> method', async () => {
|
|
456
|
+
class C extends VidaServerController {}
|
|
457
|
+
const controller = new C({}, {});
|
|
458
|
+
controller.validateParameters = jest.fn();
|
|
459
|
+
|
|
460
|
+
await controller.validateAction('getThing');
|
|
461
|
+
expect(controller.validateParameters).not.toHaveBeenCalled();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it ('calls validateParameters with the value returned by parametersFor<Action>', async () => {
|
|
465
|
+
const params = {foo: {isString: true}};
|
|
466
|
+
class C extends VidaServerController {
|
|
467
|
+
static parametersForGetThing() { return params; }
|
|
468
|
+
}
|
|
469
|
+
const controller = new C({}, {});
|
|
470
|
+
controller.validateParameters = jest.fn();
|
|
471
|
+
|
|
472
|
+
await controller.validateAction('getThing');
|
|
473
|
+
expect(controller.validateParameters).toHaveBeenCalledTimes(1);
|
|
474
|
+
expect(controller.validateParameters).toHaveBeenCalledWith(params);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it ('camelizes the action name when looking up the parametersFor method', async () => {
|
|
478
|
+
const params = {foo: {isString: true}};
|
|
479
|
+
class C extends VidaServerController {
|
|
480
|
+
static parametersForGetFooBar() { return params; }
|
|
481
|
+
}
|
|
482
|
+
const controller = new C({}, {});
|
|
483
|
+
controller.validateParameters = jest.fn();
|
|
484
|
+
|
|
485
|
+
await controller.validateAction('getFooBar');
|
|
486
|
+
expect(controller.validateParameters).toHaveBeenCalledWith(params);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it ('propagates ValidationError thrown by validateParameters', async () => {
|
|
490
|
+
class C extends VidaServerController {
|
|
491
|
+
static parametersForGetThing() { return {foo: {isString: true}}; }
|
|
492
|
+
}
|
|
493
|
+
const request = { params: {}, query: {}, body: {foo: 42} };
|
|
494
|
+
const controller = new C(request, {});
|
|
495
|
+
|
|
496
|
+
await expect(controller.validateAction('getThing')).rejects.toThrow(Errors.ValidationError);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
describe('.parametersForAction', () => {
|
|
502
|
+
it ('returns null when no parametersFor method is defined', () => {
|
|
503
|
+
class C extends VidaServerController {}
|
|
504
|
+
expect(C.parametersForAction('getThing')).toBe(null);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it ('returns the value of the matching parametersFor<Action> method', () => {
|
|
508
|
+
const params = {foo: {isString: true}};
|
|
509
|
+
class C extends VidaServerController {
|
|
510
|
+
static parametersForGetThing() { return params; }
|
|
511
|
+
}
|
|
512
|
+
expect(C.parametersForAction('getThing')).toBe(params);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
describe('.parametersMethodForAction', () => {
|
|
518
|
+
it ('returns the camelized parametersFor<Action> method name', () => {
|
|
519
|
+
class C extends VidaServerController {}
|
|
520
|
+
expect(C.parametersMethodForAction('getFooBar')).toBe('parametersForGetFooBar');
|
|
521
|
+
expect(C.parametersMethodForAction('postIndex')).toBe('parametersForPostIndex');
|
|
384
522
|
});
|
|
385
523
|
});
|
|
386
524
|
|
|
@@ -566,32 +704,32 @@ describe('VidaServerController', () => {
|
|
|
566
704
|
|
|
567
705
|
describe('.validateIsDateTime', () => {
|
|
568
706
|
const controller = new FooController({}, {});
|
|
569
|
-
it ('returns undefined when passed a string of format YYYY-MM-DD', () => {
|
|
707
|
+
it ('returns undefined when passed a date string of format YYYY-MM-DD', () => {
|
|
570
708
|
const result = controller.validateIsDateTime('2025-12-17');
|
|
571
709
|
expect(result).toBe(undefined);
|
|
572
710
|
});
|
|
573
711
|
|
|
574
|
-
it ('returns undefined when passed a string of format MM/DD/YY', () => {
|
|
712
|
+
it ('returns undefined when passed a date string of format MM/DD/YY', () => {
|
|
575
713
|
const result = controller.validateIsDateTime('12/17/25');
|
|
576
714
|
expect(result).toBe(undefined);
|
|
577
715
|
});
|
|
578
716
|
|
|
579
|
-
it ('returns undefined when passed a string of format Month name DD,
|
|
717
|
+
it ('returns undefined when passed a date string of format Month name DD, YYYY', () => {
|
|
580
718
|
const result = controller.validateIsDateTime('December 17, 2025');
|
|
581
719
|
expect(result).toBe(undefined);
|
|
582
720
|
});
|
|
583
721
|
|
|
584
|
-
it ('returns undefined when passed a string of format YYYY-MM-DD HH:MM:SS', () => {
|
|
722
|
+
it ('returns undefined when passed a datetime string of format YYYY-MM-DD HH:MM:SS', () => {
|
|
585
723
|
const result = controller.validateIsDateTime('2025-12-17 01:05:00');
|
|
586
724
|
expect(result).toBe(undefined);
|
|
587
725
|
});
|
|
588
726
|
|
|
589
|
-
it ('returns undefined when passed a string of format MM/DD/YY', () => {
|
|
727
|
+
it ('returns undefined when passed a datetime string of format MM/DD/YY HH:MM:SS', () => {
|
|
590
728
|
const result = controller.validateIsDateTime('12/17/25 01:05:00');
|
|
591
729
|
expect(result).toBe(undefined);
|
|
592
730
|
});
|
|
593
731
|
|
|
594
|
-
it ('returns undefined when passed a string of format Month name DD,
|
|
732
|
+
it ('returns undefined when passed a datetime string of format Month name DD, YYYY HH:MM:SS', () => {
|
|
595
733
|
const result = controller.validateIsDateTime('December 17, 2025 01:05:00');
|
|
596
734
|
expect(result).toBe(undefined);
|
|
597
735
|
});
|
|
@@ -655,7 +793,7 @@ describe('VidaServerController', () => {
|
|
|
655
793
|
const error = TestHelpers.Faker.Text.randomString();
|
|
656
794
|
const validator = (value) => value == correctValue ? undefined : error;
|
|
657
795
|
|
|
658
|
-
it ('returns
|
|
796
|
+
it ('returns undefined when passed a valid value', async () => {
|
|
659
797
|
const result = await controller.validateFunction(correctValue, validator);
|
|
660
798
|
expect(result).toBe(undefined);
|
|
661
799
|
});
|
|
@@ -725,6 +863,20 @@ describe('VidaServerController', () => {
|
|
|
725
863
|
expect(controller.statusCode).toEqual(500);
|
|
726
864
|
expect(controller.render).toHaveBeenCalledWith({errors: {message: null}});
|
|
727
865
|
});
|
|
866
|
+
|
|
867
|
+
it ('awaits render before calling response.end (regression: ERR_HTTP_HEADERS_SENT)', async () => {
|
|
868
|
+
const response = { json: jest.fn(), end: jest.fn(), statusCode: 200 };
|
|
869
|
+
const controller = new FooController({}, response);
|
|
870
|
+
controller.testAction = jest.fn(async () => ({foo: TestHelpers.Faker.Text.randomString()}));
|
|
871
|
+
|
|
872
|
+
await controller.performRequest('testAction');
|
|
873
|
+
|
|
874
|
+
expect(response.json).toHaveBeenCalledTimes(1);
|
|
875
|
+
expect(response.end).toHaveBeenCalledTimes(1);
|
|
876
|
+
const jsonOrder = response.json.mock.invocationCallOrder[0];
|
|
877
|
+
const endOrder = response.end.mock.invocationCallOrder[0];
|
|
878
|
+
expect(jsonOrder).toBeLessThan(endOrder);
|
|
879
|
+
});
|
|
728
880
|
});
|
|
729
881
|
|
|
730
882
|
|
|
@@ -876,6 +1028,118 @@ describe('VidaServerController', () => {
|
|
|
876
1028
|
});
|
|
877
1029
|
|
|
878
1030
|
|
|
1031
|
+
describe('#render', () => {
|
|
1032
|
+
let response;
|
|
1033
|
+
let controller;
|
|
1034
|
+
beforeEach(() => {
|
|
1035
|
+
response = {json: jest.fn(), send: jest.fn(), statusCode: 200};
|
|
1036
|
+
controller = new FooController({}, response);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it ('calls response.send (not response.json) when the body is a string', async () => {
|
|
1040
|
+
const body = TestHelpers.Faker.Text.randomString();
|
|
1041
|
+
await controller.render(body);
|
|
1042
|
+
|
|
1043
|
+
expect(response.send).toHaveBeenCalledTimes(1);
|
|
1044
|
+
expect(response.send).toHaveBeenCalledWith(body);
|
|
1045
|
+
expect(response.json).not.toHaveBeenCalled();
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it ('marks the controller as rendered after a string render', async () => {
|
|
1049
|
+
await controller.render('hello');
|
|
1050
|
+
expect(controller.rendered).toBe(true);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it ('is a no-op when the controller has already been rendered', async () => {
|
|
1054
|
+
controller.markRendered();
|
|
1055
|
+
await controller.render('hello');
|
|
1056
|
+
expect(response.send).not.toHaveBeenCalled();
|
|
1057
|
+
expect(response.json).not.toHaveBeenCalled();
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it ('is a no-op when the controller is streaming', async () => {
|
|
1061
|
+
controller._isStreaming = true;
|
|
1062
|
+
await controller.render('hello');
|
|
1063
|
+
await controller.render({foo: 'bar'});
|
|
1064
|
+
|
|
1065
|
+
expect(response.send).not.toHaveBeenCalled();
|
|
1066
|
+
expect(response.json).not.toHaveBeenCalled();
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it ('does not mark the controller as rendered when streaming', async () => {
|
|
1070
|
+
controller._isStreaming = true;
|
|
1071
|
+
await controller.render('hello');
|
|
1072
|
+
expect(controller.rendered).toBe(false);
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
describe('#processJSONBody', () => {
|
|
1078
|
+
let controller;
|
|
1079
|
+
beforeEach(() => {
|
|
1080
|
+
controller = new FooController({}, {});
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it ('returns null when the body is undefined', async () => {
|
|
1084
|
+
expect(await controller.processJSONBody(undefined, {})).toBe(null);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it ('returns null when the body is null', async () => {
|
|
1088
|
+
expect(await controller.processJSONBody(null, {})).toBe(null);
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it ('returns primitives unchanged', async () => {
|
|
1092
|
+
expect(await controller.processJSONBody('hello', {})).toBe('hello');
|
|
1093
|
+
expect(await controller.processJSONBody(42, {})).toBe(42);
|
|
1094
|
+
expect(await controller.processJSONBody(true, {})).toBe(true);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it ('returns Date instances as their epoch ms via getTime', async () => {
|
|
1098
|
+
const date = new Date('2020-01-02T03:04:05Z');
|
|
1099
|
+
expect(await controller.processJSONBody(date, {})).toBe(date.getTime());
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it ('recursively processes Date values inside objects', async () => {
|
|
1103
|
+
const date = new Date('2021-02-03T00:00:00Z');
|
|
1104
|
+
const result = await controller.processJSONBody({createdAt: date, name: 'foo'}, {});
|
|
1105
|
+
expect(result).toEqual({createdAt: date.getTime(), name: 'foo'});
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it ('recursively processes arrays', async () => {
|
|
1109
|
+
const date = new Date('2022-03-04T00:00:00Z');
|
|
1110
|
+
const result = await controller.processJSONBody([1, date, {ts: date}], {});
|
|
1111
|
+
expect(result).toEqual([1, date.getTime(), {ts: date.getTime()}]);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
it ('invokes toApiResponse on objects that define it and recurses on the result', async () => {
|
|
1115
|
+
const date = new Date('2023-04-05T00:00:00Z');
|
|
1116
|
+
const apiModel = {
|
|
1117
|
+
async toApiResponse() { return {id: 1, createdAt: date}; }
|
|
1118
|
+
};
|
|
1119
|
+
const result = await controller.processJSONBody(apiModel, {});
|
|
1120
|
+
expect(result).toEqual({id: 1, createdAt: date.getTime()});
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it ('passes the options argument through to toApiResponse', async () => {
|
|
1124
|
+
const options = {standardize: false, extra: 'flag'};
|
|
1125
|
+
const toApi = jest.fn().mockResolvedValue({});
|
|
1126
|
+
const apiModel = {toApiResponse: toApi};
|
|
1127
|
+
|
|
1128
|
+
await controller.processJSONBody(apiModel, options);
|
|
1129
|
+
expect(toApi).toHaveBeenCalledWith(options);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it ('recurses through arrays of objects with toApiResponse', async () => {
|
|
1133
|
+
const apiModels = [
|
|
1134
|
+
{toApiResponse: async () => ({id: 'a'})},
|
|
1135
|
+
{toApiResponse: async () => ({id: 'b'})},
|
|
1136
|
+
];
|
|
1137
|
+
const result = await controller.processJSONBody(apiModels, {});
|
|
1138
|
+
expect(result).toEqual([{id: 'a'}, {id: 'b'}]);
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
|
|
879
1143
|
describe('Request Properties', () => {
|
|
880
1144
|
let request;
|
|
881
1145
|
let response;
|
|
@@ -910,6 +1174,56 @@ describe('VidaServerController', () => {
|
|
|
910
1174
|
});
|
|
911
1175
|
|
|
912
1176
|
|
|
1177
|
+
describe('#userAgent', () => {
|
|
1178
|
+
it ('returns the user-agent request header', () => {
|
|
1179
|
+
const ua = TestHelpers.Faker.Text.randomString();
|
|
1180
|
+
request.headers['user-agent'] = ua;
|
|
1181
|
+
expect(controller.userAgent).toEqual(ua);
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
describe('#requestBody', () => {
|
|
1187
|
+
it ('returns the underlying request body', () => {
|
|
1188
|
+
const body = {foo: TestHelpers.Faker.Text.randomString()};
|
|
1189
|
+
request.body = body;
|
|
1190
|
+
expect(controller.requestBody).toBe(body);
|
|
1191
|
+
});
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
describe('#requestId', () => {
|
|
1196
|
+
it ('returns the request id', () => {
|
|
1197
|
+
request.id = TestHelpers.Faker.Text.randomString();
|
|
1198
|
+
expect(controller.requestId).toEqual(request.id);
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
describe('#requestIp', () => {
|
|
1204
|
+
it ('returns the request ip', () => {
|
|
1205
|
+
request.ip = TestHelpers.Faker.Text.randomString();
|
|
1206
|
+
expect(controller.requestIp).toEqual(request.ip);
|
|
1207
|
+
});
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
describe('#requestMethod', () => {
|
|
1212
|
+
it ('returns the request method', () => {
|
|
1213
|
+
request.method = 'POST';
|
|
1214
|
+
expect(controller.requestMethod).toEqual('POST');
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
describe('#url', () => {
|
|
1220
|
+
it ('returns the request originalUrl', () => {
|
|
1221
|
+
request.originalUrl = `/${TestHelpers.Faker.Text.randomString()}`;
|
|
1222
|
+
expect(controller.url).toEqual(request.originalUrl);
|
|
1223
|
+
});
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
|
|
913
1227
|
describe('#logger', () => {
|
|
914
1228
|
it ('returns a child of the passed logger, scoped to the request id', () => {
|
|
915
1229
|
const childLogger = {};
|
|
@@ -954,23 +1268,368 @@ describe('VidaServerController', () => {
|
|
|
954
1268
|
expect(response.statusCode).toEqual(code);
|
|
955
1269
|
});
|
|
956
1270
|
});
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
describe('#bearerToken', () => {
|
|
1274
|
+
beforeEach(() => {
|
|
1275
|
+
delete request.headers.authorization;
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
it ('returns null when there is no authorization header', () => {
|
|
1279
|
+
expect(controller.bearerToken).toBe(null);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
it ('returns null when the authorization header is empty', () => {
|
|
1283
|
+
request.headers.authorization = '';
|
|
1284
|
+
expect(controller.bearerToken).toBe(null);
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it ('returns null when the authorization scheme is not bearer', () => {
|
|
1288
|
+
request.headers.authorization = 'Basic dXNlcjpwYXNz';
|
|
1289
|
+
expect(controller.bearerToken).toBe(null);
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
it ('extracts the token after a lowercase bearer scheme', () => {
|
|
1293
|
+
request.headers.authorization = 'bearer abc123';
|
|
1294
|
+
expect(controller.bearerToken).toBe('abc123');
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it ('extracts the token regardless of bearer scheme casing', () => {
|
|
1298
|
+
request.headers.authorization = 'Bearer abc123';
|
|
1299
|
+
expect(controller.bearerToken).toBe('abc123');
|
|
1300
|
+
|
|
1301
|
+
const c2 = new FooController({headers: {authorization: 'BEARER xyz'}}, response);
|
|
1302
|
+
expect(c2.bearerToken).toBe('xyz');
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it ('tolerates leading whitespace and trims trailing whitespace from the token', () => {
|
|
1306
|
+
request.headers.authorization = ' Bearer tokenValue ';
|
|
1307
|
+
expect(controller.bearerToken).toBe('tokenValue');
|
|
1308
|
+
});
|
|
1309
|
+
});
|
|
957
1310
|
});
|
|
958
1311
|
|
|
959
1312
|
|
|
960
1313
|
describe('#setupCallbacks', () => {
|
|
961
|
-
it ('is
|
|
1314
|
+
it ('is called once during construction and then cleared from the instance', () => {
|
|
962
1315
|
class CallbacksTestController extends VidaServerController {}
|
|
963
1316
|
CallbacksTestController.prototype.setupCallbacks = jest.fn();
|
|
964
1317
|
const controller = new CallbacksTestController({}, {});
|
|
965
1318
|
|
|
966
1319
|
expect(CallbacksTestController.prototype.setupCallbacks).toHaveBeenCalledTimes(1);
|
|
967
1320
|
expect(controller.setupCallbacks).toBe(undefined);
|
|
1321
|
+
});
|
|
968
1322
|
|
|
1323
|
+
it ('is only called once per class across multiple instances', () => {
|
|
1324
|
+
class CallbacksOnceController extends VidaServerController {}
|
|
1325
|
+
CallbacksOnceController.prototype.setupCallbacks = jest.fn();
|
|
1326
|
+
|
|
1327
|
+
new CallbacksOnceController({}, {});
|
|
1328
|
+
new CallbacksOnceController({}, {});
|
|
1329
|
+
new CallbacksOnceController({}, {});
|
|
1330
|
+
|
|
1331
|
+
expect(CallbacksOnceController.prototype.setupCallbacks).toHaveBeenCalledTimes(1);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it ('does not duplicate registered callbacks across instances of the same class', async () => {
|
|
1335
|
+
const callback = jest.fn();
|
|
1336
|
+
|
|
1337
|
+
class CallbacksAccumController extends VidaServerController {
|
|
1338
|
+
setupCallbacks() { this.beforeCallback(callback); }
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
new CallbacksAccumController({}, {});
|
|
1342
|
+
new CallbacksAccumController({}, {});
|
|
1343
|
+
new CallbacksAccumController({}, {});
|
|
1344
|
+
const controller = new CallbacksAccumController({}, {});
|
|
1345
|
+
|
|
1346
|
+
await controller._runCallbacks('before', 'getIndex');
|
|
1347
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
it ('does not share callbacks across different controller classes', async () => {
|
|
1351
|
+
const callbackA = jest.fn();
|
|
1352
|
+
const callbackB = jest.fn();
|
|
1353
|
+
|
|
1354
|
+
class CallbacksClassA extends VidaServerController {
|
|
1355
|
+
setupCallbacks() { this.beforeCallback(callbackA); }
|
|
1356
|
+
}
|
|
1357
|
+
class CallbacksClassB extends VidaServerController {
|
|
1358
|
+
setupCallbacks() { this.beforeCallback(callbackB); }
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const a = new CallbacksClassA({}, {});
|
|
1362
|
+
const b = new CallbacksClassB({}, {});
|
|
1363
|
+
|
|
1364
|
+
await a._runCallbacks('before', 'someAction');
|
|
1365
|
+
expect(callbackA).toHaveBeenCalledTimes(1);
|
|
1366
|
+
expect(callbackB).toHaveBeenCalledTimes(0);
|
|
1367
|
+
|
|
1368
|
+
await b._runCallbacks('before', 'someAction');
|
|
1369
|
+
expect(callbackA).toHaveBeenCalledTimes(1);
|
|
1370
|
+
expect(callbackB).toHaveBeenCalledTimes(1);
|
|
969
1371
|
});
|
|
970
1372
|
});
|
|
971
1373
|
|
|
972
1374
|
|
|
973
|
-
describe('
|
|
1375
|
+
describe('#_runCallbacks', () => {
|
|
1376
|
+
it ('runs before-callbacks in registration order then the action', async () => {
|
|
1377
|
+
const order = [];
|
|
1378
|
+
class C extends VidaServerController {
|
|
1379
|
+
setupCallbacks() {
|
|
1380
|
+
this.beforeCallback(() => { order.push('before1') });
|
|
1381
|
+
this.beforeCallback('namedBefore');
|
|
1382
|
+
}
|
|
1383
|
+
namedBefore() { order.push('before2') }
|
|
1384
|
+
testAction() { order.push('action'); return {} }
|
|
1385
|
+
}
|
|
1386
|
+
const response = { json: jest.fn(), end: jest.fn(), statusCode: 200 };
|
|
1387
|
+
const controller = new C({}, response);
|
|
1388
|
+
|
|
1389
|
+
await controller.performRequest('testAction');
|
|
1390
|
+
expect(order).toEqual(['before1', 'before2', 'action']);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it ('runs after-callbacks in registration order after the action', async () => {
|
|
1394
|
+
const order = [];
|
|
1395
|
+
class C extends VidaServerController {
|
|
1396
|
+
setupCallbacks() {
|
|
1397
|
+
this.afterCallback(() => { order.push('after1') });
|
|
1398
|
+
this.afterCallback('namedAfter');
|
|
1399
|
+
}
|
|
1400
|
+
namedAfter() { order.push('after2') }
|
|
1401
|
+
testAction() { order.push('action'); return {} }
|
|
1402
|
+
}
|
|
1403
|
+
const response = { json: jest.fn(), end: jest.fn(), statusCode: 200 };
|
|
1404
|
+
const controller = new C({}, response);
|
|
1405
|
+
|
|
1406
|
+
await controller.performRequest('testAction');
|
|
1407
|
+
expect(order).toEqual(['action', 'after1', 'after2']);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
it ('skips the action when a before-callback returns false', async () => {
|
|
1411
|
+
const testAction = jest.fn();
|
|
1412
|
+
class C extends VidaServerController {
|
|
1413
|
+
setupCallbacks() { this.beforeCallback(() => false) }
|
|
1414
|
+
}
|
|
1415
|
+
C.prototype.testAction = testAction;
|
|
1416
|
+
const response = { json: jest.fn(), end: jest.fn(), statusCode: 200 };
|
|
1417
|
+
const controller = new C({}, response);
|
|
1418
|
+
|
|
1419
|
+
await controller.performRequest('testAction');
|
|
1420
|
+
expect(testAction).not.toHaveBeenCalled();
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
it ('skips remaining before-callbacks when one returns false', async () => {
|
|
1424
|
+
const second = jest.fn();
|
|
1425
|
+
class C extends VidaServerController {
|
|
1426
|
+
setupCallbacks() {
|
|
1427
|
+
this.beforeCallback(() => false);
|
|
1428
|
+
this.beforeCallback(second);
|
|
1429
|
+
}
|
|
1430
|
+
testAction() { return {} }
|
|
1431
|
+
}
|
|
1432
|
+
const response = { json: jest.fn(), end: jest.fn(), statusCode: 200 };
|
|
1433
|
+
const controller = new C({}, response);
|
|
1434
|
+
|
|
1435
|
+
await controller.performRequest('testAction');
|
|
1436
|
+
expect(second).not.toHaveBeenCalled();
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
it ('skips after-callbacks when a before-callback returns false', async () => {
|
|
1440
|
+
const after = jest.fn();
|
|
1441
|
+
class C extends VidaServerController {
|
|
1442
|
+
setupCallbacks() {
|
|
1443
|
+
this.beforeCallback(() => false);
|
|
1444
|
+
this.afterCallback(after);
|
|
1445
|
+
}
|
|
1446
|
+
testAction() { return {} }
|
|
1447
|
+
}
|
|
1448
|
+
const response = { json: jest.fn(), end: jest.fn(), statusCode: 200 };
|
|
1449
|
+
const controller = new C({}, response);
|
|
1450
|
+
|
|
1451
|
+
await controller.performRequest('testAction');
|
|
1452
|
+
expect(after).not.toHaveBeenCalled();
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
it ('respects only/except options when running callbacks', async () => {
|
|
1456
|
+
const onlyCb = jest.fn();
|
|
1457
|
+
const exceptCb = jest.fn();
|
|
1458
|
+
class C extends VidaServerController {
|
|
1459
|
+
setupCallbacks() {
|
|
1460
|
+
this.beforeCallback(onlyCb, {only: ['allowed']});
|
|
1461
|
+
this.beforeCallback(exceptCb, {except: ['blocked']});
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
const controller = new C({}, {});
|
|
1465
|
+
|
|
1466
|
+
await controller._runCallbacks('before', 'allowed');
|
|
1467
|
+
expect(onlyCb).toHaveBeenCalledTimes(1);
|
|
1468
|
+
expect(exceptCb).toHaveBeenCalledTimes(1);
|
|
1469
|
+
|
|
1470
|
+
await controller._runCallbacks('before', 'blocked');
|
|
1471
|
+
expect(onlyCb).toHaveBeenCalledTimes(1);
|
|
1472
|
+
expect(exceptCb).toHaveBeenCalledTimes(1);
|
|
1473
|
+
|
|
1474
|
+
await controller._runCallbacks('before', 'other');
|
|
1475
|
+
expect(onlyCb).toHaveBeenCalledTimes(1);
|
|
1476
|
+
expect(exceptCb).toHaveBeenCalledTimes(2);
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
it ('supports both function and method-name string callbacks', async () => {
|
|
1480
|
+
const fnCb = jest.fn();
|
|
1481
|
+
class C extends VidaServerController {
|
|
1482
|
+
setupCallbacks() {
|
|
1483
|
+
this.beforeCallback(fnCb);
|
|
1484
|
+
this.beforeCallback('namedCb');
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
C.prototype.namedCb = jest.fn();
|
|
1488
|
+
const controller = new C({}, {});
|
|
1489
|
+
|
|
1490
|
+
await controller._runCallbacks('before', 'someAction');
|
|
1491
|
+
expect(fnCb).toHaveBeenCalledTimes(1);
|
|
1492
|
+
expect(C.prototype.namedCb).toHaveBeenCalledTimes(1);
|
|
1493
|
+
});
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
describe('#skipBeforeCallback / #skipAfterCallback', () => {
|
|
1498
|
+
it ('skips a registered before-callback when the same function reference is in the skip list', async () => {
|
|
1499
|
+
const skipped = jest.fn();
|
|
1500
|
+
const kept = jest.fn();
|
|
1501
|
+
class C extends VidaServerController {
|
|
1502
|
+
setupCallbacks() {
|
|
1503
|
+
this.beforeCallback(skipped);
|
|
1504
|
+
this.beforeCallback(kept);
|
|
1505
|
+
this.skipBeforeCallback(skipped);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const controller = new C({}, {});
|
|
1509
|
+
|
|
1510
|
+
await controller._runCallbacks('before', 'someAction');
|
|
1511
|
+
expect(skipped).not.toHaveBeenCalled();
|
|
1512
|
+
expect(kept).toHaveBeenCalledTimes(1);
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
it ('skips a registered before-callback by method-name string', async () => {
|
|
1516
|
+
class C extends VidaServerController {
|
|
1517
|
+
setupCallbacks() {
|
|
1518
|
+
this.beforeCallback('namedCb');
|
|
1519
|
+
this.skipBeforeCallback('namedCb');
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
C.prototype.namedCb = jest.fn();
|
|
1523
|
+
const controller = new C({}, {});
|
|
1524
|
+
|
|
1525
|
+
await controller._runCallbacks('before', 'someAction');
|
|
1526
|
+
expect(C.prototype.namedCb).not.toHaveBeenCalled();
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
it ('skips a registered after-callback when in the after skip list', async () => {
|
|
1530
|
+
const skipped = jest.fn();
|
|
1531
|
+
const kept = jest.fn();
|
|
1532
|
+
class C extends VidaServerController {
|
|
1533
|
+
setupCallbacks() {
|
|
1534
|
+
this.afterCallback(skipped);
|
|
1535
|
+
this.afterCallback(kept);
|
|
1536
|
+
this.skipAfterCallback(skipped);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
const controller = new C({}, {});
|
|
1540
|
+
|
|
1541
|
+
await controller._runCallbacks('after', 'someAction');
|
|
1542
|
+
expect(skipped).not.toHaveBeenCalled();
|
|
1543
|
+
expect(kept).toHaveBeenCalledTimes(1);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
it ('only applies the skip on actions matching the skip\'s only option', async () => {
|
|
1547
|
+
const cb = jest.fn();
|
|
1548
|
+
class C extends VidaServerController {
|
|
1549
|
+
setupCallbacks() {
|
|
1550
|
+
this.beforeCallback(cb);
|
|
1551
|
+
this.skipBeforeCallback(cb, {only: 'skipMe'});
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const controller = new C({}, {});
|
|
1555
|
+
|
|
1556
|
+
await controller._runCallbacks('before', 'skipMe');
|
|
1557
|
+
expect(cb).not.toHaveBeenCalled();
|
|
1558
|
+
|
|
1559
|
+
await controller._runCallbacks('before', 'otherAction');
|
|
1560
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
it ('applies the skip on all actions except those listed in the skip\'s except option', async () => {
|
|
1564
|
+
const cb = jest.fn();
|
|
1565
|
+
class C extends VidaServerController {
|
|
1566
|
+
setupCallbacks() {
|
|
1567
|
+
this.beforeCallback(cb);
|
|
1568
|
+
this.skipBeforeCallback(cb, {except: 'keepMe'});
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
const controller = new C({}, {});
|
|
1572
|
+
|
|
1573
|
+
await controller._runCallbacks('before', 'keepMe');
|
|
1574
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
1575
|
+
|
|
1576
|
+
await controller._runCallbacks('before', 'somethingElse');
|
|
1577
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
it ('does not affect callbacks whose identity does not match the skip entry', async () => {
|
|
1581
|
+
const cb = jest.fn();
|
|
1582
|
+
const other = jest.fn();
|
|
1583
|
+
class C extends VidaServerController {
|
|
1584
|
+
setupCallbacks() {
|
|
1585
|
+
this.beforeCallback(cb);
|
|
1586
|
+
this.skipBeforeCallback(other);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const controller = new C({}, {});
|
|
1590
|
+
|
|
1591
|
+
await controller._runCallbacks('before', 'anyAction');
|
|
1592
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
1593
|
+
});
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
describe('#isAuthenticatedAction', () => {
|
|
1598
|
+
it ('returns false when no authenticateRequest callback is registered', () => {
|
|
1599
|
+
class NoAuthController extends VidaServerController {}
|
|
1600
|
+
const controller = new NoAuthController({}, {});
|
|
1601
|
+
expect(controller.isAuthenticatedAction('getIndex')).toBe(false);
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
it ('returns true for any action when authenticateRequest is registered without restrictions', () => {
|
|
1605
|
+
class AuthAllController extends VidaServerController {
|
|
1606
|
+
setupCallbacks() { this.beforeCallback('authenticateRequest'); }
|
|
1607
|
+
authenticateRequest() {}
|
|
1608
|
+
}
|
|
1609
|
+
const controller = new AuthAllController({}, {});
|
|
1610
|
+
expect(controller.isAuthenticatedAction('getIndex')).toBe(true);
|
|
1611
|
+
expect(controller.isAuthenticatedAction('getOther')).toBe(true);
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
it ('respects the `except` option on the authenticateRequest callback', () => {
|
|
1615
|
+
class AuthExceptController extends VidaServerController {
|
|
1616
|
+
setupCallbacks() { this.beforeCallback('authenticateRequest', {except: 'getOpen'}); }
|
|
1617
|
+
authenticateRequest() {}
|
|
1618
|
+
}
|
|
1619
|
+
const controller = new AuthExceptController({}, {});
|
|
1620
|
+
expect(controller.isAuthenticatedAction('getIndex')).toBe(true);
|
|
1621
|
+
expect(controller.isAuthenticatedAction('getOpen')).toBe(false);
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
it ('respects the `only` option on the authenticateRequest callback', () => {
|
|
1625
|
+
class AuthOnlyController extends VidaServerController {
|
|
1626
|
+
setupCallbacks() { this.beforeCallback('authenticateRequest', {only: ['getSecret']}); }
|
|
1627
|
+
authenticateRequest() {}
|
|
1628
|
+
}
|
|
1629
|
+
const controller = new AuthOnlyController({}, {});
|
|
1630
|
+
expect(controller.isAuthenticatedAction('getSecret')).toBe(true);
|
|
1631
|
+
expect(controller.isAuthenticatedAction('getIndex')).toBe(false);
|
|
1632
|
+
});
|
|
974
1633
|
});
|
|
975
1634
|
|
|
976
1635
|
|
|
@@ -1164,4 +1823,191 @@ describe('VidaServerController', () => {
|
|
|
1164
1823
|
}, jest.fn(), abortController);
|
|
1165
1824
|
});
|
|
1166
1825
|
});
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
describe('cookies / getCookie / setCookie', () => {
|
|
1829
|
+
let request;
|
|
1830
|
+
let response;
|
|
1831
|
+
let controller;
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
beforeEach(() => {
|
|
1835
|
+
request = { cookies: { sessionId: 'abc', theme: 'dark' } };
|
|
1836
|
+
response = { cookie: jest.fn() };
|
|
1837
|
+
controller = new FooController(request, response);
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
describe('#cookies', () => {
|
|
1842
|
+
it ('returns the cookies object from the request', () => {
|
|
1843
|
+
expect(controller.cookies).toEqual({ sessionId: 'abc', theme: 'dark' });
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
it ('returns an empty object when request.cookies is undefined', () => {
|
|
1848
|
+
controller = new FooController({}, response);
|
|
1849
|
+
expect(controller.cookies).toEqual({});
|
|
1850
|
+
});
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
describe('#getCookie', () => {
|
|
1855
|
+
it ('returns the value for an existing cookie', () => {
|
|
1856
|
+
expect(controller.getCookie('sessionId')).toBe('abc');
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
it ('returns null for a missing cookie', () => {
|
|
1861
|
+
expect(controller.getCookie('missing')).toBeNull();
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
it ('returns null when name is empty', () => {
|
|
1866
|
+
expect(controller.getCookie('')).toBeNull();
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
it ('returns null when name is null', () => {
|
|
1871
|
+
expect(controller.getCookie(null)).toBeNull();
|
|
1872
|
+
});
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
describe('#setCookie', () => {
|
|
1877
|
+
it ('invokes response.cookie with the given name, value, and options', () => {
|
|
1878
|
+
const options = { path: '/foo', maxAge: 60000, httpOnly: true, secure: true, sameSite: 'None' };
|
|
1879
|
+
controller.setCookie('sessionId', 'xyz', options);
|
|
1880
|
+
expect(response.cookie).toHaveBeenCalledTimes(1);
|
|
1881
|
+
expect(response.cookie).toHaveBeenCalledWith('sessionId', 'xyz', options);
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
it ('defaults options to an empty object', () => {
|
|
1886
|
+
controller.setCookie('sessionId', 'xyz');
|
|
1887
|
+
expect(response.cookie).toHaveBeenCalledWith('sessionId', 'xyz', {});
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
|
|
1891
|
+
it ('coerces null/undefined value to an empty string', () => {
|
|
1892
|
+
controller.setCookie('sessionId', null);
|
|
1893
|
+
expect(response.cookie).toHaveBeenCalledWith('sessionId', '', {});
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
it ('is a no-op when name is empty', () => {
|
|
1898
|
+
controller.setCookie('', 'xyz');
|
|
1899
|
+
expect(response.cookie).not.toHaveBeenCalled();
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
it ('is a no-op when name is null', () => {
|
|
1904
|
+
controller.setCookie(null, 'xyz');
|
|
1905
|
+
expect(response.cookie).not.toHaveBeenCalled();
|
|
1906
|
+
});
|
|
1907
|
+
});
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
describe('#renderFile', () => {
|
|
1912
|
+
let request;
|
|
1913
|
+
let response;
|
|
1914
|
+
let controller;
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
beforeEach(() => {
|
|
1918
|
+
request = {};
|
|
1919
|
+
response = {
|
|
1920
|
+
setHeader: jest.fn(),
|
|
1921
|
+
sendFile: jest.fn((path, options, cb) => cb()),
|
|
1922
|
+
};
|
|
1923
|
+
controller = new FooController(request, response);
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
it ('sets Content-Type from the explicit option when provided', async () => {
|
|
1928
|
+
await controller.renderFile('/var/www/foo.bin', { contentType: 'application/x-custom' });
|
|
1929
|
+
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'application/x-custom');
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
it.each([
|
|
1934
|
+
['/a/b.html', 'text/html; charset=utf-8'],
|
|
1935
|
+
['/a/b.htm', 'text/html; charset=utf-8'],
|
|
1936
|
+
['/a/b.css', 'text/css; charset=utf-8'],
|
|
1937
|
+
['/a/b.js', 'application/javascript; charset=utf-8'],
|
|
1938
|
+
['/a/b.mjs', 'application/javascript; charset=utf-8'],
|
|
1939
|
+
['/a/b.json', 'application/json; charset=utf-8'],
|
|
1940
|
+
['/a/b.txt', 'text/plain; charset=utf-8'],
|
|
1941
|
+
['/a/b.png', 'image/png'],
|
|
1942
|
+
['/a/b.jpg', 'image/jpeg'],
|
|
1943
|
+
['/a/b.jpeg', 'image/jpeg'],
|
|
1944
|
+
['/a/b.gif', 'image/gif'],
|
|
1945
|
+
['/a/b.svg', 'image/svg+xml'],
|
|
1946
|
+
['/a/b.ico', 'image/x-icon'],
|
|
1947
|
+
['/a/b.webp', 'image/webp'],
|
|
1948
|
+
['/a/b.woff', 'font/woff'],
|
|
1949
|
+
['/a/b.woff2', 'font/woff2'],
|
|
1950
|
+
['/a/b.ttf', 'font/ttf'],
|
|
1951
|
+
['/a/b.otf', 'font/otf'],
|
|
1952
|
+
['/a/b.JPG', 'image/jpeg'],
|
|
1953
|
+
])('guesses Content-Type for %s', async (filePath, expected) => {
|
|
1954
|
+
await controller.renderFile(filePath);
|
|
1955
|
+
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', expected);
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
it ('falls back to application/octet-stream for unknown extensions', async () => {
|
|
1960
|
+
await controller.renderFile('/var/www/foo.xyz');
|
|
1961
|
+
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'application/octet-stream');
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
it ('falls back to application/octet-stream when there is no extension', async () => {
|
|
1966
|
+
await controller.renderFile('/var/www/foo');
|
|
1967
|
+
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'application/octet-stream');
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
it ('sets Cache-Control when provided', async () => {
|
|
1972
|
+
await controller.renderFile('/var/www/foo.html', { cacheControl: 'no-store' });
|
|
1973
|
+
expect(response.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
it ('does not set Cache-Control when omitted', async () => {
|
|
1978
|
+
await controller.renderFile('/var/www/foo.html');
|
|
1979
|
+
const cacheCalls = response.setHeader.mock.calls.filter(([name]) => name === 'Cache-Control');
|
|
1980
|
+
expect(cacheCalls).toHaveLength(0);
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
|
|
1984
|
+
it ('passes maxAge to res.sendFile', async () => {
|
|
1985
|
+
await controller.renderFile('/var/www/foo.html', { maxAge: 300000 });
|
|
1986
|
+
expect(response.sendFile).toHaveBeenCalledWith('/var/www/foo.html', { maxAge: 300000 }, expect.any(Function));
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
it ('marks the controller as rendered', async () => {
|
|
1991
|
+
expect(controller.rendered).toBe(false);
|
|
1992
|
+
await controller.renderFile('/var/www/foo.html');
|
|
1993
|
+
expect(controller.rendered).toBe(true);
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
it ('throws when filePath is empty', () => {
|
|
1998
|
+
expect(() => controller.renderFile('')).toThrow('renderFile requires a filePath');
|
|
1999
|
+
});
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
it ('throws when filePath is null', () => {
|
|
2003
|
+
expect(() => controller.renderFile(null)).toThrow('renderFile requires a filePath');
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
it ('rejects the returned promise when sendFile errors', async () => {
|
|
2008
|
+
const sendError = new Error('disk read failed');
|
|
2009
|
+
response.sendFile = jest.fn((path, options, cb) => cb(sendError));
|
|
2010
|
+
await expect(controller.renderFile('/var/www/foo.html')).rejects.toBe(sendError);
|
|
2011
|
+
});
|
|
2012
|
+
});
|
|
1167
2013
|
});
|