@pxp-nd/core 1.2.90 → 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,6 +1,24 @@
1
1
  # PXP-ND core
2
2
  - Core module for pxp-nd
3
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
+
4
22
  ## 1.2.90
5
23
 
6
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.
@@ -61,3 +79,17 @@ Dependen de core con `^1.2.x`, así que tomarán esta versión en su próximo `n
61
79
  - cleaners-pxp-nd
62
80
 
63
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`.
@@ -14,6 +14,7 @@
14
14
  * Last modified : 2021-05-01 18:35:31 - Favio Figueroa
15
15
  * Last modified : 2026-10-08 - Favio Figueroa - EF-23 per-request controller clone (from 1.2.89), startTransaction inside try
16
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)
17
18
  */
18
19
  import { EntityManager } from 'typeorm';
19
20
  import { Request, Response, NextFunction } from 'express';
@@ -39,6 +39,7 @@ exports.Controller = void 0;
39
39
  * Last modified : 2021-05-01 18:35:31 - Favio Figueroa
40
40
  * Last modified : 2026-10-08 - Favio Figueroa - EF-23 per-request controller clone (from 1.2.89), startTransaction inside try
41
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)
42
43
  */
43
44
  const typeorm_1 = require("typeorm");
44
45
  const class_validator_1 = require("class-validator");
@@ -188,8 +189,15 @@ class Controller {
188
189
  const now = new Date();
189
190
  const iniAt = req.start;
190
191
  const endsAt = now.valueOf() - iniAt.valueOf();
191
- res.logId = await index_1.__(index_1.insertLog('nouser', 'mac', req.ip, 'error', ex.tecMessage, controllerForRequest.module, controllerForRequest.transactionCode, '', // query
192
- 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
+ }
193
201
  index_1.errorMiddleware(ex, req, res);
194
202
  }
195
203
  });
@@ -222,8 +230,15 @@ class Controller {
222
230
  const now = new Date();
223
231
  const iniAt = req.start;
224
232
  const endsAt = now.valueOf() - iniAt.valueOf();
225
- 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
226
- 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
+ }
227
242
  index_1.errorMiddleware(ex, req, res);
228
243
  }
229
244
  });
@@ -313,7 +328,8 @@ class Controller {
313
328
  const now = new Date();
314
329
  const iniAt = req.start;
315
330
  const endsAt = now.valueOf() - iniAt.valueOf();
316
- 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)));
317
333
  }
318
334
  //PARA REPORTES
319
335
  if (req.report && req.report.type === 'pdf') {
@@ -9,6 +9,7 @@
9
9
  * @author Favio Figueroa
10
10
  *
11
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)
12
13
  */
13
14
  import { QueryRunner } from 'typeorm';
14
15
  import { PxpError } from '../PxpError';
@@ -52,8 +52,20 @@ async function connectWithTimeout(queryRunner, ms, transactionCode) {
52
52
  }
53
53
  catch (err) {
54
54
  if (timedOut) {
55
- // A late connection must be destroyed, not leaked into the pool.
56
- connectPromise.then(() => destroyQueryRunner(queryRunner)).catch(() => { });
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(() => { });
57
69
  }
58
70
  throw err;
59
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pxp-nd/core",
3
- "version": "1.2.90",
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
+ });