@vida-global/core 2.0.1 → 2.0.4

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({});
45
+ });
46
+
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);
42
56
  });
43
57
 
44
- it ('loads instance methods and accessors from the helper', () => {
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
 
@@ -350,7 +364,6 @@ describe('VidaServerController', () => {
350
364
  const paramName2 = TestHelpers.Faker.Text.randomString();
351
365
  const value1 = TestHelpers.Faker.Text.randomString();
352
366
  const value2 = TestHelpers.Faker.Text.randomString();
353
- const params = {[paramName1]: value1, [paramName2]: value2};
354
367
  const validationType1 = TestHelpers.Faker.Text.randomString();
355
368
  const validationType2 = TestHelpers.Faker.Text.randomString();
356
369
  const options1 = [TestHelpers.Faker.Text.randomString()];
@@ -358,11 +371,13 @@ describe('VidaServerController', () => {
358
371
  const error1 = TestHelpers.Faker.Text.randomString()
359
372
  const error2 = TestHelpers.Faker.Text.randomString()
360
373
 
374
+ let params;
361
375
  let controller;
362
376
  let response;
363
377
  let validator1;
364
378
  let validator2;
365
379
  beforeEach(() => {
380
+ params = {[paramName1]: value1, [paramName2]: value2};
366
381
  response = {json: jest.fn(), statusCode: 200};
367
382
  controller = new FooController({params}, response);
368
383
  controller.render = jest.fn();
@@ -389,7 +404,14 @@ describe('VidaServerController', () => {
389
404
 
390
405
  it ('throws a `ValidationError` when there\'s an error', async () => {
391
406
  validator1.mockImplementation(() => error1);
392
- 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
+ });
393
415
  });
394
416
 
395
417
  it ('throws a `ValidationError` when there are multiple errors', async () => {
@@ -417,16 +439,86 @@ describe('VidaServerController', () => {
417
439
 
418
440
  it ('does not error when optional parameters are not included', async () => {
419
441
  const validations = {foo: {isString: true, optional: true}};
420
- expect(controller.validateParameters(validations)).resolves.not.toThrow();
442
+ await expect(controller.validateParameters(validations)).resolves.not.toThrow();
421
443
  });
422
444
 
423
- it ('does error when optional parameters are invalid', async () => {
445
+ it ('does error when optional parameters are invalid', async () => {
424
446
  const validations = {foo: {isString: {length: {gte: 2}}, optional: true}};
425
447
  params.foo = '';
426
448
 
427
449
  await expect(controller.validateParameters(validations)).rejects.toThrow(Errors.ValidationError);
450
+ });
451
+ });
452
+
453
+
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
+
428
516
 
429
- delete params.foo;
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');
430
522
  });
431
523
  });
432
524
 
@@ -612,32 +704,32 @@ describe('VidaServerController', () => {
612
704
 
613
705
  describe('.validateIsDateTime', () => {
614
706
  const controller = new FooController({}, {});
615
- 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', () => {
616
708
  const result = controller.validateIsDateTime('2025-12-17');
617
709
  expect(result).toBe(undefined);
618
710
  });
619
711
 
620
- 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', () => {
621
713
  const result = controller.validateIsDateTime('12/17/25');
622
714
  expect(result).toBe(undefined);
623
715
  });
624
716
 
625
- 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', () => {
626
718
  const result = controller.validateIsDateTime('December 17, 2025');
627
719
  expect(result).toBe(undefined);
628
720
  });
629
721
 
630
- 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', () => {
631
723
  const result = controller.validateIsDateTime('2025-12-17 01:05:00');
632
724
  expect(result).toBe(undefined);
633
725
  });
634
726
 
635
- 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', () => {
636
728
  const result = controller.validateIsDateTime('12/17/25 01:05:00');
637
729
  expect(result).toBe(undefined);
638
730
  });
639
731
 
640
- 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', () => {
641
733
  const result = controller.validateIsDateTime('December 17, 2025 01:05:00');
642
734
  expect(result).toBe(undefined);
643
735
  });
@@ -701,7 +793,7 @@ describe('VidaServerController', () => {
701
793
  const error = TestHelpers.Faker.Text.randomString();
702
794
  const validator = (value) => value == correctValue ? undefined : error;
703
795
 
704
- it ('returns undefiend when passed a valid value', async () => {
796
+ it ('returns undefined when passed a valid value', async () => {
705
797
  const result = await controller.validateFunction(correctValue, validator);
706
798
  expect(result).toBe(undefined);
707
799
  });
@@ -771,6 +863,20 @@ describe('VidaServerController', () => {
771
863
  expect(controller.statusCode).toEqual(500);
772
864
  expect(controller.render).toHaveBeenCalledWith({errors: {message: null}});
773
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
+ });
774
880
  });
775
881
 
776
882
 
@@ -922,6 +1028,118 @@ describe('VidaServerController', () => {
922
1028
  });
923
1029
 
924
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
+
925
1143
  describe('Request Properties', () => {
926
1144
  let request;
927
1145
  let response;
@@ -956,6 +1174,56 @@ describe('VidaServerController', () => {
956
1174
  });
957
1175
 
958
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
+
959
1227
  describe('#logger', () => {
960
1228
  it ('returns a child of the passed logger, scoped to the request id', () => {
961
1229
  const childLogger = {};
@@ -1000,23 +1268,368 @@ describe('VidaServerController', () => {
1000
1268
  expect(response.statusCode).toEqual(code);
1001
1269
  });
1002
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
+ });
1003
1310
  });
1004
1311
 
1005
1312
 
1006
1313
  describe('#setupCallbacks', () => {
1007
- it ('is only called once', () => {
1314
+ it ('is called once during construction and then cleared from the instance', () => {
1008
1315
  class CallbacksTestController extends VidaServerController {}
1009
1316
  CallbacksTestController.prototype.setupCallbacks = jest.fn();
1010
1317
  const controller = new CallbacksTestController({}, {});
1011
1318
 
1012
1319
  expect(CallbacksTestController.prototype.setupCallbacks).toHaveBeenCalledTimes(1);
1013
1320
  expect(controller.setupCallbacks).toBe(undefined);
1321
+ });
1014
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);
1015
1371
  });
1016
1372
  });
1017
1373
 
1018
1374
 
1019
- 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
+ });
1020
1633
  });
1021
1634
 
1022
1635
 
@@ -1210,4 +1823,191 @@ describe('VidaServerController', () => {
1210
1823
  }, jest.fn(), abortController);
1211
1824
  });
1212
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
+ });
1213
2013
  });