@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.
@@ -27,23 +27,37 @@ describe('VidaServerController', () => {
27
27
  });
28
28
 
29
29
 
30
- describe('.autoLoadHelpers', () => {
30
+ describe('.autoLoadMixins', () => {
31
31
  class AutoLoadTestController extends VidaServerController {
32
- static get autoLoadHelperPath() {
32
+ static get mixinDirectory() {
33
33
  return `${process.cwd()}/test/server/helpers/autoload/${this.name}.js`;
34
34
  }
35
35
  }
36
36
 
37
- it ('does nothing if there is no helper', () => {
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
- TmpController.autoLoadHelpers();
40
- expect(TmpController.anInstanceMethod).toBe(undefined);
41
- expect(TmpController.aProperty).toBe(undefined);
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 ('loads instance methods and accessors from the helper', () => {
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.autoLoadHelpers();
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 error when optional parameters are invalid', async () => {
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
- delete params.foo;
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, YYY', () => {
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, YYY', () => {
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 undefiend when passed a valid value', async () => {
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 only called once', () => {
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('Request Properties', () => {
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
  });