@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { logger } = require('../logger');
|
|
2
2
|
const { camelize, singularize } = require('inflection');
|
|
3
3
|
const Errors = require('./errors');
|
|
4
|
+
const nodeUtil = require('util');
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
const AUTH_CALLBACK_NAME = 'authenticateRequest';
|
|
@@ -12,10 +13,13 @@ class VidaServerController {
|
|
|
12
13
|
#beforeCallbacks = [];
|
|
13
14
|
#beforeCallbacksToSkip = [];
|
|
14
15
|
#callbacksSetUp = false;
|
|
16
|
+
#isStreaming = false;
|
|
17
|
+
#logger;
|
|
15
18
|
#params;
|
|
16
19
|
#rendered = false;
|
|
17
20
|
#request;
|
|
18
21
|
#response;
|
|
22
|
+
#streamAbortController;
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
constructor(request, response) {
|
|
@@ -41,16 +45,36 @@ class VidaServerController {
|
|
|
41
45
|
return structuredClone(this.#params);
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
get requestId() { return this._request.id; }
|
|
44
49
|
get requestHeaders() { return structuredClone(this._request.headers || {}); }
|
|
45
50
|
get responseHeaders() { return this._response.headers; };
|
|
46
51
|
get requestBody() { return this._request.body; }
|
|
52
|
+
get requestMethod() { return this._request.method; }
|
|
53
|
+
get requestIp() { return this._request.ip; }
|
|
54
|
+
get url() { return this._request.originalUrl; }
|
|
47
55
|
get contentType() { return this.requestHeaders['content-type']; }
|
|
48
|
-
get
|
|
49
|
-
get rendered() { return this.#rendered }
|
|
50
|
-
markRendered() { this.#rendered = true; }
|
|
56
|
+
get userAgent() { return this.requestHeaders['user-agent']; }
|
|
51
57
|
|
|
52
|
-
get statusCode()
|
|
53
|
-
set statusCode(_status)
|
|
58
|
+
get statusCode() { return this._response.statusCode; }
|
|
59
|
+
set statusCode(_status) { this._response.statusCode = _status; }
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
get logger() {
|
|
63
|
+
if (!this.#logger) {
|
|
64
|
+
this.#logger = logger.http.createChild(this.requestId);
|
|
65
|
+
}
|
|
66
|
+
return this.#logger;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
get bearerToken() {
|
|
71
|
+
const auth = this.requestHeaders.authorization;
|
|
72
|
+
if (!auth) return null;
|
|
73
|
+
|
|
74
|
+
const match = /^\s*bearer\s+(.+)$/i.exec(auth);
|
|
75
|
+
if (!match) return null;
|
|
76
|
+
return match[1].trim();
|
|
77
|
+
}
|
|
54
78
|
|
|
55
79
|
|
|
56
80
|
#processRequestData(key) {
|
|
@@ -148,6 +172,9 @@ class VidaServerController {
|
|
|
148
172
|
* RESPONSE RENDERING
|
|
149
173
|
***********************************************************************************************/
|
|
150
174
|
async render(body, options={}) {
|
|
175
|
+
if (this.rendered) return;
|
|
176
|
+
if (this.#isStreaming) return;
|
|
177
|
+
|
|
151
178
|
if (typeof body == 'string') {
|
|
152
179
|
this._response.send(body);
|
|
153
180
|
} else {
|
|
@@ -159,7 +186,7 @@ class VidaServerController {
|
|
|
159
186
|
body = this.formatJSONBody(body, errors, options);
|
|
160
187
|
this._response.json(body);
|
|
161
188
|
}
|
|
162
|
-
this
|
|
189
|
+
this.markRendered();
|
|
163
190
|
}
|
|
164
191
|
|
|
165
192
|
|
|
@@ -196,6 +223,8 @@ class VidaServerController {
|
|
|
196
223
|
if (body === undefined) return null;
|
|
197
224
|
if (!body || typeof body != 'object') return body;
|
|
198
225
|
|
|
226
|
+
if (nodeUtil.types.isProxy(body)) body = {...body};
|
|
227
|
+
|
|
199
228
|
body = structuredClone(body);
|
|
200
229
|
|
|
201
230
|
if (body.toApiResponse) {
|
|
@@ -324,6 +353,77 @@ class VidaServerController {
|
|
|
324
353
|
}
|
|
325
354
|
|
|
326
355
|
|
|
356
|
+
setHeader(header, value) {
|
|
357
|
+
this.#response.setHeader(header, value);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
flushHeaders() {
|
|
362
|
+
this.#response.flushHeaders();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
get rendered() { return this.#rendered; }
|
|
367
|
+
markRendered() { this.#rendered = true; }
|
|
368
|
+
get writableEnded() { return this.#response.writableEnded; }
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
/***********************************************************************************************
|
|
372
|
+
* STREAM
|
|
373
|
+
***********************************************************************************************/
|
|
374
|
+
async streamSseResponse(handler, errorHandler, abortController=null) {
|
|
375
|
+
if (this.#isStreaming) throw new StreamInProgressError();
|
|
376
|
+
this.#isStreaming = true;
|
|
377
|
+
|
|
378
|
+
let closeHandler;
|
|
379
|
+
if (abortController) {
|
|
380
|
+
this.#streamAbortController = abortController;
|
|
381
|
+
closeHandler = () => abortController.abort();
|
|
382
|
+
this.#request.on('close', closeHandler);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.#setSseHeaders();
|
|
386
|
+
this.markRendered();
|
|
387
|
+
try {
|
|
388
|
+
await handler.bind(this)();
|
|
389
|
+
} catch(err) {
|
|
390
|
+
await errorHandler(err);
|
|
391
|
+
throw err;
|
|
392
|
+
} finally {
|
|
393
|
+
this.writeStreamEvent(null, '[DONE]');
|
|
394
|
+
this.#response.end();
|
|
395
|
+
|
|
396
|
+
this.#isStreaming = false;
|
|
397
|
+
this.#streamAbortController = null;
|
|
398
|
+
|
|
399
|
+
if (closeHandler) this.#request.off('close', closeHandler);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
#setSseHeaders() {
|
|
405
|
+
this.statusCode = 200;
|
|
406
|
+
this.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
407
|
+
this.setHeader('Cache-Control', 'no-cache');
|
|
408
|
+
this.setHeader('Connection', 'keep-alive');
|
|
409
|
+
this.setHeader('X-Accel-Buffering', 'no');
|
|
410
|
+
this.flushHeaders();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
writeStreamEvent(evt, data) {
|
|
415
|
+
if (!this.#isStreaming) throw new NoActiveStreamError();
|
|
416
|
+
if (this.#streamAbortController?.signal?.aborted || this.writableEnded) return;
|
|
417
|
+
|
|
418
|
+
if (evt) {
|
|
419
|
+
this.#response.write(`event: ${evt}\n`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const msg = `data: ${data}\n\n`;
|
|
423
|
+
this.#response.write(msg);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
327
427
|
/***********************************************************************************************
|
|
328
428
|
* HELPERS
|
|
329
429
|
***********************************************************************************************/
|
|
@@ -531,6 +631,12 @@ class VidaServerController {
|
|
|
531
631
|
}
|
|
532
632
|
|
|
533
633
|
|
|
634
|
+
validateIsBoolean(value, bool) {
|
|
635
|
+
if (typeof value == 'boolean' && value === bool) return;
|
|
636
|
+
return `must be ${bool}`;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
534
640
|
async validateFunction(value, fnc) {
|
|
535
641
|
const error = await fnc.call(this, value);
|
|
536
642
|
if (error) return error;
|
|
@@ -670,15 +776,31 @@ class VidaServerController {
|
|
|
670
776
|
static authenticationDocumentation(actionName) {
|
|
671
777
|
return {apiKeyAuth: []};
|
|
672
778
|
}
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
/***********************************************************************************************
|
|
782
|
+
* SECURITY
|
|
783
|
+
***********************************************************************************************/
|
|
784
|
+
secureCompare(a, b) {
|
|
785
|
+
const aBuf = Buffer.from(a || '', 'utf8');
|
|
786
|
+
const bBuf = Buffer.from(b || '', 'utf8');
|
|
787
|
+
if (aBuf.length !== bBuf.length) return false;
|
|
788
|
+
return crypto.timingSafeEqual(aBuf, bBuf);
|
|
789
|
+
}
|
|
673
790
|
}
|
|
674
791
|
|
|
675
792
|
|
|
793
|
+
class StreamInProgressError extends Error {}
|
|
794
|
+
class NoActiveStreamError extends Error {}
|
|
795
|
+
|
|
676
796
|
const errorClasses = Object.values(Errors).filter(cls => cls == Errors.ServerError || cls.prototype instanceof Errors.AbstractServerError);
|
|
677
797
|
VidaServerController.prototype.Errors = Object.fromEntries(errorClasses.map(cls => {
|
|
678
798
|
return [cls.name.replace('Error', ''), cls]
|
|
679
799
|
}));
|
|
680
800
|
|
|
681
801
|
|
|
682
|
-
module.exports = {
|
|
802
|
+
module.exports = {
|
|
803
|
+
NoActiveStreamError,
|
|
804
|
+
StreamInProgressError,
|
|
683
805
|
VidaServerController
|
|
684
806
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vida-global/core",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
4
4
|
"description": "Core libraries for supporting Vida development",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "ISC",
|
|
@@ -20,17 +20,17 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"commander": "^13.1.0",
|
|
22
22
|
"express": "^4.21.2",
|
|
23
|
+
"express-request-id": "1.4.1",
|
|
24
|
+
"express-winston": "^4.0.0",
|
|
23
25
|
"mustache-express": "^1.2.8",
|
|
24
26
|
"pg": "^8.16.3",
|
|
25
|
-
"pino": "^9.6.0",
|
|
26
|
-
"pino-http": "^10.4.0",
|
|
27
|
-
"pino-pretty": "^13.0.0",
|
|
28
27
|
"redis": "^5.0.0",
|
|
29
28
|
"response-time": "^2.3.3",
|
|
30
29
|
"sequelize": "^6.37.7",
|
|
31
30
|
"sequelize-cli": "^6.6.3",
|
|
32
31
|
"socket.io": "^4.4.0",
|
|
33
|
-
"@vida-global/release": "^1.0.0"
|
|
32
|
+
"@vida-global/release": "^1.0.0",
|
|
33
|
+
"winston": "^3.0.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"jest": "^29.0.0",
|
|
@@ -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
|
+
});
|
package/test/http/client.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const { HttpClient } = require('../../lib/http/client');
|
|
2
|
-
const { logger }
|
|
3
|
-
const Helpers
|
|
1
|
+
const { HttpClient, HttpAbortError } = require('../../lib/http/client');
|
|
2
|
+
const { logger } = require('../../lib/logger');
|
|
3
|
+
const Helpers = require('./helpers/client');
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
afterEach(() => {
|
|
@@ -90,4 +90,73 @@ describe('HttpClient', () => {
|
|
|
90
90
|
});
|
|
91
91
|
}
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
describe('abort/timeout support', () => {
|
|
96
|
+
const client = new HttpClient();
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
it('passes a signal when timeout is provided', async () => {
|
|
100
|
+
await client.post(Helpers.apiUrl, { timeout: 5000 });
|
|
101
|
+
|
|
102
|
+
const callOpts = fetch.mock.calls[0][1];
|
|
103
|
+
expect(callOpts.signal).toBeInstanceOf(AbortSignal);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
it('passes a signal when an external signal is provided', async () => {
|
|
108
|
+
const externalSignal = new AbortController().signal;
|
|
109
|
+
|
|
110
|
+
await client.post(Helpers.apiUrl, { signal: externalSignal });
|
|
111
|
+
|
|
112
|
+
const callOpts = fetch.mock.calls[0][1];
|
|
113
|
+
expect(callOpts.signal).toBe(externalSignal);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
it('composes external signal with timeout', async () => {
|
|
118
|
+
const externalSignal = new AbortController().signal;
|
|
119
|
+
|
|
120
|
+
await client.post(Helpers.apiUrl, { signal: externalSignal, timeout: 5000 });
|
|
121
|
+
|
|
122
|
+
const callOpts = fetch.mock.calls[0][1];
|
|
123
|
+
expect(callOpts.signal).toBeInstanceOf(AbortSignal);
|
|
124
|
+
expect(callOpts.signal).not.toBe(externalSignal);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
it('omits signal when neither timeout nor signal is provided', async () => {
|
|
129
|
+
await client.post(Helpers.apiUrl);
|
|
130
|
+
|
|
131
|
+
const callOpts = fetch.mock.calls[0][1];
|
|
132
|
+
expect(callOpts.signal).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
it('throws HttpAbortError with reason "timed out" when fetch rejects with AbortError under a timeout', async () => {
|
|
137
|
+
global.fetch = jest.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'));
|
|
138
|
+
|
|
139
|
+
await expect(client.post(Helpers.apiUrl, { timeout: 1 }))
|
|
140
|
+
.rejects.toBeInstanceOf(HttpAbortError);
|
|
141
|
+
await expect(client.post(Helpers.apiUrl, { timeout: 1 }))
|
|
142
|
+
.rejects.toMatchObject({ reason: 'timed out' });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
it('throws HttpAbortError with reason "aborted" when caller-supplied signal aborts the fetch', async () => {
|
|
147
|
+
global.fetch = jest.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'));
|
|
148
|
+
const externalSignal = new AbortController().signal;
|
|
149
|
+
|
|
150
|
+
await expect(client.post(Helpers.apiUrl, { signal: externalSignal }))
|
|
151
|
+
.rejects.toMatchObject({ reason: 'aborted' });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
it('rethrows non-abort fetch errors unchanged', async () => {
|
|
156
|
+
const networkErr = new Error('connect ECONNREFUSED');
|
|
157
|
+
global.fetch = jest.fn().mockRejectedValue(networkErr);
|
|
158
|
+
|
|
159
|
+
await expect(client.post(Helpers.apiUrl)).rejects.toBe(networkErr);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
93
162
|
});
|