From 73c1416f7a4fcad2b68be1f54eaff7c4a88c572d Mon Sep 17 00:00:00 2001 From: abiandev Date: Wed, 1 Jul 2026 10:41:08 +0200 Subject: [PATCH] Enhance trips API with pagination and filtering support, including validation for parameters --- src/controllers/tripsController.js | 176 ++++++++++++++++++++++++---- test/trips.list.integration.test.js | 129 ++++++++++++++++++-- 2 files changed, 273 insertions(+), 32 deletions(-) diff --git a/src/controllers/tripsController.js b/src/controllers/tripsController.js index 7cf1328..cee4fa9 100644 --- a/src/controllers/tripsController.js +++ b/src/controllers/tripsController.js @@ -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:', { diff --git a/test/trips.list.integration.test.js b/test/trips.list.integration.test.js index a8fd0ab..2db9f3c 100644 --- a/test/trips.list.integration.test.js +++ b/test/trips.list.integration.test.js @@ -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');