@vida-global/core 1.4.5 → 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/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/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,217 @@
|
|
|
1
|
+
jest.mock('../../../lib/activeRecord/db/importSchema', () => jest.fn());
|
|
2
|
+
|
|
3
|
+
jest.mock('../../../lib/activeRecord/db/connection', () => {
|
|
4
|
+
const clearConnectionsCache = jest.fn();
|
|
5
|
+
const allTables = jest.fn();
|
|
6
|
+
function Connection() {
|
|
7
|
+
this.queryInterface = { allTables };
|
|
8
|
+
}
|
|
9
|
+
Connection.clearConnectionsCache = clearConnectionsCache;
|
|
10
|
+
Connection.__allTables = allTables;
|
|
11
|
+
return { Connection };
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
jest.mock('../../../lib/activeRecord/db/connectionConfiguration', () => ({
|
|
15
|
+
ConnectionConfiguration: { configuredDatabaseIds: jest.fn() }
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
jest.mock('../../../lib/activeRecord/utils', () => {
|
|
19
|
+
const actual = jest.requireActual('../../../lib/activeRecord/utils');
|
|
20
|
+
return { ...actual, ensureSchemasDirectory: jest.fn() };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const importSchema = require('../../../lib/activeRecord/db/importSchema');
|
|
25
|
+
const { Connection } = require('../../../lib/activeRecord/db/connection');
|
|
26
|
+
const { ConnectionConfiguration } = require('../../../lib/activeRecord/db/connectionConfiguration');
|
|
27
|
+
const utils = require('../../../lib/activeRecord/utils');
|
|
28
|
+
const { generateActiveRecordSchemaFiles, getActiveRecordSchema } = require('../../../lib/activeRecord/db/schema');
|
|
29
|
+
const { Sequelize } = require('sequelize');
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
jest.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
describe('activeRecord/db/schema', () => {
|
|
37
|
+
describe('.getActiveRecordSchema', () => {
|
|
38
|
+
it ('throws when no schema is found for the table', () => {
|
|
39
|
+
importSchema.mockImplementation(() => undefined);
|
|
40
|
+
expect(() => getActiveRecordSchema('users', 'main', 'postgres'))
|
|
41
|
+
.toThrow('No schema found for table main.users');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
describe('type mapping (cleanSchema → dataTypeForColumn)', () => {
|
|
46
|
+
it ('maps INTEGER columns to Sequelize.DataTypes.INTEGER', () => {
|
|
47
|
+
importSchema.mockImplementation(() => ({
|
|
48
|
+
id: { type: 'INTEGER', primaryKey: true }
|
|
49
|
+
}));
|
|
50
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
51
|
+
expect(schema.id.type).toBe(Sequelize.DataTypes.INTEGER);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it ('maps BOOLEAN columns to Sequelize.DataTypes.BOOLEAN', () => {
|
|
55
|
+
importSchema.mockImplementation(() => ({
|
|
56
|
+
flag: { type: 'BOOLEAN' }
|
|
57
|
+
}));
|
|
58
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
59
|
+
expect(schema.flag.type).toBe(Sequelize.DataTypes.BOOLEAN);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it ('strips parameterized type modifiers before lookup', () => {
|
|
63
|
+
importSchema.mockImplementation(() => ({
|
|
64
|
+
name: { type: 'INTEGER(10)' }
|
|
65
|
+
}));
|
|
66
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
67
|
+
expect(schema.name.type).toBe(Sequelize.DataTypes.INTEGER);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it ('falls back to STRING when type contains CHAR but is not otherwise matched', () => {
|
|
71
|
+
importSchema.mockImplementation(() => ({
|
|
72
|
+
code: { type: 'NCHARFOO' }
|
|
73
|
+
}));
|
|
74
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
75
|
+
expect(schema.code.type).toBe(Sequelize.DataTypes.STRING);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it ('leaves the original type when no match is found', () => {
|
|
79
|
+
importSchema.mockImplementation(() => ({
|
|
80
|
+
weird: { type: 'COMPLETELY_UNKNOWN_TYPE' }
|
|
81
|
+
}));
|
|
82
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
83
|
+
expect(schema.weird.type).toBe('COMPLETELY_UNKNOWN_TYPE');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
describe('autoIncrement resolution', () => {
|
|
89
|
+
it ('sets autoIncrement and removes defaultValue + allowNull when default is a PG sequence', () => {
|
|
90
|
+
importSchema.mockImplementation(() => ({
|
|
91
|
+
id: {
|
|
92
|
+
type: 'INTEGER',
|
|
93
|
+
primaryKey: true,
|
|
94
|
+
defaultValue: 'nextval(users_id_seq::regclass)',
|
|
95
|
+
allowNull: false
|
|
96
|
+
}
|
|
97
|
+
}));
|
|
98
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
99
|
+
expect(schema.id.autoIncrement).toBe(true);
|
|
100
|
+
expect(schema.id).not.toHaveProperty('defaultValue');
|
|
101
|
+
expect(schema.id).not.toHaveProperty('allowNull');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it ('does not modify columns whose defaultValue is not a sequence', () => {
|
|
105
|
+
importSchema.mockImplementation(() => ({
|
|
106
|
+
name: {
|
|
107
|
+
type: 'INTEGER',
|
|
108
|
+
defaultValue: 'literal',
|
|
109
|
+
allowNull: true
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
112
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
113
|
+
expect(schema.name.autoIncrement).toBeUndefined();
|
|
114
|
+
expect(schema.name.defaultValue).toBe('literal');
|
|
115
|
+
expect(schema.name.allowNull).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it ('does nothing when there is no defaultValue', () => {
|
|
119
|
+
importSchema.mockImplementation(() => ({
|
|
120
|
+
name: { type: 'INTEGER' }
|
|
121
|
+
}));
|
|
122
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
123
|
+
expect(schema.name.autoIncrement).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
describe('UUID resolution', () => {
|
|
129
|
+
it ('sets UUIDV4 default on UUID primary keys whose type stays as the string "UUID"', () => {
|
|
130
|
+
importSchema.mockImplementation(() => ({
|
|
131
|
+
id: { type: 'UUID', primaryKey: true }
|
|
132
|
+
}));
|
|
133
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
134
|
+
// Whether dataTypeForColumn rewrote the type or not, behavior is observable:
|
|
135
|
+
// if it stayed as 'UUID', resolveUUIDColumns sets the default.
|
|
136
|
+
if (schema.id.type === 'UUID') {
|
|
137
|
+
expect(schema.id.defaultValue).toBe(Sequelize.UUIDV4);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it ('does not set a UUIDV4 default on UUID columns that are not primary keys', () => {
|
|
142
|
+
importSchema.mockImplementation(() => ({
|
|
143
|
+
ref: { type: 'UUID' }
|
|
144
|
+
}));
|
|
145
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
146
|
+
expect(schema.ref.defaultValue).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it ('does not set a UUIDV4 default on non-UUID primary keys', () => {
|
|
150
|
+
importSchema.mockImplementation(() => ({
|
|
151
|
+
id: { type: 'INTEGER', primaryKey: true }
|
|
152
|
+
}));
|
|
153
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
154
|
+
expect(schema.id.defaultValue).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
describe('.generateActiveRecordSchemaFiles', () => {
|
|
161
|
+
let writeFileSpy;
|
|
162
|
+
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
writeFileSpy = jest.spyOn(fs, 'writeFile').mockImplementation((p, c, cb) => cb && cb(null));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
afterEach(() => {
|
|
168
|
+
writeFileSpy.mockRestore();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it ('ensures the schemas directory exists before writing', async () => {
|
|
172
|
+
ConnectionConfiguration.configuredDatabaseIds.mockReturnValue([]);
|
|
173
|
+
await generateActiveRecordSchemaFiles();
|
|
174
|
+
expect(utils.ensureSchemasDirectory).toHaveBeenCalledTimes(1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it ('writes one schema file per configured database id', async () => {
|
|
178
|
+
ConnectionConfiguration.configuredDatabaseIds.mockReturnValue(['main', 'analytics']);
|
|
179
|
+
Connection.__allTables.mockResolvedValue({ users: { id: { type: 'INTEGER' } } });
|
|
180
|
+
|
|
181
|
+
await generateActiveRecordSchemaFiles();
|
|
182
|
+
expect(Connection.clearConnectionsCache).toHaveBeenCalledTimes(2);
|
|
183
|
+
expect(writeFileSpy).toHaveBeenCalledTimes(2);
|
|
184
|
+
|
|
185
|
+
const writtenPaths = writeFileSpy.mock.calls.map(c => c[0]);
|
|
186
|
+
expect(writtenPaths[0]).toEqual(`${utils.schemasDirectory}/main.js`);
|
|
187
|
+
expect(writtenPaths[1]).toEqual(`${utils.schemasDirectory}/analytics.js`);
|
|
188
|
+
|
|
189
|
+
const writtenBody = writeFileSpy.mock.calls[0][1];
|
|
190
|
+
expect(writtenBody).toContain('module.exports = ');
|
|
191
|
+
expect(writtenBody).toContain('"users"');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it ('logs an error when fs.writeFile fails (does not reject)', async () => {
|
|
195
|
+
const { logger } = require('../../../lib/logger');
|
|
196
|
+
const errSpy = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
|
197
|
+
ConnectionConfiguration.configuredDatabaseIds.mockReturnValue(['main']);
|
|
198
|
+
Connection.__allTables.mockResolvedValue({});
|
|
199
|
+
writeFileSpy.mockImplementation((p, c, cb) => cb(new Error('disk full')));
|
|
200
|
+
|
|
201
|
+
await generateActiveRecordSchemaFiles();
|
|
202
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Unable to write new schema file'));
|
|
203
|
+
errSpy.mockRestore();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it ('logs success when fs.writeFile succeeds', async () => {
|
|
207
|
+
const { logger } = require('../../../lib/logger');
|
|
208
|
+
const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {});
|
|
209
|
+
ConnectionConfiguration.configuredDatabaseIds.mockReturnValue(['main']);
|
|
210
|
+
Connection.__allTables.mockResolvedValue({});
|
|
211
|
+
|
|
212
|
+
await generateActiveRecordSchemaFiles();
|
|
213
|
+
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('New schemas file written'));
|
|
214
|
+
infoSpy.mockRestore();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const utils = require('../../lib/activeRecord/utils');
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('activeRecord/utils', () => {
|
|
6
|
+
describe('.toCamelCase', () => {
|
|
7
|
+
it ('converts kebab-case to camelCase', () => {
|
|
8
|
+
expect(utils.toCamelCase('foo-bar-baz')).toEqual('fooBarBaz');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it ('converts snake_case to camelCase', () => {
|
|
12
|
+
expect(utils.toCamelCase('foo_bar_baz')).toEqual('fooBarBaz');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it ('converts space-separated words to camelCase', () => {
|
|
16
|
+
expect(utils.toCamelCase('foo bar baz')).toEqual('fooBarBaz');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it ('handles mixed separators', () => {
|
|
20
|
+
expect(utils.toCamelCase('foo-bar_baz qux')).toEqual('fooBarBazQux');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it ('lowercases the first token even when capitalized', () => {
|
|
24
|
+
expect(utils.toCamelCase('Foo-Bar')).toEqual('fooBar');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it ('returns a single token lowercased', () => {
|
|
28
|
+
expect(utils.toCamelCase('FOO')).toEqual('foo');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it ('returns an empty string when given an empty string', () => {
|
|
32
|
+
expect(utils.toCamelCase('')).toEqual('');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
describe('directory ensurers', () => {
|
|
38
|
+
let mkdirSpy;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
mkdirSpy.mockRestore();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('.ensureConfigDirectory', () => {
|
|
49
|
+
it ('creates the config directory recursively', () => {
|
|
50
|
+
utils.ensureConfigDirectory();
|
|
51
|
+
expect(mkdirSpy).toHaveBeenCalledTimes(1);
|
|
52
|
+
expect(mkdirSpy).toHaveBeenCalledWith(utils.configDirectory, { recursive: true });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('.ensureMigrationsDirectory', () => {
|
|
57
|
+
it ('creates the migrations directory recursively', () => {
|
|
58
|
+
utils.ensureMigrationsDirectory();
|
|
59
|
+
expect(mkdirSpy).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(mkdirSpy).toHaveBeenCalledWith(utils.migrationsDirectory, { recursive: true });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('.ensureSchemasDirectory', () => {
|
|
65
|
+
it ('creates the schemas directory recursively', () => {
|
|
66
|
+
utils.ensureSchemasDirectory();
|
|
67
|
+
expect(mkdirSpy).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(mkdirSpy).toHaveBeenCalledWith(utils.schemasDirectory, { recursive: true });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -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
|
});
|