Fix passenger startup and clean tracked generated files
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user