ABIANAPP_NODE_PRODUCCION/test/trips.status.integration.test.js

2961 lines
90 KiB
JavaScript

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');
process.env.TRIP_STATUS_UPLOAD_DIR = TEST_UPLOAD_DIR;
process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual';
const db = require('../src/config/db');
const tripStatusPhotoStorage = require('../src/services/tripStatusPhotoStorage');
const agheeraPushClient = require('../src/services/agheeraPushClient');
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();
agheeraPushClient.__resetHttpClientForTests();
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;
delete process.env.AGHEERA_PUSH_URL;
delete process.env.AGHEERA_API_KEY;
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();
agheeraPushClient.__resetHttpClientForTests();
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;
delete process.env.AGHEERA_PUSH_URL;
delete process.env.AGHEERA_API_KEY;
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 propaga estado global al viaje padre', async () => {
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /FROM t_viaje_estados/);
assert.deepEqual(params, [6]);
return [[{ id_estado: 6 }]];
}
if (step === 2) {
assert.match(sql, /FROM c_viajes/);
assert.deepEqual(params, [248230]);
return [[{ id_viaje: 248230, id_viaje_padre: 196854 }]];
}
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, /UPDATE c_viajes/);
assert.match(sql, /ind_edi_app = 1/);
assert.deepEqual(params, [6, 1, 248230]);
return [{ affectedRows: 1 }];
}
if (step === 5) {
assert.match(sql, /INSERT INTO c_cambios_estado/);
assert.equal(params[0], 248230);
assert.equal(params[3], 6);
return [{ insertId: 6, affectedRows: 1 }];
}
if (step === 6) {
assert.match(sql, /UPDATE c_viajes/);
assert.match(sql, /ind_edi_app = 1/);
assert.deepEqual(params, [6, 1, 196854]);
return [{ affectedRows: 1 }];
}
if (step === 7) {
assert.match(sql, /SELECT id_viaje_padre/);
assert.deepEqual(params, [196854]);
return [[{ id_viaje_padre: 0 }]];
}
throw new Error(`Unexpected query 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: 6
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.equal(response.body.trip_id, 248230);
assert.equal(response.body.id_estado, 6);
assert.equal(step, 7);
});
test('POST /api/trips/:id/status cliente 532 envia posicion a Agheera en estado global', async () => {
process.env.AGHEERA_API_KEY = 'test-api-key';
const agheeraCalls = [];
agheeraPushClient.__setHttpClientForTests(async (url, options) => {
agheeraCalls.push({ url, options });
return {
ok: true,
status: 200,
text: async () => 'Messages received.'
};
});
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /FROM t_viaje_estados/);
assert.deepEqual(params, [6]);
return [[{ id_estado: 6 }]];
}
if (step === 2) {
assert.match(sql, /FROM c_viajes/);
assert.match(sql, /id_cliente/);
assert.deepEqual(params, [248230]);
return [[{ id_viaje: 248230, id_viaje_padre: 0, id_cliente: 532 }]];
}
if (step === 3) {
assert.match(sql, /FROM c_viajes_proveedor/);
assert.match(sql, /id_tipovehiculo AS matricula/);
assert.deepEqual(params, [248230, '58045340X']);
return [[{ n_proveedor: 1, id_proveedor: 675, matricula: '1234ABC' }]];
}
if (step === 4) {
assert.match(sql, /UPDATE c_viajes/);
assert.deepEqual(params, [6, 1, 248230]);
return [{ affectedRows: 1 }];
}
assert.match(sql, /INSERT INTO c_cambios_estado/);
assert.equal(params[6], '40.416775');
assert.equal(params[7], '-3.70379');
return [{ insertId: 6, 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: 6,
latitud: '40,416775',
longitud: '-3.703790'
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.deepEqual(response.body.agheera_push, {
trip_id: 248230,
attempted: true,
success: true,
http_status: 200,
error: null
});
assert.equal(agheeraCalls.length, 1);
const call = agheeraCalls[0];
assert.equal(call.url, 'https://push-test.agheera.com/Telematics/Positions');
assert.equal(call.options.method, 'POST');
assert.equal(call.options.headers.apiKey, 'test-api-key');
assert.equal(call.options.headers['Content-Type'], 'application/json');
const payload = JSON.parse(call.options.body);
assert.deepEqual(Object.keys(payload), ['Vehicles']);
assert.equal(payload.Vehicles.length, 1);
assert.equal(payload.Vehicles[0].latitude, 40.416775);
assert.equal(payload.Vehicles[0].longitude, -3.70379);
assert.equal(payload.Vehicles[0].vehicleId, '1234ABC');
assert.equal(payload.Vehicles[0].licensePlate, '1234ABC');
assert.match(payload.Vehicles[0].measurementTime, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
});
test('POST /api/trips/:id/status cliente distinto de 532 no envia a Agheera', async () => {
process.env.AGHEERA_API_KEY = 'test-api-key';
const agheeraCalls = [];
agheeraPushClient.__setHttpClientForTests(async (url, options) => {
agheeraCalls.push({ url, options });
return { ok: true, status: 200, text: async () => 'Messages received.' };
});
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
return [[{ id_estado: 6 }]];
}
if (step === 2) {
return [[{ id_viaje: 248230, id_viaje_padre: 0, id_cliente: 700 }]];
}
if (step === 3) {
return [[{ n_proveedor: 1, id_proveedor: 675, matricula: '1234ABC' }]];
}
if (step === 4) {
assert.match(sql, /UPDATE c_viajes/);
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/248230/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 6,
latitud: '40.416775',
longitud: '-3.703790'
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.equal(response.body.agheera_push, undefined);
assert.equal(agheeraCalls.length, 0);
});
test('POST /api/trips/:id/status estado intermedio con id_punto no envia a Agheera', async () => {
process.env.AGHEERA_API_KEY = 'test-api-key';
const agheeraCalls = [];
agheeraPushClient.__setHttpClientForTests(async (url, options) => {
agheeraCalls.push({ url, options });
return { ok: true, status: 200, text: async () => 'Messages received.' };
});
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /FROM t_viaje_estados/);
return [[{ id_estado: 5 }]];
}
if (step === 2) {
assert.match(sql, /FROM c_viajes/);
return [[{ id_viaje: 248230 }]];
}
if (step === 3) {
assert.match(sql, /FROM c_viajes_proveedor/);
return [[{ n_proveedor: 1 }]];
}
if (step === 4) {
assert.match(sql, /FROM c_viajes_puntos/);
assert.deepEqual(params, [8123, 248230]);
return [[{ id_punto: 8123, id_estado_intermedio: 4, valor: null, foto: null }]];
}
assert.match(sql, /UPDATE c_viajes_puntos/);
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,
latitud: '40.416775',
longitud: '-3.703790'
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.equal(response.body.agheera_push, undefined);
assert.equal(agheeraCalls.length, 0);
});
test('POST /api/trips/:id/status fallo de Agheera mantiene respuesta 200', async () => {
process.env.AGHEERA_API_KEY = 'test-api-key';
const agheeraCalls = [];
agheeraPushClient.__setHttpClientForTests(async (url, options) => {
agheeraCalls.push({ url, options });
return {
ok: false,
status: 500,
text: async () => 'temporary error'
};
});
let step = 0;
db.query = async (sql) => {
step += 1;
if (step === 1) {
return [[{ id_estado: 6 }]];
}
if (step === 2) {
return [[{ id_viaje: 248230, id_viaje_padre: 0, id_cliente: 532 }]];
}
if (step === 3) {
return [[{ n_proveedor: 1, id_proveedor: 675, matricula: '1234ABC' }]];
}
if (step === 4) {
assert.match(sql, /UPDATE c_viajes/);
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/248230/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 6,
latitud: '40.416775',
longitud: '-3.703790'
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.deepEqual(response.body.agheera_push, {
trip_id: 248230,
attempted: true,
success: false,
http_status: 500,
error: 'Agheera push failed'
});
assert.equal(agheeraCalls.length, 1);
});
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);
});