@vida-global/core 2.1.0 → 2.1.2

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.
@@ -33,21 +33,74 @@ class BaseRecord extends Model {
33
33
  /***********************************************************************************************
34
34
  * QUERIES
35
35
  ***********************************************************************************************/
36
- static async find(ids) {
36
+ static async find(ids, { useWriteDB=false, ...options }={}) {
37
37
  if (this.isCacheable) {
38
- return await this._cachedFind(...arguments);
38
+ return await this._cachedFind(ids, options);
39
39
  } else if (Array.isArray(ids)) {
40
40
  const pk = this.primaryKeyAttribute
41
- return await this.where({[pk]: ids});
41
+ return await this.where({[pk]: ids}, { useMaster: useWriteDB });
42
42
  } else {
43
- return await this.findByPk(ids);
43
+ return await this.findByPk(ids, { useMaster: useWriteDB });
44
44
  }
45
45
  }
46
46
 
47
47
 
48
48
  static async where(where, options={}) {
49
- const parameters = {...options, where };
50
- return await this.findAll(parameters);
49
+ const { page, pageSize, ...queryOptions } = options;
50
+
51
+ if (!page && !pageSize) {
52
+ const parameters = {...queryOptions, where };
53
+ return await this.findAll(parameters);
54
+ }
55
+
56
+ const offset = pageSize * (page - 1);
57
+ const rows = await this.findAll({
58
+ ...queryOptions,
59
+ where,
60
+ order: queryOptions.order || [this.defaultSort()],
61
+ limit: pageSize,
62
+ offset,
63
+ });
64
+
65
+ const totalCount = await this.paginatedTotalCount(rows, where,
66
+ {
67
+ offset,
68
+ pageSize,
69
+ queryOptions,
70
+ })
71
+
72
+ return { rows, totalCount }
73
+ }
74
+
75
+
76
+ static async transaction(callback) {
77
+ return await this.connection.queryInterface.transaction(callback);
78
+ }
79
+
80
+
81
+ static defaultSort() {
82
+ return ['created_at', 'DESC'];
83
+ }
84
+
85
+
86
+ static async paginatedTotalCount(rows, where, { offset, pageSize, queryOptions }) {
87
+ const isFullPage = rows.length === pageSize
88
+ const isEmptyPageAfterFirst = rows.length === 0 && offset > 0
89
+
90
+ if (isFullPage || isEmptyPageAfterFirst) {
91
+ return await this.count({...queryOptions, where })
92
+ }
93
+
94
+ return offset + rows.length
95
+ }
96
+
97
+
98
+ static async execute(query, { raw=false, ...options }={}) {
99
+ return await this.sequelize.query(query, {
100
+ type: Sequelize.QueryTypes.SELECT,
101
+ ...(raw ? {} : { model: this, mapToModel: true }),
102
+ ...options
103
+ });
51
104
  }
52
105
 
53
106
 
@@ -78,10 +131,16 @@ class BaseRecord extends Model {
78
131
  updatedAt: 'updated_at',
79
132
  }
80
133
 
81
- if (!schema.created_at || !schema.updated_at) {
134
+ const hasTimestamps = schema.created_at && schema.updated_at;
135
+
136
+ if (!hasTimestamps) {
82
137
  options.timestamps = false;
83
138
  }
84
139
 
140
+ if (hasTimestamps && schema.deleted_at) {
141
+ options.paranoid = true;
142
+ }
143
+
85
144
  this.configureOverridenAccessors(schema);
86
145
  this.configureVirtualAccessors(schema);
87
146
 
@@ -376,17 +435,33 @@ class BaseRecord extends Model {
376
435
  toApiResponse() {
377
436
  const response = {};
378
437
  for (const column of this.apiResponseColumns) {
379
- response[column] = this[column]; // Use overridden getters when available
438
+ response[this.apiResponseKey(column)] = this[column]; // Use overridden getters when available
380
439
  }
381
440
  return response;
382
441
  }
383
442
 
384
443
 
444
+ toObject() {
445
+ return this.toApiResponse();
446
+ }
447
+
448
+
385
449
  get apiResponseColumns() {
386
450
  return Object.keys(this.constructor.documentationSchema());
387
451
  }
388
452
 
389
453
 
454
+ apiResponseKey(column) {
455
+ if (!this.camelizeFieldsForApiResponse) return column;
456
+ return camelize(column, true);
457
+ }
458
+
459
+
460
+ get camelizeFieldsForApiResponse() {
461
+ return false;
462
+ }
463
+
464
+
390
465
  /***********************************************************************************************
391
466
  * DOCUMENTATION
392
467
  ***********************************************************************************************/
@@ -25,7 +25,7 @@ class QueryInterface {
25
25
 
26
26
 
27
27
  async transaction(callback) {
28
- await this.#sequelize.transaction(callback);
28
+ return await this.#sequelize.transaction(callback);
29
29
  }
30
30
 
31
31
 
@@ -25,10 +25,8 @@ class MyApiClient extends HttpClient {
25
25
  return this.#dev ? 'https://staging.foo.com' : 'https://foo.com';
26
26
  }
27
27
 
28
- get defaultHeaders() {
29
- const headers = super.defaultHeaders;
30
- headers.Authorization = `Bearer ${this.#token}`;
31
- return headers;
28
+ get bearerToken() {
29
+ return this.#token;
32
30
  }
33
31
 
34
32
  async getTickets({ page, pageSize }) {
@@ -104,4 +102,6 @@ Subclasses customize behavior through getters on the class:
104
102
 
105
103
  - `urlRoot` — base URL prepended to every endpoint.
106
104
  - `defaultHeaders` — merged into every request (per-call `headers` override).
105
+ - `bearerToken` — when it returns a value, a `Authorization: Bearer <token>` header is added to `defaultHeaders`.
106
+ - `basicAuthUsername` / `basicAuthPassword` — when both return values, a `Authorization: Basic <base64>` header is added to `defaultHeaders`. `bearerToken` takes precedence if both are set.
107
107
  - `logger` — scoped logger used for the `API CALL: ...` debug line emitted on each request (defaults to `logger.http`).
@@ -178,10 +178,41 @@ class HttpClient {
178
178
 
179
179
 
180
180
  get defaultHeaders() {
181
- return {
181
+ const headers = {
182
182
  Accept: "application/json",
183
183
  "Content-Type": "application/json",
184
- }
184
+ };
185
+ if (this.authorizationHeader) headers.Authorization = this.authorizationHeader;
186
+ return headers;
187
+ }
188
+
189
+
190
+ get authorizationHeader() {
191
+ if (this.bearerToken) return `Bearer ${this.bearerToken}`;
192
+ if (this.basicAuthCredentials) return `Basic ${this.basicAuthCredentials}`;
193
+ return null;
194
+ }
195
+
196
+
197
+ get bearerToken() {
198
+ return null;
199
+ }
200
+
201
+
202
+ get basicAuthCredentials() {
203
+ if (!this.basicAuthUsername || !this.basicAuthPassword) return null;
204
+ const credentials = `${this.basicAuthUsername}:${this.basicAuthPassword}`;
205
+ return Buffer.from(credentials).toString('base64');
206
+ }
207
+
208
+
209
+ get basicAuthUsername() {
210
+ return null;
211
+ }
212
+
213
+
214
+ get basicAuthPassword() {
215
+ return null;
185
216
  }
186
217
 
187
218
 
@@ -51,6 +51,9 @@ function setSseHeaders(controller) {
51
51
 
52
52
 
53
53
  const InstanceMethods = {
54
+ MIME_TYPES: CONTENT_TYPE_BY_EXTENSION,
55
+
56
+
54
57
  async render(body, options={}) {
55
58
  if (this.rendered) return;
56
59
  if (this.isStreaming) return;
@@ -76,21 +79,21 @@ const InstanceMethods = {
76
79
 
77
80
 
78
81
  renderJSONResponse(body) {
79
- this.setHeader('content-type', CONTENT_TYPE_BY_EXTENSION.json);
82
+ if (!this.responseContentType) this.responseContentType = CONTENT_TYPE_BY_EXTENSION.json;
80
83
  this._response.json(body);
81
84
  },
82
85
 
83
86
 
84
87
  renderXMLResponse(body) {
85
- this.setHeader('content-type', CONTENT_TYPE_BY_EXTENSION.xml);
86
- const doc = create({ response: body });
88
+ if (!this.responseContentType) this.responseContentType = CONTENT_TYPE_BY_EXTENSION.xml;
89
+ const doc = create({ [this.xmlRootElement]: body });
87
90
  const xml = doc.end();
88
91
  this._response.send(xml);
89
92
  },
90
93
 
91
94
 
92
95
  renderTextResponse(body) {
93
- this.setHeader('content-type', CONTENT_TYPE_BY_EXTENSION.txt);
96
+ if (!this.responseContentType) this.responseContentType = CONTENT_TYPE_BY_EXTENSION.txt;
94
97
  this._response.send(body);
95
98
  },
96
99
 
@@ -163,7 +166,7 @@ const InstanceMethods = {
163
166
 
164
167
 
165
168
  _serializeDateForResponseBody(date, options) {
166
- return date.getTime();
169
+ return Math.floor(date.getTime() / 1000);
167
170
  },
168
171
 
169
172
 
@@ -186,33 +189,6 @@ const InstanceMethods = {
186
189
  },
187
190
 
188
191
 
189
- _setupStatusRenderers() {
190
- const successStatuses = {
191
- CreationSuccessful: 201,
192
- Accepted: 202,
193
- NoConent: 204,
194
- MovedPermanently: 301,
195
- Found: 302,
196
- NotModified: 304,
197
- TemporaryRedirect: 307,
198
- PermanentRedirect: 308,
199
- }
200
-
201
- for (let [type, statusCode] of Object.entries(Errors.Statuses)) {
202
- if (type == 'Server') type = 'ServerError';
203
- this[`render${type}`] = async function(message=null, body=null) {
204
- return await this._renderError(statusCode, message, body);
205
- }
206
- }
207
-
208
- for (let [type, statusCode] of Object.entries(successStatuses)) {
209
- this[`render${type}`] = async function(body=null) {
210
- return await this._renderSuccess(statusCode, body);
211
- }
212
- }
213
- },
214
-
215
-
216
192
  async _renderError(statusCode, message, body) {
217
193
  this.statusCode = statusCode;
218
194
  body = structuredClone(body || {});
@@ -305,7 +281,37 @@ const InstanceMethods = {
305
281
  }
306
282
 
307
283
 
284
+ const StatusRenderSuccessStatuses = {
285
+ CreationSuccessful: 201,
286
+ Accepted: 202,
287
+ NoContent: 204,
288
+ MovedPermanently: 301,
289
+ Found: 302,
290
+ NotModified: 304,
291
+ TemporaryRedirect: 307,
292
+ PermanentRedirect: 308,
293
+ }
294
+
295
+
308
296
  const StaticMethods = {
297
+ // Define render<Status> helpers on the prototype (not per instance) so subclasses and mixins
298
+ // can override them with a normal method of the same name.
299
+ setupStatusRenderers() {
300
+ for (let [type, statusCode] of Object.entries(Errors.Statuses)) {
301
+ if (type == 'Server') type = 'ServerError';
302
+ this.prototype[`render${type}`] = async function(message=null, body=null) {
303
+ return await this._renderError(statusCode, message, body);
304
+ }
305
+ }
306
+
307
+ for (const [type, statusCode] of Object.entries(StatusRenderSuccessStatuses)) {
308
+ this.prototype[`render${type}`] = async function(body=null) {
309
+ return await this._renderSuccess(statusCode, body);
310
+ }
311
+ }
312
+ },
313
+
314
+
309
315
  formatResponseBody(body, errors, { statusCode, statusText }) {
310
316
  const response = {data: body, status: statusText};
311
317
  if (errors) response.errors = errors;
@@ -325,7 +331,6 @@ const Accessors = {
325
331
  get() {
326
332
  const override = `${this.params._format || ''}`.toLowerCase();
327
333
  if (override === 'xml' || override === 'json') return override;
328
- if (/\bxml\b/i.test(this.requestHeaders.accept || '')) return 'xml';
329
334
  return 'json';
330
335
  }
331
336
  },
@@ -341,6 +346,19 @@ const Accessors = {
341
346
  },
342
347
 
343
348
 
349
+ responseContentType: {
350
+ get() { return this._response.getHeader('content-type') },
351
+ set(type) { return this._response.setHeader('content-type', type) }
352
+ },
353
+
354
+
355
+ // Root element wrapped around XML response bodies. Override in a subclass to emit a custom
356
+ // root (e.g. a TwiML `<Response>` for a telecom webhook).
357
+ xmlRootElement: {
358
+ get() { return 'response' }
359
+ },
360
+
361
+
344
362
  statusText: {
345
363
  get() {
346
364
  return STATUS_TEXTS[this.statusCode];
@@ -47,7 +47,7 @@ Don't set `this.statusCode` directly — use the helper that pairs the status wi
47
47
  |---|---|
48
48
  | 201 Created | `this.renderCreationSuccessful(body)` |
49
49
  | 202 Accepted | `this.renderAccepted(body)` |
50
- | 204 No Content | `this.renderNoConent(body)` |
50
+ | 204 No Content | `this.renderNoContent(body)` |
51
51
  | 301 Moved Permanently | `this.renderMovedPermanently(body)` |
52
52
  | 302 Found | `this.renderFound(body)` |
53
53
  | 304 Not Modified | `this.renderNotModified(body)` |
@@ -23,7 +23,6 @@ class VidaServerController {
23
23
 
24
24
  this._applyCallbacks();
25
25
  this._applyDocumentation();
26
- this._setupStatusRenderers();
27
26
  }
28
27
 
29
28
 
@@ -260,6 +259,8 @@ VidaServerController.loadMixin(RendererMixin);
260
259
  VidaServerController.loadMixin(RoutingMixin);
261
260
  VidaServerController.loadMixin(ValidationMixin);
262
261
 
262
+ VidaServerController.setupStatusRenderers();
263
+
263
264
 
264
265
  /***************************************************************************************************
265
266
  * ERRORS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -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,6 +477,29 @@ 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; }
@@ -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
 
@@ -842,5 +921,45 @@ describe('BaseRecord', () => {
842
921
 
843
922
  expect(record.toApiResponse()).toEqual({ name: 'OVERRIDDEN' });
844
923
  });
924
+
925
+
926
+ it ('leaves column keys unchanged when camelizeFieldsForApiResponse is disabled', () => {
927
+ const record = buildRecord(
928
+ { org_id: { type: 'integer' }, external_task_id: { type: 'string' } },
929
+ { org_id: 7, external_task_id: 'abc' },
930
+ );
931
+
932
+ expect(record.toApiResponse()).toEqual({ org_id: 7, external_task_id: 'abc' });
933
+ });
934
+
935
+
936
+ it ('camelizes column keys when camelizeFieldsForApiResponse is enabled', () => {
937
+ const record = buildRecord(
938
+ { org_id: { type: 'integer' }, external_task_id: { type: 'string' } },
939
+ { org_id: 7, external_task_id: 'abc' },
940
+ );
941
+ Object.defineProperty(record, 'camelizeFieldsForApiResponse', { get: () => true, configurable: true });
942
+
943
+ expect(record.toApiResponse()).toEqual({ orgId: 7, externalTaskId: 'abc' });
944
+ });
945
+ });
946
+
947
+
948
+ describe('#apiResponseKey', () => {
949
+ it ('returns the column unchanged by default', () => {
950
+ class Thing extends BaseRecord {}
951
+ const record = Object.create(Thing.prototype);
952
+
953
+ expect(record.apiResponseKey('external_task_id')).toEqual('external_task_id');
954
+ });
955
+
956
+
957
+ it ('camelizes the column when camelizeFieldsForApiResponse is enabled', () => {
958
+ class Thing extends BaseRecord {}
959
+ const record = Object.create(Thing.prototype);
960
+ Object.defineProperty(record, 'camelizeFieldsForApiResponse', { get: () => true, configurable: true });
961
+
962
+ expect(record.apiResponseKey('external_task_id')).toEqual('externalTaskId');
963
+ });
845
964
  });
846
965
  });
@@ -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
  });
@@ -26,6 +26,40 @@ describe('HttpClient', () => {
26
26
  });
27
27
 
28
28
 
29
+ describe('HttpClient#authorizationHeader', () => {
30
+ it('omits the Authorization header by default', async () => {
31
+ const client = new HttpClient();
32
+
33
+ await client.post(Helpers.apiUrl);
34
+
35
+ const callHeaders = fetch.mock.calls[0][1].headers;
36
+ expect(callHeaders.Authorization).toBeUndefined();
37
+ });
38
+
39
+ it('sets a bearer Authorization header when bearerToken is overridden', async () => {
40
+ const client = new Helpers.ClientWithBearerToken();
41
+
42
+ await client.post(Helpers.apiUrl);
43
+
44
+ Helpers.expectRequest('POST', Helpers.apiUrl, {
45
+ headers: { Authorization: `Bearer ${Helpers.bearerToken}` },
46
+ body: {},
47
+ });
48
+ });
49
+
50
+ it('sets a basic Authorization header when username and password are overridden', async () => {
51
+ const client = new Helpers.ClientWithBasicAuth();
52
+
53
+ await client.post(Helpers.apiUrl);
54
+
55
+ Helpers.expectRequest('POST', Helpers.apiUrl, {
56
+ headers: { Authorization: `Basic ${Helpers.basicAuthCredentials}` },
57
+ body: {},
58
+ });
59
+ });
60
+ });
61
+
62
+
29
63
  const httpMethods = [
30
64
  ['post', 'POST', { supportsBody: true }],
31
65
  ['put', 'PUT', { supportsBody: true }],
@@ -26,6 +26,33 @@ class ClientWithUrlRoot extends HttpClient {
26
26
  }
27
27
 
28
28
 
29
+ const bearerToken = TestHelpers.Faker.Text.randomString();
30
+
31
+
32
+ class ClientWithBearerToken extends HttpClient {
33
+ get bearerToken() {
34
+ return bearerToken;
35
+ }
36
+ }
37
+
38
+
39
+ const basicAuthUsername = TestHelpers.Faker.Text.randomString();
40
+ const basicAuthPassword = TestHelpers.Faker.Text.randomString();
41
+ const basicAuthCredentials = Buffer.from(`${basicAuthUsername}:${basicAuthPassword}`).toString('base64');
42
+
43
+
44
+ class ClientWithBasicAuth extends HttpClient {
45
+ get basicAuthUsername() {
46
+ return basicAuthUsername;
47
+ }
48
+
49
+
50
+ get basicAuthPassword() {
51
+ return basicAuthPassword;
52
+ }
53
+ }
54
+
55
+
29
56
  /***************************************************************************************************
30
57
  * MOCKS
31
58
  ***************************************************************************************************/
@@ -80,6 +107,10 @@ function buildExpectedRequest({ method, headers, body }) {
80
107
 
81
108
  module.exports = {
82
109
  apiUrl,
110
+ basicAuthCredentials,
111
+ bearerToken,
112
+ ClientWithBasicAuth,
113
+ ClientWithBearerToken,
83
114
  ClientWithUrlRoot,
84
115
  endpoint,
85
116
  expectRequest,
@@ -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');
@@ -1,4 +1,5 @@
1
1
  const { FooController } = require('../helpers/controllers/fooController');
2
+ const { buildResponseMock } = require('../helpers/response');
2
3
  const TestHelpers = require('@vida-global/test-helpers');
3
4
  const {
4
5
  NoActiveStreamError,
@@ -76,7 +77,7 @@ describe('VidaServerController', () => {
76
77
  const successHandlers = [
77
78
  ['renderCreationSuccessful', 201],
78
79
  ['renderAccepted', 202],
79
- ['renderNoConent', 204],
80
+ ['renderNoContent', 204],
80
81
  ['renderMovedPermanently', 301],
81
82
  ['renderFound', 302],
82
83
  ['renderNotModified', 304],
@@ -159,7 +160,7 @@ describe('VidaServerController', () => {
159
160
  let response;
160
161
  let controller;
161
162
  beforeEach(() => {
162
- response = {json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), statusCode: 200};
163
+ response = buildResponseMock();
163
164
  controller = new FooController({}, response);
164
165
  });
165
166
 
@@ -221,30 +222,34 @@ describe('VidaServerController', () => {
221
222
  expect(await controller._serializeResponseBody(true, {})).toBe(true);
222
223
  });
223
224
 
224
- it ('returns Date instances as their epoch ms via getTime', async () => {
225
+ it ('returns Date instances as their epoch seconds', async () => {
225
226
  const date = new Date('2020-01-02T03:04:05Z');
226
- expect(await controller._serializeResponseBody(date, {})).toBe(date.getTime());
227
+ const seconds = Math.floor(date.getTime() / 1000);
228
+ expect(await controller._serializeResponseBody(date, {})).toBe(seconds);
227
229
  });
228
230
 
229
231
  it ('recursively processes Date values inside objects', async () => {
230
232
  const date = new Date('2021-02-03T00:00:00Z');
233
+ const seconds = Math.floor(date.getTime() / 1000);
231
234
  const result = await controller._serializeResponseBody({createdAt: date, name: 'foo'}, {});
232
- expect(result).toEqual({createdAt: date.getTime(), name: 'foo'});
235
+ expect(result).toEqual({createdAt: seconds, name: 'foo'});
233
236
  });
234
237
 
235
238
  it ('recursively processes arrays', async () => {
236
239
  const date = new Date('2022-03-04T00:00:00Z');
240
+ const seconds = Math.floor(date.getTime() / 1000);
237
241
  const result = await controller._serializeResponseBody([1, date, {ts: date}], {});
238
- expect(result).toEqual([1, date.getTime(), {ts: date.getTime()}]);
242
+ expect(result).toEqual([1, seconds, {ts: seconds}]);
239
243
  });
240
244
 
241
245
  it ('invokes toApiResponse on objects that define it and recurses on the result', async () => {
242
246
  const date = new Date('2023-04-05T00:00:00Z');
247
+ const seconds = Math.floor(date.getTime() / 1000);
243
248
  const apiModel = {
244
249
  async toApiResponse() { return {id: 1, createdAt: date}; }
245
250
  };
246
251
  const result = await controller._serializeResponseBody(apiModel, {});
247
- expect(result).toEqual({id: 1, createdAt: date.getTime()});
252
+ expect(result).toEqual({id: 1, createdAt: seconds});
248
253
  });
249
254
 
250
255
  it ('passes the options argument through to toApiResponse', async () => {
@@ -278,24 +283,24 @@ describe('VidaServerController', () => {
278
283
  expect(controller.requestedFormat).toBe('json');
279
284
  });
280
285
 
281
- it ('returns "xml" when the Accept header requests xml', () => {
286
+ it ('ignores an xml Accept header and defaults to json', () => {
282
287
  const controller = new FooController({headers: {accept: 'application/xml'}}, {});
283
- expect(controller.requestedFormat).toBe('xml');
288
+ expect(controller.requestedFormat).toBe('json');
284
289
  });
285
290
 
286
- it ('lets the _format param override a conflicting Accept header', () => {
291
+ it ('honors the _format param regardless of the Accept header', () => {
287
292
  const controller = new FooController({query: {_format: 'json'}, headers: {accept: 'application/xml'}}, {});
288
293
  expect(controller.requestedFormat).toBe('json');
289
294
  });
290
295
 
291
- it ('defaults to json when neither the param nor the header indicate a format', () => {
296
+ it ('defaults to json when the param does not indicate a format', () => {
292
297
  const controller = new FooController({}, {});
293
298
  expect(controller.requestedFormat).toBe('json');
294
299
  });
295
300
 
296
- it ('ignores an unrecognized _format and falls back to the Accept header', () => {
301
+ it ('defaults to json for an unrecognized _format, ignoring the Accept header', () => {
297
302
  const controller = new FooController({query: {_format: 'yaml'}, headers: {accept: 'application/xml'}}, {});
298
- expect(controller.requestedFormat).toBe('xml');
303
+ expect(controller.requestedFormat).toBe('json');
299
304
  });
300
305
  });
301
306
 
@@ -329,7 +334,7 @@ describe('VidaServerController', () => {
329
334
  describe('#render format dispatch', () => {
330
335
  let response;
331
336
  beforeEach(() => {
332
- response = {json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), statusCode: 200};
337
+ response = buildResponseMock();
333
338
  });
334
339
 
335
340
  it ('renders JSON via response.json for a JSON request', async () => {
@@ -367,6 +372,18 @@ describe('VidaServerController', () => {
367
372
  expect(sent).toContain('<status>ok</status>');
368
373
  });
369
374
 
375
+ it ('wraps the body under a custom root element when xmlRootElement is overridden', async () => {
376
+ class CustomRootController extends FooController {
377
+ get xmlRootElement() { return 'Response'; }
378
+ }
379
+ const controller = new CustomRootController({query: {_format: 'xml'}}, response);
380
+ await controller.render({foo: 'bar'});
381
+ const sent = response.send.mock.calls[0][0];
382
+ expect(sent).toContain('<Response>');
383
+ expect(sent).toContain('</Response>');
384
+ expect(sent).not.toContain('<response>');
385
+ });
386
+
370
387
  it ('renders array items as separate elements instead of concatenated text', async () => {
371
388
  const controller = new FooController({query: {_format: 'xml'}}, response);
372
389
  await controller.render({tags: ['one', 'two']});
@@ -384,6 +401,49 @@ describe('VidaServerController', () => {
384
401
  });
385
402
 
386
403
 
404
+ describe('#render content-type', () => {
405
+ let response;
406
+ beforeEach(() => {
407
+ response = buildResponseMock();
408
+ });
409
+
410
+ const defaultTypeCases = [
411
+ ['JSON', {}, 'application/json; charset=utf-8'],
412
+ ['XML', {query: {_format: 'xml'}}, 'application/xml; charset=utf-8'],
413
+ ];
414
+ it.each(defaultTypeCases)('sets the default %s content-type when none is set', async (_label, params, expected) => {
415
+ const controller = new FooController(params, response);
416
+ await controller.render({foo: 'bar'});
417
+ expect(response.setHeader).toHaveBeenCalledWith('content-type', expected);
418
+ });
419
+
420
+ it ('sets the default text content-type when none is set for a string body', async () => {
421
+ const controller = new FooController({}, response);
422
+ await controller.render('hello');
423
+ expect(response.setHeader).toHaveBeenCalledWith('content-type', 'text/plain; charset=utf-8');
424
+ });
425
+
426
+
427
+ const preexistingTypeCases = [
428
+ ['JSON', {}],
429
+ ['XML', {query: {_format: 'xml'}}],
430
+ ['string', {}],
431
+ ];
432
+ it.each(preexistingTypeCases)('does not override an already-set content-type for a %s render', async (label, params) => {
433
+ const customType = TestHelpers.Faker.Text.randomString();
434
+ response.setHeader('content-type', customType);
435
+ response.setHeader.mockClear();
436
+
437
+ const controller = new FooController(params, response);
438
+ const body = label === 'string' ? 'hello' : {foo: 'bar'};
439
+ await controller.render(body);
440
+
441
+ expect(response.setHeader).not.toHaveBeenCalledWith('content-type', expect.anything());
442
+ expect(response.getHeader('content-type')).toBe(customType);
443
+ });
444
+ });
445
+
446
+
387
447
  describe('#_serializeArrayForResponseBody', () => {
388
448
  class FakeRecord {
389
449
  constructor(id) { this.id = id; }
@@ -0,0 +1,19 @@
1
+ /***************************************************************************************************
2
+ * MOCKS
3
+ ***************************************************************************************************/
4
+ // A response double that keeps a real header store so getHeader reflects what setHeader wrote.
5
+ // This lets tests exercise the renderer's "don't override an already-set content type" logic.
6
+ function buildResponseMock(overrides = {}) {
7
+ const headers = {};
8
+ const response = {
9
+ json: jest.fn(),
10
+ send: jest.fn(),
11
+ setHeader: jest.fn((name, value) => { headers[`${name}`.toLowerCase()] = value; }),
12
+ getHeader: jest.fn((name) => headers[`${name}`.toLowerCase()]),
13
+ statusCode: 200,
14
+ };
15
+ return Object.assign(response, overrides);
16
+ }
17
+
18
+
19
+ module.exports = { buildResponseMock };
@@ -1,6 +1,7 @@
1
1
  const { FooController } = require('./helpers/controllers/fooController');
2
2
  const Errors = require('../../lib/server/errors');
3
3
  const { randomPayload } = require('./helpers/serverController');
4
+ const { buildResponseMock } = require('./helpers/response');
4
5
  const TestHelpers = require('@vida-global/test-helpers');
5
6
  const {
6
7
  VidaServerController,
@@ -338,7 +339,7 @@ describe('VidaServerController', () => {
338
339
  });
339
340
 
340
341
  it ('awaits render before calling response.end (regression: ERR_HTTP_HEADERS_SENT)', async () => {
341
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
342
+ const response = buildResponseMock({ end: jest.fn() });
342
343
  const controller = new FooController({}, response);
343
344
  controller.testAction = jest.fn(async () => ({foo: TestHelpers.Faker.Text.randomString()}));
344
345
 
@@ -360,7 +361,7 @@ describe('VidaServerController', () => {
360
361
 
361
362
  beforeEach(() => {
362
363
  request = {headers: randomPayload()};
363
- response = {headers: randomPayload(), statusCode: TestHelpers.Faker.Math.randomNumber(), json: jest.fn(), send: jest.fn(), setHeader: jest.fn()};
364
+ response = buildResponseMock({headers: randomPayload(), statusCode: TestHelpers.Faker.Math.randomNumber()});
364
365
  request.headers['content-type'] = TestHelpers.Faker.Text.randomString();
365
366
  controller = new FooController(request, response);
366
367
  });
@@ -524,7 +525,7 @@ describe('VidaServerController', () => {
524
525
  namedBefore() { order.push('before2') }
525
526
  testAction() { order.push('action'); return {} }
526
527
  }
527
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
528
+ const response = buildResponseMock({ end: jest.fn() });
528
529
  const controller = new C({}, response);
529
530
 
530
531
  await controller.performRequest('testAction');
@@ -541,7 +542,7 @@ describe('VidaServerController', () => {
541
542
  namedAfter() { order.push('after2') }
542
543
  testAction() { order.push('action'); return {} }
543
544
  }
544
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
545
+ const response = buildResponseMock({ end: jest.fn() });
545
546
  const controller = new C({}, response);
546
547
 
547
548
  await controller.performRequest('testAction');
@@ -554,7 +555,7 @@ describe('VidaServerController', () => {
554
555
  setupCallbacks() { this.beforeCallback(() => false) }
555
556
  }
556
557
  C.prototype.testAction = testAction;
557
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
558
+ const response = buildResponseMock({ end: jest.fn() });
558
559
  const controller = new C({}, response);
559
560
 
560
561
  await controller.performRequest('testAction');
@@ -570,7 +571,7 @@ describe('VidaServerController', () => {
570
571
  }
571
572
  testAction() { return {} }
572
573
  }
573
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
574
+ const response = buildResponseMock({ end: jest.fn() });
574
575
  const controller = new C({}, response);
575
576
 
576
577
  await controller.performRequest('testAction');
@@ -586,7 +587,7 @@ describe('VidaServerController', () => {
586
587
  }
587
588
  testAction() { return {} }
588
589
  }
589
- const response = { json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), end: jest.fn(), statusCode: 200 };
590
+ const response = buildResponseMock({ end: jest.fn() });
590
591
  const controller = new C({}, response);
591
592
 
592
593
  await controller.performRequest('testAction');