@vida-global/core 2.0.11 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/activeRecord/README.md +1 -1
- package/lib/activeRecord/baseRecord.js +70 -4
- package/lib/http/README.md +4 -4
- package/lib/http/client.js +33 -2
- package/lib/server/README.md +8 -219
- package/lib/server/controllerImporter.js +0 -1
- package/lib/server/controllerMixins/callbacks.js +16 -61
- package/lib/server/controllerMixins/classScopedRegistry.js +48 -0
- package/lib/server/controllerMixins/documentation.js +41 -9
- package/lib/server/controllerMixins/renderer.js +140 -99
- package/lib/server/controllerMixins/validations.js +57 -0
- package/lib/server/doc/callbacks.md +23 -0
- package/lib/server/doc/documentation.md +135 -0
- package/lib/server/doc/renderer.md +115 -0
- package/lib/server/doc/requestDetails.md +52 -0
- package/lib/server/doc/validations.md +40 -0
- package/lib/server/index.js +1 -1
- package/lib/server/openApi/apiDocGenerator.js +426 -0
- package/lib/server/openApi/apiDocsGenerator.js +147 -0
- package/lib/server/openApi/schemaImporter.js +41 -0
- package/lib/server/openApi/schemaRegistry.js +43 -0
- package/lib/server/openApi/schemas.js +97 -0
- package/lib/server/openApi/tagRegistry.js +27 -0
- package/lib/server/server.js +14 -0
- package/lib/server/serverController.js +4 -9
- package/lib/server/statusTexts.js +38 -0
- package/lib/utils/yamlLoader.js +17 -0
- package/package.json +4 -2
- package/test/activeRecord/baseRecord.test.js +101 -0
- package/test/http/client.test.js +34 -0
- package/test/http/helpers/client.js +31 -0
- package/test/server/apiDocGenerator.test.js +743 -0
- package/test/server/controllerMixins/callbacks.test.js +326 -0
- package/test/server/controllerMixins/classScopedRegistry.test.js +95 -0
- package/test/server/controllerMixins/documentation.test.js +168 -0
- package/test/server/controllerMixins/renderer.test.js +734 -0
- package/test/server/controllerMixins/requestDetails.test.js +225 -0
- package/test/server/controllerMixins/routing.test.js +82 -0
- package/test/server/controllerMixins/validations.test.js +509 -0
- package/test/server/openApi/apiDocsGenerator.test.js +306 -0
- package/test/server/openApi/helpers/apiDocsPackageFixture/package.json +5 -0
- package/test/server/openApi/schemaImporter.test.js +54 -0
- package/test/server/openApi/schemaRegistry.test.js +93 -0
- package/test/server/openApi/schemas.test.js +109 -0
- package/test/server/serverController.test.js +8 -867
- package/test/server/statusTexts.test.js +30 -0
- package/test/utils/yamlLoader.test.js +43 -0
- package/lib/server/apiDocsGenerator.js +0 -86
- package/test/server/apiDocsGenerator.test.js +0 -38
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
const { FooController } = require('../helpers/controllers/fooController');
|
|
2
|
+
const TestHelpers = require('@vida-global/test-helpers');
|
|
3
|
+
const {
|
|
4
|
+
NoActiveStreamError,
|
|
5
|
+
StreamInProgressError,
|
|
6
|
+
} = require('../../../lib/server/serverController');
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
describe('VidaServerController', () => {
|
|
10
|
+
describe('Rendering', () => {
|
|
11
|
+
let controller;
|
|
12
|
+
let response;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
response = {json: jest.fn(), statusCode: 200};
|
|
15
|
+
controller = new FooController({}, response);
|
|
16
|
+
controller.render = jest.fn();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('#renderErrors', () => {
|
|
20
|
+
it ('returns a 400 response', async () => {
|
|
21
|
+
await controller.renderErrors();
|
|
22
|
+
expect(response.statusCode).toBe(400);
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it ('includes a blank message and blank fields by default', async () => {
|
|
26
|
+
await controller.renderErrors();
|
|
27
|
+
expect(controller.render).toHaveBeenCalledTimes(1);
|
|
28
|
+
expect(controller.render).toHaveBeenCalledWith({errors: {message: null, fields: {}}});
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it ('includes a provided message', async () => {
|
|
32
|
+
const msg = TestHelpers.Faker.Text.randomString();
|
|
33
|
+
await controller.renderErrors(msg);
|
|
34
|
+
expect(controller.render).toHaveBeenCalledTimes(1);
|
|
35
|
+
expect(controller.render).toHaveBeenCalledWith({errors: {message: msg, fields: {}}});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it ('includes provided field data', async () => {
|
|
39
|
+
const field1 = TestHelpers.Faker.Text.randomString();
|
|
40
|
+
const msg1 = TestHelpers.Faker.Text.randomString();
|
|
41
|
+
const msg2 = TestHelpers.Faker.Text.randomString();
|
|
42
|
+
await controller.renderErrors(null, {[field1]: [msg1, msg2]});
|
|
43
|
+
expect(controller.render).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(controller.render).toHaveBeenCalledWith({errors: {message: null, fields: {[field1]: [msg1, msg2]}}});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it ('includes provided message field data', async () => {
|
|
48
|
+
const field1 = TestHelpers.Faker.Text.randomString();
|
|
49
|
+
const msg1 = TestHelpers.Faker.Text.randomString();
|
|
50
|
+
const msg2 = TestHelpers.Faker.Text.randomString();
|
|
51
|
+
const msg3 = TestHelpers.Faker.Text.randomString();
|
|
52
|
+
await controller.renderErrors(msg1, {[field1]: [msg2, msg3]});
|
|
53
|
+
expect(controller.render).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(controller.render).toHaveBeenCalledWith({errors: {message: msg1, fields: {[field1]: [msg2, msg3]}}});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it ('uses the first parameter as fields if it is an object', async () => {
|
|
58
|
+
const field1 = TestHelpers.Faker.Text.randomString();
|
|
59
|
+
const msg1 = TestHelpers.Faker.Text.randomString();
|
|
60
|
+
const msg2 = TestHelpers.Faker.Text.randomString();
|
|
61
|
+
await controller.renderErrors({[field1]: [msg1, msg2]});
|
|
62
|
+
expect(controller.render).toHaveBeenCalledTimes(1);
|
|
63
|
+
expect(controller.render).toHaveBeenCalledWith({errors: {message: null, fields: {[field1]: [msg1, msg2]}}});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it ('converts single field error messages to arrays', async () => {
|
|
67
|
+
const field1 = TestHelpers.Faker.Text.randomString();
|
|
68
|
+
const msg1 = TestHelpers.Faker.Text.randomString();
|
|
69
|
+
await controller.renderErrors({[field1]: msg1});
|
|
70
|
+
expect(controller.render).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(controller.render).toHaveBeenCalledWith({errors: {message: null, fields: {[field1]: [msg1]}}});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
const successHandlers = [
|
|
77
|
+
['renderCreationSuccessful', 201],
|
|
78
|
+
['renderAccepted', 202],
|
|
79
|
+
['renderNoConent', 204],
|
|
80
|
+
['renderMovedPermanently', 301],
|
|
81
|
+
['renderFound', 302],
|
|
82
|
+
['renderNotModified', 304],
|
|
83
|
+
['renderTemporaryRedirect', 307],
|
|
84
|
+
['renderPermanentRedirect', 308],
|
|
85
|
+
]
|
|
86
|
+
describe.each(successHandlers)('#%s', (handlerName, statusCode) => {
|
|
87
|
+
it (`returns a ${statusCode} response`, async () => {
|
|
88
|
+
await (controller[handlerName]());
|
|
89
|
+
expect(response.statusCode).toBe(statusCode);
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it ('includes the provided body', async () => {
|
|
93
|
+
const key = TestHelpers.Faker.Text.randomString();
|
|
94
|
+
const value = TestHelpers.Faker.Text.randomString();
|
|
95
|
+
const body = {[key]: value};
|
|
96
|
+
await (controller[handlerName](body));
|
|
97
|
+
|
|
98
|
+
expect(controller.render).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(controller.render).toHaveBeenCalledWith(body);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
const errorHandlers = [
|
|
105
|
+
['renderUnauthorized', 401],
|
|
106
|
+
['renderPaymentRequired', 402],
|
|
107
|
+
['renderForbidden', 403],
|
|
108
|
+
['renderNotFound', 404],
|
|
109
|
+
['renderMethodNotAllowed', 405],
|
|
110
|
+
['renderRequestTimeout', 408],
|
|
111
|
+
['renderConflict', 409],
|
|
112
|
+
['renderGone', 410],
|
|
113
|
+
['renderContentTooLarge', 413],
|
|
114
|
+
['renderUnsupportedMediaType', 415],
|
|
115
|
+
['renderUnprocessableContent', 422],
|
|
116
|
+
['renderLocked', 423],
|
|
117
|
+
['renderTooManyRequests', 429],
|
|
118
|
+
['renderServerError', 500],
|
|
119
|
+
['renderNotImplemented', 501],
|
|
120
|
+
['renderBadGateway', 502],
|
|
121
|
+
['renderServiceUnavailable', 503],
|
|
122
|
+
['renderGatewayTimeout', 504],
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
describe.each(errorHandlers)('#%s', (handlerName, statusCode) => {
|
|
126
|
+
it (`returns a ${statusCode} response`, async () => {
|
|
127
|
+
await (controller[handlerName]());
|
|
128
|
+
expect(response.statusCode).toBe(statusCode);
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it ('includes a blank message by default', async () => {
|
|
132
|
+
await controller[handlerName]();
|
|
133
|
+
expect(controller.render).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(controller.render).toHaveBeenCalledWith({errors: {message: null}});
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it ('includes a provided message', async () => {
|
|
138
|
+
const msg = TestHelpers.Faker.Text.randomString();
|
|
139
|
+
await controller[handlerName](msg);
|
|
140
|
+
expect(controller.render).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(controller.render).toHaveBeenCalledWith({errors: {message: msg}});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it ('includes a provided body and message', async () => {
|
|
145
|
+
const key = TestHelpers.Faker.Text.randomString();
|
|
146
|
+
const val = TestHelpers.Faker.Text.randomString();
|
|
147
|
+
const body = {[key]: val};
|
|
148
|
+
const msg = TestHelpers.Faker.Text.randomString();
|
|
149
|
+
const expected = {...body, errors: {message: msg}};
|
|
150
|
+
await controller[handlerName](msg, body);
|
|
151
|
+
expect(controller.render).toHaveBeenCalledTimes(1);
|
|
152
|
+
expect(controller.render).toHaveBeenCalledWith(expected);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
describe('#render', () => {
|
|
159
|
+
let response;
|
|
160
|
+
let controller;
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
response = {json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), statusCode: 200};
|
|
163
|
+
controller = new FooController({}, response);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it ('calls response.send (not response.json) when the body is a string', async () => {
|
|
167
|
+
const body = TestHelpers.Faker.Text.randomString();
|
|
168
|
+
await controller.render(body);
|
|
169
|
+
|
|
170
|
+
expect(response.send).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(response.send).toHaveBeenCalledWith(body);
|
|
172
|
+
expect(response.json).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it ('marks the controller as rendered after a string render', async () => {
|
|
176
|
+
await controller.render('hello');
|
|
177
|
+
expect(controller.rendered).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it ('is a no-op when the controller has already been rendered', async () => {
|
|
181
|
+
controller.markRendered();
|
|
182
|
+
await controller.render('hello');
|
|
183
|
+
expect(response.send).not.toHaveBeenCalled();
|
|
184
|
+
expect(response.json).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it ('is a no-op when the controller is streaming', async () => {
|
|
188
|
+
controller._isStreaming = true;
|
|
189
|
+
await controller.render('hello');
|
|
190
|
+
await controller.render({foo: 'bar'});
|
|
191
|
+
|
|
192
|
+
expect(response.send).not.toHaveBeenCalled();
|
|
193
|
+
expect(response.json).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it ('does not mark the controller as rendered when streaming', async () => {
|
|
197
|
+
controller._isStreaming = true;
|
|
198
|
+
await controller.render('hello');
|
|
199
|
+
expect(controller.rendered).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
describe('#_serializeResponseBody', () => {
|
|
205
|
+
let controller;
|
|
206
|
+
beforeEach(() => {
|
|
207
|
+
controller = new FooController({}, {});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it ('returns null when the body is undefined', async () => {
|
|
211
|
+
expect(await controller._serializeResponseBody(undefined, {})).toBe(null);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it ('returns null when the body is null', async () => {
|
|
215
|
+
expect(await controller._serializeResponseBody(null, {})).toBe(null);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it ('returns primitives unchanged', async () => {
|
|
219
|
+
expect(await controller._serializeResponseBody('hello', {})).toBe('hello');
|
|
220
|
+
expect(await controller._serializeResponseBody(42, {})).toBe(42);
|
|
221
|
+
expect(await controller._serializeResponseBody(true, {})).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it ('returns Date instances as their epoch ms via getTime', async () => {
|
|
225
|
+
const date = new Date('2020-01-02T03:04:05Z');
|
|
226
|
+
expect(await controller._serializeResponseBody(date, {})).toBe(date.getTime());
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it ('recursively processes Date values inside objects', async () => {
|
|
230
|
+
const date = new Date('2021-02-03T00:00:00Z');
|
|
231
|
+
const result = await controller._serializeResponseBody({createdAt: date, name: 'foo'}, {});
|
|
232
|
+
expect(result).toEqual({createdAt: date.getTime(), name: 'foo'});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it ('recursively processes arrays', async () => {
|
|
236
|
+
const date = new Date('2022-03-04T00:00:00Z');
|
|
237
|
+
const result = await controller._serializeResponseBody([1, date, {ts: date}], {});
|
|
238
|
+
expect(result).toEqual([1, date.getTime(), {ts: date.getTime()}]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it ('invokes toApiResponse on objects that define it and recurses on the result', async () => {
|
|
242
|
+
const date = new Date('2023-04-05T00:00:00Z');
|
|
243
|
+
const apiModel = {
|
|
244
|
+
async toApiResponse() { return {id: 1, createdAt: date}; }
|
|
245
|
+
};
|
|
246
|
+
const result = await controller._serializeResponseBody(apiModel, {});
|
|
247
|
+
expect(result).toEqual({id: 1, createdAt: date.getTime()});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it ('passes the options argument through to toApiResponse', async () => {
|
|
251
|
+
const options = {includeTitle: true, extra: 'flag'};
|
|
252
|
+
const toApi = jest.fn().mockResolvedValue({});
|
|
253
|
+
const apiModel = {toApiResponse: toApi};
|
|
254
|
+
|
|
255
|
+
await controller._serializeResponseBody(apiModel, options);
|
|
256
|
+
expect(toApi).toHaveBeenCalledWith(options);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it ('recurses through arrays of objects with toApiResponse', async () => {
|
|
260
|
+
const apiModels = [
|
|
261
|
+
{toApiResponse: async () => ({id: 'a'})},
|
|
262
|
+
{toApiResponse: async () => ({id: 'b'})},
|
|
263
|
+
];
|
|
264
|
+
const result = await controller._serializeResponseBody(apiModels, {});
|
|
265
|
+
expect(result).toEqual([{id: 'a'}, {id: 'b'}]);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
describe('#requestedFormat', () => {
|
|
271
|
+
it ('returns "xml" when the _format param is xml', () => {
|
|
272
|
+
const controller = new FooController({query: {_format: 'xml'}}, {});
|
|
273
|
+
expect(controller.requestedFormat).toBe('xml');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it ('returns "json" when the _format param is json', () => {
|
|
277
|
+
const controller = new FooController({query: {_format: 'json'}}, {});
|
|
278
|
+
expect(controller.requestedFormat).toBe('json');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it ('returns "xml" when the Accept header requests xml', () => {
|
|
282
|
+
const controller = new FooController({headers: {accept: 'application/xml'}}, {});
|
|
283
|
+
expect(controller.requestedFormat).toBe('xml');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it ('lets the _format param override a conflicting Accept header', () => {
|
|
287
|
+
const controller = new FooController({query: {_format: 'json'}, headers: {accept: 'application/xml'}}, {});
|
|
288
|
+
expect(controller.requestedFormat).toBe('json');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it ('defaults to json when neither the param nor the header indicate a format', () => {
|
|
292
|
+
const controller = new FooController({}, {});
|
|
293
|
+
expect(controller.requestedFormat).toBe('json');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it ('ignores an unrecognized _format and falls back to the Accept header', () => {
|
|
297
|
+
const controller = new FooController({query: {_format: 'yaml'}, headers: {accept: 'application/xml'}}, {});
|
|
298
|
+
expect(controller.requestedFormat).toBe('xml');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
describe('#isXMLRequest', () => {
|
|
304
|
+
it ('is true when the requested format is xml', () => {
|
|
305
|
+
const controller = new FooController({query: {_format: 'xml'}}, {});
|
|
306
|
+
expect(controller.isXMLRequest).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it ('is false when the requested format is json', () => {
|
|
310
|
+
const controller = new FooController({}, {});
|
|
311
|
+
expect(controller.isXMLRequest).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
describe('#isJSONRequest', () => {
|
|
317
|
+
it ('is true when the requested format is json', () => {
|
|
318
|
+
const controller = new FooController({}, {});
|
|
319
|
+
expect(controller.isJSONRequest).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it ('is false when the requested format is xml', () => {
|
|
323
|
+
const controller = new FooController({query: {_format: 'xml'}}, {});
|
|
324
|
+
expect(controller.isJSONRequest).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
describe('#render format dispatch', () => {
|
|
330
|
+
let response;
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
response = {json: jest.fn(), send: jest.fn(), setHeader: jest.fn(), statusCode: 200};
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it ('renders JSON via response.json for a JSON request', async () => {
|
|
336
|
+
const controller = new FooController({}, response);
|
|
337
|
+
await controller.render({foo: 'bar'});
|
|
338
|
+
expect(response.json).toHaveBeenCalledTimes(1);
|
|
339
|
+
expect(response.send).not.toHaveBeenCalled();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it ('sets the JSON content-type for a JSON request', async () => {
|
|
343
|
+
const controller = new FooController({}, response);
|
|
344
|
+
await controller.render({foo: 'bar'});
|
|
345
|
+
expect(response.setHeader).toHaveBeenCalledWith('content-type', 'application/json; charset=utf-8');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it ('renders XML via response.send for an XML request', async () => {
|
|
349
|
+
const controller = new FooController({query: {_format: 'xml'}}, response);
|
|
350
|
+
await controller.render({foo: 'bar'});
|
|
351
|
+
expect(response.send).toHaveBeenCalledTimes(1);
|
|
352
|
+
expect(response.json).not.toHaveBeenCalled();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it ('sets the XML content-type for an XML request', async () => {
|
|
356
|
+
const controller = new FooController({query: {_format: 'xml'}}, response);
|
|
357
|
+
await controller.render({foo: 'bar'});
|
|
358
|
+
expect(response.setHeader).toHaveBeenCalledWith('content-type', 'application/xml; charset=utf-8');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it ('serializes the body under a <response> root for an XML request', async () => {
|
|
362
|
+
const controller = new FooController({query: {_format: 'xml'}}, response);
|
|
363
|
+
await controller.render({foo: 'bar'});
|
|
364
|
+
const sent = response.send.mock.calls[0][0];
|
|
365
|
+
expect(sent).toContain('<response>');
|
|
366
|
+
expect(sent).toContain('<data><foo>bar</foo></data>');
|
|
367
|
+
expect(sent).toContain('<status>ok</status>');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it ('renders array items as separate elements instead of concatenated text', async () => {
|
|
371
|
+
const controller = new FooController({query: {_format: 'xml'}}, response);
|
|
372
|
+
await controller.render({tags: ['one', 'two']});
|
|
373
|
+
const sent = response.send.mock.calls[0][0];
|
|
374
|
+
expect(sent).toContain('<item>one</item><item>two</item>');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it ('sanitizes invalid XML element names so dynamic keys do not throw', async () => {
|
|
378
|
+
const controller = new FooController({query: {_format: 'xml'}}, response);
|
|
379
|
+
await controller.render({'123': 'x', 'first name': 'y'});
|
|
380
|
+
const sent = response.send.mock.calls[0][0];
|
|
381
|
+
expect(sent).toContain('<_123>x</_123>');
|
|
382
|
+
expect(sent).toContain('<first_name>y</first_name>');
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
describe('#_serializeArrayForResponseBody', () => {
|
|
388
|
+
class FakeRecord {
|
|
389
|
+
constructor(id) { this.id = id; }
|
|
390
|
+
get apiXMLTagName() { return 'FakeRecord'; }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
it ('returns a plain array for a JSON request', async () => {
|
|
394
|
+
const controller = new FooController({}, {});
|
|
395
|
+
const result = await controller._serializeArrayForResponseBody([new FakeRecord(1), new FakeRecord(2)], {});
|
|
396
|
+
expect(result).toEqual([{id: 1}, {id: 2}]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it ('wraps records in apiXMLTagName elements under "#" for an XML request', async () => {
|
|
400
|
+
const controller = new FooController({query: {_format: 'xml'}}, {});
|
|
401
|
+
const result = await controller._serializeArrayForResponseBody([new FakeRecord(1), new FakeRecord(2)], {});
|
|
402
|
+
expect(result).toEqual({'#': [{FakeRecord: {id: 1}}, {FakeRecord: {id: 2}}]});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it ('wraps primitive elements in generic <item> tags for an XML request', async () => {
|
|
406
|
+
const controller = new FooController({query: {_format: 'xml'}}, {});
|
|
407
|
+
const result = await controller._serializeArrayForResponseBody(['one', 'two'], {});
|
|
408
|
+
expect(result).toEqual({'#': [{item: 'one'}, {item: 'two'}]});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it ('wraps null elements in generic <item> tags for an XML request', async () => {
|
|
412
|
+
const controller = new FooController({query: {_format: 'xml'}}, {});
|
|
413
|
+
const result = await controller._serializeArrayForResponseBody([null, new FakeRecord(1)], {});
|
|
414
|
+
expect(result).toEqual({'#': [{item: null}, {FakeRecord: {id: 1}}]});
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
describe('#_toXMLElementName', () => {
|
|
420
|
+
let controller;
|
|
421
|
+
beforeEach(() => {
|
|
422
|
+
controller = new FooController({}, {});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it ('leaves a valid XML name unchanged', () => {
|
|
426
|
+
expect(controller._toXMLElementName('firstName')).toBe('firstName');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it ('prefixes an underscore when the key starts with a digit', () => {
|
|
430
|
+
expect(controller._toXMLElementName('123')).toBe('_123');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it ('replaces spaces and other invalid characters with underscores', () => {
|
|
434
|
+
expect(controller._toXMLElementName('first name')).toBe('first_name');
|
|
435
|
+
expect(controller._toXMLElementName('a:b')).toBe('a_b');
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
describe('#statusText', () => {
|
|
441
|
+
const cases = [
|
|
442
|
+
[200, 'ok'],
|
|
443
|
+
[201, 'created'],
|
|
444
|
+
[202, 'accepted'],
|
|
445
|
+
[204, 'no content'],
|
|
446
|
+
[301, 'moved permanently'],
|
|
447
|
+
[302, 'found'],
|
|
448
|
+
[304, 'not modified'],
|
|
449
|
+
[307, 'temporary redirect'],
|
|
450
|
+
[308, 'permanent redirect'],
|
|
451
|
+
[400, 'bad request'],
|
|
452
|
+
[401, 'unauthorized'],
|
|
453
|
+
[402, 'payment required'],
|
|
454
|
+
[403, 'forbidden'],
|
|
455
|
+
[404, 'not found'],
|
|
456
|
+
[405, 'method not allowed'],
|
|
457
|
+
[408, 'request timeout'],
|
|
458
|
+
[409, 'conflict'],
|
|
459
|
+
[410, 'gone'],
|
|
460
|
+
[413, 'content too large'],
|
|
461
|
+
[415, 'unsupported media type'],
|
|
462
|
+
[422, 'unprocessable content'],
|
|
463
|
+
[423, 'locked'],
|
|
464
|
+
[429, 'too many requests'],
|
|
465
|
+
[500, 'server error'],
|
|
466
|
+
[501, 'not implemented'],
|
|
467
|
+
[502, 'bad gateway'],
|
|
468
|
+
[503, 'service unavailable'],
|
|
469
|
+
[504, 'gateway timeout'],
|
|
470
|
+
];
|
|
471
|
+
|
|
472
|
+
it.each(cases)('returns "%s" text for status %s', (statusCode, expected) => {
|
|
473
|
+
const controller = new FooController({}, { statusCode });
|
|
474
|
+
expect(controller.statusText).toEqual(expected);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it ('returns undefined for an unmapped status code', () => {
|
|
478
|
+
const controller = new FooController({}, { statusCode: 418 });
|
|
479
|
+
expect(controller.statusText).toBeUndefined();
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
describe('#streamSseResponse', () => {
|
|
485
|
+
let controller;
|
|
486
|
+
let response;
|
|
487
|
+
let request;
|
|
488
|
+
let setHeaderSpy;
|
|
489
|
+
|
|
490
|
+
beforeEach(() => {
|
|
491
|
+
request = { on: jest.fn(), off: jest.fn() };
|
|
492
|
+
response = {
|
|
493
|
+
setHeader: jest.fn(),
|
|
494
|
+
flushHeaders: jest.fn(),
|
|
495
|
+
write: jest.fn(),
|
|
496
|
+
end: jest.fn(),
|
|
497
|
+
writableEnded: false
|
|
498
|
+
};
|
|
499
|
+
controller = new FooController(request, response);
|
|
500
|
+
setHeaderSpy = response.setHeader;
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it ('sets SSE headers, flushes them, and marks rendered before invoking handler', async () => {
|
|
504
|
+
const handler = jest.fn(async function () {
|
|
505
|
+
expect(this).toBe(controller);
|
|
506
|
+
expect(controller.rendered).toBe(true);
|
|
507
|
+
expect(setHeaderSpy).toHaveBeenCalledWith('Content-Type', 'text/event-stream; charset=utf-8');
|
|
508
|
+
expect(setHeaderSpy).toHaveBeenCalledWith('Cache-Control', 'no-cache');
|
|
509
|
+
expect(setHeaderSpy).toHaveBeenCalledWith('Connection', 'keep-alive');
|
|
510
|
+
expect(setHeaderSpy).toHaveBeenCalledWith('X-Accel-Buffering', 'no');
|
|
511
|
+
expect(response.flushHeaders).toHaveBeenCalledTimes(1);
|
|
512
|
+
expect(controller.statusCode).toBe(200);
|
|
513
|
+
});
|
|
514
|
+
await controller.streamSseResponse(handler, jest.fn());
|
|
515
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it ('writes [DONE] and ends the response in the finally block', async () => {
|
|
519
|
+
await controller.streamSseResponse(jest.fn(), jest.fn());
|
|
520
|
+
expect(response.write).toHaveBeenLastCalledWith('data: [DONE]\n\n');
|
|
521
|
+
expect(response.end).toHaveBeenCalledTimes(1);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it ('throws StreamInProgressError when called while already streaming', async () => {
|
|
525
|
+
await controller.streamSseResponse(async () => {
|
|
526
|
+
await expect(controller.streamSseResponse(jest.fn(), jest.fn()))
|
|
527
|
+
.rejects.toBeInstanceOf(StreamInProgressError);
|
|
528
|
+
}, jest.fn());
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it ('invokes errorHandler and rethrows when handler throws', async () => {
|
|
532
|
+
const err = new Error('boom');
|
|
533
|
+
const handler = jest.fn(async () => { throw err; });
|
|
534
|
+
const errorHandler = jest.fn();
|
|
535
|
+
await expect(controller.streamSseResponse(handler, errorHandler)).rejects.toThrow('boom');
|
|
536
|
+
expect(errorHandler).toHaveBeenCalledWith(err);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it ('still ends the response when handler throws', async () => {
|
|
540
|
+
const errorHandler = jest.fn();
|
|
541
|
+
await expect(controller.streamSseResponse(
|
|
542
|
+
async () => { throw new Error('boom'); },
|
|
543
|
+
errorHandler
|
|
544
|
+
)).rejects.toThrow();
|
|
545
|
+
expect(response.end).toHaveBeenCalledTimes(1);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it ('wires close listener to abortController when one is provided', async () => {
|
|
549
|
+
const abortController = { abort: jest.fn() };
|
|
550
|
+
await controller.streamSseResponse(jest.fn(), jest.fn(), abortController);
|
|
551
|
+
expect(request.on).toHaveBeenCalledWith('close', expect.any(Function));
|
|
552
|
+
expect(request.off).toHaveBeenCalledWith('close', expect.any(Function));
|
|
553
|
+
// The handler registered on 'close' should call abort.
|
|
554
|
+
const closeHandler = request.on.mock.calls.find(c => c[0] === 'close')[1];
|
|
555
|
+
closeHandler();
|
|
556
|
+
expect(abortController.abort).toHaveBeenCalledTimes(1);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it ('does not register a close listener when no abortController is provided', async () => {
|
|
560
|
+
await controller.streamSseResponse(jest.fn(), jest.fn());
|
|
561
|
+
expect(request.on).not.toHaveBeenCalled();
|
|
562
|
+
expect(request.off).not.toHaveBeenCalled();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it ('resets streaming state after completion so a new stream can start', async () => {
|
|
566
|
+
await controller.streamSseResponse(jest.fn(), jest.fn());
|
|
567
|
+
// Second call should not throw StreamInProgressError.
|
|
568
|
+
await expect(controller.streamSseResponse(jest.fn(), jest.fn())).resolves.toBeUndefined();
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
describe('#writeStreamEvent', () => {
|
|
574
|
+
let controller;
|
|
575
|
+
let response;
|
|
576
|
+
|
|
577
|
+
beforeEach(() => {
|
|
578
|
+
response = {
|
|
579
|
+
setHeader: jest.fn(),
|
|
580
|
+
flushHeaders: jest.fn(),
|
|
581
|
+
write: jest.fn(),
|
|
582
|
+
end: jest.fn(),
|
|
583
|
+
writableEnded: false
|
|
584
|
+
};
|
|
585
|
+
controller = new FooController({ on: jest.fn(), off: jest.fn() }, response);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it ('throws NoActiveStreamError when called outside a stream', () => {
|
|
589
|
+
expect(() => controller.writeStreamEvent('msg', 'hello'))
|
|
590
|
+
.toThrow(NoActiveStreamError);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it ('writes both event and data lines when an event name is provided', async () => {
|
|
594
|
+
await controller.streamSseResponse(async () => {
|
|
595
|
+
response.write.mockClear();
|
|
596
|
+
controller.writeStreamEvent('progress', 'halfway');
|
|
597
|
+
expect(response.write).toHaveBeenCalledTimes(1);
|
|
598
|
+
expect(response.write).toHaveBeenCalledWith('event: progress\ndata: halfway\n\n');
|
|
599
|
+
}, jest.fn());
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it ('writes only the data line when no event name is provided', async () => {
|
|
603
|
+
await controller.streamSseResponse(async () => {
|
|
604
|
+
response.write.mockClear();
|
|
605
|
+
controller.writeStreamEvent(null, 'plain');
|
|
606
|
+
expect(response.write).toHaveBeenCalledTimes(1);
|
|
607
|
+
expect(response.write).toHaveBeenCalledWith('data: plain\n\n');
|
|
608
|
+
}, jest.fn());
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it ('is a no-op when the response is already ended', async () => {
|
|
612
|
+
await controller.streamSseResponse(async () => {
|
|
613
|
+
response.writableEnded = true;
|
|
614
|
+
response.write.mockClear();
|
|
615
|
+
controller.writeStreamEvent(null, 'ignored');
|
|
616
|
+
expect(response.write).not.toHaveBeenCalled();
|
|
617
|
+
}, jest.fn());
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it ('is a no-op when the abort signal is aborted', async () => {
|
|
621
|
+
const abortController = { abort: jest.fn(), signal: { aborted: false } };
|
|
622
|
+
await controller.streamSseResponse(async () => {
|
|
623
|
+
abortController.signal.aborted = true;
|
|
624
|
+
response.write.mockClear();
|
|
625
|
+
controller.writeStreamEvent(null, 'ignored');
|
|
626
|
+
expect(response.write).not.toHaveBeenCalled();
|
|
627
|
+
}, jest.fn(), abortController);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
describe('#renderFile', () => {
|
|
633
|
+
let request;
|
|
634
|
+
let response;
|
|
635
|
+
let controller;
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
beforeEach(() => {
|
|
639
|
+
request = {};
|
|
640
|
+
response = {
|
|
641
|
+
setHeader: jest.fn(),
|
|
642
|
+
sendFile: jest.fn((path, options, cb) => cb()),
|
|
643
|
+
};
|
|
644
|
+
controller = new FooController(request, response);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
it ('sets Content-Type from the explicit option when provided', async () => {
|
|
649
|
+
await controller.renderFile('/var/www/foo.bin', { contentType: 'application/x-custom' });
|
|
650
|
+
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'application/x-custom');
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
it.each([
|
|
655
|
+
['/a/b.html', 'text/html; charset=utf-8'],
|
|
656
|
+
['/a/b.htm', 'text/html; charset=utf-8'],
|
|
657
|
+
['/a/b.css', 'text/css; charset=utf-8'],
|
|
658
|
+
['/a/b.js', 'application/javascript; charset=utf-8'],
|
|
659
|
+
['/a/b.mjs', 'application/javascript; charset=utf-8'],
|
|
660
|
+
['/a/b.json', 'application/json; charset=utf-8'],
|
|
661
|
+
['/a/b.txt', 'text/plain; charset=utf-8'],
|
|
662
|
+
['/a/b.png', 'image/png'],
|
|
663
|
+
['/a/b.jpg', 'image/jpeg'],
|
|
664
|
+
['/a/b.jpeg', 'image/jpeg'],
|
|
665
|
+
['/a/b.gif', 'image/gif'],
|
|
666
|
+
['/a/b.svg', 'image/svg+xml'],
|
|
667
|
+
['/a/b.ico', 'image/x-icon'],
|
|
668
|
+
['/a/b.webp', 'image/webp'],
|
|
669
|
+
['/a/b.woff', 'font/woff'],
|
|
670
|
+
['/a/b.woff2', 'font/woff2'],
|
|
671
|
+
['/a/b.ttf', 'font/ttf'],
|
|
672
|
+
['/a/b.otf', 'font/otf'],
|
|
673
|
+
['/a/b.JPG', 'image/jpeg'],
|
|
674
|
+
])('guesses Content-Type for %s', async (filePath, expected) => {
|
|
675
|
+
await controller.renderFile(filePath);
|
|
676
|
+
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', expected);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
it ('falls back to application/octet-stream for unknown extensions', async () => {
|
|
681
|
+
await controller.renderFile('/var/www/foo.xyz');
|
|
682
|
+
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'application/octet-stream');
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
it ('falls back to application/octet-stream when there is no extension', async () => {
|
|
687
|
+
await controller.renderFile('/var/www/foo');
|
|
688
|
+
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'application/octet-stream');
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
it ('sets Cache-Control when provided', async () => {
|
|
693
|
+
await controller.renderFile('/var/www/foo.html', { cacheControl: 'no-store' });
|
|
694
|
+
expect(response.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
it ('does not set Cache-Control when omitted', async () => {
|
|
699
|
+
await controller.renderFile('/var/www/foo.html');
|
|
700
|
+
const cacheCalls = response.setHeader.mock.calls.filter(([name]) => name === 'Cache-Control');
|
|
701
|
+
expect(cacheCalls).toHaveLength(0);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
it ('passes maxAge to res.sendFile', async () => {
|
|
706
|
+
await controller.renderFile('/var/www/foo.html', { maxAge: 300000 });
|
|
707
|
+
expect(response.sendFile).toHaveBeenCalledWith('/var/www/foo.html', { maxAge: 300000 }, expect.any(Function));
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
it ('marks the controller as rendered', async () => {
|
|
712
|
+
expect(controller.rendered).toBe(false);
|
|
713
|
+
await controller.renderFile('/var/www/foo.html');
|
|
714
|
+
expect(controller.rendered).toBe(true);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
it ('throws when filePath is empty', () => {
|
|
719
|
+
expect(() => controller.renderFile('')).toThrow('renderFile requires a filePath');
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
it ('throws when filePath is null', () => {
|
|
724
|
+
expect(() => controller.renderFile(null)).toThrow('renderFile requires a filePath');
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
it ('rejects the returned promise when sendFile errors', async () => {
|
|
729
|
+
const sendError = new Error('disk read failed');
|
|
730
|
+
response.sendFile = jest.fn((path, options, cb) => cb(sendError));
|
|
731
|
+
await expect(controller.renderFile('/var/www/foo.html')).rejects.toBe(sendError);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
});
|