Fix passenger startup and clean tracked generated files

This commit is contained in:
abiandev
2026-03-31 15:14:12 +02:00
parent c1b9fe666b
commit 5385841a62
5908 changed files with 15305 additions and 872091 deletions
+333
View File
@@ -0,0 +1,333 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const app = require('../app');
const db = require('../src/config/db');
const TEST_JWT_SECRET = 'test-jwt-secret';
let originalQuery;
let originalLegacyMode;
let originalJwtSecret;
let originalJwtExpiresIn;
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 postForm = async ({ port, path, form }) =>
new Promise((resolve, reject) => {
const body = new URLSearchParams(form).toString();
const req = http.request(
{
hostname: '127.0.0.1',
port,
method: 'POST',
path,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body)
}
},
(res) => {
let rawBody = '';
res.on('data', (chunk) => {
rawBody += chunk;
});
res.on('end', () => {
resolve({
statusCode: res.statusCode,
body: rawBody ? JSON.parse(rawBody) : null
});
});
}
);
req.on('error', reject);
req.write(body);
req.end();
});
test.before(() => {
originalQuery = db.query;
originalLegacyMode = process.env.LOGIN_LEGACY_MODE;
originalJwtSecret = process.env.JWT_SECRET;
originalJwtExpiresIn = process.env.JWT_EXPIRES_IN;
});
test.after(() => {
db.query = originalQuery;
process.env.LOGIN_LEGACY_MODE = originalLegacyMode;
process.env.JWT_SECRET = originalJwtSecret;
process.env.JWT_EXPIRES_IN = originalJwtExpiresIn;
});
test('POST /login válido devuelve contrato JWT estándar', async () => {
process.env.LOGIN_LEGACY_MODE = '0';
process.env.JWT_SECRET = TEST_JWT_SECRET;
process.env.JWT_EXPIRES_IN = '8h';
db.query = async (sql) => {
if (sql.startsWith('SELECT')) {
return [[{
id_transportista: 433,
nombre: 'Transportista Demo',
id_proveedor: 675,
dni: '58045340X',
foto_perfil: null,
email_operaciones: 'ops@example.com'
}]];
}
if (sql.startsWith('INSERT INTO c_log_app')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in login success test: ${sql}`);
};
const response = await withServer(async (server) =>
postForm({
port: server.address().port,
path: '/login',
form: {
usuario: '58045340X',
contrasena: 'dummy-password'
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.equal(typeof response.body.token, 'string');
assert.ok(response.body.token.length > 20);
assert.notEqual(response.body, '0');
assert.equal(response.body.user.dni, '58045340X');
assert.equal(response.body.user.id_proveedor, 675);
assert.equal(response.body.user.usuario, '58045340X');
assert.equal(response.body.user.email_operaciones, 'ops@example.com');
const verified = jwt.verify(response.body.token, TEST_JWT_SECRET);
assert.equal(verified.dni, '58045340X');
assert.equal(verified.id_proveedor, 675);
assert.equal(verified.usuario, '58045340X');
});
test('POST /login estándar bloquea con 403 si falta email_operaciones', async () => {
process.env.LOGIN_LEGACY_MODE = '0';
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async (sql) => {
if (sql.startsWith('SELECT')) {
return [[{
id_transportista: 433,
nombre: 'Transportista Demo',
id_proveedor: 675,
dni: '58045340X',
foto_perfil: null,
email_operaciones: null
}]];
}
if (sql.startsWith('INSERT INTO c_log_app')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in missing email login test: ${sql}`);
};
const response = await withServer(async (server) =>
postForm({
port: server.address().port,
path: '/login',
form: {
usuario: '58045340X',
contrasena: 'dummy-password'
}
})
);
assert.equal(response.statusCode, 403);
assert.equal(response.body.success, false);
assert.equal(response.body.error, 'Forbidden');
assert.equal(response.body.token, undefined);
});
test('POST /login inválido devuelve 401 y no "0" por defecto', async () => {
process.env.LOGIN_LEGACY_MODE = '0';
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async (sql) => {
if (sql.startsWith('SELECT')) {
return [[]];
}
if (sql.startsWith('INSERT INTO c_log_app')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in login invalid test: ${sql}`);
};
const response = await withServer(async (server) =>
postForm({
port: server.address().port,
path: '/login',
form: {
usuario: 'usuario-invalido',
contrasena: 'bad-password'
}
})
);
assert.equal(response.statusCode, 401);
assert.deepEqual(response.body, {
success: false,
error: 'Invalid credentials'
});
});
test('POST /login error interno devuelve 500 estándar', async () => {
process.env.LOGIN_LEGACY_MODE = '0';
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async () => {
throw new Error('forced db failure');
};
const response = await withServer(async (server) =>
postForm({
port: server.address().port,
path: '/login',
form: {
usuario: '58045340X',
contrasena: 'dummy-password'
}
})
);
assert.equal(response.statusCode, 500);
assert.deepEqual(response.body, {
success: false,
error: 'Internal server error'
});
});
test('POST /login en modo legacy devuelve formato antiguo por flag', async () => {
process.env.LOGIN_LEGACY_MODE = '1';
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async (sql) => {
if (sql.startsWith('SELECT')) {
return [[{
id_transportista: 433,
nombre: 'Transportista Demo',
id_proveedor: 675,
dni: '58045340X',
foto_perfil: null,
email_operaciones: 'ops@example.com'
}]];
}
if (sql.startsWith('INSERT INTO c_log_app')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in legacy login test: ${sql}`);
};
const successResponse = await withServer(async (server) =>
postForm({
port: server.address().port,
path: '/login',
form: {
usuario: '58045340X',
contrasena: 'dummy-password'
}
})
);
assert.equal(successResponse.statusCode, 200);
assert.deepEqual(successResponse.body, ['58045340X', 675]);
db.query = async (sql) => {
if (sql.startsWith('SELECT')) {
return [[{
id_transportista: 433,
nombre: 'Transportista Demo',
id_proveedor: 675,
dni: '58045340X',
foto_perfil: null,
email_operaciones: ''
}]];
}
if (sql.startsWith('INSERT INTO c_log_app')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in legacy missing email login test: ${sql}`);
};
const missingEmailResponse = await withServer(async (server) =>
postForm({
port: server.address().port,
path: '/login',
form: {
usuario: '58045340X',
contrasena: 'dummy-password'
}
})
);
assert.equal(missingEmailResponse.statusCode, 200);
assert.equal(missingEmailResponse.body, '0');
db.query = async (sql) => {
if (sql.startsWith('SELECT')) {
return [[]];
}
if (sql.startsWith('INSERT INTO c_log_app')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in legacy invalid login test: ${sql}`);
};
const invalidResponse = await withServer(async (server) =>
postForm({
port: server.address().port,
path: '/login',
form: {
usuario: 'usuario-invalido',
contrasena: 'bad-password'
}
})
);
assert.equal(invalidResponse.statusCode, 200);
assert.equal(invalidResponse.body, '0');
});
@@ -0,0 +1,647 @@
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_SECURE_STORAGE_DIR = path.resolve(__dirname, '..', 'tmp', 'test-driver-license-storage');
const TEST_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('hex');
const app = require('../app');
const db = require('../src/config/db');
const { encryptBuffer } = require('../src/services/driverLicenseCrypto');
const { persistEncryptedBuffer } = require('../src/services/driverLicenseStorage');
process.env.DRIVER_LICENSE_STORAGE_DIR = TEST_SECURE_STORAGE_DIR;
process.env.DRIVER_LICENSE_ENCRYPTION_KEY = TEST_ENCRYPTION_KEY;
process.env.DRIVER_LICENSE_KEY_VERSION = 'test-v2';
process.env.DRIVER_LICENSE_RETENTION_DAYS = '30';
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
let originalQuery;
let originalGetConnection;
const FRONT_JPEG_BUFFER = Buffer.from([
0xff, 0xd8, 0xff, 0xe0,
0x00, 0x10, 0x4a, 0x46,
0x49, 0x46, 0x00, 0x01,
0xff, 0xd9, 0x00, 0x00
]);
const listFilesRecursively = (rootDir) => {
if (!fs.existsSync(rootDir)) {
return [];
}
const collected = [];
const visit = (currentDir) => {
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
continue;
}
if (entry.isFile()) {
collected.push(fullPath);
}
}
};
visit(rootDir);
return collected;
};
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 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,
headers: res.headers
});
});
}
);
req.on('error', reject);
req.write(bodyBuffer);
req.end();
});
const requestBinary = async ({ port, path: requestPath, authorization }) =>
new Promise((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
method: 'GET',
path: requestPath,
headers: {
...(authorization ? { authorization } : {})
}
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
resolve({
statusCode: res.statusCode,
headers: res.headers,
body: Buffer.concat(chunks)
});
});
}
);
req.on('error', reject);
req.end();
});
test.before(() => {
originalQuery = db.query;
originalGetConnection = db.getConnection;
fs.rmSync(TEST_SECURE_STORAGE_DIR, { recursive: true, force: true });
});
test.after(() => {
db.query = originalQuery;
db.getConnection = originalGetConnection;
fs.rmSync(TEST_SECURE_STORAGE_DIR, { recursive: true, force: true });
});
test.afterEach(() => {
db.query = originalQuery;
db.getConnection = originalGetConnection;
});
test('POST /api/update_driver_license devuelve 401 sin token', async () => {
db.query = async () => {
throw new Error('db.query should not be called when no token is provided');
};
const response = await withServer(async (server) =>
requestMultipart({
port: server.address().port,
method: 'POST',
path: '/api/update_driver_license',
fields: {
dni: '58045340X',
id_proveedor: 675,
document_type: 'driver_license',
document_side: 'front'
},
files: [
{
fieldName: 'carnet_conducir_frontal',
filename: 'front.jpg',
contentType: 'image/jpeg',
content: FRONT_JPEG_BUFFER
}
]
})
);
assert.equal(response.statusCode, 401);
assert.deepEqual(response.body, { error: 'Unauthorized' });
});
test('POST /api/update_driver_license valida document_side', async () => {
db.query = async () => {
throw new Error('db.query should not be called for invalid side');
};
const response = await withServer(async (server) =>
requestMultipart({
port: server.address().port,
method: 'POST',
path: '/api/update_driver_license',
authorization: `Bearer ${createToken()}`,
fields: {
dni: '58045340X',
id_proveedor: 675,
document_type: 'driver_license',
document_side: 'left'
},
files: [
{
fieldName: 'carnet_conducir_frontal',
filename: 'front.jpg',
contentType: 'image/jpeg',
content: FRONT_JPEG_BUFFER
}
]
})
);
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body, {
success: false,
error: 'document_side invalido. Valores permitidos: front, back.'
});
});
test('POST /api/update_driver_license exige campo correcto según side', async () => {
db.query = async () => {
throw new Error('db.query should not be called for side/file mismatch');
};
const response = await withServer(async (server) =>
requestMultipart({
port: server.address().port,
method: 'POST',
path: '/api/update_driver_license',
authorization: `Bearer ${createToken()}`,
fields: {
dni: '58045340X',
id_proveedor: 675,
document_type: 'driver_license',
document_side: 'front'
},
files: [
{
fieldName: 'carnet_conducir_trasera',
filename: 'back.jpg',
contentType: 'image/jpeg',
content: FRONT_JPEG_BUFFER
}
]
})
);
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body, {
success: false,
error: 'Para document_side=front debe enviarse carnet_conducir_frontal.'
});
});
test('POST /api/update_driver_license rechaza MIME/extension no permitidos', async () => {
db.query = async () => {
throw new Error('db.query should not be called for invalid MIME');
};
const response = await withServer(async (server) =>
requestMultipart({
port: server.address().port,
method: 'POST',
path: '/api/update_driver_license',
authorization: `Bearer ${createToken()}`,
fields: {
dni: '58045340X',
id_proveedor: 675,
document_type: 'driver_license',
document_side: 'front'
},
files: [
{
fieldName: 'carnet_conducir_frontal',
filename: 'front.txt',
contentType: 'text/plain',
content: Buffer.from('not-image')
}
]
})
);
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body, {
success: false,
error: 'Tipo de archivo invalido. Solo image/jpeg, image/png o image/webp.'
});
});
test('POST /api/update_driver_license rechaza archivo mayor a 5MB', async () => {
db.query = async () => {
throw new Error('db.query should not be called for oversized files');
};
const oversizedBuffer = Buffer.alloc(5 * 1024 * 1024 + 1, 0xff);
oversizedBuffer[0] = 0xff;
oversizedBuffer[1] = 0xd8;
oversizedBuffer[2] = 0xff;
const response = await withServer(async (server) =>
requestMultipart({
port: server.address().port,
method: 'POST',
path: '/api/update_driver_license',
authorization: `Bearer ${createToken()}`,
fields: {
dni: '58045340X',
id_proveedor: 675,
document_type: 'driver_license',
document_side: 'front'
},
files: [
{
fieldName: 'carnet_conducir_frontal',
filename: 'front.jpg',
contentType: 'image/jpeg',
content: oversizedBuffer
}
]
})
);
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body, {
success: false,
error: 'Archivo demasiado grande. Maximo 5MB.'
});
});
test('POST /api/update_driver_license devuelve 403 para destino no autorizado', async () => {
db.query = async (sql) => {
if (sql.includes('INSERT INTO driver_license_access_audit')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in forbidden test: ${sql}`);
};
const response = await withServer(async (server) =>
requestMultipart({
port: server.address().port,
method: 'POST',
path: '/api/update_driver_license',
authorization: `Bearer ${createToken({ dni: '11111111A', id_proveedor: 999 })}`,
fields: {
dni: '58045340X',
id_proveedor: 675,
document_type: 'driver_license',
document_side: 'front'
},
files: [
{
fieldName: 'carnet_conducir_frontal',
filename: 'front.jpg',
contentType: 'image/jpeg',
content: FRONT_JPEG_BUFFER
}
]
})
);
assert.equal(response.statusCode, 403);
assert.deepEqual(response.body, {
success: false,
error: 'Forbidden'
});
});
test('POST /api/update_driver_license side=front guarda cifrado y responde contrato esperado', async () => {
let insertedStorageKey = null;
let beginCalled = false;
let commitCalled = false;
let rollbackCalled = false;
db.query = async (sql, params) => {
if (sql.includes('FROM m_proveedores_trasportistas')) {
assert.deepEqual(params, ['58045340X', 675]);
return [[{ id_transportista: 1 }]];
}
if (sql.includes('INSERT INTO driver_license_access_audit')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in success test: ${sql}`);
};
db.getConnection = async () => ({
beginTransaction: async () => {
beginCalled = true;
},
query: async (sql, params) => {
if (sql.includes('UPDATE driver_license_files')) {
assert.deepEqual(params, ['58045340X', 675, 'front', 'driver_license']);
return [{ affectedRows: 1 }];
}
if (sql.includes('INSERT INTO driver_license_files')) {
insertedStorageKey = params[5];
assert.equal(params[1], '58045340X');
assert.equal(params[2], 675);
assert.equal(params[3], 'front');
assert.equal(params[4], 'driver_license');
assert.equal(params[6], 'image/jpeg');
assert.equal(params[7], FRONT_JPEG_BUFFER.length);
assert.match(params[8], /^[a-f0-9]{64}$/);
assert.equal(params[9], 'aes-256-gcm');
assert.match(params[10], /^[a-f0-9]{24}$/);
assert.match(params[11], /^[a-f0-9]{32}$/);
assert.equal(params[12], 'test-v2');
return [{ insertId: 77, affectedRows: 1 }];
}
throw new Error(`Unexpected TX SQL in success test: ${sql}`);
},
commit: async () => {
commitCalled = true;
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {}
});
const response = await withServer(async (server) =>
requestMultipart({
port: server.address().port,
method: 'POST',
path: '/api/update_driver_license',
authorization: `Bearer ${createToken()}`,
fields: {
dni: '58045340X',
id_proveedor: 675,
document_type: 'driver_license',
document_side: 'front'
},
files: [
{
fieldName: 'carnet_conducir_frontal',
filename: 'front.jpg',
contentType: 'image/jpeg',
content: FRONT_JPEG_BUFFER
}
]
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.equal(response.body.document_side, 'front');
assert.match(response.body.carnet_conducir_frontal, /^secure\/driver-license\/front\/[0-9a-f-]{36}$/i);
assert.equal(response.body.driverLicenseFrontImage, response.body.carnet_conducir_frontal);
assert.equal(beginCalled, true);
assert.equal(commitCalled, true);
assert.equal(rollbackCalled, false);
assert.equal(typeof insertedStorageKey, 'string');
const storedFiles = listFilesRecursively(TEST_SECURE_STORAGE_DIR).filter((item) => item.endsWith('.bin'));
assert.equal(storedFiles.length > 0, true);
const encryptedBody = fs.readFileSync(storedFiles[0]);
assert.equal(encryptedBody.equals(FRONT_JPEG_BUFFER), false);
});
test('POST /api/upload_driver_license side=back responde contrato esperado', async () => {
db.query = async (sql) => {
if (sql.includes('FROM m_proveedores_trasportistas')) {
return [[{ id_transportista: 1 }]];
}
if (sql.includes('INSERT INTO driver_license_access_audit')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in back upload test: ${sql}`);
};
db.getConnection = async () => ({
beginTransaction: async () => {},
query: async (sql, params) => {
if (sql.includes('UPDATE driver_license_files')) {
assert.deepEqual(params, ['58045340X', 675, 'back', 'driver_license']);
return [{ affectedRows: 1 }];
}
if (sql.includes('INSERT INTO driver_license_files')) {
assert.equal(params[3], 'back');
return [{ insertId: 78, affectedRows: 1 }];
}
throw new Error(`Unexpected TX SQL in back upload test: ${sql}`);
},
commit: async () => {},
rollback: async () => {},
release: () => {}
});
const response = await withServer(async (server) =>
requestMultipart({
port: server.address().port,
method: 'POST',
path: '/api/upload_driver_license',
authorization: `Bearer ${createToken()}`,
fields: {
dni: '58045340X',
id_proveedor: 675,
document_type: 'driver_license',
document_side: 'back'
},
files: [
{
fieldName: 'carnet_conducir_trasera',
filename: 'back.jpg',
contentType: 'image/jpeg',
content: FRONT_JPEG_BUFFER
}
]
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.equal(response.body.document_side, 'back');
assert.match(response.body.carnet_conducir_trasera, /^secure\/driver-license\/back\/[0-9a-f-]{36}$/i);
assert.equal(response.body.driverLicenseBackImage, response.body.carnet_conducir_trasera);
});
test('GET /api/secure/driver-license/side/:side devuelve 403 para destino no autorizado', async () => {
db.query = async (sql) => {
if (sql.includes('INSERT INTO driver_license_access_audit')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in forbidden download test: ${sql}`);
};
const response = await withServer(async (server) =>
requestBinary({
port: server.address().port,
path: '/api/secure/driver-license/side/front?dni=58045340X&id_proveedor=675',
authorization: `Bearer ${createToken({ dni: '11111111A', id_proveedor: 999 })}`
})
);
assert.equal(response.statusCode, 403);
assert.deepEqual(JSON.parse(response.body.toString('utf8')), {
success: false,
error: 'Forbidden'
});
});
test('GET /api/secure/driver-license/side/:side desencripta y devuelve binario', async () => {
const encrypted = encryptBuffer(FRONT_JPEG_BUFFER);
const persisted = await persistEncryptedBuffer(encrypted.ciphertext);
db.query = async (sql, params) => {
if (sql.includes('FROM driver_license_files') && sql.includes('AND side = ?')) {
assert.deepEqual(params, ['58045340X', 675, 'front', 'driver_license']);
return [[{
id: 77,
public_id: '11111111-1111-4111-8111-111111111111',
dni: '58045340X',
id_proveedor: 675,
side: 'front',
storage_key: persisted.storageKey,
mime_type: 'image/jpeg',
encryption_alg: 'aes-256-gcm',
encryption_iv: encrypted.ivHex,
encryption_tag: encrypted.authTagHex,
expires_at: new Date(Date.now() + 3600_000),
deleted_at: null
}]];
}
if (sql.includes('INSERT INTO driver_license_access_audit')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL in download success test: ${sql}`);
};
const response = await withServer(async (server) =>
requestBinary({
port: server.address().port,
path: '/api/secure/driver-license/side/front?dni=58045340X&id_proveedor=675',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'image/jpeg');
assert.equal(response.body.equals(FRONT_JPEG_BUFFER), true);
});
@@ -0,0 +1,216 @@
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 app = require('../app');
const db = require('../src/config/db');
const PROFILE_UPLOADS_DIR = path.resolve(__dirname, '..', 'uploads', 'profile');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
const TEST_JPEG_BUFFER = Buffer.from([
0xff, 0xd8, 0xff, 0xe0,
0x00, 0x10, 0x4a, 0x46,
0x49, 0x46, 0x00, 0x01,
0xff, 0xd9, 0x00, 0x00
]);
let originalQuery;
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 requestMultipart = async ({
port,
path: requestPath,
authorization,
fields = {},
file
}) =>
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`)
);
}
if (file) {
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: 'POST',
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 listProfileUploads = () => {
if (!fs.existsSync(PROFILE_UPLOADS_DIR)) {
return [];
}
return fs
.readdirSync(PROFILE_UPLOADS_DIR, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
};
test.before(() => {
originalQuery = db.query;
fs.mkdirSync(PROFILE_UPLOADS_DIR, { recursive: true });
});
test.after(() => {
db.query = originalQuery;
fs.rmSync(PROFILE_UPLOADS_DIR, { recursive: true, force: true });
});
test.afterEach(() => {
db.query = originalQuery;
fs.rmSync(PROFILE_UPLOADS_DIR, { recursive: true, force: true });
fs.mkdirSync(PROFILE_UPLOADS_DIR, { recursive: true });
});
test('POST /update_profile_photo rechaza payload de carnet y borra temporal', async () => {
db.query = async () => {
throw new Error('db.query should not be called when payload is identified as driver license');
};
const beforeFiles = listProfileUploads();
const response = await withServer(async (server) =>
requestMultipart({
port: server.address().port,
path: '/update_profile_photo',
authorization: `Bearer ${createToken()}`,
fields: {
dni: '58045340X',
id_proveedor: 675,
document_type: 'driver_license',
document_side: 'front'
},
file: {
fieldName: 'foto_perfil',
filename: 'carnet-frontal.jpg',
contentType: 'image/jpeg',
content: TEST_JPEG_BUFFER
}
})
);
const afterFiles = listProfileUploads();
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body, {
error: 'Para carnet de conducir usa /api/update_driver_license (document_type=driver_license).'
});
assert.deepEqual(afterFiles, beforeFiles);
});
test('POST /update_profile_photo devuelve 403 al intentar actualizar otro dni/proveedor', async () => {
db.query = async () => {
throw new Error('db.query should not be called for unauthorized target');
};
const beforeFiles = listProfileUploads();
const response = await withServer(async (server) =>
requestMultipart({
port: server.address().port,
path: '/update_profile_photo',
authorization: `Bearer ${createToken()}`,
fields: {
dni: '11111111A',
id_proveedor: 9999
},
file: {
fieldName: 'foto_perfil',
filename: 'perfil.jpg',
contentType: 'image/jpeg',
content: TEST_JPEG_BUFFER
}
})
);
const afterFiles = listProfileUploads();
assert.equal(response.statusCode, 403);
assert.deepEqual(response.body, { error: 'Forbidden' });
assert.deepEqual(afterFiles, beforeFiles);
});
+232
View File
@@ -0,0 +1,232 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const app = require('../app');
const db = require('../src/config/db');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
let originalQuery;
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, path, authorization }) =>
new Promise((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
method: 'GET',
path,
headers: authorization ? { authorization } : {}
},
(res) => {
let rawBody = '';
res.on('data', (chunk) => {
rawBody += chunk;
});
res.on('end', () => {
const body = rawBody ? JSON.parse(rawBody) : null;
resolve({ statusCode: res.statusCode, body });
});
}
);
req.on('error', reject);
req.end();
});
test.before(() => {
originalQuery = db.query;
});
test.after(() => {
db.query = originalQuery;
});
test('GET /api/trips/active 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 activeRouteLayer = apiRouterLayers
.flatMap((routerLayer) => routerLayer.handle.stack)
.find(
(layer) =>
layer.route &&
layer.route.path === '/trips/active' &&
layer.route.methods.get
);
assert.ok(activeRouteLayer, 'GET /api/trips/active route is not defined');
});
test('GET /api/trips/active devuelve viaje activo', async () => {
const mockedTrip = {
id_viaje: 123,
cod_viaje: 'AB-2024-001',
n_proveedor: 1,
id_estado: 4,
estado: 'CARGA DE MERCANCÍA',
estado_en: 'CARGO LOADING',
nombrea: 'ORIGEN',
nombreb: 'DESTINO',
direcciona: 'Direccion A',
direccionb: 'Direccion B',
fecha_salida: '2026-02-06 08:30:00',
fecha_llegada: '2026-02-06 14:45:00',
inicio_fin: 'ES-28001/ES-08001',
matricula: '1234ABC',
observaciones_mercancia: 'Fragil'
};
db.query = async (sql, params) => {
assert.match(sql, /FROM c_viajes_proveedor p/);
assert.match(sql, /INNER JOIN t_viaje_estados e/);
assert.match(sql, /fecha_salida/);
assert.match(sql, /fecha_llegada/);
assert.match(sql, /INNER JOIN m_proveedores_trasportistas t/);
assert.deepEqual(params, ['58045340X', 2, 6]);
return [[mockedTrip]];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/active',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
active_trip: mockedTrip
});
});
test('GET /api/trips/active devuelve active_trip null cuando no hay', async () => {
db.query = async () => [[]];
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/active',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
active_trip: null
});
});
test('GET /api/trips/active devuelve fechas con hora completa', async () => {
db.query = async () => [[{
id_viaje: 123,
cod_viaje: 'AB-2024-001',
n_proveedor: 1,
id_estado: 4,
estado: 'CARGA DE MERCANCÍA',
estado_en: 'CARGO LOADING',
nombrea: 'ORIGEN',
nombreb: 'DESTINO',
direcciona: 'Direccion A',
direccionb: 'Direccion B',
fecha_salida: '2026-02-06 08:30:00',
fecha_llegada: '2026-02-06 14:45:00',
inicio_fin: 'ES-28001/ES-08001',
matricula: '1234ABC',
observaciones_mercancia: 'Fragil'
}]];
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/active',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.match(response.body.active_trip.fecha_salida, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
assert.match(response.body.active_trip.fecha_llegada, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
test('GET /api/trips/active 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,
path: '/api/trips/active'
})
);
assert.equal(response.statusCode, 401);
});
test('GET /api/trips/active devuelve 500 en error interno', async () => {
db.query = async () => {
throw new Error('forced db failure');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/active',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 500);
assert.deepEqual(response.body, {
success: false,
error: 'Internal server error'
});
});
+314
View File
@@ -0,0 +1,314 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const app = require('../app');
const db = require('../src/config/db');
const tripIncidenceMailer = require('../src/services/tripIncidenceMailer');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
let originalQuery;
let originalSendTripIncidenceEmail;
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();
});
test.before(() => {
originalQuery = db.query;
originalSendTripIncidenceEmail = tripIncidenceMailer.sendTripIncidenceEmail;
});
test.after(() => {
db.query = originalQuery;
tripIncidenceMailer.sendTripIncidenceEmail = originalSendTripIncidenceEmail;
});
test('POST /api/trips/:tripId/incidencias 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 routeLayer = apiRouterLayers
.flatMap((routerLayer) => routerLayer.handle.stack)
.find(
(layer) =>
layer.route &&
layer.route.path === '/trips/:tripId/incidencias' &&
layer.route.methods.post
);
assert.ok(routeLayer, 'POST /api/trips/:tripId/incidencias route is not defined');
});
test('POST /api/trips/:tripId/incidencias 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/incidencias',
body: { incidencia: 'Retraso por tráfico' }
})
);
assert.equal(response.statusCode, 401);
assert.deepEqual(response.body, { error: 'Unauthorized' });
});
test('POST /api/trips/:tripId/incidencias valida payload inválido => 400', async () => {
db.query = async () => {
throw new Error('db.query should not be called for invalid payload');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/not-a-number/incidencias',
authorization: `Bearer ${createToken()}`,
body: { incidencia: ' ' }
})
);
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body, {
success: false,
error: 'Invalid payload'
});
});
test('POST /api/trips/:tripId/incidencias devuelve 404 si viaje no existe', async () => {
db.query = async (sql, params) => {
assert.match(sql, /FROM c_viajes/);
assert.deepEqual(params, [248230]);
return [[]];
};
tripIncidenceMailer.sendTripIncidenceEmail = async () => {
throw new Error('mailer should not run when trip does not exist');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/incidencias',
authorization: `Bearer ${createToken()}`,
body: { incidencia: 'Incidencia de prueba' }
})
);
assert.equal(response.statusCode, 404);
assert.deepEqual(response.body, {
success: false,
error: 'Trip not found'
});
});
test('POST /api/trips/:tripId/incidencias devuelve 403 si no autorizado', async () => {
let step = 0;
db.query = async (_sql, _params) => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 248230 }]];
}
return [[]];
};
tripIncidenceMailer.sendTripIncidenceEmail = async () => {
throw new Error('mailer should not run on forbidden');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/incidencias',
authorization: `Bearer ${createToken()}`,
body: { incidencia: 'Incidencia de prueba' }
})
);
assert.equal(response.statusCode, 403);
assert.deepEqual(response.body, {
success: false,
error: 'Forbidden'
});
assert.equal(step, 2);
});
test('POST /api/trips/:tripId/incidencias crea incidencia con avisos forzados y devuelve 201', async () => {
const sentPayloads = [];
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 [[{ n_proveedor: 1 }]];
}
assert.match(sql, /INSERT INTO c_viajes_incidencias/);
assert.equal(params[0], 248230);
assert.equal(params[1], 'Retraso por atasco');
assert.equal(params[2], null);
assert.equal(params[3], '58045340X');
assert.equal(params[4], 1);
assert.equal(params[5], 1);
return [{ affectedRows: 1 }];
};
tripIncidenceMailer.sendTripIncidenceEmail = async (payload) => {
sentPayloads.push(payload);
return { status: 'sent', recipientsCount: 3 };
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/incidencias',
authorization: `Bearer ${createToken({ id: '1' })}`,
body: {
incidencia: ' Retraso por atasco\u0000 ',
notificar: 0,
notificar_cr: 0
}
})
);
assert.equal(response.statusCode, 201);
assert.deepEqual(response.body, {
success: true,
message: 'correcto'
});
assert.deepEqual(sentPayloads, [
{
tripId: 248230,
incidencia: 'Retraso por atasco',
userId: 1
}
]);
assert.equal(step, 3);
});
test('POST /api/trips/:tripId/incidencias responde 201 con warning si falla email', async () => {
let step = 0;
db.query = async () => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 248230 }]];
}
if (step === 2) {
return [[{ n_proveedor: 1 }]];
}
return [{ affectedRows: 1 }];
};
tripIncidenceMailer.sendTripIncidenceEmail = async () => {
throw new Error('forced SMTP failure');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/incidencias',
authorization: `Bearer ${createToken()}`,
body: { incidencia: 'Incidencia de prueba' }
})
);
assert.equal(response.statusCode, 201);
assert.deepEqual(response.body, {
success: true,
message: 'correcto',
warning: 'email_failed'
});
assert.equal(step, 3);
});
@@ -0,0 +1,423 @@
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 app = require('../app');
const db = require('../src/config/db');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
const TEST_STATUS_LOG_PATH = path.resolve(__dirname, '..', 'tmp', 'test-intermediate-point-status.log');
let originalQuery;
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, authorization, body }) =>
new Promise((resolve, reject) => {
const rawBody = body ? JSON.stringify(body) : '';
const req = http.request(
{
hostname: '127.0.0.1',
port,
method,
path,
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 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;
fs.rmSync(TEST_STATUS_LOG_PATH, { force: true });
});
test.after(() => {
db.query = originalQuery;
delete process.env.TRIP_STATUS_UPDATES_LOG_PATH;
fs.rmSync(TEST_STATUS_LOG_PATH, { force: true });
});
test.afterEach(() => {
db.query = originalQuery;
delete process.env.TRIP_STATUS_UPDATES_LOG_PATH;
fs.rmSync(TEST_STATUS_LOG_PATH, { force: true });
});
test('POST /api/trips/:id/intermediate-points/:pointId/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 pointStatusRouteLayer = apiRouterLayers
.flatMap((routerLayer) => routerLayer.handle.stack)
.find(
(layer) =>
layer.route &&
layer.route.path === '/trips/:id/intermediate-points/:pointId/status' &&
layer.route.methods.post
);
assert.ok(
pointStatusRouteLayer,
'POST /api/trips/:id/intermediate-points/:pointId/status route is not defined'
);
});
test('POST /api/trips/:id/intermediate-points/:pointId/status actualiza estado intermedio', async () => {
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /FROM c_viajes/);
assert.deepEqual(params, [136924]);
return [[{ id_viaje: 136924 }]];
}
if (step === 2) {
assert.match(sql, /FROM c_viajes_proveedor/);
assert.deepEqual(params, [136924, '58045340X']);
return [[{ authorized: 1 }]];
}
if (step === 3) {
assert.match(sql, /FROM c_viajes_puntos/);
assert.deepEqual(params, [50101, 136924]);
return [[{ id_punto: 50101 }]];
}
assert.match(sql, /UPDATE c_viajes_puntos/);
assert.deepEqual(params, [3, '2026-02-16 12:34:56', 0, '40.416775', '-3.70379', 50101, 136924]);
return [{ affectedRows: 1 }];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/136924/intermediate-points/50101/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado_intermedio: 3,
fecha_y_hora: '2026-02-16 12:34:56',
latitud: '40,416775',
longitud: '-3,70379',
ind_fallido: 0
}
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
trip_id: 136924,
id_punto: 50101,
id_estado_intermedio: 3,
fecha_y_hora: '2026-02-16 12:34:56',
latitud: '40.416775',
longitud: '-3.70379',
ind_fallido: 0
});
});
test('POST /api/trips/:id/intermediate-points/:pointId/status registra auditoria 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_viaje: 136924 }]];
}
if (step === 2) {
return [[{ authorized: 1 }]];
}
if (step === 3) {
return [[{ id_punto: 50101 }]];
}
return [{ affectedRows: 1 }];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/136924/intermediate-points/50101/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado_intermedio: 3,
fecha_y_hora: '2026-02-16 12:34:56',
latitud: '40.416775',
longitud: '-3.70379',
ind_fallido: 0
}
})
);
assert.equal(response.statusCode, 200);
await waitFor(() => fs.existsSync(TEST_STATUS_LOG_PATH));
const [entry] = readJsonLines(TEST_STATUS_LOG_PATH);
assert.equal(entry.flow, 'intermediate_point_endpoint');
assert.equal(entry.operation, 'update_point_status');
assert.equal(entry.result, 'SUCCESS');
assert.equal(entry.trip_id, 136924);
assert.equal(entry.id_punto, 50101);
assert.equal(entry.id_estado, 3);
assert.equal(entry.new_intermediate_status_id, 3);
assert.equal(entry.fecha_y_hora, '2026-02-16 12:34:56');
assert.equal(entry.latitud, '40.416775');
assert.equal(entry.longitud, '-3.70379');
assert.equal(entry.ind_fallido, 0);
});
test('POST /api/trips/:id/intermediate-points/:pointId/status devuelve 400 para payload inválido', async () => {
db.query = async () => {
throw new Error('db.query should not run for invalid payload');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/136924/intermediate-points/50101/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado_intermedio: 3,
fecha_y_hora: '2026-02-16',
latitud: '40.416775',
longitud: '-3.70379',
ind_fallido: 0
}
})
);
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body, {
success: false,
error: 'Invalid payload'
});
});
test('POST /api/trips/:id/intermediate-points/:pointId/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/136924/intermediate-points/50101/status',
body: {
id_estado_intermedio: 3,
fecha_y_hora: '2026-02-16 12:34:56',
latitud: '40.416775',
longitud: '-3.70379',
ind_fallido: 0
}
})
);
assert.equal(response.statusCode, 401);
assert.deepEqual(response.body, { error: 'Unauthorized' });
});
test('POST /api/trips/:id/intermediate-points/:pointId/status devuelve 403 para viaje no autorizado', async () => {
let step = 0;
db.query = async () => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 136924 }]];
}
if (step === 2) {
return [[]];
}
throw new Error('Point query should not run for forbidden trip');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/136924/intermediate-points/50101/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado_intermedio: 3,
fecha_y_hora: '2026-02-16 12:34:56',
latitud: '40.416775',
longitud: '-3.70379',
ind_fallido: 0
}
})
);
assert.equal(response.statusCode, 403);
assert.deepEqual(response.body, {
success: false,
error: 'Forbidden'
});
});
test('POST /api/trips/:id/intermediate-points/:pointId/status devuelve 404 cuando viaje no existe', async () => {
db.query = async () => [[]];
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/99999999/intermediate-points/50101/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado_intermedio: 3,
fecha_y_hora: '2026-02-16 12:34:56',
latitud: '40.416775',
longitud: '-3.70379',
ind_fallido: 0
}
})
);
assert.equal(response.statusCode, 404);
assert.deepEqual(response.body, {
success: false,
error: 'Trip not found'
});
});
test('POST /api/trips/:id/intermediate-points/:pointId/status devuelve 404 cuando punto no existe', async () => {
let step = 0;
db.query = async () => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 136924 }]];
}
if (step === 2) {
return [[{ authorized: 1 }]];
}
if (step === 3) {
return [[]];
}
throw new Error('Update should not run when point does not exist');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/136924/intermediate-points/99999/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado_intermedio: 3,
fecha_y_hora: '2026-02-16 12:34:56',
latitud: '40.416775',
longitud: '-3.70379',
ind_fallido: 0
}
})
);
assert.equal(response.statusCode, 404);
assert.deepEqual(response.body, {
success: false,
error: 'Intermediate point not found'
});
});
@@ -0,0 +1,487 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const app = require('../app');
const db = require('../src/config/db');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
let originalQuery;
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, path, authorization }) =>
new Promise((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
method: 'GET',
path,
headers: authorization ? { authorization } : {}
},
(res) => {
let rawBody = '';
res.on('data', (chunk) => {
rawBody += chunk;
});
res.on('end', () => {
const body = rawBody ? JSON.parse(rawBody) : null;
resolve({ statusCode: res.statusCode, body });
});
}
);
req.on('error', reject);
req.end();
});
test.before(() => {
originalQuery = db.query;
});
test.after(() => {
db.query = originalQuery;
});
test('GET /api/trips/:id/intermediate-points 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 pointsRouteLayer = apiRouterLayers
.flatMap((routerLayer) => routerLayer.handle.stack)
.find(
(layer) =>
layer.route &&
layer.route.path === '/trips/:id/intermediate-points' &&
layer.route.methods.get
);
assert.ok(pointsRouteLayer, 'GET /api/trips/:id/intermediate-points route is not defined');
});
test('GET /api/trips/:id/intermediate-points devuelve lista ordenada', async () => {
let step = 0;
const mockedPoints = [
{
id_punto_viaje: 50101,
id_punto: 151,
id_punto_ref: 151,
posicion: 1,
nombre: 'PUNTO A',
contacto: 'Contacto A',
telefono: '600000001',
direccion: 'Direccion A',
latitud: 40.416775,
longitud: -3.70379,
obs: 'DEVOLUCION 9 CAJAS',
id_estado_intermedio: 3,
estado_intermedio: 'En curso',
estado_intermedio_en: 'In progress',
fecha_y_hora: '2026-02-06 08:35:00',
ind_fallido: 0,
latitud_estado: '40.416775',
longitud_estado: '-3.70379',
fecha_hora: '2026-02-06 08:30:00',
datetime: '2026-02-06 08:30:00'
},
{
id_punto_viaje: 50102,
id_punto: 265,
id_punto_ref: 265,
posicion: 2,
nombre: 'PUNTO B',
contacto: 'Contacto B',
telefono: '600000002',
direccion: 'Direccion B',
latitud: 41.385064,
longitud: 2.173404,
obs: 'SE NECESITA TRASPALETA',
id_estado_intermedio: null,
estado_intermedio: '',
estado_intermedio_en: '',
fecha_y_hora: null,
ind_fallido: 0,
latitud_estado: null,
longitud_estado: null,
fecha_hora: null,
datetime: null
}
];
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /FROM c_viajes/);
assert.deepEqual(params, [136924]);
return [[{ id_viaje: 136924 }]];
}
if (step === 2) {
assert.match(sql, /FROM c_viajes_proveedor/);
assert.deepEqual(params, [136924, '58045340X']);
return [[{ authorized: 1 }]];
}
assert.match(sql, /FROM c_viajes_puntos/);
assert.match(sql, /LEFT JOIN m_puntos_envio_recogida/);
assert.match(sql, /LEFT JOIN t_viaje_estados/);
assert.match(sql, /id_estado_intermedio/);
assert.match(sql, /COALESCE\(actualizado_automaticamente,\s*0\)\s+AS\s+actualizado_automaticamente/);
assert.match(sql, /vp\.actualizado_automaticamente\s*=\s*1/);
assert.match(sql, /vp\.fecha_hora/);
assert.match(sql, /CAST\(vp\.id_punto AS SIGNED\) AS id_punto/);
assert.match(sql, /AS id_punto_ref/);
assert.match(sql, /NULLIF\(TRIM\(valor\), ''\) AS valor_plain/);
assert.match(sql, /ELSE NULLIF\(TRIM\(valor\), ''\)/);
assert.match(sql, /REGEXP '\^\[0-9\]\+\$'/);
assert.match(sql, /ORDER BY vp.posicion ASC/);
assert.deepEqual(params, [136924]);
return [mockedPoints];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/136924/intermediate-points',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
trip_id: 136924,
points: mockedPoints
});
assert.equal(response.body.points.length, 2);
assert.match(response.body.points[0].fecha_hora, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
assert.equal(response.body.points[0].id_punto_viaje, 50101);
assert.equal(response.body.points[0].id_estado_intermedio, 3);
assert.equal(response.body.points[0].estado_intermedio, 'En curso');
assert.equal(response.body.points[0].fecha_y_hora, '2026-02-06 08:35:00');
assert.equal(response.body.points[0].ind_fallido, 0);
assert.equal(response.body.points[1].fecha_hora, null);
assert.equal(response.body.points[1].id_estado_intermedio, null);
});
test('GET /api/trips/:id/intermediate-points mantiene punto y oculta estado automatico', async () => {
let step = 0;
const mockedPoints = [
{
id_punto_viaje: 50110,
id_punto: 901,
id_punto_ref: 901,
posicion: 1,
nombre: 'PUNTO AUTO',
contacto: 'Contacto Auto',
telefono: '600000099',
direccion: 'Direccion Auto',
latitud: 40.5,
longitud: -3.6,
obs: 'actualizacion automatica',
id_estado_intermedio: null,
estado_intermedio: '',
estado_intermedio_en: '',
fecha_y_hora: null,
ind_fallido: null,
latitud_estado: null,
longitud_estado: null,
fecha_hora: null,
datetime: null
}
];
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 136924 }]];
}
if (step === 2) {
return [[{ authorized: 1 }]];
}
assert.match(sql, /CASE\s+WHEN vp\.actualizado_automaticamente\s*=\s*1 THEN NULL\s+ELSE vp\.id_estado_intermedio/);
assert.match(sql, /CASE\s+WHEN vp\.actualizado_automaticamente\s*=\s*1 THEN ''/);
assert.match(sql, /CASE\s+WHEN vp\.actualizado_automaticamente\s*=\s*1 THEN NULL\s+ELSE vp\.fecha_hora/);
assert.deepEqual(params, [136924]);
return [mockedPoints];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/136924/intermediate-points',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
trip_id: 136924,
points: mockedPoints
});
assert.equal(response.body.points.length, 1);
assert.equal(response.body.points[0].id_punto, 901);
assert.equal(response.body.points[0].id_estado_intermedio, null);
assert.equal(response.body.points[0].estado_intermedio, '');
assert.equal(response.body.points[0].fecha_hora, null);
});
test('GET /api/trips/:id/intermediate-points tolera valor texto plano antiguo como observacion', async () => {
let step = 0;
const mockedPoints = [
{
id_punto_viaje: 50120,
id_punto: null,
id_punto_ref: null,
posicion: 1,
nombre: '',
contacto: '',
telefono: '',
direccion: '',
latitud: null,
longitud: null,
obs: 'texto plano legado',
id_estado_intermedio: 3,
estado_intermedio: 'Posicionado',
estado_intermedio_en: 'Positioned',
fecha_y_hora: '2026-03-10 09:00:15',
ind_fallido: 0,
latitud_estado: '40.532405853271484',
longitud_estado: '-3.307368516921997',
fecha_hora: '2026-03-10 09:00:15',
datetime: '2026-03-10 09:00:15'
}
];
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 136924 }]];
}
if (step === 2) {
return [[{ authorized: 1 }]];
}
assert.match(sql, /ELSE NULLIF\(TRIM\(valor\), ''\)/);
assert.deepEqual(params, [136924]);
return [mockedPoints];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/136924/intermediate-points',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.points[0].id_punto_ref, null);
assert.equal(response.body.points[0].obs, 'texto plano legado');
});
test('GET /api/trips/:id/intermediate-points conserva fallback a observacion base cuando valor legado no trae obs', async () => {
let step = 0;
const mockedPoints = [
{
id_punto_viaje: 50121,
id_punto: 151,
id_punto_ref: 151,
posicion: 1,
nombre: 'PUNTO BASE',
contacto: 'Contacto Base',
telefono: '600000123',
direccion: 'Direccion Base',
latitud: 40.41,
longitud: -3.7,
obs: 'OBSERVACION BASE',
id_estado_intermedio: null,
estado_intermedio: '',
estado_intermedio_en: '',
fecha_y_hora: null,
ind_fallido: 0,
latitud_estado: null,
longitud_estado: null,
fecha_hora: null,
datetime: null
}
];
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 136924 }]];
}
if (step === 2) {
return [[{ authorized: 1 }]];
}
assert.match(sql, /COALESCE\(\s*vp\.obs,\s*COALESCE\(m\.observaciones, ''\)/);
assert.match(sql, /REGEXP '\^\[0-9\]\+\$'/);
assert.deepEqual(params, [136924]);
return [mockedPoints];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/136924/intermediate-points',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.points[0].id_punto_ref, 151);
assert.equal(response.body.points[0].obs, 'OBSERVACION BASE');
});
test('GET /api/trips/:id/intermediate-points devuelve [] cuando no hay puntos', async () => {
let step = 0;
db.query = async () => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 248230 }]];
}
if (step === 2) {
return [[{ authorized: 1 }]];
}
return [[]];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/248230/intermediate-points',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
trip_id: 248230,
points: []
});
});
test('GET /api/trips/:id/intermediate-points 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,
path: '/api/trips/136924/intermediate-points'
})
);
assert.equal(response.statusCode, 401);
assert.deepEqual(response.body, { error: 'Unauthorized' });
});
test('GET /api/trips/:id/intermediate-points devuelve 403 para viaje no autorizado', async () => {
let step = 0;
db.query = async () => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 263483 }]];
}
if (step === 2) {
return [[]];
}
throw new Error('Points query should not run when trip is forbidden');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/263483/intermediate-points',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 403);
assert.deepEqual(response.body, {
success: false,
error: 'Forbidden'
});
});
test('GET /api/trips/:id/intermediate-points devuelve 404 cuando viaje no existe', async () => {
db.query = async () => [[]];
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/99999999/intermediate-points',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 404);
assert.deepEqual(response.body, {
success: false,
error: 'Trip not found'
});
});
+236
View File
@@ -0,0 +1,236 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const app = require('../app');
const db = require('../src/config/db');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
let originalQuery;
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, path, authorization }) =>
new Promise((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
method: 'GET',
path,
headers: authorization ? { authorization } : {}
},
(res) => {
let rawBody = '';
res.on('data', (chunk) => {
rawBody += chunk;
});
res.on('end', () => {
const body = rawBody ? JSON.parse(rawBody) : null;
resolve({ statusCode: res.statusCode, body });
});
}
);
req.on('error', reject);
req.end();
});
test.before(() => {
originalQuery = db.query;
});
test.after(() => {
db.query = originalQuery;
});
test('GET /api/trips 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 tripsRouteLayer = apiRouterLayers
.flatMap((routerLayer) => routerLayer.handle.stack)
.find(
(layer) =>
layer.route &&
layer.route.path === '/trips' &&
layer.route.methods.get
);
assert.ok(tripsRouteLayer, 'GET /api/trips route is not defined');
});
test('GET /api/trips devuelve viajes del transportista autenticado con aliases legacy', async () => {
const mockedTrips = [
{
id_viaje: 84919,
cod_viaje: 'VIA-2026-0001',
id_estado: 7,
nombrea: 'Barcelona, ES',
nombreb: 'Lyon, FR',
fecha_salida: '2026-01-22 06:00:00',
fecha_llegada: '2026-01-22 18:11:00'
},
{
id_viaje: 84918,
cod_viaje: 'VIA-2026-0000',
id_estado: 4,
nombrea: 'Madrid, ES',
nombreb: 'Bilbao, ES',
fecha_salida: '2026-01-21',
fecha_llegada: '2026-01-21'
}
];
db.query = async (sql, params) => {
assert.match(sql, /FROM c_viajes_proveedor p/);
assert.match(sql, /INNER JOIN c_viajes v/);
assert.match(sql, /INNER JOIN m_proveedores_trasportistas t/);
assert.match(sql, /id_estado IN \(\?, \?, \?\)/);
assert.match(sql, /AS id_viaje/);
assert.match(sql, /AS id_estado/);
assert.match(sql, /AS nombrea/);
assert.match(sql, /AS nombreb/);
assert.match(sql, /AS fecha_salida/);
assert.match(sql, /AS fecha_llegada/);
assert.deepEqual(params, ['58045340X', 7, 8, 9]);
return [mockedTrips];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
trips: mockedTrips
});
assert.equal(response.body.trips[0].id_estado, 7);
});
test('GET /api/trips devuelve lista vacia cuando no hay viajes', async () => {
db.query = async () => [[]];
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
trips: []
});
});
test('GET /api/trips responde en menos de 1s para 500 viajes mockeados', async () => {
const mockedTrips = Array.from({ length: 500 }, (_, index) => ({
id_viaje: 100000 + index,
cod_viaje: `VIA-2026-${String(index + 1).padStart(4, '0')}`,
id_estado: index % 2 === 0 ? 7 : 4,
nombrea: `ORIGEN ${index + 1}`,
nombreb: `DESTINO ${index + 1}`,
fecha_salida: '2026-01-22 06:00:00',
fecha_llegada: '2026-01-22 18:11:00'
}));
db.query = async () => [mockedTrips];
const startedAt = Date.now();
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips',
authorization: `Bearer ${createToken()}`
})
);
const elapsedMs = Date.now() - startedAt;
assert.equal(response.statusCode, 200);
assert.equal(response.body.trips.length, 500);
assert.ok(elapsedMs < 1000, `Expected < 1000ms, got ${elapsedMs}ms`);
});
test('GET /api/trips 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,
path: '/api/trips'
})
);
assert.equal(response.statusCode, 401);
assert.deepEqual(response.body, { error: 'Unauthorized' });
});
test('GET /api/trips devuelve 500 en error interno', async () => {
db.query = async () => {
throw new Error('forced db failure');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 500);
assert.deepEqual(response.body, {
success: false,
error: 'Internal server error'
});
});
@@ -0,0 +1,623 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const app = require('../app');
const db = require('../src/config/db');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
let 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, authorization, body }) =>
new Promise((resolve, reject) => {
const rawBody = body ? JSON.stringify(body) : '';
const req = http.request(
{
hostname: '127.0.0.1',
port,
method,
path,
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();
});
test.before(() => {
originalQuery = db.query;
originalGetConnection = db.getConnection;
});
test.after(() => {
db.query = originalQuery;
db.getConnection = originalGetConnection;
});
test.afterEach(() => {
db.query = originalQuery;
db.getConnection = originalGetConnection;
});
test('POST /api/trips/:id/auto-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 routeLayer = apiRouterLayers
.flatMap((routerLayer) => routerLayer.handle.stack)
.find(
(layer) =>
layer.route &&
layer.route.path === '/trips/:id/auto-status' &&
layer.route.methods.post
);
assert.ok(routeLayer, 'POST /api/trips/:id/auto-status route is not defined');
});
test('POST /api/trips/:id/auto-status con id_punto inserta c_cambios_estado y actualiza punto', async () => {
let txStep = 0;
let beginCalled = false;
let commitCalled = false;
let rollbackCalled = false;
let releaseCalled = false;
const connection = {
beginTransaction: async () => {
beginCalled = true;
},
query: async (sql, params) => {
txStep += 1;
if (/UPDATE c_viajes\s/i.test(sql)) {
throw new Error('Unexpected update on c_viajes');
}
if (txStep === 1) {
assert.match(sql, /FROM t_viaje_estados/);
assert.deepEqual(params, [5]);
return [[{ id_estado: 5 }]];
}
if (txStep === 2) {
assert.match(sql, /FROM c_viajes/);
assert.match(sql, /FOR UPDATE/);
assert.deepEqual(params, [248230]);
return [[{ id_viaje: 248230 }]];
}
if (txStep === 3) {
assert.match(sql, /FROM c_viajes_proveedor/);
assert.match(sql, /FOR UPDATE/);
assert.deepEqual(params, [248230, '58045340X']);
return [[{ n_proveedor: 1 }]];
}
if (txStep === 4) {
assert.match(sql, /INSERT INTO c_cambios_estado/);
assert.match(sql, /actualizado_automaticamente/);
assert.equal(params[0], 248230);
assert.equal(params[1], 1);
assert.equal(params[2], '58045340X');
assert.equal(params[3], 5);
assert.equal(params[4], 1);
assert.equal(params[5], 'estado automático punto');
assert.equal(params[6], '40.416775');
assert.equal(params[7], '-3.70379');
assert.equal(params[8], '2026-02-17 12:34:56');
assert.equal(params[11], 1);
return [{ affectedRows: 1, insertId: 9001 }];
}
if (txStep === 5) {
assert.match(sql, /FROM c_viajes_puntos/);
assert.match(sql, /FOR UPDATE/);
assert.deepEqual(params, [8123, 248230]);
return [[{ id_punto: 8123 }]];
}
if (txStep === 6) {
assert.match(sql, /UPDATE c_viajes_puntos/);
assert.match(sql, /actualizado_automaticamente = 1/);
assert.deepEqual(params, [5, '2026-02-17 12:34:56', 1, '40.416775', '-3.70379', 8123, 248230]);
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected query on step ${txStep}: ${sql}`);
},
commit: async () => {
commitCalled = true;
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {
releaseCalled = true;
}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/auto-status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 5,
id_punto: 8123,
observaciones: 'estado automático punto',
ind_fallido: 1,
latitud: '40,416775',
longitud: '-3,70379',
fecha_y_hora: '2026-02-17 12:34:56'
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.equal(response.body.trip_id, 248230);
assert.equal(response.body.id_estado, 5);
assert.equal(response.body.id_punto, 8123);
assert.equal(response.body.actualizado_automaticamente, 1);
assert.match(response.body.updated_at, /^\d{4}-\d{2}-\d{2}T/);
assert.equal(beginCalled, true);
assert.equal(commitCalled, true);
assert.equal(rollbackCalled, false);
assert.equal(releaseCalled, true);
assert.equal(txStep, 6);
});
test('POST /api/trips/:id/auto-status global inserta c_cambios_estado y no toca puntos', async () => {
let txStep = 0;
const connection = {
beginTransaction: async () => {},
query: async (sql, params) => {
txStep += 1;
if (/UPDATE c_viajes\s/i.test(sql)) {
throw new Error('Unexpected update on c_viajes');
}
if (/c_viajes_puntos/i.test(sql)) {
throw new Error('Point tables should not be touched for global status');
}
if (txStep === 1) {
assert.match(sql, /FROM t_viaje_estados/);
assert.deepEqual(params, [7]);
return [[{ id_estado: 7 }]];
}
if (txStep === 2) {
assert.match(sql, /FROM c_viajes/);
assert.deepEqual(params, [248230]);
return [[{ id_viaje: 248230 }]];
}
if (txStep === 3) {
assert.match(sql, /FROM c_viajes_proveedor/);
assert.deepEqual(params, [248230, '58045340X']);
return [[{ n_proveedor: 1 }]];
}
if (txStep === 4) {
assert.match(sql, /INSERT INTO c_cambios_estado/);
assert.equal(params[0], 248230);
assert.equal(params[3], 7);
assert.equal(params[11], 1);
return [{ affectedRows: 1, insertId: 9002 }];
}
throw new Error(`Unexpected query on step ${txStep}: ${sql}`);
},
commit: async () => {},
rollback: async () => {
throw new Error('rollback should not be called for successful request');
},
release: () => {}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/auto-status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 7,
observaciones: 'estado global automático',
latitud: '40.1',
longitud: '-3.7'
}
})
);
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.equal(response.body.actualizado_automaticamente, 1);
assert.equal(Object.prototype.hasOwnProperty.call(response.body, 'id_punto'), false);
assert.equal(txStep, 4);
});
test('POST /api/trips/:id/auto-status con id_punto y estado no intermedio devuelve 422', async () => {
db.getConnection = async () => {
throw new Error('db.getConnection should not run for invalid point status');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/auto-status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 7,
id_punto: 8123
}
})
);
assert.equal(response.statusCode, 422);
assert.deepEqual(response.body, {
success: false,
error: 'Invalid point status'
});
});
test('POST /api/trips/:id/auto-status devuelve 400 para payload inválido', async () => {
db.getConnection = async () => {
throw new Error('db.getConnection should not run for invalid payload');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/auto-status',
authorization: `Bearer ${createToken()}`,
body: {
id_punto: 8123
}
})
);
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body, {
success: false,
error: 'Invalid payload'
});
});
test('POST /api/trips/:id/auto-status devuelve 401 sin token', async () => {
db.getConnection = async () => {
throw new Error('db.getConnection should not be called without token');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/auto-status',
body: {
id_estado: 5,
id_punto: 8123
}
})
);
assert.equal(response.statusCode, 401);
assert.deepEqual(response.body, { error: 'Unauthorized' });
});
test('POST /api/trips/:id/auto-status devuelve 403 para viaje no autorizado', async () => {
let txStep = 0;
let rollbackCalled = false;
const connection = {
beginTransaction: async () => {},
query: async (sql) => {
txStep += 1;
if (txStep === 1) {
assert.match(sql, /FROM t_viaje_estados/);
return [[{ id_estado: 7 }]];
}
if (txStep === 2) {
assert.match(sql, /FROM c_viajes/);
return [[{ id_viaje: 248230 }]];
}
if (txStep === 3) {
assert.match(sql, /FROM c_viajes_proveedor/);
return [[]];
}
throw new Error('No further queries should run for forbidden trip');
},
commit: async () => {
throw new Error('commit should not be called');
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/auto-status',
authorization: `Bearer ${createToken({ dni: '00000000T' })}`,
body: {
id_estado: 7
}
})
);
assert.equal(response.statusCode, 403);
assert.deepEqual(response.body, {
success: false,
error: 'Forbidden'
});
assert.equal(rollbackCalled, true);
assert.equal(txStep, 3);
});
test('POST /api/trips/:id/auto-status devuelve 404 cuando viaje no existe', async () => {
let txStep = 0;
let rollbackCalled = false;
const connection = {
beginTransaction: async () => {},
query: async (sql) => {
txStep += 1;
if (txStep === 1) {
assert.match(sql, /FROM t_viaje_estados/);
return [[{ id_estado: 7 }]];
}
if (txStep === 2) {
assert.match(sql, /FROM c_viajes/);
return [[]];
}
throw new Error('Authorization query should not run when trip does not exist');
},
commit: async () => {
throw new Error('commit should not be called');
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/99999999/auto-status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 7
}
})
);
assert.equal(response.statusCode, 404);
assert.deepEqual(response.body, {
success: false,
error: 'Trip not found'
});
assert.equal(rollbackCalled, true);
assert.equal(txStep, 2);
});
test('POST /api/trips/:id/auto-status devuelve 404 cuando punto no existe', async () => {
let txStep = 0;
let rollbackCalled = false;
const connection = {
beginTransaction: async () => {},
query: async (sql) => {
txStep += 1;
if (txStep === 1) {
return [[{ id_estado: 5 }]];
}
if (txStep === 2) {
return [[{ id_viaje: 248230 }]];
}
if (txStep === 3) {
return [[{ n_proveedor: 1 }]];
}
if (txStep === 4) {
assert.match(sql, /INSERT INTO c_cambios_estado/);
return [{ affectedRows: 1, insertId: 9003 }];
}
if (txStep === 5) {
assert.match(sql, /FROM c_viajes_puntos/);
return [[]];
}
throw new Error('Point update should not run when point does not exist');
},
commit: async () => {
throw new Error('commit should not be called');
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/auto-status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 5,
id_punto: 99999
}
})
);
assert.equal(response.statusCode, 404);
assert.deepEqual(response.body, {
success: false,
error: 'Trip point not found'
});
assert.equal(rollbackCalled, true);
assert.equal(txStep, 5);
});
test('POST /api/trips/:id/auto-status devuelve 500 y hace rollback en error transaccional', async () => {
let txStep = 0;
let rollbackCalled = false;
let releaseCalled = false;
const connection = {
beginTransaction: async () => {},
query: async (sql) => {
txStep += 1;
if (txStep === 1) {
return [[{ id_estado: 7 }]];
}
if (txStep === 2) {
return [[{ id_viaje: 248230 }]];
}
if (txStep === 3) {
return [[{ n_proveedor: 1 }]];
}
if (txStep === 4) {
assert.match(sql, /INSERT INTO c_cambios_estado/);
throw new Error('insert failed');
}
throw new Error('Unexpected query execution');
},
commit: async () => {
throw new Error('commit should not be called after error');
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {
releaseCalled = true;
}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/auto-status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 7
}
})
);
assert.equal(response.statusCode, 500);
assert.deepEqual(response.body, {
success: false,
error: 'Internal server error'
});
assert.equal(rollbackCalled, true);
assert.equal(releaseCalled, true);
assert.equal(txStep, 4);
});
+610
View File
@@ -0,0 +1,610 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const app = require('../app');
const db = require('../src/config/db');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
let originalGetConnection;
const createToken = (payload = {}) =>
jwt.sign(
{
id: 1,
dni: '58045340X',
id_proveedor: 675,
...payload
},
JWT_SECRET,
{ expiresIn: '1h' }
);
const withServer = async (callback) =>
new Promise((resolve, reject) => {
const server = app.listen(0, '127.0.0.1');
server.on('error', reject);
server.on('listening', async () => {
try {
const result = await callback(server);
server.close((closeError) => {
if (closeError) {
reject(closeError);
return;
}
resolve(result);
});
} catch (error) {
server.close(() => reject(error));
}
});
});
const requestJson = async ({ port, method, path: requestPath, authorization, body }) =>
new Promise((resolve, reject) => {
const rawBody = body ? JSON.stringify(body) : '';
const req = http.request(
{
hostname: '127.0.0.1',
port,
method,
path: requestPath,
headers: {
...(authorization ? { authorization } : {}),
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(rawBody)
}
},
(res) => {
let responseBody = '';
res.on('data', (chunk) => {
responseBody += chunk;
});
res.on('end', () => {
resolve({
statusCode: res.statusCode,
body: responseBody ? JSON.parse(responseBody) : null
});
});
}
);
req.on('error', reject);
req.write(rawBody);
req.end();
});
const formatSqlDateTime = (date) => {
const value = date instanceof Date ? date : new Date(date);
return value.toISOString().slice(0, 19).replace('T', ' ');
};
test.before(() => {
originalGetConnection = db.getConnection;
});
test.after(() => {
db.getConnection = originalGetConnection;
});
test('POST /api/trips/:tripId/start está registrado en /api', () => {
const apiRouterLayers = app._router.stack.filter(
(layer) =>
layer.name === 'router' &&
layer.regexp &&
layer.regexp.toString().includes('^\\/api\\/?(?=\\/|$)')
);
assert.ok(apiRouterLayers.length > 0, 'Router /api is not mounted');
const startRouteLayer = apiRouterLayers
.flatMap((routerLayer) => routerLayer.handle.stack)
.find(
(layer) =>
layer.route &&
layer.route.path === '/trips/:tripId/start' &&
layer.route.methods.post
);
assert.ok(startRouteLayer, 'POST /api/trips/:tripId/start route is not defined');
});
test('POST /api/trips/:tripId/start inicia viaje asignado sin otro activo => 200', async () => {
let step = 0;
let beginCalled = false;
let commitCalled = false;
let rollbackCalled = false;
let releaseCalled = false;
const connection = {
beginTransaction: async () => {
beginCalled = true;
},
query: async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /FROM c_viajes_proveedor/);
assert.match(sql, /WHERE dni = \?/);
assert.match(sql, /FOR UPDATE/);
assert.deepEqual(params, ['58045340X']);
return [[{ id_viaje: 248230 }]];
}
if (step === 2) {
assert.match(sql, /FROM c_viajes/);
assert.match(sql, /WHERE id_viaje = \?/);
assert.match(sql, /FOR UPDATE/);
assert.deepEqual(params, [248230]);
return [[{ id_viaje: 248230, cod_viaje: 'VIA-2026-0001', id_estado: 1 }]];
}
if (step === 3) {
assert.match(sql, /FROM c_viajes_proveedor/);
assert.match(sql, /AND dni = \?/);
assert.match(sql, /FOR UPDATE/);
assert.deepEqual(params, [248230, '58045340X']);
return [[{ n_proveedor: 1 }]];
}
if (step === 4) {
assert.match(sql, /INNER JOIN c_viajes v/);
assert.match(sql, /id_estado BETWEEN \? AND \?/);
assert.match(sql, /FOR UPDATE/);
assert.deepEqual(params, ['58045340X', 248230, 2, 6]);
return [[]];
}
if (step === 5) {
assert.match(sql, /UPDATE c_viajes/);
assert.equal(params[0], 2);
assert.ok(params[1] instanceof Date);
assert.equal(params[2], 1);
assert.equal(params[3], 248230);
return [{ affectedRows: 1 }];
}
if (step === 6) {
assert.match(sql, /INSERT INTO c_cambios_estado/);
assert.equal(params[0], 248230);
assert.equal(params[1], 1);
assert.equal(params[2], '58045340X');
assert.equal(params[3], 2);
assert.ok(params[7] instanceof Date);
assert.equal(params[9], 1);
return [{ insertId: 10, affectedRows: 1 }];
}
assert.match(sql, /SELECT/);
assert.match(sql, /fecha_inicio_real/);
assert.deepEqual(params, [248230]);
return [[{
id_viaje: 248230,
cod_viaje: 'VIA-2026-0001',
id_estado: 2,
fecha_inicio_real: '2026-02-11 12:30:00'
}]];
},
commit: async () => {
commitCalled = true;
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {
releaseCalled = true;
}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/start',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
trip: {
id_viaje: 248230,
cod_viaje: 'VIA-2026-0001',
id_estado: 2,
fecha_inicio_real: '2026-02-11 12:30:00'
}
});
assert.equal(beginCalled, true);
assert.equal(commitCalled, true);
assert.equal(rollbackCalled, false);
assert.equal(releaseCalled, true);
assert.equal(step, 7);
});
test('POST /api/trips/:tripId/start devuelve 409 ACTIVE_TRIP_EXISTS si hay otro viaje en curso', async () => {
let step = 0;
let rollbackCalled = false;
const connection = {
beginTransaction: async () => {},
query: async (_sql, _params) => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 248230 }]];
}
if (step === 2) {
return [[{ id_viaje: 248230, cod_viaje: 'VIA-2026-0001', id_estado: 1 }]];
}
if (step === 3) {
return [[{ n_proveedor: 1 }]];
}
return [[{ id_viaje: 248231, cod_viaje: 'VIA-2026-0002', id_estado: 4 }]];
},
commit: async () => {
throw new Error('commit should not run for conflict');
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/start',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 409);
assert.deepEqual(response.body, {
success: false,
code: 'ACTIVE_TRIP_EXISTS',
message: 'Ya existe un viaje en curso',
activeTrip: {
id_viaje: 248231,
cod_viaje: 'VIA-2026-0002',
id_estado: 4
}
});
assert.equal(rollbackCalled, true);
assert.equal(step, 4);
});
test('POST /api/trips/:tripId/start devuelve 422 cuando el viaje no está asignado', async () => {
let step = 0;
let rollbackCalled = false;
const connection = {
beginTransaction: async () => {},
query: async () => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 248230 }]];
}
if (step === 2) {
return [[{ id_viaje: 248230, cod_viaje: 'VIA-2026-0001', id_estado: 5 }]];
}
return [[{ n_proveedor: 1 }]];
},
commit: async () => {
throw new Error('commit should not run for invalid status');
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/start',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 422);
assert.deepEqual(response.body, {
success: false,
code: 'INVALID_STATUS',
message: 'El viaje no está en estado asignado'
});
assert.equal(rollbackCalled, true);
assert.equal(step, 3);
});
test('POST /api/trips/:tripId/start devuelve 403 si el viaje no pertenece al usuario', async () => {
let step = 0;
let rollbackCalled = false;
const connection = {
beginTransaction: async () => {},
query: async () => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 248230 }]];
}
if (step === 2) {
return [[{ id_viaje: 248230, cod_viaje: 'VIA-2026-0001', id_estado: 1 }]];
}
return [[]];
},
commit: async () => {
throw new Error('commit should not run for forbidden');
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/start',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 403);
assert.deepEqual(response.body, {
success: false,
error: 'Forbidden'
});
assert.equal(rollbackCalled, true);
assert.equal(step, 3);
});
test('POST /api/trips/:tripId/start devuelve 404 cuando el viaje no existe', async () => {
let step = 0;
let rollbackCalled = false;
const connection = {
beginTransaction: async () => {},
query: async () => {
step += 1;
if (step === 1) {
return [[{ id_viaje: 248230 }]];
}
return [[]];
},
commit: async () => {
throw new Error('commit should not run for missing trip');
},
rollback: async () => {
rollbackCalled = true;
},
release: () => {}
};
db.getConnection = async () => connection;
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/99999999/start',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 404);
assert.deepEqual(response.body, {
success: false,
error: 'Trip not found'
});
assert.equal(rollbackCalled, true);
assert.equal(step, 2);
});
test('POST /api/trips/:tripId/start devuelve 401 sin token', async () => {
db.getConnection = async () => {
throw new Error('db.getConnection should not run without token');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/start'
})
);
assert.equal(response.statusCode, 401);
assert.deepEqual(response.body, { error: 'Unauthorized' });
});
test('POST /api/trips/:tripId/start serializa inicios simultáneos y evita dos viajes activos', async () => {
const trips = new Map([
[31001, { id_viaje: 31001, cod_viaje: 'VIA-2026-31001', id_estado: 1, fecha_inicio_real: null }],
[31002, { id_viaje: 31002, cod_viaje: 'VIA-2026-31002', id_estado: 1, fecha_inicio_real: null }]
]);
const providerTripIds = [31001, 31002];
const providerLock = {
locked: false,
waiters: []
};
let commitCount = 0;
let rollbackCount = 0;
const acquireProviderLock = async () => {
if (!providerLock.locked) {
providerLock.locked = true;
return;
}
await new Promise((resolve) => {
providerLock.waiters.push(resolve);
});
};
const releaseProviderLock = () => {
if (providerLock.waiters.length > 0) {
const next = providerLock.waiters.shift();
next();
return;
}
providerLock.locked = false;
};
db.getConnection = async () => {
let hasProviderLock = false;
return {
beginTransaction: async () => {},
query: async (sql, params) => {
if (sql.includes('FROM c_viajes_proveedor') && sql.includes('WHERE dni = ?') && sql.includes('ORDER BY id_viaje ASC') && sql.includes('FOR UPDATE')) {
await acquireProviderLock();
hasProviderLock = true;
return [providerTripIds.map((id_viaje) => ({ id_viaje }))];
}
if (sql.includes('FROM c_viajes') && sql.includes('WHERE id_viaje = ?') && sql.includes('FOR UPDATE')) {
const trip = trips.get(params[0]);
return [trip ? [{
id_viaje: trip.id_viaje,
cod_viaje: trip.cod_viaje,
id_estado: trip.id_estado
}] : []];
}
if (sql.includes('FROM c_viajes_proveedor') && sql.includes('AND dni = ?') && sql.includes('LIMIT 1') && sql.includes('FOR UPDATE')) {
const trip = trips.get(params[0]);
return [trip ? [{ n_proveedor: 1 }] : []];
}
if (sql.includes('INNER JOIN c_viajes v') && sql.includes('id_estado BETWEEN ? AND ?') && sql.includes('FOR UPDATE')) {
const requestedTripId = params[1];
const activeTrip = Array.from(trips.values()).find(
(trip) =>
trip.id_viaje !== requestedTripId &&
trip.id_estado >= 2 &&
trip.id_estado <= 6
);
if (!activeTrip) {
return [[]];
}
return [[{
id_viaje: activeTrip.id_viaje,
cod_viaje: activeTrip.cod_viaje,
id_estado: activeTrip.id_estado
}]];
}
if (sql.includes('UPDATE c_viajes')) {
const trip = trips.get(params[3]);
trip.id_estado = params[0];
trip.fecha_inicio_real = params[1];
return [{ affectedRows: 1 }];
}
if (sql.includes('INSERT INTO c_cambios_estado')) {
return [{ insertId: 99, affectedRows: 1 }];
}
if (sql.includes('DATE_FORMAT(fecha_inicio_real')) {
const trip = trips.get(params[0]);
return [[{
id_viaje: trip.id_viaje,
cod_viaje: trip.cod_viaje,
id_estado: trip.id_estado,
fecha_inicio_real: formatSqlDateTime(trip.fecha_inicio_real)
}]];
}
throw new Error(`Unexpected SQL in concurrency test: ${sql}`);
},
commit: async () => {
commitCount += 1;
if (hasProviderLock) {
hasProviderLock = false;
releaseProviderLock();
}
},
rollback: async () => {
rollbackCount += 1;
if (hasProviderLock) {
hasProviderLock = false;
releaseProviderLock();
}
},
release: () => {
if (hasProviderLock) {
hasProviderLock = false;
releaseProviderLock();
}
}
};
};
const [responseA, responseB] = await withServer(async (server) =>
Promise.all([
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/31001/start',
authorization: `Bearer ${createToken()}`
}),
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/31002/start',
authorization: `Bearer ${createToken()}`
})
])
);
const statusCodes = [responseA.statusCode, responseB.statusCode].sort((a, b) => a - b);
assert.deepEqual(statusCodes, [200, 409]);
const successResponse = responseA.statusCode === 200 ? responseA : responseB;
const conflictResponse = responseA.statusCode === 409 ? responseA : responseB;
assert.equal(successResponse.body.success, true);
assert.equal(conflictResponse.body.success, false);
assert.equal(conflictResponse.body.code, 'ACTIVE_TRIP_EXISTS');
assert.equal(conflictResponse.body.activeTrip.id_viaje, successResponse.body.trip.id_viaje);
const activeTrips = Array.from(trips.values()).filter(
(trip) => trip.id_estado >= 2 && trip.id_estado <= 6
);
assert.equal(activeTrips.length, 1);
assert.equal(commitCount, 1);
assert.equal(rollbackCount, 1);
});
+173
View File
@@ -0,0 +1,173 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const app = require('../app');
const db = require('../src/config/db');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
let originalQuery;
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, path, authorization }) =>
new Promise((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
method: 'GET',
path,
headers: authorization ? { authorization } : {}
},
(res) => {
let rawBody = '';
res.on('data', (chunk) => {
rawBody += chunk;
});
res.on('end', () => {
const body = rawBody ? JSON.parse(rawBody) : null;
resolve({ statusCode: res.statusCode, body });
});
}
);
req.on('error', reject);
req.end();
});
test.before(() => {
originalQuery = db.query;
});
test.after(() => {
db.query = originalQuery;
});
test('GET /api/trips/states 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 statesRouteLayer = apiRouterLayers
.flatMap((routerLayer) => routerLayer.handle.stack)
.find(
(layer) =>
layer.route &&
layer.route.path === '/trips/states' &&
layer.route.methods.get
);
assert.ok(statesRouteLayer, 'GET /api/trips/states route is not defined');
});
test('GET /api/trips/states devuelve solo estados 2..7 ordenados ASC', async () => {
const mockedStates = [
{ id_estado: 2, estado: 'EN CURSO', estado_en: 'IN PROGRESS' },
{ id_estado: 3, estado: 'POSICIONADO', estado_en: 'POSITIONED' },
{ id_estado: 4, estado: 'CARGA DE MERCANCÍA', estado_en: 'CARGO LOADING' },
{ id_estado: 5, estado: 'TRÁNSITO', estado_en: 'IN TRANSIT' },
{ id_estado: 6, estado: 'LLEGADA AL DESTINO', estado_en: 'ARRIVED AT DESTINATION' },
{ id_estado: 7, estado: 'ENTREGADO', estado_en: 'DELIVERED' }
];
db.query = async (sql, params) => {
assert.match(sql, /FROM t_viaje_estados/);
assert.match(sql, /WHERE id_estado BETWEEN \? AND \?/);
assert.match(sql, /ORDER BY id_estado ASC/);
assert.deepEqual(params, [2, 7]);
return [mockedStates];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/states',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
states: mockedStates
});
assert.equal(response.body.states.length, 6);
});
test('GET /api/trips/states 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,
path: '/api/trips/states'
})
);
assert.equal(response.statusCode, 401);
assert.deepEqual(response.body, { error: 'Unauthorized' });
});
test('GET /api/trips/states devuelve 500 en error interno', async () => {
db.query = async () => {
throw new Error('forced db failure');
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips/states',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 500);
assert.deepEqual(response.body, {
success: false,
error: 'Internal server error'
});
});
File diff suppressed because it is too large Load Diff