const assert = require('node:assert/strict'); const http = require('node:http'); const test = require('node:test'); const jwt = require('jsonwebtoken'); const app = require('../app'); const db = require('../src/config/db'); const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; process.env.JWT_SECRET = JWT_SECRET; let originalGetConnection; const createToken = (payload = {}) => jwt.sign( { id: 1, dni: '58045340X', id_proveedor: 675, ...payload }, JWT_SECRET, { expiresIn: '1h' } ); const withServer = async (callback) => new Promise((resolve, reject) => { const server = app.listen(0, '127.0.0.1'); server.on('error', reject); server.on('listening', async () => { try { const result = await callback(server); server.close((closeError) => { if (closeError) { reject(closeError); return; } resolve(result); }); } catch (error) { server.close(() => reject(error)); } }); }); const requestJson = async ({ port, method, path: requestPath, authorization, body }) => new Promise((resolve, reject) => { const rawBody = body ? JSON.stringify(body) : ''; const req = http.request( { hostname: '127.0.0.1', port, method, path: requestPath, headers: { ...(authorization ? { authorization } : {}), 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(rawBody) } }, (res) => { let responseBody = ''; res.on('data', (chunk) => { responseBody += chunk; }); res.on('end', () => { resolve({ statusCode: res.statusCode, body: responseBody ? JSON.parse(responseBody) : null }); }); } ); req.on('error', reject); req.write(rawBody); req.end(); }); const formatSqlDateTime = (date) => { const value = date instanceof Date ? date : new Date(date); return value.toISOString().slice(0, 19).replace('T', ' '); }; test.before(() => { originalGetConnection = db.getConnection; }); test.after(() => { db.getConnection = originalGetConnection; }); test('POST /api/trips/:tripId/start está registrado en /api', () => { const apiRouterLayers = app._router.stack.filter( (layer) => layer.name === 'router' && layer.regexp && layer.regexp.toString().includes('^\\/api\\/?(?=\\/|$)') ); assert.ok(apiRouterLayers.length > 0, 'Router /api is not mounted'); const startRouteLayer = apiRouterLayers .flatMap((routerLayer) => routerLayer.handle.stack) .find( (layer) => layer.route && layer.route.path === '/trips/:tripId/start' && layer.route.methods.post ); assert.ok(startRouteLayer, 'POST /api/trips/:tripId/start route is not defined'); }); test('POST /api/trips/:tripId/start inicia viaje asignado sin otro activo => 200', async () => { let step = 0; let beginCalled = false; let commitCalled = false; let rollbackCalled = false; let releaseCalled = false; const connection = { beginTransaction: async () => { beginCalled = true; }, query: async (sql, params) => { step += 1; if (step === 1) { assert.match(sql, /FROM c_viajes_proveedor/); assert.match(sql, /WHERE dni = \?/); assert.match(sql, /FOR UPDATE/); assert.deepEqual(params, ['58045340X']); return [[{ id_viaje: 248230 }]]; } if (step === 2) { assert.match(sql, /FROM c_viajes/); assert.match(sql, /WHERE id_viaje = \?/); assert.match(sql, /FOR UPDATE/); assert.deepEqual(params, [248230]); return [[{ id_viaje: 248230, cod_viaje: 'VIA-2026-0001', id_estado: 1 }]]; } if (step === 3) { assert.match(sql, /FROM c_viajes_proveedor/); assert.match(sql, /AND dni = \?/); assert.match(sql, /FOR UPDATE/); assert.deepEqual(params, [248230, '58045340X']); return [[{ n_proveedor: 1 }]]; } if (step === 4) { assert.match(sql, /INNER JOIN c_viajes v/); assert.match(sql, /id_estado BETWEEN \? AND \?/); assert.match(sql, /FOR UPDATE/); assert.deepEqual(params, ['58045340X', 248230, 2, 6]); return [[]]; } if (step === 5) { assert.match(sql, /UPDATE c_viajes/); assert.equal(params[0], 2); assert.ok(params[1] instanceof Date); assert.equal(params[2], 1); assert.equal(params[3], 248230); return [{ affectedRows: 1 }]; } if (step === 6) { assert.match(sql, /INSERT INTO c_cambios_estado/); assert.equal(params[0], 248230); assert.equal(params[1], 1); assert.equal(params[2], '58045340X'); assert.equal(params[3], 2); assert.ok(params[8] instanceof Date); assert.equal(params[10], 1); return [{ insertId: 10, affectedRows: 1 }]; } assert.match(sql, /SELECT/); assert.match(sql, /fecha_inicio_real/); assert.deepEqual(params, [248230]); return [[{ id_viaje: 248230, cod_viaje: 'VIA-2026-0001', id_estado: 2, fecha_inicio_real: '2026-02-11 12:30:00' }]]; }, commit: async () => { commitCalled = true; }, rollback: async () => { rollbackCalled = true; }, release: () => { releaseCalled = true; } }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/start', authorization: `Bearer ${createToken()}` }) ); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { success: true, trip: { id_viaje: 248230, cod_viaje: 'VIA-2026-0001', id_estado: 2, fecha_inicio_real: '2026-02-11 12:30:00' } }); assert.equal(beginCalled, true); assert.equal(commitCalled, true); assert.equal(rollbackCalled, false); assert.equal(releaseCalled, true); assert.equal(step, 7); }); test('POST /api/trips/:tripId/start devuelve 409 ACTIVE_TRIP_EXISTS si hay otro viaje en curso', async () => { let step = 0; let rollbackCalled = false; const connection = { beginTransaction: async () => {}, query: async (_sql, _params) => { step += 1; if (step === 1) { return [[{ id_viaje: 248230 }]]; } if (step === 2) { return [[{ id_viaje: 248230, cod_viaje: 'VIA-2026-0001', id_estado: 1 }]]; } if (step === 3) { return [[{ n_proveedor: 1 }]]; } return [[{ id_viaje: 248231, cod_viaje: 'VIA-2026-0002', id_estado: 4 }]]; }, commit: async () => { throw new Error('commit should not run for conflict'); }, rollback: async () => { rollbackCalled = true; }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/start', authorization: `Bearer ${createToken()}` }) ); assert.equal(response.statusCode, 409); assert.deepEqual(response.body, { success: false, code: 'ACTIVE_TRIP_EXISTS', message: 'Ya existe un viaje en curso', activeTrip: { id_viaje: 248231, cod_viaje: 'VIA-2026-0002', id_estado: 4 } }); assert.equal(rollbackCalled, true); assert.equal(step, 4); }); test('POST /api/trips/:tripId/start devuelve 422 cuando el viaje no está asignado', async () => { let step = 0; let rollbackCalled = false; const connection = { beginTransaction: async () => {}, query: async () => { step += 1; if (step === 1) { return [[{ id_viaje: 248230 }]]; } if (step === 2) { return [[{ id_viaje: 248230, cod_viaje: 'VIA-2026-0001', id_estado: 5 }]]; } return [[{ n_proveedor: 1 }]]; }, commit: async () => { throw new Error('commit should not run for invalid status'); }, rollback: async () => { rollbackCalled = true; }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/start', authorization: `Bearer ${createToken()}` }) ); assert.equal(response.statusCode, 422); assert.deepEqual(response.body, { success: false, code: 'INVALID_STATUS', message: 'El viaje no está en estado asignado' }); assert.equal(rollbackCalled, true); assert.equal(step, 3); }); test('POST /api/trips/:tripId/start devuelve 403 si el viaje no pertenece al usuario', async () => { let step = 0; let rollbackCalled = false; const connection = { beginTransaction: async () => {}, query: async () => { step += 1; if (step === 1) { return [[{ id_viaje: 248230 }]]; } if (step === 2) { return [[{ id_viaje: 248230, cod_viaje: 'VIA-2026-0001', id_estado: 1 }]]; } return [[]]; }, commit: async () => { throw new Error('commit should not run for forbidden'); }, rollback: async () => { rollbackCalled = true; }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/start', authorization: `Bearer ${createToken()}` }) ); assert.equal(response.statusCode, 403); assert.deepEqual(response.body, { success: false, error: 'Forbidden' }); assert.equal(rollbackCalled, true); assert.equal(step, 3); }); test('POST /api/trips/:tripId/start devuelve 404 cuando el viaje no existe', async () => { let step = 0; let rollbackCalled = false; const connection = { beginTransaction: async () => {}, query: async () => { step += 1; if (step === 1) { return [[{ id_viaje: 248230 }]]; } return [[]]; }, commit: async () => { throw new Error('commit should not run for missing trip'); }, rollback: async () => { rollbackCalled = true; }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/99999999/start', authorization: `Bearer ${createToken()}` }) ); assert.equal(response.statusCode, 404); assert.deepEqual(response.body, { success: false, error: 'Trip not found' }); assert.equal(rollbackCalled, true); assert.equal(step, 2); }); test('POST /api/trips/:tripId/start devuelve 401 sin token', async () => { db.getConnection = async () => { throw new Error('db.getConnection should not run without token'); }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/start' }) ); assert.equal(response.statusCode, 401); assert.deepEqual(response.body, { error: 'Unauthorized' }); }); test('POST /api/trips/:tripId/start serializa inicios simultáneos y evita dos viajes activos', async () => { const trips = new Map([ [31001, { id_viaje: 31001, cod_viaje: 'VIA-2026-31001', id_estado: 1, fecha_inicio_real: null }], [31002, { id_viaje: 31002, cod_viaje: 'VIA-2026-31002', id_estado: 1, fecha_inicio_real: null }] ]); const providerTripIds = [31001, 31002]; const providerLock = { locked: false, waiters: [] }; let commitCount = 0; let rollbackCount = 0; const acquireProviderLock = async () => { if (!providerLock.locked) { providerLock.locked = true; return; } await new Promise((resolve) => { providerLock.waiters.push(resolve); }); }; const releaseProviderLock = () => { if (providerLock.waiters.length > 0) { const next = providerLock.waiters.shift(); next(); return; } providerLock.locked = false; }; db.getConnection = async () => { let hasProviderLock = false; return { beginTransaction: async () => {}, query: async (sql, params) => { if (sql.includes('FROM c_viajes_proveedor') && sql.includes('WHERE dni = ?') && sql.includes('ORDER BY id_viaje ASC') && sql.includes('FOR UPDATE')) { await acquireProviderLock(); hasProviderLock = true; return [providerTripIds.map((id_viaje) => ({ id_viaje }))]; } if (sql.includes('FROM c_viajes') && sql.includes('WHERE id_viaje = ?') && sql.includes('FOR UPDATE')) { const trip = trips.get(params[0]); return [trip ? [{ id_viaje: trip.id_viaje, cod_viaje: trip.cod_viaje, id_estado: trip.id_estado }] : []]; } if (sql.includes('FROM c_viajes_proveedor') && sql.includes('AND dni = ?') && sql.includes('LIMIT 1') && sql.includes('FOR UPDATE')) { const trip = trips.get(params[0]); return [trip ? [{ n_proveedor: 1 }] : []]; } if (sql.includes('INNER JOIN c_viajes v') && sql.includes('id_estado BETWEEN ? AND ?') && sql.includes('FOR UPDATE')) { const requestedTripId = params[1]; const activeTrip = Array.from(trips.values()).find( (trip) => trip.id_viaje !== requestedTripId && trip.id_estado >= 2 && trip.id_estado <= 6 ); if (!activeTrip) { return [[]]; } return [[{ id_viaje: activeTrip.id_viaje, cod_viaje: activeTrip.cod_viaje, id_estado: activeTrip.id_estado }]]; } if (sql.includes('UPDATE c_viajes')) { const trip = trips.get(params[3]); trip.id_estado = params[0]; trip.fecha_inicio_real = params[1]; return [{ affectedRows: 1 }]; } if (sql.includes('INSERT INTO c_cambios_estado')) { return [{ insertId: 99, affectedRows: 1 }]; } if (sql.includes('DATE_FORMAT(fecha_inicio_real')) { const trip = trips.get(params[0]); return [[{ id_viaje: trip.id_viaje, cod_viaje: trip.cod_viaje, id_estado: trip.id_estado, fecha_inicio_real: formatSqlDateTime(trip.fecha_inicio_real) }]]; } throw new Error(`Unexpected SQL in concurrency test: ${sql}`); }, commit: async () => { commitCount += 1; if (hasProviderLock) { hasProviderLock = false; releaseProviderLock(); } }, rollback: async () => { rollbackCount += 1; if (hasProviderLock) { hasProviderLock = false; releaseProviderLock(); } }, release: () => { if (hasProviderLock) { hasProviderLock = false; releaseProviderLock(); } } }; }; const [responseA, responseB] = await withServer(async (server) => Promise.all([ requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/31001/start', authorization: `Bearer ${createToken()}` }), requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/31002/start', authorization: `Bearer ${createToken()}` }) ]) ); const statusCodes = [responseA.statusCode, responseB.statusCode].sort((a, b) => a - b); assert.deepEqual(statusCodes, [200, 409]); const successResponse = responseA.statusCode === 200 ? responseA : responseB; const conflictResponse = responseA.statusCode === 409 ? responseA : responseB; assert.equal(successResponse.body.success, true); assert.equal(conflictResponse.body.success, false); assert.equal(conflictResponse.body.code, 'ACTIVE_TRIP_EXISTS'); assert.equal(conflictResponse.body.activeTrip.id_viaje, successResponse.body.trip.id_viaje); const activeTrips = Array.from(trips.values()).filter( (trip) => trip.id_estado >= 2 && trip.id_estado <= 6 ); assert.equal(activeTrips.length, 1); assert.equal(commitCount, 1); assert.equal(rollbackCount, 1); });