Enhance trips API with pagination and filtering support, including validation for parameters

This commit is contained in:
abiandev
2026-07-01 10:41:08 +02:00
parent 9b4ea0b415
commit 73c1416f7a
2 changed files with 273 additions and 32 deletions
+155 -21
View File
@@ -19,6 +19,9 @@ const LEGACY_STATUS_PHOTO_FIELD_MAX_LENGTH = 100;
const LEGACY_INTERMEDIATE_POINT_VALUE_SEPARATOR = ':|:';
const LEGACY_INTERMEDIATE_POINT_REFERENCE_REGEX = /^[0-9]+$/;
const MOBILE_TRIPS_ALLOWED_STATES = [7, 8, 9, 1];
const MOBILE_TRIPS_DEFAULT_PAGE = 1;
const MOBILE_TRIPS_DEFAULT_LIMIT = 25;
const MOBILE_TRIPS_MAX_LIMIT = 100;
const CLEAR_STATUS_FALLBACK_STATE = 1;
const INTERMEDIATE_POINT_ALLOWED_STATES = [3, 4, 5];
const INTERMEDIATE_POINT_ALLOWED_STATES_SET = new Set(INTERMEDIATE_POINT_ALLOWED_STATES);
@@ -27,6 +30,7 @@ const FAILED_TRIP_STATE = 9;
const AGHEERA_CLIENT_ID = 532;
const INTERMEDIATE_POINT_STATUS_IDS = new Set([3, 4, 5]);
const INCIDENCE_TEXT_CONTROL_CHARACTERS_REGEX = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
const SQL_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
const GLOBAL_STATUS_KEYS_BY_STATE_ID = new Map([
[1, 'assigned'],
[2, 'en_camino'],
@@ -48,6 +52,59 @@ const appendTripStatusDebugLog = (payload) => {
console.info('[TripStatusDebug]', payload);
};
const parseTripsPositiveInteger = (value, defaultValue) => {
if (value === undefined) {
return defaultValue;
}
if (!/^\d+$/.test(String(value))) {
return null;
}
const parsedValue = Number.parseInt(value, 10);
return parsedValue >= 1 ? parsedValue : null;
};
const parseTripsStatusIds = (value) => {
if (value === undefined || value === '') {
return MOBILE_TRIPS_ALLOWED_STATES;
}
const rawStatusIds = String(value).split(',');
if (rawStatusIds.some((statusId) => !/^\d+$/.test(statusId))) {
return null;
}
return rawStatusIds.map((statusId) => Number.parseInt(statusId, 10));
};
const isValidSqlDate = (value) => {
if (value === undefined) {
return true;
}
if (!SQL_DATE_REGEX.test(String(value))) {
return false;
}
const [year, month, day] = String(value).split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
);
};
const addOneDayToSqlDate = (value) => {
const [year, month, day] = String(value).split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day + 1));
return date.toISOString().slice(0, 10);
};
const getTripStatusUpdatesLogPath = () =>
process.env.TRIP_STATUS_UPDATES_LOG_PATH ||
'/var/log/status.log';
@@ -3587,6 +3644,8 @@ const getActiveTrip = async (req, res) => {
};
const getTrips = async (req, res) => {
const startedAt = Date.now();
try {
const dni = req.user?.dni;
@@ -3594,6 +3653,79 @@ const getTrips = async (req, res) => {
return res.status(401).json({ error: 'Unauthorized' });
}
const page = parseTripsPositiveInteger(req.query.page, MOBILE_TRIPS_DEFAULT_PAGE);
const requestedLimit = parseTripsPositiveInteger(req.query.limit, MOBILE_TRIPS_DEFAULT_LIMIT);
const statusIds = parseTripsStatusIds(req.query.status_ids);
if (page === null || requestedLimit === null || requestedLimit > MOBILE_TRIPS_MAX_LIMIT) {
return res.status(400).json({
success: false,
error: 'Invalid pagination parameters'
});
}
if (!statusIds || statusIds.length === 0) {
return res.status(400).json({
success: false,
error: 'Invalid status_ids parameter'
});
}
if (!isValidSqlDate(req.query.date_from) || !isValidSqlDate(req.query.date_to)) {
return res.status(400).json({
success: false,
error: 'Invalid date parameter'
});
}
if (
req.query.date_from !== undefined &&
req.query.date_to !== undefined &&
req.query.date_from > req.query.date_to
) {
return res.status(400).json({
success: false,
error: 'Invalid date range'
});
}
const limit = requestedLimit;
const offset = (page - 1) * limit;
const whereClauses = [
'p.dni = ?',
'v.id_estado IN (' + statusIds.map(() => '?').join(', ') + ')'
];
const queryParams = [dni, ...statusIds];
if (req.query.date_from !== undefined) {
whereClauses.push('COALESCE(p.fecha_salida, v.fecha_salida) >= ?');
queryParams.push(req.query.date_from);
}
if (req.query.date_to !== undefined) {
whereClauses.push('COALESCE(p.fecha_salida, v.fecha_salida) < ?');
queryParams.push(addOneDayToSqlDate(req.query.date_to));
}
const fromAndWhereSql = `
FROM c_viajes_proveedor p
INNER JOIN c_viajes v
ON v.id_viaje = p.id_viaje
INNER JOIN m_proveedores_trasportistas t
ON t.dni = p.dni
AND t.desactivado = 0
LEFT JOIN m_puntos_envio_recogida p1
ON p1.id_punto = p.id_punto_recogida
LEFT JOIN m_puntos_envio_recogida p2
ON p2.id_punto = p.id_punto_entrega
WHERE ${whereClauses.join('\n AND ')}`;
const [[countRow]] = await db.query(
`SELECT COUNT(*) AS total ${fromAndWhereSql}`,
queryParams
);
const total = Number.parseInt(countRow?.total, 10) || 0;
const [rows] = await db.query(
`SELECT
p.id_viaje AS id_viaje,
@@ -3688,30 +3820,32 @@ const getTrips = async (req, res) => {
END AS fecha_llegada,
NULLIF(TRIM(v.observaciones_mercancia), '') AS observaciones_mercancia,
NULLIF(TRIM(v.observaciones_cliente), '') AS observaciones_cliente
FROM c_viajes_proveedor p
INNER JOIN c_viajes v
ON v.id_viaje = p.id_viaje
INNER JOIN m_proveedores_trasportistas t
ON t.dni = p.dni
AND t.desactivado = 0
LEFT JOIN m_puntos_envio_recogida p1
ON p1.id_punto = p.id_punto_recogida
LEFT JOIN m_puntos_envio_recogida p2
ON p2.id_punto = p.id_punto_entrega
WHERE p.dni = ?
AND v.id_estado IN (?, ?, ?, ?)
ORDER BY COALESCE(p.fecha_salida, v.fecha_salida) DESC, p.id_viaje DESC`,
[
dni,
MOBILE_TRIPS_ALLOWED_STATES[0],
MOBILE_TRIPS_ALLOWED_STATES[1],
MOBILE_TRIPS_ALLOWED_STATES[2],
MOBILE_TRIPS_ALLOWED_STATES[3]
]
${fromAndWhereSql}
ORDER BY COALESCE(p.fecha_salida, v.fecha_salida) DESC, p.id_viaje DESC
LIMIT ? OFFSET ?`,
[...queryParams, limit, offset]
);
console.info('[TripsList]', {
dni,
page,
limit,
filters: {
status_ids: statusIds,
date_from: req.query.date_from || null,
date_to: req.query.date_to || null
},
rows: rows.length,
total,
elapsed_ms: Date.now() - startedAt
});
return res.status(200).json({
trips: rows
trips: rows,
page,
limit,
total,
has_more: offset + rows.length < total
});
} catch (error) {
console.error('Error getting trips list:', {
+118 -11
View File
@@ -102,7 +102,7 @@ test('GET /api/trips está registrado en /api', () => {
assert.ok(tripsRouteLayer, 'GET /api/trips route is not defined');
});
test('GET /api/trips devuelve viajes del transportista autenticado con aliases legacy', async () => {
test('GET /api/trips devuelve viajes del transportista autenticado con aliases legacy e incluye asignados', async () => {
const mockedTrips = [
{
id_viaje: 84919,
@@ -116,26 +116,36 @@ test('GET /api/trips devuelve viajes del transportista autenticado con aliases l
{
id_viaje: 84918,
cod_viaje: 'VIA-2026-0000',
id_estado: 4,
id_estado: 1,
nombrea: 'Madrid, ES',
nombreb: 'Bilbao, ES',
fecha_salida: '2026-01-21',
fecha_llegada: '2026-01-21'
}
];
let callCount = 0;
db.query = async (sql, params) => {
callCount += 1;
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, /id_estado IN \(\?, \?, \?, \?\)/);
if (callCount === 1) {
assert.match(sql, /COUNT\(\*\) AS total/);
assert.deepEqual(params, ['58045340X', 7, 8, 9, 1]);
return [[{ total: 2 }]];
}
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]);
assert.match(sql, /LIMIT \? OFFSET \?/);
assert.deepEqual(params, ['58045340X', 7, 8, 9, 1, 25, 0]);
return [mockedTrips];
};
@@ -149,13 +159,23 @@ test('GET /api/trips devuelve viajes del transportista autenticado con aliases l
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
trips: mockedTrips
trips: mockedTrips,
page: 1,
limit: 25,
total: 2,
has_more: false
});
assert.equal(response.body.trips[0].id_estado, 7);
assert.equal(response.body.trips[1].id_estado, 1);
});
test('GET /api/trips devuelve lista vacia cuando no hay viajes', async () => {
db.query = async () => [[]];
let callCount = 0;
db.query = async () => {
callCount += 1;
return callCount === 1 ? [[{ total: 0 }]] : [[]];
};
const response = await withServer(async (server) =>
requestJson({
@@ -167,12 +187,16 @@ test('GET /api/trips devuelve lista vacia cuando no hay viajes', async () => {
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
trips: []
trips: [],
page: 1,
limit: 25,
total: 0,
has_more: false
});
});
test('GET /api/trips responde en menos de 1s para 500 viajes mockeados', async () => {
const mockedTrips = Array.from({ length: 500 }, (_, index) => ({
test('GET /api/trips responde en menos de 1s para 25 viajes mockeados', async () => {
const mockedTrips = Array.from({ length: 25 }, (_, index) => ({
id_viaje: 100000 + index,
cod_viaje: `VIA-2026-${String(index + 1).padStart(4, '0')}`,
id_estado: index % 2 === 0 ? 7 : 4,
@@ -182,7 +206,12 @@ test('GET /api/trips responde en menos de 1s para 500 viajes mockeados', async (
fecha_llegada: '2026-01-22 18:11:00'
}));
db.query = async () => [mockedTrips];
let callCount = 0;
db.query = async () => {
callCount += 1;
return callCount === 1 ? [[{ total: 500 }]] : [mockedTrips];
};
const startedAt = Date.now();
const response = await withServer(async (server) =>
@@ -195,10 +224,88 @@ test('GET /api/trips responde en menos de 1s para 500 viajes mockeados', async (
const elapsedMs = Date.now() - startedAt;
assert.equal(response.statusCode, 200);
assert.equal(response.body.trips.length, 500);
assert.equal(response.body.trips.length, 25);
assert.equal(response.body.total, 500);
assert.equal(response.body.has_more, true);
assert.ok(elapsedMs < 1000, `Expected < 1000ms, got ${elapsedMs}ms`);
});
test('GET /api/trips aplica paginacion y filtros en SQL', async () => {
const mockedTrips = [
{
id_viaje: 84919,
cod_viaje: 'VIA-2026-0001',
id_estado: 7,
nombrea: 'Barcelona, ES',
nombreb: 'Lyon, FR',
fecha_salida: '2026-06-30 06:00:00',
fecha_llegada: '2026-06-30 18:11:00'
}
];
let callCount = 0;
db.query = async (sql, params) => {
callCount += 1;
assert.match(sql, /v\.id_estado IN \(\?, \?, \?\)/);
assert.match(sql, /COALESCE\(p\.fecha_salida, v\.fecha_salida\) >= \?/);
assert.match(sql, /COALESCE\(p\.fecha_salida, v\.fecha_salida\) < \?/);
if (callCount === 1) {
assert.match(sql, /COUNT\(\*\) AS total/);
assert.deepEqual(params, ['58045340X', 7, 8, 9, '2026-06-01', '2026-07-01']);
return [[{ total: 26 }]];
}
assert.match(sql, /ORDER BY COALESCE\(p\.fecha_salida, v\.fecha_salida\) DESC, p\.id_viaje DESC/);
assert.match(sql, /LIMIT \? OFFSET \?/);
assert.deepEqual(params, ['58045340X', 7, 8, 9, '2026-06-01', '2026-07-01', 25, 25]);
return [mockedTrips];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path: '/api/trips?page=2&limit=25&status_ids=7,8,9&date_from=2026-06-01&date_to=2026-06-30',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.page, 2);
assert.equal(response.body.limit, 25);
assert.equal(response.body.total, 26);
assert.equal(response.body.has_more, false);
assert.deepEqual(response.body.trips, mockedTrips);
});
test('GET /api/trips valida parametros invalidos', async () => {
db.query = async () => {
throw new Error('db.query should not be called with invalid params');
};
const invalidPaths = [
'/api/trips?page=0',
'/api/trips?limit=101',
'/api/trips?status_ids=7,x',
'/api/trips?date_from=2026-02-30',
'/api/trips?date_from=2026-07-01&date_to=2026-06-30'
];
for (const path of invalidPaths) {
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
path,
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 400, path);
assert.equal(response.body.success, false);
}
});
test('GET /api/trips devuelve 401 sin token', async () => {
db.query = async () => {
throw new Error('db.query should not be called without token');