@pxp-nd/core 1.2.89 → 1.2.91

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/README.md CHANGED
@@ -1,2 +1,95 @@
1
1
  # PXP-ND core
2
- - Core module for pxp-nd
2
+ - Core module for pxp-nd
3
+
4
+ ## 1.2.91
5
+
6
+ Correcciones encontradas en las pruebas de carga con la base de datos caída. Sin cambios de API ni de configuración.
7
+
8
+ ### A. La respuesta de error ya no se cuelga si falla el log
9
+
10
+ Antes, en ambos handlers de ruta (con y sin autenticación), el `insertLog` del error se esperaba antes de llamar a `errorMiddleware`. Con la base caída `insertLog` fallaba, la excepción escapaba del `catch`, nunca se enviaba la respuesta y la request quedaba colgada hasta el timeout del cliente/ALB (en el log: `unhandledRejection PxpError: connect ECONNREFUSED`).
11
+
12
+ Ahora el log de error va en su propio `try/catch`: `errorMiddleware` **siempre** responde con el error original. Si el log falla se escribe `[pxp-core] error log failed <mensaje>` en consola y la respuesta sale sin `logId`.
13
+
14
+ ### B. El log de éxito ya no genera `unhandledRejection`
15
+
16
+ El `insertLog` del camino de éxito de `ormMethodWrapper` no se espera (fire-and-forget) y no tenía `.catch`: si fallaba, el rechazo quedaba sin manejar y en Node ≥15 sin handler mata el proceso. Ahora tiene `.catch` que escribe `[pxp-core] success log failed <mensaje>`. (Los wrappers `Procedure` y `Sql` no registran log, no aplica.)
17
+
18
+ ### C. La conexión que llega tarde tras un acquire timeout vuelve al pool
19
+
20
+ Con `db.acquireTimeoutMs`, cuando la conexión llegaba después del timeout se destruía. Esa conexión está limpia (nunca se abrió transacción), así que destruirla obligaba a mysql2 a abrir otra y dejaba una cola residual (observado: health en 503 unos 6 s después de una ráfaga de 40 requests). Ahora se devuelve al pool con `queryRunner.release()`. El timeout de request (`@Timeout`) sigue destruyendo la conexión, porque ahí sí puede haber una transacción abierta.
21
+
22
+ ## 1.2.90
23
+
24
+ Release de resiliencia de conexiones a base de datos. **Sin configurar nada, el comportamiento es idéntico a 1.2.89**: todas las opciones nuevas son opt-in.
25
+
26
+ ### `db.acquireTimeoutMs` (opcional)
27
+
28
+ Tiempo máximo, en milisegundos, que una ruta no-readonly espera para obtener una conexión del pool antes de fallar.
29
+
30
+ ```ts
31
+ new PxpApp({
32
+ // ...
33
+ db: { acquireTimeoutMs: 5000 },
34
+ });
35
+ ```
36
+
37
+ - Si vence, la request responde **503** con el mensaje `Database acquire timeout after N ms`.
38
+ - Solo se dispara cuando el pool está agotado; con conexiones libres no agrega latencia.
39
+ - `undefined` o `0` = comportamiento legacy (espera indefinida).
40
+
41
+ ### Decorador `@Timeout(ms)` y opción de ruta `timeoutMs`
42
+
43
+ Límite de duración por ruta. Se puede usar el decorador o la opción en la definición de la ruta:
44
+
45
+ ```ts
46
+ @Post('/save')
47
+ @Timeout(30000)
48
+ async save(params: any) { /* ... */ }
49
+
50
+ // o bien
51
+ @Post('/save', { timeoutMs: 30000 })
52
+ ```
53
+
54
+ Al vencer el tiempo:
55
+ - La request responde **503**.
56
+ - La conexión física se destruye, por lo que MySQL hace rollback de la transacción abierta.
57
+ - El handler puede seguir ejecutándose en segundo plano, pero ya sin acceso a la base de datos.
58
+ - Se registra la línea de log `[pxp-core] timeout-abandoned <transactionCode>`.
59
+
60
+ Sin decorador ni `timeoutMs`, la ruta no tiene límite (igual que 1.2.89).
61
+
62
+ ### Fix: fuga de conexiones si falla `startTransaction()`
63
+
64
+ Antes, si `startTransaction()` lanzaba un error, la conexión obtenida del pool nunca se liberaba y con el tiempo el pool quedaba agotado. Ahora la conexión se libera siempre.
65
+
66
+ ### Clon del controlador por request (`controllerForRequest`)
67
+
68
+ Portado desde 1.2.89: cada request trabaja sobre un clon del controlador, de modo que requests concurrentes ya no se pisan `user`, `headers` ni `transactionCode`.
69
+
70
+ ### Proyectos afectados
71
+
72
+ Dependen de core con `^1.2.x`, así que tomarán esta versión en su próximo `npm install`:
73
+
74
+ - tempo
75
+ - conductor-nd
76
+ - go-pxp-nd
77
+ - Api_Boa_ND
78
+ - electrica360_nd
79
+ - cleaners-pxp-nd
80
+
81
+ Sin configurar `db.acquireTimeoutMs` ni `@Timeout`/`timeoutMs`, ninguno de ellos cambia de comportamiento respecto a 1.2.89.
82
+
83
+ ### Tests (`npm test`)
84
+
85
+ `test/db-timeouts.test.ts` (jest + ts-jest) prueba contra un MySQL **real y desechable**: comportamiento legado sin `db`, `db.acquireTimeoutMs` con un pool `write` de 1 conexión, y `@Timeout` (503, sin transacción abierta en `innodb_trx`, nada commiteado, el pool se recupera).
86
+
87
+ ```bash
88
+ docker run --rm -d --name pxp-core-test-mysql -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=pxptest -p 33306:3306 mysql:8
89
+ npm test
90
+ docker stop pxp-core-test-mysql
91
+ ```
92
+
93
+ Variables: `TEST_DB_HOST` (127.0.0.1), `TEST_DB_PORT` (33306), `TEST_DB_USER` (root), `TEST_DB_PASSWORD` (test), `TEST_DB_NAME` (pxptest). Usar solo una BD desechable: los tests crean las tablas `tsec_log` y `pxp_timeout_probe` y ocupan el pool con `SLEEP`, así que no apuntarlos a dev/beta/prod compartidas. Si la BD no responde, la suite se marca `skipped` (nunca falla por falta de BD).
94
+
95
+ `test/error-path.test.ts` no necesita MySQL: `insertLog` está mockeado para fallar y verifica que una ruta que lanza `PxpError(400)` responde 400 con el mensaje original (sin colgarse) y que el fallo del log de éxito no produce `unhandledRejection`.
@@ -49,5 +49,10 @@ export interface IConfigPxpApp {
49
49
  expressJsonConfig?: IConfigExpress;
50
50
  expressUrlEncodedConfig?: IConfigExpress;
51
51
  permissionFunction?: boolean | ((user: any, transaction: string) => Promise<boolean>);
52
+ /** Database safety options. All optional; when omitted the behaviour is identical to <=1.2.89. */
53
+ db?: {
54
+ /** Max ms to wait for a pool connection before a non-readonly route fails with 503. Undefined/0 = wait forever (legacy). */
55
+ acquireTimeoutMs?: number;
56
+ };
52
57
  }
53
58
  export {};
@@ -12,6 +12,9 @@
12
12
  * Last modified : 2020-10-13 15:36:31
13
13
  * Last modified : 2021-03-10 15:36:31 - Favio Figueroa
14
14
  * Last modified : 2021-05-01 18:35:31 - Favio Figueroa
15
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 per-request controller clone (from 1.2.89), startTransaction inside try
16
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
17
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 log failures never block or crash the response (1.2.91)
15
18
  */
16
19
  import { EntityManager } from 'typeorm';
17
20
  import { Request, Response, NextFunction } from 'express';
@@ -37,8 +40,8 @@ export declare class Controller implements ControllerInterface {
37
40
  private basicReadOnly;
38
41
  constructor(module: string, Entity?: any, config?: IConfigPxpApp, io?: any);
39
42
  private initializeRoutes;
40
- genericMethodWrapper(params: Record<string, unknown>, req: Request, next: NextFunction, res: Response, methodName: string, dbsettings: string, readonly: boolean, permission: boolean, log: boolean, logConfig: {}, isHtml: boolean, isFile: boolean): Promise<void>;
41
- ormMethodWrapper(params: Record<string, unknown>, req: Request | any, next: NextFunction, res: Response, methodName: string, readonly: boolean, permission?: boolean, log?: boolean, logConfig?: {}, isHtml?: boolean, isFile?: boolean): Promise<void>;
43
+ genericMethodWrapper(params: Record<string, unknown>, req: Request, next: NextFunction, res: Response, methodName: string, dbsettings: string, readonly: boolean, permission: boolean, log: boolean, logConfig: {}, isHtml: boolean, isFile: boolean, timeoutMs?: number): Promise<void>;
44
+ ormMethodWrapper(params: Record<string, unknown>, req: Request | any, next: NextFunction, res: Response, methodName: string, readonly: boolean, permission?: boolean, log?: boolean, logConfig?: {}, isHtml?: boolean, isFile?: boolean, timeoutMs?: number): Promise<void>;
42
45
  list(params: Record<string, unknown>): Promise<unknown>;
43
46
  add(params: Record<string, unknown>, manager: EntityManager): Promise<unknown>;
44
47
  edit(params: Record<string, unknown>, manager: EntityManager): Promise<unknown>;
@@ -37,6 +37,9 @@ exports.Controller = void 0;
37
37
  * Last modified : 2020-10-13 15:36:31
38
38
  * Last modified : 2021-03-10 15:36:31 - Favio Figueroa
39
39
  * Last modified : 2021-05-01 18:35:31 - Favio Figueroa
40
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 per-request controller clone (from 1.2.89), startTransaction inside try
41
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
42
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 log failures never block or crash the response (1.2.91)
40
43
  */
41
44
  const typeorm_1 = require("typeorm");
42
45
  const class_validator_1 = require("class-validator");
@@ -51,6 +54,7 @@ const ParseParams_1 = require("./middlewares/ParseParams");
51
54
  const isReportMiddleware_1 = require("./middlewares/isReportMiddleware");
52
55
  const pdf_1 = require("../reports/pdf");
53
56
  const xlsx_1 = require("../reports/xlsx");
57
+ const DbTimeouts_1 = require("./utils/DbTimeouts");
54
58
  // import * as entities from '@pxp-nd/entities';
55
59
  // const pxpEntities: any = entities;
56
60
  class Controller {
@@ -128,6 +132,8 @@ class Controller {
128
132
  // get type return
129
133
  let ishtml = Reflect.getMetadata('ishtml', this.constructor) || {};
130
134
  let isfile = Reflect.getMetadata('isfile', this.constructor) || {};
135
+ // get request timeouts (@Timeout)
136
+ const timeouts = Reflect.getMetadata('timeout', this.constructor) || {};
131
137
  const defaultConfig = Reflect.getMetadata('optionsRoute', this.constructor) || {};
132
138
  // define basic routes
133
139
  if (this.modelString !== '') {
@@ -146,6 +152,8 @@ class Controller {
146
152
  const isHtmlMethod = ishtml[route.methodName] !== undefined ? ishtml[route.methodName] : (defaultConfig[route.methodName] ? defaultConfig[route.methodName].ishtml : false);
147
153
  // is file
148
154
  const isFileMethod = isfile[route.methodName] !== undefined ? isfile[route.methodName] : (defaultConfig[route.methodName] ? defaultConfig[route.methodName].isfile : false);
155
+ // request timeout (ms), undefined = no timeout
156
+ const timeoutMethod = timeouts[route.methodName] !== undefined ? timeouts[route.methodName] : (defaultConfig[route.methodName] ? defaultConfig[route.methodName].timeoutMs : undefined);
149
157
  if (readOnlyMethod === null || readOnlyMethod === undefined) {
150
158
  throw new index_1.PxpError(500, 'ReadOnly decorator was not defined for ' +
151
159
  route.methodName +
@@ -175,14 +183,21 @@ class Controller {
175
183
  .toLowerCase();
176
184
  controllerForRequest.validated = false;
177
185
  try {
178
- await index_1.__(controllerForRequest.genericMethodWrapper(params, req, next, res, route.methodName, methodDbSettings, readOnlyMethod, false, logKey, logValue, isHtmlMethod, isFileMethod));
186
+ await index_1.__(controllerForRequest.genericMethodWrapper(params, req, next, res, route.methodName, methodDbSettings, readOnlyMethod, false, logKey, logValue, isHtmlMethod, isFileMethod, timeoutMethod));
179
187
  }
180
188
  catch (ex) {
181
189
  const now = new Date();
182
190
  const iniAt = req.start;
183
191
  const endsAt = now.valueOf() - iniAt.valueOf();
184
- res.logId = await index_1.__(index_1.insertLog('nouser', 'mac', req.ip, 'error', ex.tecMessage, this.module, this.transactionCode, '', // query
185
- params, ex.stack, ex.statusCode, endsAt, logValue));
192
+ // A failing error log (e.g. database down) must never prevent the response.
193
+ try {
194
+ res.logId = await index_1.__(index_1.insertLog('nouser', 'mac', req.ip, 'error', ex.tecMessage, controllerForRequest.module, controllerForRequest.transactionCode, '', // query
195
+ params, ex.stack, ex.statusCode, endsAt, logValue));
196
+ }
197
+ catch (logErr) {
198
+ // __ wraps driver errors in PxpError(500) with a generic message; the real cause is tecMessage.
199
+ console.error('[pxp-core] error log failed', logErr && (logErr.tecMessage || logErr.message));
200
+ }
186
201
  index_1.errorMiddleware(ex, req, res);
187
202
  }
188
203
  });
@@ -209,23 +224,30 @@ class Controller {
209
224
  .toLowerCase();
210
225
  controllerForRequest.validated = false;
211
226
  try {
212
- await index_1.__(controllerForRequest.genericMethodWrapper(params, req, next, res, route.methodName, methodDbSettings, readOnlyMethod, permission[route.methodName], logKey, logValue, isHtmlMethod, isFileMethod));
227
+ await index_1.__(controllerForRequest.genericMethodWrapper(params, req, next, res, route.methodName, methodDbSettings, readOnlyMethod, permission[route.methodName], logKey, logValue, isHtmlMethod, isFileMethod, timeoutMethod));
213
228
  }
214
229
  catch (ex) {
215
230
  const now = new Date();
216
231
  const iniAt = req.start;
217
232
  const endsAt = now.valueOf() - iniAt.valueOf();
218
- res.logId = (await index_1.__(index_1.insertLog(controllerForRequest.user && controllerForRequest.user.username ? controllerForRequest.user.username : 'nouser', 'mac', req.ip, 'error', ex.tecMessage, this.module, this.transactionCode, '', // query
219
- params, ex.stack, ex.statusCode, endsAt, logValue)));
233
+ // A failing error log (e.g. database down) must never prevent the response.
234
+ try {
235
+ res.logId = (await index_1.__(index_1.insertLog(controllerForRequest.user && controllerForRequest.user.username ? controllerForRequest.user.username : 'nouser', 'mac', req.ip, 'error', ex.tecMessage, controllerForRequest.module, controllerForRequest.transactionCode, '', // query
236
+ params, ex.stack, ex.statusCode, endsAt, logValue)));
237
+ }
238
+ catch (logErr) {
239
+ // __ wraps driver errors in PxpError(500) with a generic message; the real cause is tecMessage.
240
+ console.error('[pxp-core] error log failed', logErr && (logErr.tecMessage || logErr.message));
241
+ }
220
242
  index_1.errorMiddleware(ex, req, res);
221
243
  }
222
244
  });
223
245
  }
224
246
  });
225
247
  }
226
- async genericMethodWrapper(params, req, next, res, methodName, dbsettings, readonly, permission = true, log = true, logConfig = {}, isHtml, isFile) {
248
+ async genericMethodWrapper(params, req, next, res, methodName, dbsettings, readonly, permission = true, log = true, logConfig = {}, isHtml, isFile, timeoutMs) {
227
249
  if (dbsettings === 'Orm') {
228
- await index_1.__(this.ormMethodWrapper(params, req, next, res, methodName, readonly, permission, log, logConfig, isHtml, isFile));
250
+ await index_1.__(this.ormMethodWrapper(params, req, next, res, methodName, readonly, permission, log, logConfig, isHtml, isFile, timeoutMs));
229
251
  }
230
252
  else if (dbsettings === 'Procedure') {
231
253
  await index_1.__(this.procedureMethodWrapper(params, next, res, methodName, readonly, permission, log, logConfig));
@@ -234,7 +256,8 @@ class Controller {
234
256
  await this.sqlMethodWrapper(params, next, res, methodName, readonly, permission, log, logConfig);
235
257
  }
236
258
  }
237
- async ormMethodWrapper(params, req, next, res, methodName, readonly, permission = true, log = true, logConfig = {}, isHtml = false, isFile = false) {
259
+ async ormMethodWrapper(params, req, next, res, methodName, readonly, permission = true, log = true, logConfig = {}, isHtml = false, isFile = false, timeoutMs) {
260
+ var _a;
238
261
  let metResponse;
239
262
  if (permission) {
240
263
  if (this.user) {
@@ -250,26 +273,63 @@ class Controller {
250
273
  else {
251
274
  const connection = typeorm_1.getConnection(process.env.DB_WRITE_CONNECTION_NAME);
252
275
  const queryRunner = connection.createQueryRunner();
253
- // establish real database connection using our new query runner
254
- await index_1.__(queryRunner.connect());
255
- await index_1.__(queryRunner.startTransaction());
276
+ // establish real database connection using our new query runner.
277
+ // `db` is added to IConfigPxpApp by a later change; without it this is a plain connect().
278
+ const acquireTimeoutMs = (_a = this.config.db) === null || _a === void 0 ? void 0 : _a.acquireTimeoutMs;
279
+ await index_1.__(DbTimeouts_1.connectWithTimeout(queryRunner, acquireTimeoutMs, this.transactionCode));
280
+ let timedOut = false;
281
+ let timer;
256
282
  try {
257
- metResponse = (await eval(`this.${methodName}(params, queryRunner.manager, res, req)`));
283
+ // Start the transaction inside try so a failure here still releases the connection
284
+ await index_1.__(queryRunner.startTransaction());
285
+ const work = Promise.resolve(eval(`this.${methodName}(params, queryRunner.manager, res, req)`));
286
+ if (timeoutMs && timeoutMs > 0) {
287
+ metResponse = await Promise.race([
288
+ work,
289
+ new Promise((_, reject) => {
290
+ timer = setTimeout(() => {
291
+ timedOut = true;
292
+ reject(new DbTimeouts_1.DbTimeoutError('request', timeoutMs, this.transactionCode));
293
+ }, timeoutMs);
294
+ })
295
+ ]);
296
+ }
297
+ else {
298
+ metResponse = await work;
299
+ }
258
300
  await queryRunner.commitTransaction();
259
301
  }
260
302
  catch (err) {
261
- await queryRunner.rollbackTransaction();
303
+ // On request timeout skip rollback: it would queue behind the abandoned statement and delay the 503 (destroy rolls back server-side).
304
+ if (!timedOut && queryRunner.isTransactionActive) {
305
+ try {
306
+ await queryRunner.rollbackTransaction();
307
+ }
308
+ catch (_) {
309
+ // keep the original error; the connection is released or destroyed below
310
+ }
311
+ }
262
312
  throw err;
263
313
  }
264
314
  finally {
265
- await index_1.__(queryRunner.release());
315
+ if (timer)
316
+ clearTimeout(timer);
317
+ if (timedOut) {
318
+ // The handler may keep running in background; its connection is destroyed so it cannot commit.
319
+ console.warn(`[pxp-core] timeout-abandoned ${this.transactionCode}`);
320
+ await DbTimeouts_1.destroyQueryRunner(queryRunner);
321
+ }
322
+ else {
323
+ await index_1.__(queryRunner.release());
324
+ }
266
325
  }
267
326
  }
268
327
  if (log) {
269
328
  const now = new Date();
270
329
  const iniAt = req.start;
271
330
  const endsAt = now.valueOf() - iniAt.valueOf();
272
- index_1.__(index_1.insertLog(this.user && this.user.username ? this.user.username : 'nouser', 'mac', req.ip, 'success', 'successful transaction', this.module, this.transactionCode, '', params, JSON.stringify(metResponse), '200', endsAt, logConfig));
331
+ // Fire-and-forget: without the catch a failing log is an unhandledRejection (fatal on Node >= 15).
332
+ index_1.__(index_1.insertLog(this.user && this.user.username ? this.user.username : 'nouser', 'mac', req.ip, 'success', 'successful transaction', this.module, this.transactionCode, '', params, JSON.stringify(metResponse), '200', endsAt, logConfig)).catch(err => console.error('[pxp-core] success log failed', err && (err.tecMessage || err.message)));
273
333
  }
274
334
  //PARA REPORTES
275
335
  if (req.report && req.report.type === 'pdf') {
@@ -8,6 +8,7 @@ export declare interface IOptionsRoute {
8
8
  dbSettings?: 'Procedure' | 'Orm' | 'Query';
9
9
  authentication?: boolean;
10
10
  log?: boolean | ILog;
11
+ timeoutMs?: number;
11
12
  }
12
13
  declare const Get: (path?: string, options?: IOptionsRoute) => (target: any, propertyKey: string) => void;
13
14
  declare const Post: (path?: string, options?: IOptionsRoute) => (target: any, propertyKey: string) => void;
@@ -19,9 +20,10 @@ declare const Model: (model: string) => ClassDecorator;
19
20
  declare const StoredProcedure: (storedProcedure: string) => ClassDecorator;
20
21
  declare const Authentication: (authentication?: boolean) => (target: any, propertyKey: string) => void;
21
22
  declare const ReadOnly: (ronly?: boolean) => (target: any, propertyKey: string) => void;
23
+ declare const Timeout: (ms: number) => (target: any, propertyKey: string) => void;
22
24
  declare const IsHtml: (ishtml?: boolean) => (target: any, propertyKey: string) => void;
23
25
  declare const IsFile: (isFile?: boolean) => (target: any, propertyKey: string) => void;
24
26
  declare const DbSettings: (modelType: 'Procedure' | 'Orm' | 'Query') => (target: any, propertyKey: string) => void;
25
27
  declare const Permission: (permission?: boolean) => (target: any, propertyKey: string) => void;
26
28
  declare const Log: (log?: boolean, config?: Record<string, unknown>) => (target: any, propertyKey: string) => void;
27
- export { Get, Post, Put, Delete, Patch, Route, Authentication, Log, Permission, DbSettings, ReadOnly, IsHtml, IsFile, Model, StoredProcedure };
29
+ export { Get, Post, Put, Delete, Patch, Route, Authentication, Log, Permission, DbSettings, ReadOnly, Timeout, IsHtml, IsFile, Model, StoredProcedure };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.StoredProcedure = exports.Model = exports.IsFile = exports.IsHtml = exports.ReadOnly = exports.DbSettings = exports.Permission = exports.Log = exports.Authentication = exports.Route = exports.Patch = exports.Delete = exports.Put = exports.Post = exports.Get = void 0;
3
+ exports.StoredProcedure = exports.Model = exports.IsFile = exports.IsHtml = exports.Timeout = exports.ReadOnly = exports.DbSettings = exports.Permission = exports.Log = exports.Authentication = exports.Route = exports.Patch = exports.Delete = exports.Put = exports.Post = exports.Get = void 0;
4
4
  /**
5
5
  * Kplian Ltda 2020
6
6
  *
@@ -13,6 +13,7 @@ exports.StoredProcedure = exports.Model = exports.IsFile = exports.IsHtml = expo
13
13
  *
14
14
  * Created at : 2020-06-13 18:09:48
15
15
  * Last modified : 2020-09-17 18:29:51
16
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
16
17
  */
17
18
  const RouteDefinition_1 = require("./RouteDefinition");
18
19
  ;
@@ -102,6 +103,10 @@ const Authentication = (authentication = true) => (target, propertyKey) => setPr
102
103
  exports.Authentication = Authentication;
103
104
  const ReadOnly = (ronly = true) => (target, propertyKey) => setProperty(target, propertyKey)(ronly, 'readonly');
104
105
  exports.ReadOnly = ReadOnly;
106
+ // Request timeout (ms) for a write transaction: on expiry the request fails with 503,
107
+ // the transaction is rolled back and the physical connection is destroyed.
108
+ const Timeout = (ms) => (target, propertyKey) => setProperty(target, propertyKey)(ms, 'timeout');
109
+ exports.Timeout = Timeout;
105
110
  const IsHtml = (ishtml = false) => (target, propertyKey) => setProperty(target, propertyKey)(ishtml, 'ishtml');
106
111
  exports.IsHtml = IsHtml;
107
112
  const IsFile = (isFile = false) => (target, propertyKey) => setProperty(target, propertyKey)(isFile, 'isfile');
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Kplian Ltda 2020
3
+ *
4
+ * MIT
5
+ *
6
+ * Database timeout helpers.
7
+ *
8
+ * @summary Connection acquire timeout, request timeout error and physical connection destroy.
9
+ * @author Favio Figueroa
10
+ *
11
+ * Created at : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
12
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 late acquired connection is released to the pool, not destroyed (1.2.91)
13
+ */
14
+ import { QueryRunner } from 'typeorm';
15
+ import { PxpError } from '../PxpError';
16
+ export declare class DbTimeoutError extends PxpError {
17
+ kind: 'acquire' | 'request';
18
+ timeoutMs: number;
19
+ transactionCode: string | undefined;
20
+ constructor(kind: 'acquire' | 'request', ms: number, transactionCode?: string);
21
+ }
22
+ /**
23
+ * Destroys the physical driver connection behind the query runner (so it never
24
+ * goes back to the pool with an open transaction) and then releases the runner.
25
+ * mysql2 removes a destroyed connection from its pool on its own.
26
+ */
27
+ export declare function destroyQueryRunner(queryRunner: QueryRunner): Promise<void>;
28
+ /**
29
+ * Acquires the query runner connection with an upper bound.
30
+ * When ms is undefined or <= 0 this is exactly `await queryRunner.connect()`.
31
+ */
32
+ export declare function connectWithTimeout(queryRunner: QueryRunner, ms: number | undefined, transactionCode?: string): Promise<void>;
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.connectWithTimeout = exports.destroyQueryRunner = exports.DbTimeoutError = void 0;
4
+ const PxpError_1 = require("../PxpError");
5
+ class DbTimeoutError extends PxpError_1.PxpError {
6
+ constructor(kind, ms, transactionCode) {
7
+ super(503, `Database ${kind} timeout after ${ms} ms`);
8
+ this.kind = kind;
9
+ this.timeoutMs = ms;
10
+ this.transactionCode = transactionCode;
11
+ }
12
+ }
13
+ exports.DbTimeoutError = DbTimeoutError;
14
+ /**
15
+ * Destroys the physical driver connection behind the query runner (so it never
16
+ * goes back to the pool with an open transaction) and then releases the runner.
17
+ * mysql2 removes a destroyed connection from its pool on its own.
18
+ */
19
+ async function destroyQueryRunner(queryRunner) {
20
+ const databaseConnection = queryRunner.databaseConnection;
21
+ if (databaseConnection && typeof databaseConnection.destroy === 'function') {
22
+ databaseConnection.destroy();
23
+ }
24
+ try {
25
+ await queryRunner.release();
26
+ }
27
+ catch (_) {
28
+ // the connection was already destroyed; release failures are expected here
29
+ }
30
+ }
31
+ exports.destroyQueryRunner = destroyQueryRunner;
32
+ /**
33
+ * Acquires the query runner connection with an upper bound.
34
+ * When ms is undefined or <= 0 this is exactly `await queryRunner.connect()`.
35
+ */
36
+ async function connectWithTimeout(queryRunner, ms, transactionCode) {
37
+ if (!ms || ms <= 0) {
38
+ await queryRunner.connect();
39
+ return;
40
+ }
41
+ let timer;
42
+ let timedOut = false;
43
+ const connectPromise = queryRunner.connect();
44
+ const timeoutPromise = new Promise((_, reject) => {
45
+ timer = setTimeout(() => {
46
+ timedOut = true;
47
+ reject(new DbTimeoutError('acquire', ms, transactionCode));
48
+ }, ms);
49
+ });
50
+ try {
51
+ await Promise.race([connectPromise, timeoutPromise]);
52
+ }
53
+ catch (err) {
54
+ if (timedOut) {
55
+ // A late connection is pristine (no transaction was started on it), so it is returned
56
+ // to the pool instead of destroyed: destroying it would force the driver to open a new
57
+ // one and keep a residual queue after a burst. destroyQueryRunner stays for the
58
+ // request-timeout path, where a transaction may still be open.
59
+ connectPromise
60
+ .then(async () => {
61
+ try {
62
+ await queryRunner.release();
63
+ }
64
+ catch (releaseErr) {
65
+ console.error('[pxp-core] late connection release failed', releaseErr && releaseErr.message);
66
+ }
67
+ })
68
+ .catch(() => { });
69
+ }
70
+ throw err;
71
+ }
72
+ finally {
73
+ if (timer)
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ exports.connectWithTimeout = connectWithTimeout;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pxp-nd/core",
3
- "version": "1.2.89",
3
+ "version": "1.2.91",
4
4
  "description": "Contains main files for pxp-nd",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  "prod:publish": "npm publish",
13
13
  "start": "npm run build && node dist/index.js",
14
14
  "dev": "tsc-watch --target es2017 --outDir ./dist --onSuccess \"node .\"",
15
- "test": "echo \"Error: no test specified\" && exit 1"
15
+ "test": "jest --runInBand"
16
16
  },
17
17
  "keywords": [
18
18
  "pxp-nd"
@@ -28,6 +28,7 @@
28
28
  "@types/express-fileupload": "^1.1.6",
29
29
  "@types/express-session": "^1.17.3",
30
30
  "@types/figlet": "^1.5.1",
31
+ "@types/jest": "^27.5.2",
31
32
  "@types/joi": "^17.2.3",
32
33
  "@types/lodash": "^4.14.170",
33
34
  "@types/node": "^15.3.1",
@@ -35,7 +36,10 @@
35
36
  "@types/socket.io": "^3.0.2",
36
37
  "@types/swagger-jsdoc": "^6.0.1",
37
38
  "@types/swagger-ui-express": "^4.1.3",
39
+ "jest": "^27.5.1",
40
+ "mysql2": "^2.3.3",
38
41
  "rimraf": "^3.0.2",
42
+ "ts-jest": "^27.1.5",
39
43
  "tsc-watch": "^4.2.9",
40
44
  "typescript": "^4.2.4"
41
45
  },
@@ -61,5 +65,23 @@
61
65
  "typeorm-store": "^2.0.0",
62
66
  "xlsx": "^0.17.0"
63
67
  },
64
- "peerDependencies": {}
68
+ "jest": {
69
+ "preset": "ts-jest",
70
+ "testEnvironment": "node",
71
+ "roots": [
72
+ "<rootDir>/test"
73
+ ],
74
+ "testTimeout": 30000,
75
+ "globals": {
76
+ "ts-jest": {
77
+ "tsconfig": "<rootDir>/test/tsconfig.json"
78
+ }
79
+ },
80
+ "moduleNameMapper": {
81
+ "^(\\.{1,2}/.*)\\.js$": "$1"
82
+ }
83
+ },
84
+ "overrides": {
85
+ "@types/babel__traverse": "7.17.1"
86
+ }
65
87
  }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Regression and timeout tests for write transactions in ormMethodWrapper:
3
+ * 1. no `db` config and no @Timeout: legacy behaviour (connect -> transaction -> commit -> release)
4
+ * 2. `db.acquireTimeoutMs`: waiting for a busy pool fails with 503 and the pool recovers
5
+ * 3. `@Timeout(ms)`: the request fails with 503, no transaction stays open, nothing is committed
6
+ *
7
+ * Requires a REAL, disposable MySQL. Connection settings (defaults target a local throwaway container):
8
+ * TEST_DB_HOST (127.0.0.1) TEST_DB_PORT (33306) TEST_DB_USER (root) TEST_DB_PASSWORD (test) TEST_DB_NAME (pxptest)
9
+ * docker run --rm -d -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=pxptest -p 33306:3306 mysql:8
10
+ * If the database is not reachable the whole suite is skipped (it never fails for lack of a database).
11
+ *
12
+ * Harness: a Controller subclass mounted on a bare express app. A full PxpApp is not used because
13
+ * its run() pulls in sessions, auth modules, swagger and dist/modules folder scanning, none of which
14
+ * is involved in the code under test (Controller.initializeRoutes + ormMethodWrapper).
15
+ */
16
+ import 'reflect-metadata';
17
+ import { execFileSync } from 'child_process';
18
+ import http from 'http';
19
+ import { AddressInfo } from 'net';
20
+ import express from 'express';
21
+ import { createConnections, Connection, EntityManager, getConnection } from 'typeorm';
22
+ import { Controller } from '../src/lib/pxp/Controller';
23
+ import { Post, ReadOnly, Timeout } from '../src/lib/pxp/Decorators';
24
+ import { IConfigPxpApp } from '../src/interfaces';
25
+ import Log from '../src/entities/Log';
26
+
27
+ const DB = {
28
+ host: process.env.TEST_DB_HOST || '127.0.0.1',
29
+ port: Number(process.env.TEST_DB_PORT || 33306),
30
+ username: process.env.TEST_DB_USER || 'root',
31
+ password: process.env.TEST_DB_PASSWORD || 'test',
32
+ database: process.env.TEST_DB_NAME || 'pxptest',
33
+ };
34
+
35
+ // Synchronous reachability probe so the suite can be declared with describe.skip.
36
+ const probeDb = (): string | null => {
37
+ const script = `
38
+ const mysql = require('mysql2');
39
+ const c = mysql.createConnection({ host: process.argv[1], port: +process.argv[2], user: process.argv[3],
40
+ password: process.argv[4], database: process.argv[5], connectTimeout: 3000 });
41
+ c.query('SELECT 1', (err) => { c.destroy(); if (err) { console.error(err.message); process.exit(1); } process.exit(0); });
42
+ `;
43
+ try {
44
+ execFileSync(process.execPath, ['-e', script, DB.host, String(DB.port), DB.username, DB.password, DB.database], {
45
+ cwd: __dirname,
46
+ stdio: 'pipe',
47
+ timeout: 10000,
48
+ });
49
+ return null;
50
+ } catch (e) {
51
+ return String((e as any).stderr || (e as any).message).trim();
52
+ }
53
+ };
54
+
55
+ const dbError = probeDb();
56
+ if (dbError) {
57
+ // eslint-disable-next-line no-console
58
+ console.warn(
59
+ `[db-timeouts.test] SKIPPED: test MySQL not reachable at ${DB.host}:${DB.port}/${DB.database} (${dbError}). ` +
60
+ 'Start one with: docker run --rm -d -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=pxptest -p 33306:3306 mysql:8'
61
+ );
62
+ }
63
+ const describeDb = dbError ? describe.skip : describe;
64
+
65
+ class Slow extends Controller {
66
+ @Post()
67
+ @ReadOnly(false)
68
+ async hold(params: any, manager: EntityManager) {
69
+ await manager.query('SELECT SLEEP(?)', [Number(params.secs)]);
70
+ return { ok: true };
71
+ }
72
+
73
+ // Writes a row first so the transaction is a real InnoDB transaction (visible in innodb_trx),
74
+ // then outlives its @Timeout.
75
+ @Post()
76
+ @ReadOnly(false)
77
+ @Timeout(500)
78
+ async fast(params: any, manager: EntityManager) {
79
+ await manager.query('INSERT INTO pxp_timeout_probe (marker) VALUES (?)', [String(params.marker)]);
80
+ await manager.query('SELECT SLEEP(2)');
81
+ return { ok: true };
82
+ }
83
+ }
84
+
85
+ const baseConfig: IConfigPxpApp = {
86
+ apiPrefix: '/api',
87
+ defaultDbSettings: 'Orm',
88
+ middlewares: [],
89
+ entities: {},
90
+ };
91
+
92
+ const startApp = async (config: IConfigPxpApp) => {
93
+ const app = express();
94
+ app.use(express.json());
95
+ app.use((req: any, _res, next) => {
96
+ req.start = new Date();
97
+ next();
98
+ });
99
+ const controller = new Slow('test', null, config);
100
+ app.use('/', controller.router);
101
+ const server = http.createServer(app);
102
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
103
+ const { port } = server.address() as AddressInfo;
104
+ return { server, base: `http://127.0.0.1:${port}/api/test/Slow` };
105
+ };
106
+
107
+ // Plain http client (the jest node environment does not expose global fetch).
108
+ const call = (url: string, body: Record<string, unknown>) =>
109
+ new Promise<{ status: number; json: any; ms: number }>((resolve, reject) => {
110
+ const t0 = Date.now();
111
+ const payload = JSON.stringify(body);
112
+ const req = http.request(
113
+ url,
114
+ { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
115
+ (res) => {
116
+ let raw = '';
117
+ res.setEncoding('utf8');
118
+ res.on('data', (chunk) => (raw += chunk));
119
+ res.on('end', () => resolve({ status: res.statusCode as number, json: JSON.parse(raw), ms: Date.now() - t0 }));
120
+ }
121
+ );
122
+ req.on('error', reject);
123
+ req.end(payload);
124
+ });
125
+
126
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
127
+
128
+ describeDb('ormMethodWrapper db timeouts (real MySQL)', () => {
129
+ let connections: Connection[] = [];
130
+ const servers: http.Server[] = [];
131
+ const timings: string[] = [];
132
+
133
+ beforeAll(async () => {
134
+ process.env.DB_WRITE_CONNECTION_NAME = 'write';
135
+ process.env.DB_LOG_CONNECTION_NAME = 'default';
136
+ connections = await createConnections([
137
+ { name: 'default', type: 'mysql', ...DB, entities: [Log], synchronize: true, logging: false },
138
+ { name: 'write', type: 'mysql', ...DB, entities: [], synchronize: false, logging: false, extra: { connectionLimit: 1 } },
139
+ ]);
140
+ const def = getConnection('default');
141
+ await def.query(
142
+ 'CREATE TABLE IF NOT EXISTS pxp_timeout_probe (id INT AUTO_INCREMENT PRIMARY KEY, marker VARCHAR(64) NOT NULL) ENGINE=InnoDB'
143
+ );
144
+ });
145
+
146
+ afterAll(async () => {
147
+ for (const s of servers) await new Promise((r) => s.close(r));
148
+ // ormMethodWrapper writes the success log fire-and-forget (not awaited); let in-flight inserts finish
149
+ // before closing the log connection, otherwise they reject unhandled and crash the runner.
150
+ await sleep(1000);
151
+ for (const c of connections) if (c.isConnected) await c.close();
152
+ // eslint-disable-next-line no-console
153
+ console.log(`[db-timeouts.test] timings:\n ${timings.join('\n ')}`);
154
+ });
155
+
156
+ test('1. legacy: no db config, no @Timeout -> hold secs=1 returns 200 in ~1 s', async () => {
157
+ const { server, base } = await startApp({ ...baseConfig });
158
+ servers.push(server);
159
+
160
+ const r = await call(`${base}/hold`, { secs: 1 });
161
+ timings.push(`case1 hold(1): ${r.status} in ${r.ms} ms`);
162
+
163
+ expect(r.status).toBe(200);
164
+ expect(r.json).toEqual({ ok: true });
165
+ expect(r.ms).toBeGreaterThanOrEqual(950);
166
+ expect(r.ms).toBeLessThan(2000);
167
+ });
168
+
169
+ test('2. db.acquireTimeoutMs=1000 -> second write on a busy 1-connection pool gets 503 in ~1 s; pool recovers', async () => {
170
+ const { server, base } = await startApp({ ...baseConfig, db: { acquireTimeoutMs: 1000 } });
171
+ servers.push(server);
172
+
173
+ const first = call(`${base}/hold`, { secs: 5 });
174
+ await sleep(200);
175
+ const second = await call(`${base}/hold`, { secs: 0 });
176
+ timings.push(`case2 second hold(0): ${second.status} in ${second.ms} ms -> ${second.json?.error?.message}`);
177
+
178
+ expect(second.status).toBe(503);
179
+ expect(second.json.error.message).toBe('Database acquire timeout after 1000 ms');
180
+ expect(second.ms).toBeGreaterThanOrEqual(950);
181
+ expect(second.ms).toBeLessThan(1800);
182
+
183
+ const firstRes = await first;
184
+ timings.push(`case2 first hold(5): ${firstRes.status} in ${firstRes.ms} ms`);
185
+ expect(firstRes.status).toBe(200);
186
+ expect(firstRes.ms).toBeGreaterThanOrEqual(4500);
187
+
188
+ // The late connection handed to the timed-out waiter is released back to the pool (not leaked): a new call succeeds.
189
+ const third = await call(`${base}/hold`, { secs: 0 });
190
+ timings.push(`case2 third hold(0): ${third.status} in ${third.ms} ms`);
191
+ expect(third.status).toBe(200);
192
+ expect(third.ms).toBeLessThan(1000);
193
+
194
+ const pool: any = (getConnection('write').driver as any).pool;
195
+ timings.push(
196
+ `case2 write pool after: all=${pool._allConnections.length} free=${pool._freeConnections.length} queued=${pool._connectionQueue.length}`
197
+ );
198
+ expect(pool._connectionQueue.length).toBe(0);
199
+ expect(pool._allConnections.length).toBeLessThanOrEqual(1);
200
+ expect(pool._freeConnections.length).toBe(pool._allConnections.length);
201
+ });
202
+
203
+ test('3. @Timeout(500) -> 503 in ~0.5 s, no open transaction, nothing committed, pool recovers', async () => {
204
+ const { server, base } = await startApp({ ...baseConfig });
205
+ servers.push(server);
206
+ const warn = jest.spyOn(console, 'warn');
207
+ const marker = `t${Date.now()}`;
208
+
209
+ const r = await call(`${base}/fast`, { marker });
210
+ timings.push(`case3 fast: ${r.status} in ${r.ms} ms -> ${r.json?.error?.message}`);
211
+
212
+ expect(r.status).toBe(503);
213
+ expect(r.json.error.message).toBe('Database request timeout after 500 ms');
214
+ expect(r.ms).toBeGreaterThanOrEqual(450);
215
+ expect(r.ms).toBeLessThan(1200);
216
+ expect(warn).toHaveBeenCalledWith('[pxp-core] timeout-abandoned test.slow.fast');
217
+ warn.mockRestore();
218
+
219
+ const def = getConnection('default');
220
+ let open = -1;
221
+ const t0 = Date.now();
222
+ while (Date.now() - t0 < 3000) {
223
+ const [row] = await def.query('SELECT COUNT(*) AS n FROM information_schema.innodb_trx');
224
+ open = Number(row.n);
225
+ if (open === 0) break;
226
+ await sleep(100);
227
+ }
228
+ timings.push(`case3 innodb_trx open after ${Date.now() - t0} ms: ${open}`);
229
+ expect(open).toBe(0);
230
+
231
+ // MySQL rolled the abandoned transaction back server-side: the row was never committed.
232
+ const [committed] = await def.query('SELECT COUNT(*) AS n FROM pxp_timeout_probe WHERE marker = ?', [marker]);
233
+ expect(Number(committed.n)).toBe(0);
234
+
235
+ const after = await call(`${base}/hold`, { secs: 0 });
236
+ timings.push(`case3 hold(0) after: ${after.status} in ${after.ms} ms`);
237
+ expect(after.status).toBe(200);
238
+ });
239
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Error/log path tests for Controller route handlers. No database needed.
3
+ *
4
+ * `insertLog` (src/lib/pxp/utils/Security) is mocked to reject, simulating a log database that is down:
5
+ * 1. a route that throws PxpError(400) must still answer 400 with the original message, in both
6
+ * the unauthenticated and the authenticated route handler (before 1.2.91 the request hung forever)
7
+ * 2. a successful route must answer 200 and the failed fire-and-forget success log must not become
8
+ * an unhandledRejection
9
+ *
10
+ * Harness: same as db-timeouts.test.ts (Controller subclass mounted on a bare express app). All routes
11
+ * are @ReadOnly(true), so ormMethodWrapper never touches a connection.
12
+ */
13
+ import 'reflect-metadata';
14
+ import http from 'http';
15
+ import { AddressInfo } from 'net';
16
+ import express from 'express';
17
+
18
+ jest.mock('../src/lib/pxp/utils/Security', () => ({
19
+ ...jest.requireActual('../src/lib/pxp/utils/Security'),
20
+ insertLog: jest.fn(),
21
+ }));
22
+
23
+ import { insertLog } from '../src/lib/pxp/utils/Security';
24
+ import { Controller } from '../src/lib/pxp/Controller';
25
+ import { Authentication, Post, ReadOnly } from '../src/lib/pxp/Decorators';
26
+ import { PxpError } from '../src/lib/pxp/PxpError';
27
+ import { IConfigPxpApp } from '../src/interfaces';
28
+
29
+ const insertLogMock = insertLog as jest.MockedFunction<typeof insertLog>;
30
+
31
+ class Broken extends Controller {
32
+ @Post()
33
+ @ReadOnly(true)
34
+ @Authentication(false)
35
+ async publicFail() {
36
+ throw new PxpError(400, 'public route failed on purpose');
37
+ }
38
+
39
+ @Post()
40
+ @ReadOnly(true)
41
+ async privateFail() {
42
+ throw new PxpError(400, 'private route failed on purpose');
43
+ }
44
+
45
+ @Post()
46
+ @ReadOnly(true)
47
+ async ok() {
48
+ return { ok: true };
49
+ }
50
+ }
51
+
52
+ const config: IConfigPxpApp = {
53
+ apiPrefix: '/api',
54
+ defaultDbSettings: 'Orm',
55
+ middlewares: [],
56
+ entities: {},
57
+ };
58
+
59
+ // Plain http client with a hard deadline so a hanging handler fails the test instead of the runner.
60
+ const call = (url: string, deadlineMs = 2000) =>
61
+ new Promise<{ status: number; json: any; ms: number }>((resolve, reject) => {
62
+ const t0 = Date.now();
63
+ const payload = '{}';
64
+ const req = http.request(
65
+ url,
66
+ { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
67
+ (res) => {
68
+ let raw = '';
69
+ res.setEncoding('utf8');
70
+ res.on('data', (chunk) => (raw += chunk));
71
+ res.on('end', () => resolve({ status: res.statusCode as number, json: JSON.parse(raw), ms: Date.now() - t0 }));
72
+ }
73
+ );
74
+ req.setTimeout(deadlineMs, () => {
75
+ req.destroy(new Error(`no response within ${deadlineMs} ms (handler hung)`));
76
+ });
77
+ req.on('error', reject);
78
+ req.end(payload);
79
+ });
80
+
81
+ describe('Controller error path with a failing insertLog (no database)', () => {
82
+ let server: http.Server;
83
+ let base: string;
84
+ const unhandled: unknown[] = [];
85
+ const onUnhandled = (reason: unknown) => unhandled.push(reason);
86
+ let consoleError: jest.SpyInstance;
87
+
88
+ beforeAll(async () => {
89
+ process.on('unhandledRejection', onUnhandled);
90
+ const app = express();
91
+ app.use(express.json());
92
+ app.use((req: any, _res, next) => {
93
+ req.start = new Date();
94
+ next();
95
+ });
96
+ app.use('/', new Broken('test', null, config).router);
97
+ server = http.createServer(app);
98
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
99
+ const { port } = server.address() as AddressInfo;
100
+ base = `http://127.0.0.1:${port}/api/test/Broken`;
101
+ });
102
+
103
+ beforeEach(() => {
104
+ insertLogMock.mockReset();
105
+ insertLogMock.mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:3306'));
106
+ consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
107
+ });
108
+
109
+ afterEach(() => consoleError.mockRestore());
110
+
111
+ afterAll(async () => {
112
+ process.removeListener('unhandledRejection', onUnhandled);
113
+ await new Promise((r) => server.close(r));
114
+ });
115
+
116
+ test.each([
117
+ ['unauthenticated handler', 'publicFail', 'public route failed on purpose'],
118
+ ['authenticated handler', 'privateFail', 'private route failed on purpose'],
119
+ ])('%s: PxpError(400) is answered with 400 and the original message', async (_label, path, message) => {
120
+ const r = await call(`${base}/${path}`);
121
+
122
+ expect(r.status).toBe(400);
123
+ expect(r.json.error.code).toBe(400);
124
+ expect(r.json.error.message).toBe(message);
125
+ expect(r.json.error.logId).toBeUndefined();
126
+ expect(r.ms).toBeLessThan(2000);
127
+ expect(insertLogMock).toHaveBeenCalledTimes(1);
128
+ expect(insertLogMock.mock.calls[0][3]).toBe('error');
129
+ expect(consoleError).toHaveBeenCalledWith('[pxp-core] error log failed', 'connect ECONNREFUSED 127.0.0.1:3306');
130
+ });
131
+
132
+ test('success path: 200 and the failed success log is caught (no unhandledRejection)', async () => {
133
+ const r = await call(`${base}/ok`);
134
+
135
+ expect(r.status).toBe(200);
136
+ expect(r.json).toEqual({ ok: true });
137
+ // let the rejected fire-and-forget log settle
138
+ await new Promise((resolve) => setImmediate(resolve));
139
+ await new Promise((resolve) => setTimeout(resolve, 50));
140
+ expect(insertLogMock).toHaveBeenCalledTimes(1);
141
+ expect(insertLogMock.mock.calls[0][3]).toBe('success');
142
+ expect(consoleError).toHaveBeenCalledWith('[pxp-core] success log failed', 'connect ECONNREFUSED 127.0.0.1:3306');
143
+ expect(unhandled).toEqual([]);
144
+ });
145
+ });