feat: incorporar eCMR y mercancias desde 7fbfd30

Traslada los cambios de 7fbfd300b4ea17212c04528145d366a3f00b91e5 conservando la configuracion de entorno de produccion.
This commit is contained in:
2026-09-29 13:32:12 +02:00
parent fd3a63809b
commit dc545c9d86
10 changed files with 1110 additions and 2 deletions
+112
View File
@@ -0,0 +1,112 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const express = require('express');
const jwt = require('jsonwebtoken');
const db = require('../src/config/db');
const writer = require('../src/services/tripCargoWriter');
const routes = require('../src/routes/tripsRoutes');
const originalQuery = db.query;
const originalSave = writer.save;
process.env.JWT_SECRET = 'cargo-test-secret';
let server;
let base;
const valid = { id_tipo_embalaje: 1, cantidad: 2, peso: 400, largo: 120, ancho: 80, alto: 150, remontable: true };
test.before(async () => {
const app = express(); app.use(express.json()); app.use('/api', routes);
await new Promise(resolve => { server = app.listen(0, '127.0.0.1', resolve); });
base = `http://127.0.0.1:${server.address().port}/api/trips`;
});
test.afterEach(() => { db.query = originalQuery; writer.save = originalSave; });
test.after(async () => { await new Promise(resolve => server.close(resolve)); });
const request = async (method = 'GET', suffix = '/123/cargo', body, identity = { dni: '12345678A' }) => {
const headers = { 'Content-Type': 'application/json' };
if (identity) headers.Authorization = `Bearer ${jwt.sign(identity, process.env.JWT_SECRET)}`;
const response = await fetch(base + suffix, { method, headers, body: method === 'GET' || body === undefined ? undefined : JSON.stringify(body) });
return { status: response.status, body: await response.json() };
};
for (const method of ['GET', 'POST', 'PUT']) {
for (const identity of [null, { id: 1 }]) {
test(`${method} requires authenticated driver ${JSON.stringify(identity)}`, async () => {
db.query = writer.save = async () => assert.fail('Must not access data');
assert.equal((await request(method, method === 'PUT' ? '/123/cargo/7' : '/123/cargo', valid, identity)).status, 401);
});
}
}
for (const id of ['0', '-1', '123abc', '2147483648']) {
test(`invalid trip ${id}`, async () => {
writer.save = async () => assert.fail('Must not save');
assert.equal((await request('POST', `/${id}/cargo`, valid)).status, 404);
});
}
test('unknown trip and unauthorized driver cannot read cargo', async () => {
db.query = async () => [[]];
assert.equal((await request()).status, 404);
let calls = 0;
db.query = async (sql, params) => {
calls++;
if (calls === 1) return [[{ id_viaje: 123 }]];
assert.deepEqual(params, [123, '12345678A']);
return [[]];
};
assert.equal((await request()).status, 403);
assert.equal(calls, 2);
});
test('reads cargo, active packaging and document status', async () => {
let calls = 0;
db.query = async (sql, params) => {
calls++;
if (calls === 1) return [[{ id_viaje: 123, observaciones_mercancia: 'Material' }]];
if (calls === 2) return [[{ n_proveedor: 1 }]];
if (calls === 3) { assert.deepEqual(params, [123]); return [[{ id_mercancia: 7, peso: '400' }]]; }
if (calls === 4) { assert.match(sql, /activo = 1/); return [[{ id_tipo_embalaje: 1 }]]; }
assert.deepEqual(params, [123]); return [[{ id_documento: 8 }]];
};
const response = await request();
assert.equal(response.status, 200);
assert.equal(response.body.data.ecmr_emitido, true);
assert.equal(response.body.data.mercancias[0].id_mercancia, 7);
assert.equal(response.body.data.observaciones_mercancia, 'Material');
});
for (const [field, value] of [
['cantidad', 0], ['cantidad', 1.5], ['cantidad', '2'], ['cantidad', null],
['peso', -1], ['peso', 1.5], ['peso', 10000000000], ['peso', null],
['largo', -10], ['ancho', 2147483648], ['alto', 0.5],
['remontable', 'true'], ['remontable', 2], ['id_tipo_embalaje', 0]
]) {
test(`rejects invalid ${field}=${value}`, async () => {
writer.save = async () => assert.fail('Must not save invalid cargo');
const response = await request('POST', '/123/cargo', { ...valid, [field]: value });
assert.equal(response.status, 422);
assert.equal(response.body.field, field);
});
}
for (const editing of [false, true]) {
test(`saves ${editing ? 'edited' : 'new'} cargo using server identity and new eCMR versions`, async () => {
writer.save = async payload => {
assert.equal(payload.dni, '12345678A');
assert.equal(payload.trip_id, 123);
assert.equal(payload.cargo_id, editing ? 7 : null);
assert.equal(payload.mercancia.remontable, 1);
assert.equal(payload.database, process.env.DB_NAME);
return { status: editing ? 200 : 201, body: { success: true, data: { id_mercancia: 7 }, ecmr_versiones: [{ id_documento: 9, version: 2 }] } };
};
const response = await request(editing ? 'PUT' : 'POST', editing ? '/123/cargo/7' : '/123/cargo', { ...valid, dni: 'forged', trip_id: 999 });
assert.equal(response.status, editing ? 200 : 201);
assert.equal(response.body.ecmr_versiones[0].version, 2);
});
}
for (const status of [403, 404, 422, 500]) {
test(`propagates transaction failure ${status}`, async () => {
writer.save = async () => ({ status, body: { success: false, error: 'Failed' } });
const response = await request('POST', '/123/cargo', valid);
assert.equal(response.status, status);
assert.equal(response.body.success, false);
});
}
test('process failures return 500 without exposing internals', async () => {
writer.save = async () => { throw new Error('/private/internal'); };
const response = await request('POST', '/123/cargo', valid);
assert.equal(response.status, 500);
assert.doesNotMatch(response.body.error, /private/);
});
+254
View File
@@ -0,0 +1,254 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const crypto = require('node:crypto');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const express = require('express');
const db = require('../src/config/db');
const routes = require('../src/routes/tripsRoutes');
process.env.JWT_SECRET = 'ecmr-test-secret';
const originalQuery = db.query;
const originalGetConnection = db.getConnection;
let transaction;
let server;
let port;
test.before(async () => {
const app = express();
app.use('/api', routes);
server = http.createServer(app);
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
port = server.address().port;
});
test.beforeEach(() => {
transaction = { committed: false, rolledBack: false, released: false };
db.getConnection = async () => ({
beginTransaction: async () => {},
query: (...args) => db.query(...args),
commit: async () => { transaction.committed = true; },
rollback: async () => { transaction.rolledBack = true; },
release: () => { transaction.released = true; }
});
});
test.after(async () => {
db.getConnection = originalGetConnection;
db.query = originalQuery;
await new Promise(resolve => server.close(resolve));
});
test.afterEach(() => { db.query = originalQuery; });
const request = async (id = '123', payload = { dni: '12345678A' }) => {
const headers = payload === null ? {} : {
authorization: `Bearer ${jwt.sign(payload, process.env.JWT_SECRET)}`
};
const response = await fetch(`http://127.0.0.1:${port}/api/trips/${id}/ecmr`, { headers });
return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') };
};
for (const payload of [null, { id: 1 }]) {
test(`rejects missing authentication or driver: ${JSON.stringify(payload)}`, async () => {
db.query = async () => { assert.fail('Must not query database'); };
assert.equal((await request('123', payload)).status, 401);
});
}
for (const id of ['0', '-1', '123abc', '1.5', '9007199254740992']) {
test(`rejects invalid trip id ${id}`, async () => {
db.query = async () => { assert.fail('Must not query database'); };
assert.equal((await request(id)).status, 404);
});
}
test('returns 404 for unknown trip', async () => {
db.query = async () => [[]];
assert.equal((await request()).status, 404);
});
test('does not retrieve documents for another driver', async () => {
let calls = 0;
db.query = async (sql, params) => {
calls++;
if (calls === 1) return [[{ id_viaje: 123 }]];
assert.equal(calls, 2);
assert.match(sql, /dni = \?/);
assert.deepEqual(params, [123, '12345678A']);
return [[]];
};
assert.equal((await request()).status, 403);
assert.equal(calls, 2);
});
for (const [loadSigned, unloadSigned] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
test(`returns links only for unsigned roles (${loadSigned}, ${unloadSigned})`, async () => {
const inserted = [];
let calls = 0;
db.query = async (sql, params) => {
calls++;
if (calls === 1) return [[{ id_viaje: 123 }]];
if (calls === 2) return [[{ authorized: 1 }]];
if (calls === 3) {
assert.deepEqual(params, [123, '12345678A']);
assert.match(sql, /p\.n_proveedor = d\.n_proveedor/);
assert.match(sql, /p\.dni = \?/);
assert.match(sql, /d\.activo = 1/);
assert.match(sql, /d\.pdf_ruta IS NOT NULL/);
assert.match(sql, /FOR UPDATE/);
assert.match(sql, /ecmr_firmas/);
return [[{ id_documento: 7, document_url: 'https://example.test/ecmr/documento.php?t=pdf-token',
firma_carga_completada: loadSigned, firma_descarga_completada: unloadSigned }]];
}
assert.match(sql, /INSERT INTO ecmr_invitaciones/);
assert.match(sql, /'app'/);
assert.match(sql, /NOW\(\)/);
assert.equal(params[0], 7);
assert.equal(params[2], '12345678A');
assert.match(params[3], /^[a-f0-9]{64}$/);
assert.match(params[4], /^[a-f0-9]{64}$/);
inserted.push(params);
return [{ insertId: 100 + calls }];
};
const response = await request();
assert.equal(response.status, 200);
assert.equal(response.cache, 'no-store');
const document = response.body.data[0];
assert.equal(document.firma_carga_completada, Boolean(loadSigned));
assert.equal(document.firma_descarga_completada, Boolean(unloadSigned));
assert.equal(document.firma_expira_en_segundos, 900);
const tokens = [];
for (const [role, field, signed] of [
['toma_carga', 'firma_carga_url', loadSigned],
['entrega', 'firma_descarga_url', unloadSigned]
]) {
if (signed) { assert.equal(document[field], null); continue; }
const url = new URL(document[field]);
assert.equal(url.origin + url.pathname, 'https://example.test/ecmr/firmar.php');
const token = url.searchParams.get('t');
assert.match(token, /^[a-f0-9]{64}$/);
tokens.push(token);
const row = inserted.find(params => params[1] === role);
assert.equal(row[3], crypto.createHash('sha256').update(token).digest('hex'));
assert.notEqual(row[4], token);
}
assert.equal(new Set(tokens).size, tokens.length);
assert.equal(inserted.length, 2 - loadSigned - unloadSigned);
assert.deepEqual(transaction, { committed: true, rolledBack: false, released: true });
});
}
test('returns an empty list without creating invitations', async () => {
let calls = 0;
db.query = async () => {
calls++;
assert.ok(calls <= 3);
return calls < 3 ? [[{ authorized: 1 }]] : [[]];
};
const response = await request();
assert.equal(response.status, 200);
assert.deepEqual(response.body.data, []);
assert.equal(transaction.committed, true);
});
test('rolls back all links if an invitation fails', async () => {
let calls = 0;
db.query = async () => {
calls++;
if (calls < 3) return [[{ authorized: 1 }]];
if (calls === 3) return [[{ id_documento: 7, document_url: 'https://example.test/documento.php' }]];
if (calls === 4) return [{ insertId: 1 }];
throw new Error('forced insert failure');
};
const response = await request();
assert.equal(response.status, 500);
assert.equal(response.body.success, false);
assert.deepEqual(transaction, { committed: false, rolledBack: true, released: true });
});
test('rejects signing URLs without HTTPS', async () => {
let calls = 0;
db.query = async () => {
calls++;
assert.ok(calls <= 3);
if (calls < 3) return [[{ authorized: 1 }]];
return [[{ id_documento: 7, document_url: 'http://example.test/documento.php' }]];
};
assert.equal((await request()).status, 500);
assert.equal(transaction.rolledBack, true);
});
test('database errors do not expose internal details', async () => {
db.query = async () => { throw new Error('internal database detail'); };
const response = await request();
assert.equal(response.status, 500);
assert.equal(response.body.error, 'Could not load eCMR documents');
});
const requestQr = async (tripId = '123', documentId = '7', payload = { dni: '12345678A' }) => {
const headers = payload === null ? {} : { authorization: `Bearer ${jwt.sign(payload, process.env.JWT_SECRET)}` };
const response = await fetch(`http://127.0.0.1:${port}/api/trips/${tripId}/ecmr/${documentId}/qr`, { headers });
return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') };
};
for (const payload of [null, { id: 1 }]) {
test(`QR rejects missing authentication or driver: ${JSON.stringify(payload)}`, async () => {
db.query = async () => assert.fail('Must not query database');
assert.equal((await requestQr('123', '7', payload)).status, 401);
});
}
for (const invalid of ['0', '-1', '7abc', '1.5', '9007199254740992']) {
test(`QR rejects invalid identifiers: ${invalid}`, async () => {
db.query = async () => assert.fail('Must not query database');
assert.equal((await requestQr(invalid, '7')).status, 404);
assert.equal((await requestQr('123', invalid)).status, 404);
});
}
test('QR returns only the requested active document of the assigned driver and creates no invitations', async () => {
const QRCode = require('qrcode');
const originalEncode = QRCode.toDataURL;
const url = 'https://example.test/ecmr/documento.php?t=' + 'a'.repeat(64);
let encoded;
QRCode.toDataURL = async (text, options) => { encoded = text; return originalEncode(text, options); };
try {
db.getConnection = async () => assert.fail('QR must not create signing invitations');
let queries = 0;
db.query = async (sql, params) => {
queries++;
assert.match(sql, /^SELECT/);
assert.match(sql, /p\.n_proveedor = d\.n_proveedor/);
assert.match(sql, /d\.id_viaje = \? AND d\.id_documento = \? AND p\.dni = \? AND d\.activo = 1/);
assert.match(sql, /d\.pdf_ruta IS NOT NULL AND d\.pdf_ruta <> ''/);
assert.deepEqual(params, [123, 7, '12345678A']);
return [[{ id_documento: 7, n_proveedor: 2, numero: 'ECMR-123', version: 3, document_url: url }]];
};
const response = await requestQr();
assert.equal(response.status, 200);
assert.equal(response.cache, 'no-store');
assert.equal(response.body.data.id_documento, 7);
assert.equal(response.body.data.version, 3);
assert.equal(response.body.data.document_url, url);
assert.equal(encoded, url);
assert.equal(queries, 1);
assert.match(response.body.data.qr_data_url, /^data:image\/png;base64,/);
const png = Buffer.from(response.body.data.qr_data_url.split(',')[1], 'base64');
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
assert.equal(png.readUInt32BE(16), 320);
assert.equal(png.readUInt32BE(20), 320);
assert.equal(response.body.data.firma_carga_url, undefined);
} finally { QRCode.toDataURL = originalEncode; }
});
test('QR returns 404 when no authorized active PDF matches', async () => {
db.query = async () => [[]];
const response = await requestQr();
assert.equal(response.status, 404);
assert.equal(response.cache, 'no-store');
assert.equal(response.body.data, undefined);
});
for (const url of ['http://example.test/pdf', 'not-a-url', '']) {
test(`QR rejects invalid PDF URL ${url}`, async () => {
db.query = async () => [[{ document_url: url }]];
assert.equal((await requestQr()).status, 500);
});
}
test('QR handles database and image generation errors without leaking URLs or internals', async () => {
db.query = async () => { throw new Error('private database details'); };
assert.deepEqual((await requestQr()).body, { success: false, error: 'Could not load eCMR QR' });
const QRCode = require('qrcode'), originalEncode = QRCode.toDataURL;
try {
db.query = async () => [[{ document_url: 'https://example.test/documento.php?t=private' }]];
QRCode.toDataURL = async () => { throw new Error('private QR details'); };
const response = await requestQr();
assert.equal(response.status, 500);
assert.deepEqual(response.body, { success: false, error: 'Could not load eCMR QR' });
} finally { QRCode.toDataURL = originalEncode; }
});