@vida-global/core 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ const { Connection } = require('../../lib/activeRecord/db/connection'
3
3
  const { getActiveRecordSchema } = require('../../lib/activeRecord/db/schema');
4
4
  const importSchema = require('../../lib/activeRecord/db/importSchema');
5
5
  const { Model, Sequelize } = require('sequelize');
6
- const { mockRedisClientFactory } = require('./helpers/baseRecordMocks');
6
+ const { mockRedisClientFactory, mockScannedKeys, randomCacheKeys } = require('./helpers/baseRecordMocks');
7
7
  const TestHelpers = require('@vida-global/test-helpers');
8
8
  const { camelize } = require('inflection');
9
9
  const Helpers = require('./helpers/baseRecord');
@@ -165,8 +165,58 @@ describe('BaseRecord', () => {
165
165
  sequelize: new Helpers.MockSequelize(),
166
166
  tableName: 'users',
167
167
  timestamps: false,
168
- updatedAt: 'updated_at'
169
- });
168
+ updatedAt: 'updated_at'
169
+ });
170
+ });
171
+
172
+ it ('uses `paranoid: true` when the schema has timestamps and deleted_at', () => {
173
+ importSchema.mockImplementation(() => ({
174
+ created_at: {type: Helpers.dataTypeValue1},
175
+ deleted_at: {type: Helpers.dataTypeValue2},
176
+ updated_at: {type: Helpers.dataTypeValue2},
177
+ }));
178
+ class User extends BaseRecord {}
179
+ User.initialize();
180
+
181
+ expect(Model.init).toHaveBeenCalledWith(
182
+ {
183
+ created_at: {type: Sequelize.DataTypes[Helpers.dataTypeKey1]},
184
+ deleted_at: {type: Sequelize.DataTypes[Helpers.dataTypeKey2]},
185
+ updated_at: {type: Sequelize.DataTypes[Helpers.dataTypeKey2]},
186
+ },
187
+ {
188
+ createdAt: 'created_at',
189
+ deletedAt: 'deleted_at',
190
+ modelName: 'User',
191
+ paranoid: true,
192
+ sequelize: new Helpers.MockSequelize(),
193
+ tableName: 'users',
194
+ updatedAt: 'updated_at'
195
+ }
196
+ );
197
+ });
198
+
199
+ it ('does not use `paranoid: true` when the schema has deleted_at but no timestamps', () => {
200
+ importSchema.mockImplementation(() => ({
201
+ deleted_at: {type: Helpers.dataTypeValue2},
202
+ }));
203
+ class User extends BaseRecord {}
204
+ User.initialize();
205
+
206
+ expect(Model.init).toHaveBeenCalledWith(
207
+ {
208
+ deleted_at: {type: Sequelize.DataTypes[Helpers.dataTypeKey2]},
209
+ },
210
+ {
211
+ createdAt: 'created_at',
212
+ deletedAt: 'deleted_at',
213
+ modelName: 'User',
214
+ sequelize: new Helpers.MockSequelize(),
215
+ tableName: 'users',
216
+ timestamps: false,
217
+ updatedAt: 'updated_at'
218
+ }
219
+ );
170
220
  });
171
221
 
172
222
  it ('does not run twice on the same model', () => {
@@ -382,7 +432,7 @@ describe('BaseRecord', () => {
382
432
  const id = Math.random();
383
433
  await Person.find(id);
384
434
  expect(Person.findByPk).toHaveBeenCalledTimes(1);
385
- expect(Person.findByPk).toHaveBeenCalledWith(id);
435
+ expect(Person.findByPk).toHaveBeenCalledWith(id, {useMaster: false});
386
436
  });
387
437
 
388
438
  it ('calls the sequelize `findAll` method when passed multiple value', async () => {
@@ -390,7 +440,13 @@ describe('BaseRecord', () => {
390
440
  const id2 = Math.random();
391
441
  await Person.find([id1, id2]);
392
442
  expect(Person.findAll).toHaveBeenCalledTimes(1);
393
- expect(Person.findAll).toHaveBeenCalledWith({where: {[pk]: [id1, id2]}});
443
+ expect(Person.findAll).toHaveBeenCalledWith({useMaster: false, where: {[pk]: [id1, id2]}});
444
+ });
445
+
446
+ it ('forwards useWriteDB to findByPk as useMaster', async () => {
447
+ const id = Math.random();
448
+ await Person.find(id, {useWriteDB: true});
449
+ expect(Person.findByPk).toHaveBeenCalledWith(id, {useMaster: true});
394
450
  });
395
451
  });
396
452
 
@@ -421,10 +477,33 @@ describe('BaseRecord', () => {
421
477
  });
422
478
 
423
479
 
480
+ describe('BaseRecord.transaction', () => {
481
+ it ('delegates to the query interface and returns the result', async () => {
482
+ const expected = {};
483
+ const callback = jest.fn(() => expected);
484
+ const queryInterface = {
485
+ transaction: jest.fn(cb => cb())
486
+ };
487
+ class FakeConnection {
488
+ get queryInterface() { return queryInterface; }
489
+ }
490
+ class User extends BaseRecord {
491
+ static get connectionClass() { return FakeConnection; }
492
+ }
493
+
494
+ const result = await User.transaction(callback);
495
+
496
+ expect(queryInterface.transaction).toHaveBeenCalledTimes(1);
497
+ expect(queryInterface.transaction).toHaveBeenCalledWith(callback);
498
+ expect(result).toBe(expected);
499
+ });
500
+ });
501
+
502
+
424
503
  describe('Caching', () => {
425
504
  class Person extends BaseRecord {
426
505
  static get isCacheable() { return true; }
427
- static get redisClientFactory() { return mockRedisClientFactory; }
506
+ static get _redisClientFactory() { return mockRedisClientFactory; }
428
507
  }
429
508
  let redisClient
430
509
  beforeEach(() => {
@@ -439,7 +518,7 @@ describe('BaseRecord', () => {
439
518
  await Person.find(id);
440
519
  expect(Person.findByPk).toHaveBeenCalledTimes(0);
441
520
  expect(findSpy).toHaveBeenCalledTimes(1);
442
- expect(findSpy).toHaveBeenCalledWith(id);
521
+ expect(findSpy).toHaveBeenCalledWith(id, {});
443
522
  });
444
523
 
445
524
  it ('calls _cachedFind when multiple ids are passed', async () => {
@@ -448,7 +527,7 @@ describe('BaseRecord', () => {
448
527
  await Person.find([id1, id2]);
449
528
  expect(Person.findByPk).toHaveBeenCalledTimes(0);
450
529
  expect(findSpy).toHaveBeenCalledTimes(1);
451
- expect(findSpy).toHaveBeenCalledWith([id1, id2]);
530
+ expect(findSpy).toHaveBeenCalledWith([id1, id2], {});
452
531
  });
453
532
  });
454
533
 
@@ -693,6 +772,116 @@ describe('BaseRecord', () => {
693
772
  });
694
773
 
695
774
 
775
+ describe('BaseRecord.clearCacheKey', () => {
776
+ it ('deletes the given key from cache', async () => {
777
+ const key = `Person#find:${Math.random()}`;
778
+
779
+ await Person.clearCacheKey(key);
780
+ expect(redisClient.del).toHaveBeenCalledTimes(1);
781
+ expect(redisClient.del).toHaveBeenCalledWith(key);
782
+ });
783
+ });
784
+
785
+
786
+ describe('BaseRecord.forEachCacheKeyByPattern', () => {
787
+ const pattern = `Person#find:*`;
788
+
789
+ it ('scans with the given pattern and a default count of 100', async () => {
790
+ mockScannedKeys([]);
791
+
792
+ await Person.forEachCacheKeyByPattern(pattern, jest.fn());
793
+
794
+ expect(redisClient.scanIterator).toHaveBeenCalledTimes(1);
795
+ expect(redisClient.scanIterator).toHaveBeenCalledWith({ MATCH: pattern, COUNT: 100 });
796
+ });
797
+
798
+ it ('passes a custom count to the scan', async () => {
799
+ const count = TestHelpers.Faker.Math.randomNumber();
800
+ mockScannedKeys([]);
801
+
802
+ await Person.forEachCacheKeyByPattern(pattern, jest.fn(), count);
803
+
804
+ expect(redisClient.scanIterator).toHaveBeenCalledWith({ MATCH: pattern, COUNT: count });
805
+ });
806
+
807
+ it ('buffers the individually yielded keys into one batch per count', async () => {
808
+ const keys = randomCacheKeys(5);
809
+ const callback = jest.fn();
810
+ mockScannedKeys(keys);
811
+
812
+ await Person.forEachCacheKeyByPattern(pattern, callback, 2);
813
+
814
+ expect(callback).toHaveBeenCalledTimes(3);
815
+ expect(callback).toHaveBeenNthCalledWith(1, keys.slice(0, 2), redisClient);
816
+ expect(callback).toHaveBeenNthCalledWith(2, keys.slice(2, 4), redisClient);
817
+ expect(callback).toHaveBeenNthCalledWith(3, keys.slice(4), redisClient);
818
+ });
819
+
820
+ it ('invokes the callback once when fewer keys than the count are scanned', async () => {
821
+ const keys = randomCacheKeys(2);
822
+ const callback = jest.fn();
823
+ mockScannedKeys(keys);
824
+
825
+ await Person.forEachCacheKeyByPattern(pattern, callback);
826
+
827
+ expect(callback).toHaveBeenCalledTimes(1);
828
+ expect(callback).toHaveBeenCalledWith(keys, redisClient);
829
+ });
830
+
831
+ it ('flattens keys yielded as arrays', async () => {
832
+ const keys = randomCacheKeys(3);
833
+ const callback = jest.fn();
834
+ mockScannedKeys([keys.slice(0, 2), keys.slice(2)]);
835
+
836
+ await Person.forEachCacheKeyByPattern(pattern, callback);
837
+
838
+ expect(callback).toHaveBeenCalledTimes(1);
839
+ expect(callback).toHaveBeenCalledWith(keys, redisClient);
840
+ });
841
+
842
+ it ('does not invoke the callback when the scan yields nothing', async () => {
843
+ const callback = jest.fn();
844
+ mockScannedKeys([]);
845
+
846
+ await Person.forEachCacheKeyByPattern(pattern, callback);
847
+
848
+ expect(callback).not.toHaveBeenCalled();
849
+ });
850
+ });
851
+
852
+
853
+ describe('BaseRecord.clearCacheKeysByPattern', () => {
854
+ const pattern = `Person#find:*`;
855
+
856
+ it ('scans for matching keys with the given pattern', async () => {
857
+ mockScannedKeys([]);
858
+
859
+ await Person.clearCacheKeysByPattern(pattern);
860
+
861
+ expect(redisClient.scanIterator).toHaveBeenCalledTimes(1);
862
+ expect(redisClient.scanIterator).toHaveBeenCalledWith({ MATCH: pattern, COUNT: 100 });
863
+ });
864
+
865
+ it ('deletes all scanned keys in a single batched call', async () => {
866
+ const keys = randomCacheKeys(3);
867
+ mockScannedKeys(keys);
868
+
869
+ await Person.clearCacheKeysByPattern(pattern);
870
+
871
+ expect(redisClient.del).toHaveBeenCalledTimes(1);
872
+ expect(redisClient.del).toHaveBeenCalledWith(keys);
873
+ });
874
+
875
+ it ('does not delete when the scan yields nothing', async () => {
876
+ mockScannedKeys([]);
877
+
878
+ await Person.clearCacheKeysByPattern(pattern);
879
+
880
+ expect(redisClient.del).not.toHaveBeenCalled();
881
+ });
882
+ });
883
+
884
+
696
885
  describe('BaseRecord.#clearSelfCache', () => {
697
886
  it ('deletes the key from cache', async () => {
698
887
  const pk = Person.primaryKeyAttribute;
@@ -704,6 +893,18 @@ describe('BaseRecord', () => {
704
893
  expect(redisClient.del).toHaveBeenCalledTimes(1);
705
894
  expect(redisClient.del).toHaveBeenCalledWith(key);
706
895
  });
896
+
897
+ it ('delegates deletion to clearCacheKey', async () => {
898
+ const pk = Person.primaryKeyAttribute;
899
+ const id = Math.random();
900
+ const key = Person._recordCacheKey(id);
901
+ const person = new Person({[pk]: id});
902
+ const clearSpy = jest.spyOn(Person, 'clearCacheKey');
903
+
904
+ await person.clearSelfCache();
905
+ expect(clearSpy).toHaveBeenCalledTimes(1);
906
+ expect(clearSpy).toHaveBeenCalledWith(key);
907
+ });
707
908
  });
708
909
 
709
910
 
@@ -711,7 +912,7 @@ describe('BaseRecord', () => {
711
912
  describe.each(hooks)('BaseRecord._after%sCacheHook', (hook) => {
712
913
  class Cacheable extends BaseRecord {
713
914
  static get isCacheable() { return true; }
714
- static get redisClientFactory() { return mockRedisClientFactory; }
915
+ static get _redisClientFactory() { return mockRedisClientFactory; }
715
916
  }
716
917
  const hookMethod = `_after${hook}CacheHook`;
717
918
 
@@ -842,5 +1043,45 @@ describe('BaseRecord', () => {
842
1043
 
843
1044
  expect(record.toApiResponse()).toEqual({ name: 'OVERRIDDEN' });
844
1045
  });
1046
+
1047
+
1048
+ it ('leaves column keys unchanged when camelizeFieldsForApiResponse is disabled', () => {
1049
+ const record = buildRecord(
1050
+ { org_id: { type: 'integer' }, external_task_id: { type: 'string' } },
1051
+ { org_id: 7, external_task_id: 'abc' },
1052
+ );
1053
+
1054
+ expect(record.toApiResponse()).toEqual({ org_id: 7, external_task_id: 'abc' });
1055
+ });
1056
+
1057
+
1058
+ it ('camelizes column keys when camelizeFieldsForApiResponse is enabled', () => {
1059
+ const record = buildRecord(
1060
+ { org_id: { type: 'integer' }, external_task_id: { type: 'string' } },
1061
+ { org_id: 7, external_task_id: 'abc' },
1062
+ );
1063
+ Object.defineProperty(record, 'camelizeFieldsForApiResponse', { get: () => true, configurable: true });
1064
+
1065
+ expect(record.toApiResponse()).toEqual({ orgId: 7, externalTaskId: 'abc' });
1066
+ });
1067
+ });
1068
+
1069
+
1070
+ describe('#apiResponseKey', () => {
1071
+ it ('returns the column unchanged by default', () => {
1072
+ class Thing extends BaseRecord {}
1073
+ const record = Object.create(Thing.prototype);
1074
+
1075
+ expect(record.apiResponseKey('external_task_id')).toEqual('external_task_id');
1076
+ });
1077
+
1078
+
1079
+ it ('camelizes the column when camelizeFieldsForApiResponse is enabled', () => {
1080
+ class Thing extends BaseRecord {}
1081
+ const record = Object.create(Thing.prototype);
1082
+ Object.defineProperty(record, 'camelizeFieldsForApiResponse', { get: () => true, configurable: true });
1083
+
1084
+ expect(record.apiResponseKey('external_task_id')).toEqual('externalTaskId');
1085
+ });
845
1086
  });
846
1087
  });
@@ -61,6 +61,27 @@ describe('Migrator', () => {
61
61
  expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, details);
62
62
  });
63
63
 
64
+ it ('adds a deleted_at column for paranoid tables', async () => {
65
+ const tableName = Helpers.randomString();
66
+ const details = { ...defaultColumns };
67
+
68
+ await Helpers.migrator.createTable(tableName, details, { paranoid: true });
69
+
70
+ expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, {
71
+ ...defaultColumns,
72
+ deleted_at: { allowNull: true, type: 'DATE' },
73
+ });
74
+ });
75
+
76
+ it ('leaves deleted_at off tables that are not paranoid', async () => {
77
+ const tableName = Helpers.randomString();
78
+ const details = { ...defaultColumns };
79
+
80
+ await Helpers.migrator.createTable(tableName, details);
81
+
82
+ expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, defaultColumns);
83
+ });
84
+
64
85
  it ('snake_cases created table columns', async () => {
65
86
  const tableName = Helpers.randomString();
66
87
  const details = { ...defaultColumns, myColumn: 1 };
@@ -29,10 +29,12 @@ describe('QueryInterface', () => {
29
29
 
30
30
  describe('QueryInterface#transaction', () => {
31
31
  it('delegates transaction callback to sequelize', async () => {
32
- const callback = jest.fn();
32
+ const expected = {};
33
+ const callback = jest.fn(() => expected);
33
34
 
34
- await queryInterface.transaction(callback);
35
+ const result = await queryInterface.transaction(callback);
35
36
 
37
+ expect(result).toBe(expected);
36
38
  expect(connection._sequelize.transaction).toHaveBeenCalledTimes(1);
37
39
  expect(connection._sequelize.transaction).toHaveBeenCalledWith(callback);
38
40
  });
@@ -10,6 +10,11 @@ const dataTypeKey2 = TestHelpers.Faker.Text.randomString();
10
10
  const dataTypeValue2 = TestHelpers.Faker.Text.randomString();
11
11
 
12
12
 
13
+ function randomCacheKeys(count) {
14
+ return Array.from({ length: count }, () => `Person#find:${TestHelpers.Faker.Text.randomString()}`);
15
+ }
16
+
17
+
13
18
  /***************************************************************************************************
14
19
  * MOCKS
15
20
  ***************************************************************************************************/
@@ -42,14 +47,26 @@ class MockSequelize {
42
47
 
43
48
 
44
49
  const redisClient = {
45
- del: jest.fn(),
46
- mGet: jest.fn(),
47
- mSet: jest.fn(),
48
- connect: jest.fn(),
50
+ del: jest.fn(),
51
+ mGet: jest.fn(),
52
+ mSet: jest.fn(),
53
+ connect: jest.fn(),
54
+ scanIterator: jest.fn(),
49
55
  }
50
56
  const mockRedisClientFactory = () => redisClient;
51
57
 
52
58
 
59
+ // redis 4's scanIterator yields one key string at a time. Pass an array element to model the
60
+ // batches redis 5 yields instead.
61
+ function mockScannedKeys(scanned) {
62
+ redisClient.scanIterator.mockImplementation(async function* () {
63
+ for (const item of scanned) {
64
+ yield item;
65
+ }
66
+ });
67
+ }
68
+
69
+
53
70
  module.exports = {
54
71
  dataTypeKey1,
55
72
  dataTypeValue1,
@@ -57,5 +74,7 @@ module.exports = {
57
74
  dataTypeValue2,
58
75
  MockModel,
59
76
  mockRedisClientFactory,
77
+ mockScannedKeys,
60
78
  MockSequelize,
79
+ randomCacheKeys,
61
80
  }
@@ -16,6 +16,82 @@ describe('MemoryCache', () => {
16
16
  });
17
17
 
18
18
 
19
+ describe('#delete', () => {
20
+ const options = {
21
+ namespace: 'squire:shop-data',
22
+ key: { shopId: 'shop-1' },
23
+ ttlSeconds: 300,
24
+ };
25
+
26
+
27
+ it('deletes a cached value', async () => {
28
+ await cache.fetch(options, async () => ({ version: 1 }));
29
+
30
+ await expect(cache.delete(options)).resolves.toBe(true);
31
+ await expect(cache.fetch(options, async () => ({ version: 2 }))).resolves.toEqual({
32
+ value: { version: 2 },
33
+ cacheHit: false,
34
+ });
35
+ });
36
+
37
+
38
+ it('deletes values using an equivalent object key', async () => {
39
+ await cache.fetch({
40
+ ...options,
41
+ key: { agentId: 'agent-1', shopId: 'shop-1' },
42
+ }, async () => ({ version: 1 }));
43
+
44
+ await expect(cache.delete({
45
+ namespace: options.namespace,
46
+ key: { shopId: 'shop-1', agentId: 'agent-1' },
47
+ })).resolves.toBe(true);
48
+ });
49
+
50
+
51
+ it('returns false when the value is not cached', async () => {
52
+ await expect(cache.delete(options)).resolves.toBe(false);
53
+ });
54
+
55
+
56
+ it('invalidates an in-flight fetch without interfering with its replacement', async () => {
57
+ let resolveOldFetch;
58
+ let resolveNewFetch;
59
+ const oldFetcher = jest.fn(() => new Promise((resolve) => {
60
+ resolveOldFetch = resolve;
61
+ }));
62
+ const newFetcher = jest.fn(() => new Promise((resolve) => {
63
+ resolveNewFetch = resolve;
64
+ }));
65
+ const oldFetch = cache.fetch(options, oldFetcher);
66
+ await expect(cache.delete(options)).resolves.toBe(true);
67
+ const newFetch = cache.fetch(options, newFetcher);
68
+
69
+ resolveOldFetch({ version: 1 });
70
+ await expect(oldFetch).resolves.toEqual({ value: { version: 1 }, cacheHit: false });
71
+ const joinedFetcher = jest.fn();
72
+ const joinedFetch = cache.fetch(options, joinedFetcher);
73
+ resolveNewFetch({ version: 2 });
74
+
75
+ await expect(Promise.all([newFetch, joinedFetch])).resolves.toEqual([
76
+ { value: { version: 2 }, cacheHit: false },
77
+ { value: { version: 2 }, cacheHit: false },
78
+ ]);
79
+ expect(oldFetcher).toHaveBeenCalledTimes(1);
80
+ expect(newFetcher).toHaveBeenCalledTimes(1);
81
+ expect(joinedFetcher).not.toHaveBeenCalled();
82
+ await expect(cache.fetch(options, joinedFetcher)).resolves.toEqual({
83
+ value: { version: 2 },
84
+ cacheHit: true,
85
+ });
86
+ });
87
+
88
+
89
+ it('requires a namespace', async () => {
90
+ await expect(cache.delete()).rejects.toThrow('MemoryCache namespace is required.');
91
+ });
92
+ });
93
+
94
+
19
95
  describe('#fetch', () => {
20
96
  const options = {
21
97
  namespace: 'squire:shop-data',
@@ -1,4 +1,5 @@
1
1
  const { VidaServerController } = require('../../../lib/server/serverController');
2
+ const { buildResponseMock } = require('../helpers/response');
2
3
 
3
4
 
4
5
  describe('VidaServerController', () => {
@@ -75,7 +76,7 @@ describe('VidaServerController', () => {
75
76
  namedBefore() { order.push('before2') }
76
77
  testAction() { order.push('action'); return {} }
77
78
  }
78
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
79
+ const response = buildResponseMock({ end: jest.fn() });
79
80
  const controller = new C({}, response);
80
81
 
81
82
  await controller.performRequest('testAction');
@@ -92,7 +93,7 @@ describe('VidaServerController', () => {
92
93
  namedAfter() { order.push('after2') }
93
94
  testAction() { order.push('action'); return {} }
94
95
  }
95
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
96
+ const response = buildResponseMock({ end: jest.fn() });
96
97
  const controller = new C({}, response);
97
98
 
98
99
  await controller.performRequest('testAction');
@@ -105,7 +106,7 @@ describe('VidaServerController', () => {
105
106
  setupCallbacks() { this.beforeCallback(() => false) }
106
107
  }
107
108
  C.prototype.testAction = testAction;
108
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
109
+ const response = buildResponseMock({ end: jest.fn() });
109
110
  const controller = new C({}, response);
110
111
 
111
112
  await controller.performRequest('testAction');
@@ -121,7 +122,7 @@ describe('VidaServerController', () => {
121
122
  }
122
123
  testAction() { return {} }
123
124
  }
124
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
125
+ const response = buildResponseMock({ end: jest.fn() });
125
126
  const controller = new C({}, response);
126
127
 
127
128
  await controller.performRequest('testAction');
@@ -137,7 +138,7 @@ describe('VidaServerController', () => {
137
138
  }
138
139
  testAction() { return {} }
139
140
  }
140
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
141
+ const response = buildResponseMock({ end: jest.fn() });
141
142
  const controller = new C({}, response);
142
143
 
143
144
  await controller.performRequest('testAction');