Enhance trips API with pagination and filtering support, including validation for parameters
This commit is contained in:
@@ -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:', {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user