@vida-global/core 1.4.4 → 1.4.6
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/AGENTS.md +13 -0
- package/lib/http/README.md +10 -0
- package/lib/http/client.js +55 -12
- package/lib/http/error.js +19 -1
- package/lib/logger/README.md +33 -2
- package/lib/logger/index.js +99 -20
- package/lib/logger/serverMiddleware.js +39 -0
- package/lib/server/server.js +8 -62
- package/lib/server/serverController.js +129 -7
- package/package.json +5 -5
- package/test/activeRecord/db/schema.test.js +217 -0
- package/test/activeRecord/utils.test.js +72 -0
- package/test/http/client.test.js +72 -3
- package/test/logger/serverMiddleware.test.js +73 -0
- package/test/server/apiDocsGenerator.test.js +38 -0
- package/test/server/server.test.js +186 -4
- package/test/server/serverController.test.js +237 -5
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
jest.mock('express-winston', () => {
|
|
2
|
+
return {
|
|
3
|
+
logger: jest.fn(() => jest.fn()) // returns a noop middleware
|
|
4
|
+
};
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
const expressWinston = require('express-winston');
|
|
8
|
+
|
|
9
|
+
// Loading the module triggers expressWinston.logger(options) — capturing options gives us
|
|
10
|
+
// access to the otherwise-unexported msgFormatter
|
|
11
|
+
require('../../lib/logger/serverMiddleware');
|
|
12
|
+
|
|
13
|
+
const options = expressWinston.logger.mock.calls[0][0];
|
|
14
|
+
const msgFormatter = options.msg;
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
describe('logger/serverMiddleware', () => {
|
|
18
|
+
describe('msgFormatter', () => {
|
|
19
|
+
const baseRes = (overrides = {}) => ({
|
|
20
|
+
statusCode: 200,
|
|
21
|
+
_header: 'HTTP/1.1 200 OK\nX-Response-Time: 12ms\nContent-Type: application/json',
|
|
22
|
+
...overrides
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const baseReq = (overrides = {}) => ({
|
|
26
|
+
method: 'GET',
|
|
27
|
+
url: '/api/things',
|
|
28
|
+
ip: '127.0.0.1',
|
|
29
|
+
...overrides
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it ('uses Controller#action when req.controller is set', () => {
|
|
33
|
+
class FooController {}
|
|
34
|
+
const req = baseReq({ controller: new FooController(), action: 'getIndex' });
|
|
35
|
+
const res = baseRes();
|
|
36
|
+
const msg = msgFormatter(req, res);
|
|
37
|
+
expect(msg).toContain('FooController#getIndex');
|
|
38
|
+
expect(msg).toContain('req: GET /api/things for 127.0.0.1');
|
|
39
|
+
// The header parser splits on /:(.*)/, preserving the space after the colon —
|
|
40
|
+
// hence "responseTime= 12ms" rather than "responseTime=12ms".
|
|
41
|
+
expect(msg).toContain('res: statusCode=200 responseTime= 12ms');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it ('uses "request completed" when no controller and statusCode < 500', () => {
|
|
45
|
+
const msg = msgFormatter(baseReq(), baseRes({ statusCode: 200 }));
|
|
46
|
+
expect(msg).toContain('request completed');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it ('uses "request errored" when no controller and statusCode >= 500', () => {
|
|
50
|
+
const msg = msgFormatter(baseReq(), baseRes({ statusCode: 503 }));
|
|
51
|
+
expect(msg).toContain('request errored');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it ('strips the query string from the request URL', () => {
|
|
55
|
+
const req = baseReq({ url: '/api/things?limit=10&q=foo' });
|
|
56
|
+
const msg = msgFormatter(req, baseRes());
|
|
57
|
+
expect(msg).toContain('req: GET /api/things for 127.0.0.1');
|
|
58
|
+
expect(msg).not.toContain('limit=10');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it ('appends the error stack when res.error is present', () => {
|
|
62
|
+
const err = new Error('boom');
|
|
63
|
+
err.stack = 'Error: boom\n at somewhere';
|
|
64
|
+
const msg = msgFormatter(baseReq(), baseRes({ error: err }));
|
|
65
|
+
expect(msg).toContain('Error: boom\n at somewhere');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it ('does not append a stack when there is no res.error', () => {
|
|
69
|
+
const msg = msgFormatter(baseReq(), baseRes());
|
|
70
|
+
expect(msg).not.toContain('Error:');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const { ApiDocsGenerator } = require('../../lib/server/apiDocsGenerator');
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
describe('ApiDocsGenerator', () => {
|
|
5
|
+
let generator;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
generator = new ApiDocsGenerator({ controllerDirectories: [] });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
describe('#formatEndpoint', () => {
|
|
13
|
+
it ('replaces a single :param with {param}', () => {
|
|
14
|
+
expect(generator.formatEndpoint({ path: '/users/:id' })).toEqual('/users/{id}');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it ('replaces multiple :params with {params}', () => {
|
|
18
|
+
expect(generator.formatEndpoint({ path: '/users/:userId/posts/:postId' }))
|
|
19
|
+
.toEqual('/users/{userId}/posts/{postId}');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it ('returns the path unchanged when there are no params', () => {
|
|
23
|
+
expect(generator.formatEndpoint({ path: '/users' })).toEqual('/users');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it ('handles a trailing :param', () => {
|
|
27
|
+
expect(generator.formatEndpoint({ path: '/foo/bar/:baz' })).toEqual('/foo/bar/{baz}');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it ('handles an empty path', () => {
|
|
31
|
+
expect(generator.formatEndpoint({ path: '' })).toEqual('');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it ('only replaces up to the next slash', () => {
|
|
35
|
+
expect(generator.formatEndpoint({ path: '/a/:x/b/:y' })).toEqual('/a/{x}/b/{y}');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -5,17 +5,39 @@ const { Request } = require('jest-express/lib/request');
|
|
|
5
5
|
const { Response } = require('jest-express/lib/response');
|
|
6
6
|
const { TestServer } = require('./helpers/server');
|
|
7
7
|
const { VidaServer } = require('../../lib/server');
|
|
8
|
+
const express = require('express');
|
|
9
|
+
const IoServer = require('socket.io');
|
|
10
|
+
const { Server: HttpServer } = require('http');
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const lastApp = () => express.__instances[express.__instances.length - 1];
|
|
8
14
|
|
|
9
15
|
|
|
10
16
|
jest.mock('express', () => {
|
|
11
|
-
const
|
|
12
|
-
const { Response }
|
|
17
|
+
const jestExpress = require('jest-express');
|
|
18
|
+
const { Response } = require('jest-express/lib/response');
|
|
13
19
|
|
|
14
20
|
// missing functions in mock express
|
|
15
21
|
Response.prototype.on = jest.fn();
|
|
16
|
-
express.raw = jest.fn();
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
// Wrap the factory so tests can inspect the most recently created Express app.
|
|
24
|
+
const instances = [];
|
|
25
|
+
const wrapped = (...args) => {
|
|
26
|
+
const app = jestExpress(...args);
|
|
27
|
+
instances.push(app);
|
|
28
|
+
return app;
|
|
29
|
+
};
|
|
30
|
+
Object.assign(wrapped, jestExpress);
|
|
31
|
+
wrapped.raw = jest.fn();
|
|
32
|
+
wrapped.__instances = instances;
|
|
33
|
+
|
|
34
|
+
return wrapped;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
jest.mock('socket.io', () => jest.fn());
|
|
38
|
+
jest.mock('http', () => {
|
|
39
|
+
const actual = jest.requireActual('http');
|
|
40
|
+
return { ...actual, Server: jest.fn() };
|
|
19
41
|
});
|
|
20
42
|
|
|
21
43
|
|
|
@@ -187,4 +209,164 @@ describe('VidaServer', () => {
|
|
|
187
209
|
expect(afterSpy5).toHaveBeenCalledTimes(0);
|
|
188
210
|
});
|
|
189
211
|
});
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
describe('VidaServer#listen', () => {
|
|
215
|
+
it ('starts the http server on the configured host/port and invokes the callback', async () => {
|
|
216
|
+
const fakeHttpServer = { listen: jest.fn((port, host, cb) => cb()) };
|
|
217
|
+
HttpServer.mockReturnValueOnce(fakeHttpServer);
|
|
218
|
+
|
|
219
|
+
const server = new TestServer({ port: 4321, host: '0.0.0.0' });
|
|
220
|
+
const callback = jest.fn();
|
|
221
|
+
jest.spyOn(server, 'registerControllers').mockResolvedValue();
|
|
222
|
+
|
|
223
|
+
await server.listen(callback);
|
|
224
|
+
expect(fakeHttpServer.listen).toHaveBeenCalledWith(4321, '0.0.0.0', expect.any(Function));
|
|
225
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it ('does not require a callback', async () => {
|
|
229
|
+
const fakeHttpServer = { listen: jest.fn((port, host, cb) => cb()) };
|
|
230
|
+
HttpServer.mockReturnValueOnce(fakeHttpServer);
|
|
231
|
+
|
|
232
|
+
const server = new TestServer({ port: 5000, host: 'localhost' });
|
|
233
|
+
jest.spyOn(server, 'registerControllers').mockResolvedValue();
|
|
234
|
+
|
|
235
|
+
await expect(server.listen()).resolves.toBeUndefined();
|
|
236
|
+
expect(fakeHttpServer.listen).toHaveBeenCalledTimes(1);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
describe('VidaServer#corsOrigins', () => {
|
|
242
|
+
it ('defaults to an empty array', () => {
|
|
243
|
+
expect(new TestServer().corsOrigins).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
describe('VidaServer#viewsDirectory / staticFilesDirectory', () => {
|
|
249
|
+
it ('returns absolute paths under cwd', () => {
|
|
250
|
+
const server = new TestServer();
|
|
251
|
+
expect(server.viewsDirectory).toEqual(`${process.cwd()}/views`);
|
|
252
|
+
expect(server.staticFilesDirectory).toEqual(`${process.cwd()}/static`);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
describe('VidaServer#controllerDirectories (default)', () => {
|
|
258
|
+
it ('returns cwd/controllers when not overridden', () => {
|
|
259
|
+
const server = new VidaServer();
|
|
260
|
+
expect(server.controllerDirectories).toEqual([`${process.cwd()}/controllers`]);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
describe('VidaServer#connectionAbortedMiddleware', () => {
|
|
266
|
+
it ('short-circuits with a 400 when err.code === ECONNABORTED', async () => {
|
|
267
|
+
const server = new TestServer();
|
|
268
|
+
const middleware = server.connectionAbortedMiddleware;
|
|
269
|
+
const status = jest.fn().mockReturnValue({ end: jest.fn() });
|
|
270
|
+
const res = { status };
|
|
271
|
+
const next = jest.fn();
|
|
272
|
+
|
|
273
|
+
await middleware({ code: 'ECONNABORTED' }, {}, res, next);
|
|
274
|
+
expect(status).toHaveBeenCalledWith(400);
|
|
275
|
+
expect(next).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it ('calls next() for other errors', async () => {
|
|
279
|
+
const server = new TestServer();
|
|
280
|
+
const middleware = server.connectionAbortedMiddleware;
|
|
281
|
+
const next = jest.fn();
|
|
282
|
+
|
|
283
|
+
await middleware(new Error('something else'), {}, {}, next);
|
|
284
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it ('calls next() when there is no error', async () => {
|
|
288
|
+
const server = new TestServer();
|
|
289
|
+
const middleware = server.connectionAbortedMiddleware;
|
|
290
|
+
const next = jest.fn();
|
|
291
|
+
|
|
292
|
+
await middleware(null, {}, {}, next);
|
|
293
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
describe('VidaServer HTTP verb wrappers', () => {
|
|
299
|
+
const verbs = ['get', 'post', 'put', 'delete', 'patch', 'head'];
|
|
300
|
+
|
|
301
|
+
it.each(verbs)('_%s delegates to express app.%s', (verb) => {
|
|
302
|
+
const server = new TestServer();
|
|
303
|
+
const handler = jest.fn();
|
|
304
|
+
const app = lastApp();
|
|
305
|
+
app[verb].mockClear();
|
|
306
|
+
|
|
307
|
+
server[`_${verb}`]('/path', handler);
|
|
308
|
+
expect(app[verb]).toHaveBeenCalledWith('/path', handler);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
describe('VidaServer deprecated routing methods', () => {
|
|
314
|
+
const verbs = ['get', 'post', 'put', 'patch', 'head', 'delete'];
|
|
315
|
+
|
|
316
|
+
it.each(verbs)('%s emits a DeprecationWarning and delegates to express app.%s', (verb) => {
|
|
317
|
+
const emitSpy = jest.spyOn(process, 'emitWarning').mockImplementation(() => {});
|
|
318
|
+
try {
|
|
319
|
+
const server = new TestServer();
|
|
320
|
+
const handler = jest.fn();
|
|
321
|
+
const app = lastApp();
|
|
322
|
+
app[verb].mockClear();
|
|
323
|
+
|
|
324
|
+
server[verb]('/path', handler);
|
|
325
|
+
expect(emitSpy).toHaveBeenCalledWith(expect.stringContaining(`\`${verb}\` is deprecated`), 'DeprecationWarning');
|
|
326
|
+
expect(app[verb]).toHaveBeenCalledWith('/path', handler);
|
|
327
|
+
} finally {
|
|
328
|
+
emitSpy.mockRestore();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it ('only emits the warning once per method name', () => {
|
|
333
|
+
const emitSpy = jest.spyOn(process, 'emitWarning').mockImplementation(() => {});
|
|
334
|
+
try {
|
|
335
|
+
const server = new TestServer();
|
|
336
|
+
server.post('/a', jest.fn());
|
|
337
|
+
server.post('/b', jest.fn());
|
|
338
|
+
server.post('/c', jest.fn());
|
|
339
|
+
|
|
340
|
+
const postWarnings = emitSpy.mock.calls.filter(c => c[0].includes('`post`'));
|
|
341
|
+
expect(postWarnings).toHaveLength(1);
|
|
342
|
+
} finally {
|
|
343
|
+
emitSpy.mockRestore();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
describe('VidaServer web socket helpers', () => {
|
|
350
|
+
let fakeIo;
|
|
351
|
+
let server;
|
|
352
|
+
|
|
353
|
+
beforeEach(() => {
|
|
354
|
+
fakeIo = { to: jest.fn(() => fakeIo), emit: jest.fn(), on: jest.fn() };
|
|
355
|
+
IoServer.mockReturnValueOnce(fakeIo);
|
|
356
|
+
HttpServer.mockReturnValueOnce({ listen: jest.fn() });
|
|
357
|
+
server = new TestServer();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it ('emitWebSocketEvent forwards channel/event/data to the io server', () => {
|
|
361
|
+
server.emitWebSocketEvent('chan', 'evt', { hello: 'world' });
|
|
362
|
+
expect(fakeIo.to).toHaveBeenCalledWith('chan');
|
|
363
|
+
expect(fakeIo.emit).toHaveBeenCalledWith('evt', { hello: 'world' });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it ('addWebSocketEventHandler registers the handler on the io server', () => {
|
|
367
|
+
const handler = jest.fn();
|
|
368
|
+
server.addWebSocketEventHandler('connection', handler);
|
|
369
|
+
expect(fakeIo.on).toHaveBeenCalledWith('connection', handler);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
190
372
|
});
|
|
@@ -5,7 +5,11 @@ const Errors = require('../../lib/server/errors');
|
|
|
5
5
|
const { logger } = require('../../lib/logger');
|
|
6
6
|
const { randomPayload } = require('./helpers/serverController');
|
|
7
7
|
const TestHelpers = require('@vida-global/test-helpers');
|
|
8
|
-
const {
|
|
8
|
+
const {
|
|
9
|
+
NoActiveStreamError,
|
|
10
|
+
StreamInProgressError,
|
|
11
|
+
VidaServerController
|
|
12
|
+
} = require('../../lib/server/serverController');
|
|
9
13
|
|
|
10
14
|
describe('VidaServerController', () => {
|
|
11
15
|
describe('.actionNames', () => {
|
|
@@ -444,6 +448,35 @@ describe('VidaServerController', () => {
|
|
|
444
448
|
});
|
|
445
449
|
|
|
446
450
|
|
|
451
|
+
describe('.validateIsBoolean', () => {
|
|
452
|
+
const controller = new FooController({}, {});
|
|
453
|
+
it ('returns undefined when passed a matching boolean', () => {
|
|
454
|
+
const result1 = controller.validateIsBoolean(true, true);
|
|
455
|
+
const result2 = controller.validateIsBoolean(false, false);
|
|
456
|
+
expect(result1).toBe(undefined);
|
|
457
|
+
expect(result2).toBe(undefined);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it ('returns an error when passed an non matching boolean', () => {
|
|
461
|
+
const result1 = controller.validateIsBoolean(true, false);
|
|
462
|
+
const result2 = controller.validateIsBoolean(false, true);
|
|
463
|
+
expect(result1).toBe('must be false');
|
|
464
|
+
expect(result2).toBe('must be true');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it ('returns an error when passed an non boolean', () => {
|
|
468
|
+
const result1 = controller.validateIsBoolean('', false);
|
|
469
|
+
const result2 = controller.validateIsBoolean(0, false);
|
|
470
|
+
const result3 = controller.validateIsBoolean('foo', true);
|
|
471
|
+
const result4 = controller.validateIsBoolean(1, true);
|
|
472
|
+
expect(result1).toBe('must be false');
|
|
473
|
+
expect(result2).toBe('must be false');
|
|
474
|
+
expect(result3).toBe('must be true');
|
|
475
|
+
expect(result4).toBe('must be true');
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
|
|
447
480
|
describe('.validateFunction', () => {
|
|
448
481
|
const controller = new FooController({}, {});
|
|
449
482
|
const correctValue = Math.random();
|
|
@@ -494,8 +527,6 @@ describe('VidaServerController', () => {
|
|
|
494
527
|
const body = {[key]: value};
|
|
495
528
|
const message = TestHelpers.Faker.Text.randomString();
|
|
496
529
|
|
|
497
|
-
if (!Errors[errorClsName]) console.log(errorClsName);
|
|
498
|
-
|
|
499
530
|
const error = new Errors[errorClsName](message, body);
|
|
500
531
|
if (handlerName == 'renderErrors') error.errors = randomPayload();
|
|
501
532
|
|
|
@@ -708,8 +739,17 @@ describe('VidaServerController', () => {
|
|
|
708
739
|
|
|
709
740
|
|
|
710
741
|
describe('#logger', () => {
|
|
711
|
-
it ('uses the
|
|
712
|
-
expect(controller.logger).toBe(
|
|
742
|
+
it ('uses the a logger with http scope', () => {
|
|
743
|
+
expect(controller.logger.scope).toBe('http');
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it ('uses the request id', () => {
|
|
747
|
+
const id1 = TestHelpers.Faker.Text.randomString();
|
|
748
|
+
const id2 = TestHelpers.Faker.Text.randomString();
|
|
749
|
+
const controller1 = new FooController({ id: id1 }, response);
|
|
750
|
+
const controller2 = new FooController({ id: id2 }, response);
|
|
751
|
+
expect(controller1.logger.id).toBe(id1);
|
|
752
|
+
expect(controller2.logger.id).toBe(id2);
|
|
713
753
|
});
|
|
714
754
|
});
|
|
715
755
|
|
|
@@ -762,4 +802,196 @@ describe('VidaServerController', () => {
|
|
|
762
802
|
|
|
763
803
|
describe('Request Properties', () => {
|
|
764
804
|
});
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
describe('#statusText', () => {
|
|
808
|
+
const cases = [
|
|
809
|
+
[200, 'ok'],
|
|
810
|
+
[201, 'created'],
|
|
811
|
+
[202, 'accepted'],
|
|
812
|
+
[204, 'no content'],
|
|
813
|
+
[301, 'moved permanently'],
|
|
814
|
+
[302, 'found'],
|
|
815
|
+
[304, 'not modified'],
|
|
816
|
+
[307, 'temporary redirect'],
|
|
817
|
+
[308, 'permanent redirect'],
|
|
818
|
+
[400, 'bad request'],
|
|
819
|
+
[401, 'unauthorized'],
|
|
820
|
+
[402, 'payment required'],
|
|
821
|
+
[403, 'forbidden'],
|
|
822
|
+
[404, 'not found'],
|
|
823
|
+
[405, 'method not allowed'],
|
|
824
|
+
[408, 'request timeout'],
|
|
825
|
+
[409, 'conflict'],
|
|
826
|
+
[410, 'gone'],
|
|
827
|
+
[413, 'content too large'],
|
|
828
|
+
[415, 'unsupported media type'],
|
|
829
|
+
[422, 'unprocessable content'],
|
|
830
|
+
[423, 'locked'],
|
|
831
|
+
[429, 'too many requests'],
|
|
832
|
+
[500, 'server error'],
|
|
833
|
+
[501, 'not implemented'],
|
|
834
|
+
[502, 'bad gateway'],
|
|
835
|
+
[503, 'service unavailable'],
|
|
836
|
+
[504, 'gateway timeout'],
|
|
837
|
+
];
|
|
838
|
+
|
|
839
|
+
it.each(cases)('returns "%s" text for status %s', (statusCode, expected) => {
|
|
840
|
+
const controller = new FooController({}, { statusCode });
|
|
841
|
+
expect(controller.statusText).toEqual(expected);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it ('returns undefined for an unmapped status code', () => {
|
|
845
|
+
const controller = new FooController({}, { statusCode: 418 });
|
|
846
|
+
expect(controller.statusText).toBeUndefined();
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
describe('#streamSseResponse', () => {
|
|
852
|
+
let controller;
|
|
853
|
+
let response;
|
|
854
|
+
let request;
|
|
855
|
+
let setHeaderSpy;
|
|
856
|
+
|
|
857
|
+
beforeEach(() => {
|
|
858
|
+
request = { on: jest.fn(), off: jest.fn() };
|
|
859
|
+
response = {
|
|
860
|
+
setHeader: jest.fn(),
|
|
861
|
+
flushHeaders: jest.fn(),
|
|
862
|
+
write: jest.fn(),
|
|
863
|
+
end: jest.fn(),
|
|
864
|
+
writableEnded: false
|
|
865
|
+
};
|
|
866
|
+
controller = new FooController(request, response);
|
|
867
|
+
setHeaderSpy = response.setHeader;
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it ('sets SSE headers, flushes them, and marks rendered before invoking handler', async () => {
|
|
871
|
+
const handler = jest.fn(async function () {
|
|
872
|
+
expect(this).toBe(controller);
|
|
873
|
+
expect(controller.rendered).toBe(true);
|
|
874
|
+
expect(setHeaderSpy).toHaveBeenCalledWith('Content-Type', 'text/event-stream; charset=utf-8');
|
|
875
|
+
expect(setHeaderSpy).toHaveBeenCalledWith('Cache-Control', 'no-cache');
|
|
876
|
+
expect(setHeaderSpy).toHaveBeenCalledWith('Connection', 'keep-alive');
|
|
877
|
+
expect(setHeaderSpy).toHaveBeenCalledWith('X-Accel-Buffering', 'no');
|
|
878
|
+
expect(response.flushHeaders).toHaveBeenCalledTimes(1);
|
|
879
|
+
expect(controller.statusCode).toBe(200);
|
|
880
|
+
});
|
|
881
|
+
await controller.streamSseResponse(handler, jest.fn());
|
|
882
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it ('writes [DONE] and ends the response in the finally block', async () => {
|
|
886
|
+
await controller.streamSseResponse(jest.fn(), jest.fn());
|
|
887
|
+
expect(response.write).toHaveBeenLastCalledWith('data: [DONE]\n\n');
|
|
888
|
+
expect(response.end).toHaveBeenCalledTimes(1);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it ('throws StreamInProgressError when called while already streaming', async () => {
|
|
892
|
+
await controller.streamSseResponse(async () => {
|
|
893
|
+
await expect(controller.streamSseResponse(jest.fn(), jest.fn()))
|
|
894
|
+
.rejects.toBeInstanceOf(StreamInProgressError);
|
|
895
|
+
}, jest.fn());
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it ('invokes errorHandler and rethrows when handler throws', async () => {
|
|
899
|
+
const err = new Error('boom');
|
|
900
|
+
const handler = jest.fn(async () => { throw err; });
|
|
901
|
+
const errorHandler = jest.fn();
|
|
902
|
+
await expect(controller.streamSseResponse(handler, errorHandler)).rejects.toThrow('boom');
|
|
903
|
+
expect(errorHandler).toHaveBeenCalledWith(err);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it ('still ends the response when handler throws', async () => {
|
|
907
|
+
const errorHandler = jest.fn();
|
|
908
|
+
await expect(controller.streamSseResponse(
|
|
909
|
+
async () => { throw new Error('boom'); },
|
|
910
|
+
errorHandler
|
|
911
|
+
)).rejects.toThrow();
|
|
912
|
+
expect(response.end).toHaveBeenCalledTimes(1);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it ('wires close listener to abortController when one is provided', async () => {
|
|
916
|
+
const abortController = { abort: jest.fn() };
|
|
917
|
+
await controller.streamSseResponse(jest.fn(), jest.fn(), abortController);
|
|
918
|
+
expect(request.on).toHaveBeenCalledWith('close', expect.any(Function));
|
|
919
|
+
expect(request.off).toHaveBeenCalledWith('close', expect.any(Function));
|
|
920
|
+
// The handler registered on 'close' should call abort.
|
|
921
|
+
const closeHandler = request.on.mock.calls.find(c => c[0] === 'close')[1];
|
|
922
|
+
closeHandler();
|
|
923
|
+
expect(abortController.abort).toHaveBeenCalledTimes(1);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it ('does not register a close listener when no abortController is provided', async () => {
|
|
927
|
+
await controller.streamSseResponse(jest.fn(), jest.fn());
|
|
928
|
+
expect(request.on).not.toHaveBeenCalled();
|
|
929
|
+
expect(request.off).not.toHaveBeenCalled();
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it ('resets streaming state after completion so a new stream can start', async () => {
|
|
933
|
+
await controller.streamSseResponse(jest.fn(), jest.fn());
|
|
934
|
+
// Second call should not throw StreamInProgressError.
|
|
935
|
+
await expect(controller.streamSseResponse(jest.fn(), jest.fn())).resolves.toBeUndefined();
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
describe('#writeStreamEvent', () => {
|
|
941
|
+
let controller;
|
|
942
|
+
let response;
|
|
943
|
+
|
|
944
|
+
beforeEach(() => {
|
|
945
|
+
response = {
|
|
946
|
+
setHeader: jest.fn(),
|
|
947
|
+
flushHeaders: jest.fn(),
|
|
948
|
+
write: jest.fn(),
|
|
949
|
+
end: jest.fn(),
|
|
950
|
+
writableEnded: false
|
|
951
|
+
};
|
|
952
|
+
controller = new FooController({ on: jest.fn(), off: jest.fn() }, response);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it ('throws NoActiveStreamError when called outside a stream', () => {
|
|
956
|
+
expect(() => controller.writeStreamEvent('msg', 'hello'))
|
|
957
|
+
.toThrow(NoActiveStreamError);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it ('writes both event and data lines when an event name is provided', async () => {
|
|
961
|
+
await controller.streamSseResponse(async () => {
|
|
962
|
+
response.write.mockClear();
|
|
963
|
+
controller.writeStreamEvent('progress', 'halfway');
|
|
964
|
+
expect(response.write).toHaveBeenNthCalledWith(1, 'event: progress\n');
|
|
965
|
+
expect(response.write).toHaveBeenNthCalledWith(2, 'data: halfway\n\n');
|
|
966
|
+
}, jest.fn());
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it ('writes only the data line when no event name is provided', async () => {
|
|
970
|
+
await controller.streamSseResponse(async () => {
|
|
971
|
+
response.write.mockClear();
|
|
972
|
+
controller.writeStreamEvent(null, 'plain');
|
|
973
|
+
expect(response.write).toHaveBeenCalledTimes(1);
|
|
974
|
+
expect(response.write).toHaveBeenCalledWith('data: plain\n\n');
|
|
975
|
+
}, jest.fn());
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it ('is a no-op when the response is already ended', async () => {
|
|
979
|
+
await controller.streamSseResponse(async () => {
|
|
980
|
+
response.writableEnded = true;
|
|
981
|
+
response.write.mockClear();
|
|
982
|
+
controller.writeStreamEvent(null, 'ignored');
|
|
983
|
+
expect(response.write).not.toHaveBeenCalled();
|
|
984
|
+
}, jest.fn());
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it ('is a no-op when the abort signal is aborted', async () => {
|
|
988
|
+
const abortController = { abort: jest.fn(), signal: { aborted: false } };
|
|
989
|
+
await controller.streamSseResponse(async () => {
|
|
990
|
+
abortController.signal.aborted = true;
|
|
991
|
+
response.write.mockClear();
|
|
992
|
+
controller.writeStreamEvent(null, 'ignored');
|
|
993
|
+
expect(response.write).not.toHaveBeenCalled();
|
|
994
|
+
}, jest.fn(), abortController);
|
|
995
|
+
});
|
|
996
|
+
});
|
|
765
997
|
});
|