const assert = require('node:assert/strict'); const fs = require('node:fs'); const http = require('node:http'); const path = require('node:path'); const test = require('node:test'); const jwt = require('jsonwebtoken'); const TEST_UPLOAD_DIR = path.resolve(__dirname, '..', 'tmp', 'test-status-uploads'); const TEST_POSTS_LOG_PATH = path.resolve(__dirname, '..', 'tmp', 'test-posts.log'); const TEST_STATUS_LOG_PATH = path.resolve(__dirname, '..', 'tmp', 'test-status.log'); process.env.TRIP_STATUS_UPLOAD_DIR = TEST_UPLOAD_DIR; process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual' const app = require('../app'); const db = require('../src/config/db'); const tripStatusPhotoStorage = require('../src/services/tripStatusPhotoStorage'); const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; process.env.JWT_SECRET = JWT_SECRET; let originalQuery; 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 requestMultipart = async ({ port, method, path: requestPath, authorization, fields = {}, files = [] }) => new Promise((resolve, reject) => { const boundary = `----NodeBoundary${Date.now().toString(16)}`; const chunks = []; for (const [key, value] of Object.entries(fields)) { chunks.push(Buffer.from(`--${boundary}\r\n`)); chunks.push( Buffer.from(`Content-Disposition: form-data; name="${key}"\r\n\r\n${String(value)}\r\n`) ); } for (const file of files) { chunks.push(Buffer.from(`--${boundary}\r\n`)); chunks.push( Buffer.from( `Content-Disposition: form-data; name="${file.fieldName}"; filename="${file.filename}"\r\n` + `Content-Type: ${file.contentType}\r\n\r\n` ) ); chunks.push(file.content); chunks.push(Buffer.from('\r\n')); } chunks.push(Buffer.from(`--${boundary}--\r\n`)); const bodyBuffer = Buffer.concat(chunks); const req = http.request( { hostname: '127.0.0.1', port, method, path: requestPath, headers: { ...(authorization ? { authorization } : {}), 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': bodyBuffer.length } }, (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(bodyBuffer); req.end(); }); const createSftpRecorder = () => ({ connectCalls: 0, mkdirCalls: [], putCalls: [], deleteCalls: [], endCalls: 0 }); const createFakeSftpClientFactory = (recorder, options = {}) => () => ({ connect: async () => { recorder.connectCalls += 1; if (options.failConnect) { throw new Error('SFTP_CONNECT_FAILED'); } }, exists: async (remotePath) => { const normalizedPath = String(remotePath || ''); const lastSegment = normalizedPath.split('/').filter(Boolean).pop() || ''; // Simulate an existing base dir and a missing per-trip dir (numeric segment). return !/^\d+$/.test(lastSegment); }, mkdir: async (remoteDir, recursive) => { recorder.mkdirCalls.push({ remoteDir, recursive }); }, put: async (localPath, remotePath) => { recorder.putCalls.push({ localPath, remotePath }); if (options.failPut) { throw new Error('SFTP_PUT_FAILED'); } }, delete: async (remotePath) => { recorder.deleteCalls.push(remotePath); if (options.failDelete) { throw new Error('SFTP_DELETE_FAILED'); } }, end: async () => { recorder.endCalls += 1; } }); const waitFor = async (predicate, { timeoutMs = 1500, intervalMs = 25 } = {}) => { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { if (predicate()) { return; } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error('Timed out waiting for condition'); }; const readJsonLines = (filePath) => fs .readFileSync(filePath, 'utf8') .split('\n') .map((line) => line.trim()) .filter(Boolean) .map((line) => JSON.parse(line)); test.before(() => { originalQuery = db.query; originalGetConnection = db.getConnection; fs.rmSync(TEST_UPLOAD_DIR, { recursive: true, force: true }); fs.rmSync(TEST_POSTS_LOG_PATH, { force: true }); fs.rmSync(TEST_STATUS_LOG_PATH, { force: true }); }); test.after(() => { db.query = originalQuery; db.getConnection = originalGetConnection; tripStatusPhotoStorage.__resetSftpClientFactoryForTests(); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; delete process.env.TRIP_STATUS_SFTP_HOST; delete process.env.TRIP_STATUS_SFTP_PORT; delete process.env.TRIP_STATUS_SFTP_USERNAME; delete process.env.TRIP_STATUS_SFTP_PASSWORD; delete process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR; delete process.env.POSTS_LOG_PATH; delete process.env.TRIP_STATUS_UPDATES_LOG_PATH; fs.rmSync(TEST_UPLOAD_DIR, { recursive: true, force: true }); fs.rmSync(TEST_POSTS_LOG_PATH, { force: true }); fs.rmSync(TEST_STATUS_LOG_PATH, { force: true }); }); test.afterEach(() => { db.query = originalQuery; db.getConnection = originalGetConnection; tripStatusPhotoStorage.__resetSftpClientFactoryForTests(); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; delete process.env.TRIP_STATUS_SFTP_HOST; delete process.env.TRIP_STATUS_SFTP_PORT; delete process.env.TRIP_STATUS_SFTP_USERNAME; delete process.env.TRIP_STATUS_SFTP_PASSWORD; delete process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR; delete process.env.POSTS_LOG_PATH; delete process.env.TRIP_STATUS_UPDATES_LOG_PATH; fs.rmSync(TEST_POSTS_LOG_PATH, { force: true }); fs.rmSync(TEST_STATUS_LOG_PATH, { force: true }); }); test('POST /api/trips/:id/status 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 statusRouteLayer = apiRouterLayers .flatMap((routerLayer) => routerLayer.handle.stack) .find( (layer) => layer.route && layer.route.path === '/trips/:id/status' && layer.route.methods.post ); assert.ok(statusRouteLayer, 'POST /api/trips/:id/status route is not defined'); }); test('POST /api/trips/:id/status estado intermedio 5 con id_punto => OK', async () => { let step = 0; const legacyPointValue = '151:|:obs previa:|:2026-03-10:|:09:00'; db.query = async (sql, params) => { step += 1; if (step === 1) { assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [5]); return [[{ id_estado: 5 }]]; } if (step === 2) { assert.match(sql, /FROM c_viajes/); assert.deepEqual(params, [248230]); return [[{ id_viaje: 248230 }]]; } if (step === 3) { assert.match(sql, /FROM c_viajes_proveedor/); assert.deepEqual(params, [248230, '58045340X']); return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4) { assert.match(sql, /FROM c_viajes_puntos/); assert.deepEqual(params, [8123, 248230]); return [[{ id_punto: 8123, id_estado_intermedio: 4, valor: legacyPointValue, foto: 'previa.png' }]]; } if (step === 5) { assert.match(sql, /UPDATE c_viajes_puntos/); assert.equal(params[0], 5); assert.equal(params[1], '151:|:ok sin fotos:|:2026-03-10:|:09:00'); assert.equal(params[2], null); assert.equal(params[3], null); assert.equal(params[7], 8123); assert.equal(params[8], 248230); return [{ affectedRows: 1 }]; } throw new Error(`Unexpected query on step ${step}: ${sql}`); }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 5, id_punto: 8123, observaciones: 'ok sin fotos' } }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.trip_id, 248230); assert.equal(response.body.status_id, 5); assert.equal(response.body.id_estado, 5); assert.equal(response.body.id_punto, 8123); assert.match(response.body.updated_at, /^\d{4}-\d{2}-\d{2}T/); assert.equal(step, 5); }); test('POST /api/trips/:id/status intermedio sin observaciones ni fotos limpia valor y foto previos', async () => { let step = 0; const legacyPointValue = '151:|:comentario previo:|:2026-03-08:|:10:00'; db.query = async (sql, params) => { step += 1; if (step === 1) { return [[{ id_estado: 4 }]]; } if (step === 2) { return [[{ id_viaje: 248230 }]]; } if (step === 3) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4) { assert.match(sql, /FROM c_viajes_puntos/); return [[{ id_punto: 8123, id_estado_intermedio: 3, valor: legacyPointValue, foto: 'foto-previa.png', fecha_foto: '2026-03-08 10:00:00' }]]; } assert.match(sql, /UPDATE c_viajes_puntos/); assert.equal(params[0], 4); assert.equal(params[1], '151:|::|:2026-03-08:|:10:00'); assert.equal(params[2], null); assert.equal(params[3], null); assert.equal(params[7], 8123); assert.equal(params[8], 248230); return [{ affectedRows: 1 }]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 4, id_punto: 8123, observaciones: ' ' } }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.trip_id, 248230); assert.equal(response.body.id_estado, 4); assert.equal(response.body.id_punto, 8123); assert.equal(step, 5); }); test('POST /api/trips/:id/status intermedio multipart con foto conserva ref_punto_id aunque observaciones queden vacias', async () => { let step = 0; db.query = async (sql, params) => { step += 1; if (step === 1) { return [[{ id_estado: 5 }]]; } if (step === 2) { return [[{ id_viaje: 248230 }]]; } if (step === 3) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4) { assert.match(sql, /FROM c_viajes_puntos/); return [[{ id_punto: 8123, id_estado_intermedio: 4, valor: '151:|:comentario previo:|:2026-03-08:|:10:00', foto: null, fecha_foto: null }]]; } assert.match(sql, /UPDATE c_viajes_puntos/); assert.equal(params[0], 5); assert.equal(params[1], '151:|::|:2026-03-08:|:10:00'); assert.match(params[2], /^[a-f0-9]{6}\.png$/); assert.ok(params[3] instanceof Date); assert.equal(params[7], 8123); assert.equal(params[8], 248230); return [{ affectedRows: 1 }]; }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, fields: { id_estado: 5, id_punto: 8123, observaciones: ' ' }, files: [{ fieldName: 'fotos', filename: 'evidence.png', contentType: 'image/png', content: Buffer.from('png-binary') }] }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(step, 5); }); test('POST /api/trips/:id/status multipart registra body parseado y ficheros en posts.log', async () => { let step = 0; process.env.POSTS_LOG_PATH = TEST_POSTS_LOG_PATH; db.query = async (sql, params) => { step += 1; if (step === 1) { return [[{ id_estado: 5 }]]; } if (step === 2) { return [[{ id_viaje: 248230 }]]; } if (step === 3) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4) { return [[{ id_punto: 8123, id_estado_intermedio: 4 }]]; } assert.match(sql, /UPDATE c_viajes_puntos/); assert.equal(params[0], 5); assert.equal(params[1], 'desde multipart'); assert.match(params[2], /^[a-f0-9]{6}\.png$/); assert.ok(params[3] instanceof Date); return [{ affectedRows: 1 }]; }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, fields: { id_estado: 5, id_punto: 8123, observaciones: 'desde multipart' }, files: [{ fieldName: 'fotos', filename: 'evidence.png', contentType: 'image/png', content: Buffer.from('png-binary') }] }) ); assert.equal(response.statusCode, 200); await waitFor(() => fs.existsSync(TEST_POSTS_LOG_PATH)); const [entry] = readJsonLines(TEST_POSTS_LOG_PATH); assert.equal(entry.method, 'POST'); assert.equal(entry.path, '/api/trips/248230/status'); assert.equal(entry.body.id_estado, '5'); assert.equal(entry.body.id_punto, '8123'); assert.equal(entry.body.observaciones, 'desde multipart'); assert.equal(entry.raw_body, null); assert.equal(entry.files.length, 1); assert.equal(entry.files[0].field_name, 'fotos'); assert.match(entry.files[0].filename, /^[a-f0-9]{6}\.png$/); assert.equal(entry.files[0].originalname, 'evidence.png'); }); test('POST /api/trips/:id/status intermedio registra detalle rico en status.log', async () => { let step = 0; process.env.TRIP_STATUS_UPDATES_LOG_PATH = TEST_STATUS_LOG_PATH; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 5 }]]; } if (step === 2) { return [[{ id_viaje: 248230 }]]; } if (step === 3) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4) { return [[{ id_punto: 8123, id_estado_intermedio: 4, valor: 'previo', foto: 'previa.png' }]]; } return [{ affectedRows: 1 }]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 5, id_punto: 8123, observaciones: 'audit me' } }) ); assert.equal(response.statusCode, 200); await waitFor(() => fs.existsSync(TEST_STATUS_LOG_PATH)); const entries = readJsonLines(TEST_STATUS_LOG_PATH); const entry = entries.find((candidate) => candidate.operation === 'upsert_point_status'); assert.ok(entry, 'Expected intermediate point status entry in status.log'); assert.equal(entry.flow, 'intermediate_point_manual'); assert.equal(entry.result, 'SUCCESS'); assert.equal(entry.trip_id, 248230); assert.equal(entry.id_punto, 8123); assert.equal(entry.id_estado, 5); assert.equal(entry.previous_intermediate_status_id, 4); assert.equal(entry.new_intermediate_status_id, 5); assert.equal(entry.valor_written, 'audit me'); assert.equal(entry.foto_written, null); assert.equal(entry.valor_cleared, false); assert.equal(entry.foto_cleared, true); }); test('POST /api/trips/:id/status estado 7 sin fotos => 422', async () => { const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 7, observaciones: 'sin fotos' } }) ); assert.equal(response.statusCode, 422); assert.deepEqual(response.body, { success: false, error: 'Photo required for status 7' }); }); test('POST /api/trips/:id/status estado 7 con múltiples fotos => OK', async () => { let step = 0; db.query = async (sql, params) => { step += 1; if (step === 1) { return [[{ id_estado: 7 }]]; } if (step === 2) { return [[{ id_viaje: 248230 }]]; } if (step === 3) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4) { return [{ affectedRows: 1 }]; } if (step === 5) { return [{ affectedRows: 1 }]; } assert.match(sql, /INSERT INTO c_cambios_estado/); return [{ insertId: 2, affectedRows: 1 }]; }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, fields: { id_estado: 7, observaciones: 'entregado', fotos_concat: '' }, files: [ { fieldName: 'fotos', filename: 'photo1.jpg', contentType: 'image/jpeg', content: Buffer.from('fake-jpeg-1') }, { fieldName: 'fotos', filename: 'photo2.jpg', contentType: 'image/jpeg', content: Buffer.from('fake-jpeg-2') } ] }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.trip_id, 248230); assert.equal(response.body.id_estado, 7); assert.match(response.body.fotos_concat, /^[a-f0-9]{6}\.(jpg|png|webp|heic|heif);[a-f0-9]{6}\.(jpg|png|webp|heic|heif)$/); const storedPhotos = response.body.fotos_concat.split(';').filter(Boolean); assert.equal(storedPhotos.length, 2); for (const fileName of storedPhotos) { assert.equal( fs.existsSync(path.join(TEST_UPLOAD_DIR, '248230', fileName)), true ); } assert.match(response.body.updated_at, /^\d{4}-\d{2}-\d{2}T/); }); test('POST /api/trips/:id/status modo dual replica foto a SFTP y mantiene local', async () => { const recorder = createSftpRecorder(); tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder)); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51'; process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR = '/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native'; let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 7 }]]; } if (step === 2) { return [[{ id_viaje: 248230 }]]; } if (step === 3) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4 || step === 5) { return [{ affectedRows: 1 }]; } return [{ insertId: 91, affectedRows: 1 }]; }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, fields: { id_estado: 7, observaciones: 'modo dual' }, files: [ { fieldName: 'fotos', filename: 'dual-photo.jpg', contentType: 'image/jpeg', content: Buffer.from('fake-dual-jpeg') } ] }) ); assert.equal(response.statusCode, 200); const storedPhotoName = response.body.fotos_concat; assert.match(storedPhotoName, /^[a-f0-9]{6}\.(jpg|png|webp|heic|heif)$/); assert.equal( fs.existsSync(path.join(TEST_UPLOAD_DIR, '248230', storedPhotoName)), true ); assert.equal(recorder.connectCalls, 1); assert.equal(recorder.mkdirCalls.length, 1); assert.equal( recorder.mkdirCalls[0].remoteDir, '/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native/248230' ); assert.equal(recorder.putCalls.length, 1); assert.equal( recorder.putCalls[0].remotePath, `/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native/248230/${storedPhotoName}` ); }); test('POST /api/trips/:id/status modo dual con fallo SFTP mantiene fallback local', async () => { const recorder = createSftpRecorder(); tripStatusPhotoStorage.__setSftpClientFactoryForTests( createFakeSftpClientFactory(recorder, { failPut: true }) ); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51'; process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR = '/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native'; let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 7 }]]; } if (step === 2) { return [[{ id_viaje: 248230 }]]; } if (step === 3) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4 || step === 5) { return [{ affectedRows: 1 }]; } return [{ insertId: 92, affectedRows: 1 }]; }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, fields: { id_estado: 7, observaciones: 'fallback local' }, files: [ { fieldName: 'fotos', filename: 'dual-fallback-photo.jpg', contentType: 'image/jpeg', content: Buffer.from('fake-dual-fallback-jpeg') } ] }) ); assert.equal(response.statusCode, 200); const storedPhotoName = response.body.fotos_concat; assert.match(storedPhotoName, /^[a-f0-9]{6}\.(jpg|png|webp|heic|heif)$/); assert.equal( fs.existsSync(path.join(TEST_UPLOAD_DIR, '248230', storedPhotoName)), true ); assert.equal(recorder.putCalls.length, 1); }); test('POST /api/trips/:id/status payload inválido tras upload limpia remoto y local en modo dual', async () => { const invalidPayloadTripId = 248299; const recorder = createSftpRecorder(); tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder)); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51'; process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR = '/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native'; const localTripDir = path.join(TEST_UPLOAD_DIR, String(invalidPayloadTripId)); fs.rmSync(localTripDir, { recursive: true, force: true }); db.query = async () => { throw new Error('db.query should not run for invalid payload'); }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: `/api/trips/${invalidPayloadTripId}/status`, authorization: `Bearer ${createToken()}`, fields: { observaciones: 'sin id_estado' }, files: [ { fieldName: 'fotos', filename: 'dual-invalid-photo.jpg', contentType: 'image/jpeg', content: Buffer.from('fake-dual-invalid-jpeg') } ] }) ); assert.equal(response.statusCode, 400); assert.equal(recorder.putCalls.length, 1); await waitFor(() => recorder.deleteCalls.length === 1); const localFiles = fs.existsSync(localTripDir) ? fs.readdirSync(localTripDir) : []; assert.equal(localFiles.length, 0); }); test('POST /api/trips/:id/status estado 7 con fotos_concat (sin archivo) => OK', async () => { let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 7 }]]; } if (step === 2) { return [[{ id_viaje: 248230 }]]; } if (step === 3) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4) { return [{ affectedRows: 1 }]; } if (step === 5) { return [{ affectedRows: 1 }]; } return [{ insertId: 3, affectedRows: 1 }]; }; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, fields: { id_estado: 7, observaciones: 'entregado con fallback', fotos_concat: 'legacy1.jpg;legacy2.jpg' } }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.id_estado, 7); assert.equal(response.body.fotos_concat, 'legacy1.jpg;legacy2.jpg'); }); test('POST /api/trips/:id/status permite rollback 7 -> 6', async () => { let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 6 }]]; } if (step === 2) { return [[{ id_viaje: 196854 }]]; } if (step === 3) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4) { return [{ affectedRows: 1 }]; } if (step === 5) { return [{ affectedRows: 1 }]; } return [{ insertId: 4, affectedRows: 1 }]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/196854/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 6, observaciones: 'rollback a 6' } }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.id_estado, 6); }); test('POST /api/trips/:id/status estado 5 sin id_punto usa flujo global legacy => 200', async () => { let step = 0; db.query = async (sql, params) => { step += 1; if (step === 1) { assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [5]); return [[{ id_estado: 5 }]]; } if (step === 2) { assert.match(sql, /FROM c_viajes/); assert.deepEqual(params, [196854]); return [[{ id_viaje: 196854 }]]; } if (step === 3) { assert.match(sql, /FROM c_viajes_proveedor/); assert.deepEqual(params, [196854, '58045340X']); return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (step === 4) { assert.match(sql, /UPDATE c_viajes/); assert.deepEqual(params, [5, 1, 196854]); return [{ affectedRows: 1 }]; } if (step === 5) { assert.match(sql, /UPDATE c_viajes_proveedor/); return [{ affectedRows: 1 }]; } assert.match(sql, /INSERT INTO c_cambios_estado/); return [{ insertId: 6, affectedRows: 1 }]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/196854/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 5, observaciones: 'falta id_punto' } }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.trip_id, 196854); assert.equal(response.body.id_estado, 5); assert.equal(step, 6); }); test('POST /api/trips/:id/status estado intermedio con id_punto inválido => 400', async () => { db.query = async () => { throw new Error('db.query should not run for invalid id_punto'); }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/196854/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 5, id_punto: 'abc' } }) ); assert.equal(response.statusCode, 400); assert.deepEqual(response.body, { success: false, error: 'Invalid payload' }); }); test('POST /api/trips/:id/status estado intermedio con id_punto inexistente => 404', async () => { let step = 0; db.query = async (sql, params) => { step += 1; if (step === 1) { assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [5]); return [[{ id_estado: 5 }]]; } if (step === 2) { assert.match(sql, /FROM c_viajes/); assert.deepEqual(params, [248230]); return [[{ id_viaje: 248230 }]]; } if (step === 3) { assert.match(sql, /FROM c_viajes_proveedor/); assert.deepEqual(params, [248230, '58045340X']); return [[{ n_proveedor: 1 }]]; } assert.match(sql, /FROM c_viajes_puntos/); assert.deepEqual(params, [99999, 248230]); return [[]]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 5, id_punto: 99999, observaciones: 'punto inválido' } }) ); assert.equal(response.statusCode, 404); assert.deepEqual(response.body, { success: false, error: 'Trip point not found' }); assert.equal(step, 4); }); test('POST /api/trips/:id/status devuelve 401 sin token', async () => { db.query = async () => { throw new Error('db.query should not be called without token'); }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', body: { id_estado: 6 } }) ); assert.equal(response.statusCode, 401); assert.deepEqual(response.body, { error: 'Unauthorized' }); }); test('POST /api/trips/:id/status devuelve 403 para viaje no autorizado', async () => { let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 6 }]]; } if (step === 2) { return [[{ id_viaje: 248080 }]]; } if (step === 3) { return [[]]; } throw new Error('No update query should run for forbidden trip'); }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/248080/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 6 } }) ); assert.equal(response.statusCode, 403); assert.deepEqual(response.body, { success: false, error: 'Forbidden' }); }); test('POST /api/trips/:id/status devuelve 404 cuando viaje no existe', async () => { let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 6 }]]; } if (step === 2) { return [[]]; } throw new Error('Authorization query should not run when trip does not exist'); }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'POST', path: '/api/trips/99999999/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 6 } }) ); assert.equal(response.statusCode, 404); assert.deepEqual(response.body, { success: false, error: 'Trip not found' }); }); test('POST /api/trips/:id/status ind_fallido=1 actualiza estado existente X y agrega 9 => OK', async () => { let validationStep = 0; let txStep = 0; let beginCalled = false; let commitCalled = false; let rollbackCalled = false; let releaseCalled = false; db.query = async (sql, params) => { validationStep += 1; if (validationStep === 1) { assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [6]); return [[{ id_estado: 6 }]]; } assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [9]); return [[{ id_estado: 9 }]]; }; const connection = { beginTransaction: async () => { beginCalled = true; }, query: async (sql, params) => { txStep += 1; if (txStep === 1) { assert.match(sql, /FROM c_viajes/); assert.match(sql, /FOR UPDATE/); assert.deepEqual(params, [248230]); return [[{ id_viaje: 248230, id_estado: 6, ind_fallido: 0 }]]; } if (txStep === 2) { assert.match(sql, /FROM c_viajes_proveedor/); assert.match(sql, /FOR UPDATE/); assert.deepEqual(params, [248230, '58045340X']); return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (txStep === 3) { assert.match(sql, /UPDATE c_viajes_proveedor/); assert.deepEqual(params, ['fallido en estado 6', 248230, '58045340X']); return [{ affectedRows: 1 }]; } if (txStep === 4) { assert.match(sql, /UPDATE c_cambios_estado/); assert.equal(params[9], 6); return [{ affectedRows: 1 }]; } if (txStep === 5) { assert.match(sql, /SELECT id_estado/); assert.match(sql, /ORDER BY fecha_y_hora DESC/); assert.deepEqual(params, [248230]); return [[{ id_estado: 6 }]]; } if (txStep === 6) { assert.match(sql, /INSERT INTO c_cambios_estado/); assert.equal(params[3], 9); assert.equal(params[4], 0); return [{ insertId: 20, affectedRows: 1 }]; } assert.match(sql, /UPDATE c_viajes/); assert.deepEqual(params, [9, 1, 248230]); return [{ affectedRows: 1 }]; }, commit: async () => { commitCalled = true; }, rollback: async () => { rollbackCalled = true; }, release: () => { releaseCalled = true; } }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, fields: { id_estado: 6, ind_fallido: 1, observaciones: 'fallido en estado 6' } }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.trip_id, 248230); assert.equal(response.body.updated_status_id, 6); assert.equal(response.body.inserted_failed_status_id, 9); assert.equal(response.body.action_taken, 'update+insert_failed'); assert.equal(response.body.id_estado, 9); assert.equal(response.body.failed_marked, true); assert.equal(beginCalled, true); assert.equal(commitCalled, true); assert.equal(rollbackCalled, false); assert.equal(releaseCalled, true); }); test('POST /api/trips/:id/status ind_fallido=1 inserta X si no existe y luego inserta 9 => OK', async () => { let validationStep = 0; let txStep = 0; db.query = async (sql, params) => { validationStep += 1; if (validationStep === 1) { assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [6]); return [[{ id_estado: 6 }]]; } assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [9]); return [[{ id_estado: 9 }]]; }; const connection = { beginTransaction: async () => {}, query: async (sql, params) => { txStep += 1; if (txStep === 1) { return [[{ id_viaje: 248230, id_estado: 6, ind_fallido: 0 }]]; } if (txStep === 2) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (txStep === 3) { assert.match(sql, /UPDATE c_cambios_estado/); assert.equal(params[9], 6); return [{ affectedRows: 0 }]; } if (txStep === 4) { assert.match(sql, /INSERT INTO c_cambios_estado/); assert.equal(params[3], 6); assert.equal(params[4], 1); return [{ insertId: 21, affectedRows: 1 }]; } if (txStep === 5) { assert.match(sql, /SELECT id_estado/); return [[{ id_estado: 6 }]]; } if (txStep === 6) { assert.match(sql, /INSERT INTO c_cambios_estado/); assert.equal(params[3], 9); assert.equal(params[4], 0); return [{ insertId: 22, affectedRows: 1 }]; } assert.match(sql, /UPDATE c_viajes/); assert.deepEqual(params, [9, 1, 248230]); return [{ affectedRows: 1 }]; }, commit: async () => {}, rollback: async () => { throw new Error('rollback should not be called for successful transaction'); }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, fields: { id_estado: 6, ind_fallido: 1 } }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.updated_status_id, 6); assert.equal(response.body.inserted_failed_status_id, 9); assert.equal(response.body.action_taken, 'insert+insert_failed'); assert.equal(response.body.message, 'Estado marcado como fallido y estado 9 registrado'); }); test('POST /api/trips/:id/status ind_fallido=1 evita 9 consecutivo si el último ya es 9', async () => { let validationStep = 0; let txStep = 0; db.query = async (sql, params) => { validationStep += 1; if (validationStep === 1) { assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [6]); return [[{ id_estado: 6 }]]; } assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [9]); return [[{ id_estado: 9 }]]; }; const connection = { beginTransaction: async () => {}, query: async (sql, params) => { txStep += 1; if (txStep === 1) { return [[{ id_viaje: 248230, id_estado: 9, ind_fallido: 1 }]]; } if (txStep === 2) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (txStep === 3) { assert.match(sql, /UPDATE c_cambios_estado/); assert.equal(params[9], 6); return [{ affectedRows: 1 }]; } if (txStep === 4) { assert.match(sql, /SELECT id_estado/); return [[{ id_estado: 9 }]]; } assert.match(sql, /UPDATE c_viajes/); assert.deepEqual(params, [9, 1, 248230]); return [{ affectedRows: 1 }]; }, commit: async () => {}, rollback: async () => { throw new Error('rollback should not be called for successful transaction'); }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, fields: { id_estado: 6, ind_fallido: 1 } }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.updated_status_id, 6); assert.equal(response.body.inserted_failed_status_id, null); assert.equal(response.body.action_taken, 'update+skip_failed_insert'); assert.equal( response.body.message, 'Estado marcado como fallido; no se insertó 9 porque ya era el último estado' ); }); test('POST /api/trips/:id/status ind_fallido=1 hace rollback ante error intermedio', async () => { let validationStep = 0; let txStep = 0; let commitCalled = false; let rollbackCalled = false; let releaseCalled = false; db.query = async (sql, params) => { validationStep += 1; if (validationStep === 1) { assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [6]); return [[{ id_estado: 6 }]]; } assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [9]); return [[{ id_estado: 9 }]]; }; const connection = { beginTransaction: async () => {}, query: async (sql) => { txStep += 1; if (txStep === 1) { return [[{ id_viaje: 248230, id_estado: 6, ind_fallido: 0 }]]; } if (txStep === 2) { return [[{ n_proveedor: 1, id_proveedor: 675 }]]; } if (txStep === 3) { return [{ affectedRows: 1 }]; } if (txStep === 4) { return [[{ id_estado: 6 }]]; } if (txStep === 5) { return [{ insertId: 23, affectedRows: 1 }]; } assert.match(sql, /UPDATE c_viajes/); throw new Error('forced update failure'); }, commit: async () => { commitCalled = true; }, rollback: async () => { rollbackCalled = true; }, release: () => { releaseCalled = true; } }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestMultipart({ port: server.address().port, method: 'POST', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, fields: { id_estado: 6, ind_fallido: 1 } }) ); assert.equal(response.statusCode, 500); assert.deepEqual(response.body, { success: false, error: 'Internal server error' }); assert.equal(commitCalled, false); assert.equal(rollbackCalled, true); assert.equal(releaseCalled, true); }); test('GET /api/trips/:id/status-history combina global + intermedios con status_key', async () => { let step = 0; db.query = async (sql, params) => { step += 1; if (step === 1) { assert.match(sql, /FROM c_viajes/); assert.deepEqual(params, [248230]); return [[{ id_viaje: 248230 }]]; } if (step === 2) { assert.match(sql, /FROM c_viajes_proveedor/); assert.deepEqual(params, [248230, '58045340X']); return [[{ ok: 1 }]]; } assert.match(sql, /FROM c_cambios_estado/); assert.match(sql, /UNION ALL/); assert.match(sql, /FROM c_viajes_puntos/); assert.match(sql, /COALESCE\(c\.actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.match(sql, /COALESCE\(vp\.actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.deepEqual(params, [248230, 248230, 3, 4, 5]); return [[ { id_estado: 1, fecha_hora: '2026-02-17 08:00:00', id_punto: null }, { id_estado: 3, fecha_hora: '2026-02-17 08:10:00', id_punto: null }, { id_estado: 3, fecha_hora: '2026-02-17 08:20:00', id_punto: 8123 }, { id_estado: 4, fecha_hora: '2026-02-17 08:30:00', id_punto: 8123 }, { id_estado: 5, fecha_hora: '2026-02-17 08:40:00', id_punto: 8123 }, { id_estado: 6, fecha_hora: '2026-02-17 09:00:00', id_punto: null }, { id_estado: 7, fecha_hora: '2026-02-17 09:30:00', id_punto: null } ]]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'GET', path: '/api/trips/248230/status-history', authorization: `Bearer ${createToken()}` }) ); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { success: true, trip_id: 248230, history: [ { id_estado: 1, fecha_hora: '2026-02-17 08:00:00', id_punto: null, status_key: 'assigned' }, { id_estado: 3, fecha_hora: '2026-02-17 08:10:00', id_punto: null, status_key: 'positioned' }, { id_estado: 3, fecha_hora: '2026-02-17 08:20:00', id_punto: 8123, status_key: 'point_8123_positioned' }, { id_estado: 4, fecha_hora: '2026-02-17 08:30:00', id_punto: 8123, status_key: 'point_8123_loading_unloading' }, { id_estado: 5, fecha_hora: '2026-02-17 08:40:00', id_punto: 8123, status_key: 'point_8123_transit' }, { id_estado: 6, fecha_hora: '2026-02-17 09:00:00', id_punto: null, status_key: 'arrival' }, { id_estado: 7, fecha_hora: '2026-02-17 09:30:00', id_punto: null, status_key: 'closed' } ] }); assert.equal(step, 3); }); test('GET /api/trips/:id/status-history mantiene status_key para eventos manuales', async () => { let step = 0; db.query = async (sql, params) => { step += 1; if (step === 1) { return [[{ id_viaje: 248230 }]]; } if (step === 2) { return [[{ ok: 1 }]]; } assert.match(sql, /COALESCE\(c\.actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.match(sql, /COALESCE\(vp\.actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.deepEqual(params, [248230, 248230, 3, 4, 5]); return [[ { id_estado: 3, fecha_hora: '2026-02-17 08:10:00', id_punto: null }, { id_estado: 5, fecha_hora: '2026-02-17 08:40:00', id_punto: 8123 } ]]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'GET', path: '/api/trips/248230/status-history', authorization: `Bearer ${createToken()}` }) ); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { success: true, trip_id: 248230, history: [ { id_estado: 3, fecha_hora: '2026-02-17 08:10:00', id_punto: null, status_key: 'positioned' }, { id_estado: 5, fecha_hora: '2026-02-17 08:40:00', id_punto: 8123, status_key: 'point_8123_transit' } ] }); assert.equal(step, 3); }); test('GET /api/trips/:id/status-history devuelve 403 para viaje no autorizado', async () => { let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_viaje: 248230 }]]; } return [[]]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'GET', path: '/api/trips/248230/status-history', authorization: `Bearer ${createToken()}` }) ); assert.equal(response.statusCode, 403); assert.deepEqual(response.body, { success: false, error: 'Forbidden' }); assert.equal(step, 2); }); test('DELETE /api/trips/:id/status intermedio 5 retrocede a 4', async () => { let step = 0; db.getConnection = async () => { throw new Error('transaction should not run for intermediate point delete'); }; db.query = async (sql, params) => { step += 1; if (step === 1) { assert.match(sql, /FROM t_viaje_estados/); assert.deepEqual(params, [5]); return [[{ id_estado: 5 }]]; } if (step === 2) { assert.match(sql, /FROM c_viajes/); assert.deepEqual(params, [248230]); return [[{ id_viaje: 248230, id_estado: 6 }]]; } if (step === 3) { assert.match(sql, /FROM c_viajes_proveedor/); assert.deepEqual(params, [248230, '58045340X']); return [[{ n_proveedor: 1 }]]; } if (step === 4) { assert.match(sql, /FROM c_viajes_puntos/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s+AS\s+actualizado_automaticamente/); assert.deepEqual(params, [8123, 248230]); return [[{ id_punto: 8123, id_estado_intermedio: 5, valor: '151:|:comentario previo:|:2026-03-10:|:09:00', actualizado_automaticamente: 0 }]]; } assert.match(sql, /UPDATE c_viajes_puntos/); assert.match(sql, /borrado_en_app = 1/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.deepEqual(params, [4, '151:|::|:2026-03-10:|:09:00', 8123, 248230]); return [{ affectedRows: 1 }]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 5, id_punto: 8123 } }) ); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { success: true, trip_id: 248230, removed_status_id: 5, new_intermediate_status_id: 4, current_status_id: 6, id_punto: 8123 }); assert.equal(step, 5); }); test('DELETE /api/trips/:id/status intermedio limpia valor foto y fecha_foto y registra status.log', async () => { let step = 0; process.env.TRIP_STATUS_UPDATES_LOG_PATH = TEST_STATUS_LOG_PATH; db.query = async (sql, params) => { step += 1; if (step === 1) { return [[{ id_estado: 5 }]]; } if (step === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } if (step === 3) { return [[{ n_proveedor: 1 }]]; } if (step === 4) { return [[{ id_punto: 8123, id_estado_intermedio: 5, valor: '151:|:comentario previo:|:2026-03-10:|:09:00', actualizado_automaticamente: 0 }]]; } assert.match(sql, /UPDATE c_viajes_puntos/); assert.match(sql, /valor = \?/); assert.match(sql, /foto = NULL/); assert.match(sql, /fecha_foto = NULL/); assert.deepEqual(params, [4, '151:|::|:2026-03-10:|:09:00', 8123, 248230]); return [{ affectedRows: 1 }]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 5, id_punto: 8123 } }) ); assert.equal(response.statusCode, 200); await waitFor(() => fs.existsSync(TEST_STATUS_LOG_PATH)); const entries = readJsonLines(TEST_STATUS_LOG_PATH); const entry = entries.find((candidate) => candidate.operation === 'clear_point_status'); assert.ok(entry, 'Expected clear_point_status entry in status.log'); assert.equal(entry.result, 'ROLLBACK_APPLIED'); assert.equal(entry.previous_intermediate_status_id, 5); assert.equal(entry.new_intermediate_status_id, 4); assert.equal(entry.valor_cleared, true); assert.equal(entry.foto_cleared, true); assert.equal(step, 5); }); test('DELETE /api/trips/:id/status intermedio 4 retrocede a 3', async () => { let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 4 }]]; } if (step === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } if (step === 3) { return [[{ n_proveedor: 1 }]]; } if (step === 4) { return [[{ id_punto: 8123, id_estado_intermedio: 4 }]]; } return [{ affectedRows: 1 }]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 4, id_punto: 8123 } }) ); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { success: true, trip_id: 248230, removed_status_id: 4, new_intermediate_status_id: 3, current_status_id: 6, id_punto: 8123 }); assert.equal(step, 5); }); test('DELETE /api/trips/:id/status intermedio 3 retrocede a NULL', async () => { let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 3 }]]; } if (step === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } if (step === 3) { return [[{ n_proveedor: 1 }]]; } if (step === 4) { return [[{ id_punto: 8123, id_estado_intermedio: 3 }]]; } return [{ affectedRows: 1 }]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 3, id_punto: 8123 } }) ); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { success: true, trip_id: 248230, removed_status_id: 3, new_intermediate_status_id: null, current_status_id: 6, id_punto: 8123 }); assert.equal(step, 5); }); test('DELETE /api/trips/:id/status intermedio devuelve 404 si el punto no tiene estado intermedio válido', async () => { let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 3 }]]; } if (step === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } if (step === 3) { return [[{ n_proveedor: 1 }]]; } return [[{ id_punto: 8123, id_estado_intermedio: null }]]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 3, id_punto: 8123 } }) ); assert.equal(response.statusCode, 404); assert.deepEqual(response.body, { success: false, error: 'Trip point intermediate status not found' }); assert.equal(step, 4); }); test('DELETE /api/trips/:id/status intermedio ignora estado automatico y devuelve 404', async () => { let step = 0; db.getConnection = async () => { throw new Error('transaction should not run for intermediate point delete'); }; db.query = async (sql) => { step += 1; if (step === 1) { return [[{ id_estado: 3 }]]; } if (step === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } if (step === 3) { return [[{ n_proveedor: 1 }]]; } assert.match(sql, /FROM c_viajes_puntos/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s+AS\s+actualizado_automaticamente/); return [[{ id_punto: 8123, id_estado_intermedio: 3, actualizado_automaticamente: 1 }]]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 3, id_punto: 8123 } }) ); assert.equal(response.statusCode, 404); assert.deepEqual(response.body, { success: false, error: 'Trip point intermediate status not found' }); assert.equal(step, 4); }); test('DELETE /api/trips/:id/status estado 3 sin id_punto usa flujo global legacy', async () => { let validationStep = 0; let txStep = 0; db.query = async () => { validationStep += 1; if (validationStep === 1) { return [[{ id_estado: 3 }]]; } if (validationStep === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } return [[{ n_proveedor: 1 }]]; }; const connection = { beginTransaction: async () => {}, query: async (sql, params) => { txStep += 1; if (txStep === 1) { assert.match(sql, /FROM c_cambios_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.match(sql, /FOR UPDATE/); assert.deepEqual(params, [248230, 1, '58045340X', 3]); return [[{ id_estado: 3 }]]; } if (txStep === 2) { assert.match(sql, /DELETE FROM c_cambios_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.equal(/LIMIT\s+1/i.test(sql), false); assert.deepEqual(params, [248230, 1, '58045340X', 3]); return [{ affectedRows: 1 }]; } if (txStep === 3) { assert.match(sql, /SELECT id_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); return [[{ id_estado: 2 }]]; } assert.match(sql, /UPDATE c_viajes/); assert.deepEqual(params, [2, 1, 248230]); return [{ affectedRows: 1 }]; }, commit: async () => {}, rollback: async () => { throw new Error('rollback should not run in successful global clear test'); }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 3 } }) ); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { success: true, trip_id: 248230, removed_status_id: 3, current_status_id: 2 }); assert.equal(validationStep, 3); assert.equal(txStep, 4); }); test('DELETE /api/trips/:id/status global devuelve 404 cuando no hay cambios manuales elegibles', async () => { let validationStep = 0; let txStep = 0; let rollbackCalls = 0; db.query = async () => { validationStep += 1; if (validationStep === 1) { return [[{ id_estado: 6 }]]; } if (validationStep === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } return [[{ n_proveedor: 1 }]]; }; const connection = { beginTransaction: async () => {}, query: async (sql) => { txStep += 1; assert.equal(txStep, 1); assert.match(sql, /FROM c_cambios_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.match(sql, /FOR UPDATE/); return [[]]; }, commit: async () => { throw new Error('commit should not run when no manual status exists'); }, rollback: async () => { rollbackCalls += 1; }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 6 } }) ); assert.equal(response.statusCode, 404); assert.deepEqual(response.body, { success: false, error: 'Trip status change not found' }); assert.equal(validationStep, 3); assert.equal(txStep, 1); assert.equal(rollbackCalls, 1); }); test('DELETE /api/trips/:id/status intermedio con id_punto inválido => 400', async () => { db.query = async () => { throw new Error('db.query should not run for invalid id_punto'); }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 3, id_punto: 'abc' } }) ); assert.equal(response.statusCode, 400); assert.deepEqual(response.body, { success: false, error: 'Invalid payload' }); }); test('DELETE /api/trips/:id/status intermedio devuelve 404 si punto no existe', async () => { let step = 0; db.query = async () => { step += 1; if (step === 1) { return [[{ id_estado: 3 }]]; } if (step === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } if (step === 3) { return [[{ n_proveedor: 1 }]]; } return [[]]; }; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 3, id_punto: 99999 } }) ); assert.equal(response.statusCode, 404); assert.deepEqual(response.body, { success: false, error: 'Trip point not found' }); assert.equal(step, 4); }); test('DELETE /api/trips/:id/status no intermedio mantiene flujo de c_cambios_estado', async () => { let validationStep = 0; let txStep = 0; db.query = async () => { validationStep += 1; if (validationStep === 1) { return [[{ id_estado: 6 }]]; } if (validationStep === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } return [[{ n_proveedor: 1 }]]; }; const connection = { beginTransaction: async () => {}, query: async (sql, params) => { txStep += 1; if (txStep === 1) { assert.match(sql, /FROM c_cambios_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.match(sql, /FOR UPDATE/); return [[{ id_estado: 6 }]]; } if (txStep === 2) { assert.match(sql, /DELETE FROM c_cambios_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.equal(/LIMIT\s+1/i.test(sql), false); assert.deepEqual(params, [248230, 1, '58045340X', 6]); return [{ affectedRows: 1 }]; } if (txStep === 3) { assert.match(sql, /SELECT id_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); return [[{ id_estado: 5 }]]; } assert.match(sql, /UPDATE c_viajes/); assert.deepEqual(params, [5, 1, 248230]); return [{ affectedRows: 1 }]; }, commit: async () => {}, rollback: async () => { throw new Error('rollback should not run in successful clear test'); }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 6 } }) ); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { success: true, trip_id: 248230, removed_status_id: 6, current_status_id: 5 }); assert.equal(validationStep, 3); assert.equal(txStep, 4); }); test('DELETE /api/trips/:id/status no intermedio borra todas las coincidencias manuales del estado', async () => { let validationStep = 0; let txStep = 0; db.query = async () => { validationStep += 1; if (validationStep === 1) { return [[{ id_estado: 6 }]]; } if (validationStep === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } return [[{ n_proveedor: 1 }]]; }; const connection = { beginTransaction: async () => {}, query: async (sql, params) => { txStep += 1; if (txStep === 1) { assert.match(sql, /FROM c_cambios_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.match(sql, /FOR UPDATE/); assert.deepEqual(params, [248230, 1, '58045340X', 6]); return [[{ id_estado: 6 }, { id_estado: 6 }]]; } if (txStep === 2) { assert.match(sql, /DELETE FROM c_cambios_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); assert.equal(/LIMIT\s+1/i.test(sql), false); assert.deepEqual(params, [248230, 1, '58045340X', 6]); return [{ affectedRows: 2 }]; } if (txStep === 3) { assert.match(sql, /SELECT id_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); return [[{ id_estado: 4 }]]; } assert.match(sql, /UPDATE c_viajes/); assert.deepEqual(params, [4, 1, 248230]); return [{ affectedRows: 1 }]; }, commit: async () => {}, rollback: async () => { throw new Error('rollback should not run in successful mass clear test'); }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 6 } }) ); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { success: true, trip_id: 248230, removed_status_id: 6, current_status_id: 4 }); assert.equal(validationStep, 3); assert.equal(txStep, 4); }); test('DELETE /api/trips/:id/status en modo dual no borra foto en remoto ni local', async () => { const recorder = createSftpRecorder(); tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder)); process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51'; process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR = '/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native'; const localTripDir = path.join(TEST_UPLOAD_DIR, '248230'); const localPhotoName = 'abc123.jpg'; const localPhotoPath = path.join(localTripDir, localPhotoName); fs.mkdirSync(localTripDir, { recursive: true }); fs.writeFileSync(localPhotoPath, 'fake-photo-content'); let validationStep = 0; let txStep = 0; db.query = async () => { validationStep += 1; if (validationStep === 1) { return [[{ id_estado: 6 }]]; } if (validationStep === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } return [[{ n_proveedor: 1 }]]; }; const connection = { beginTransaction: async () => {}, query: async (sql, params) => { txStep += 1; if (txStep === 1) { assert.match(sql, /FROM c_cambios_estado/); assert.match(sql, /FOR UPDATE/); return [[{ id_estado: 6 }]]; } if (txStep === 2) { assert.match(sql, /DELETE FROM c_cambios_estado/); assert.equal(/LIMIT\s+1/i.test(sql), false); assert.deepEqual(params, [248230, 1, '58045340X', 6]); return [{ affectedRows: 1 }]; } if (txStep === 3) { assert.match(sql, /SELECT id_estado/); return [[{ id_estado: 5 }]]; } assert.match(sql, /UPDATE c_viajes/); assert.deepEqual(params, [5, 1, 248230]); return [{ affectedRows: 1 }]; }, commit: async () => {}, rollback: async () => { throw new Error('rollback should not run in successful dual clear test'); }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 6 } }) ); assert.equal(response.statusCode, 200); assert.equal(response.body.success, true); assert.equal(response.body.current_status_id, 5); assert.equal(fs.existsSync(localPhotoPath), true); assert.equal(recorder.connectCalls, 0); assert.equal(recorder.deleteCalls.length, 0); assert.equal(validationStep, 3); assert.equal(txStep, 4); }); test('DELETE /api/trips/:id/status recalculo ignora cambios automaticos para current_status_id', async () => { let validationStep = 0; let txStep = 0; db.query = async () => { validationStep += 1; if (validationStep === 1) { return [[{ id_estado: 6 }]]; } if (validationStep === 2) { return [[{ id_viaje: 248230, id_estado: 6 }]]; } return [[{ n_proveedor: 1 }]]; }; const connection = { beginTransaction: async () => {}, query: async (sql, params) => { txStep += 1; if (txStep === 1) { assert.deepEqual(params, [248230, 1, '58045340X', 6]); return [[{ id_estado: 6 }]]; } if (txStep === 2) { assert.deepEqual(params, [248230, 1, '58045340X', 6]); assert.equal(/LIMIT\s+1/i.test(sql), false); return [{ affectedRows: 1 }]; } if (txStep === 3) { assert.match(sql, /SELECT id_estado/); assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s*=\s*0/); return [[{ id_estado: 4 }]]; } assert.match(sql, /UPDATE c_viajes/); assert.deepEqual(params, [4, 1, 248230]); return [{ affectedRows: 1 }]; }, commit: async () => {}, rollback: async () => { throw new Error('rollback should not run in recalculo manual clear test'); }, release: () => {} }; db.getConnection = async () => connection; const response = await withServer(async (server) => requestJson({ port: server.address().port, method: 'DELETE', path: '/api/trips/248230/status', authorization: `Bearer ${createToken()}`, body: { id_estado: 6 } }) ); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { success: true, trip_id: 248230, removed_status_id: 6, current_status_id: 4 }); assert.equal(validationStep, 3); assert.equal(txStep, 4); });