Compare commits

...
5 Commits
16 changed files with 1183 additions and 85 deletions
+72
View File
@@ -7,6 +7,7 @@ const authRoutes = require('./src/routes/authRoutes');
const profileRoutes = require('./src/routes/profileRoutes'); const profileRoutes = require('./src/routes/profileRoutes');
const tripsRoutes = require('./src/routes/tripsRoutes'); const tripsRoutes = require('./src/routes/tripsRoutes');
const driverLicenseRoutes = require('./src/routes/driverLicenseRoutes'); const driverLicenseRoutes = require('./src/routes/driverLicenseRoutes');
const availabilityRoutes = require('./src/routes/availabilityRoutes');
const { appendPostLog } = require('./src/utils/postLog'); const { appendPostLog } = require('./src/utils/postLog');
dotenv.config({ path: path.resolve(__dirname, '.env'), override: true }); dotenv.config({ path: path.resolve(__dirname, '.env'), override: true });
@@ -54,6 +55,76 @@ app.use((req, res, next) => {
contentType.toLowerCase().startsWith('multipart/form-data'); contentType.toLowerCase().startsWith('multipart/form-data');
if (isMultipartFormData) { if (isMultipartFormData) {
const startedAt = process.hrtime.bigint();
const baseLogPayload = {
request_id: requestId,
method: req.method,
path: req.originalUrl || req.url,
ip: req.ip || null,
content_type: contentType,
content_length: Number.parseInt(req.get('content-length'), 10) || null,
has_authorization_header: Boolean(authorizationHeader),
user_agent: String(req.get('user-agent') || '').slice(0, 255) || null
};
let responseFinished = false;
appendPostLog({
event: 'upload_request_started',
...baseLogPayload
});
res.once('finish', () => {
responseFinished = true;
const parserStatus = req.uploadDiagnostics?.parser_status || 'not_reached';
let failureStage = null;
if (res.statusCode >= 400) {
if (parserStatus === 'rejected') {
failureStage = 'multipart_parser';
} else if (parserStatus === 'parsed') {
failureStage = 'controller_or_persistence';
} else {
failureStage = 'authentication_rate_limit_or_route';
}
}
appendPostLog({
event: 'upload_request_finished',
...baseLogPayload,
status_code: res.statusCode,
duration_ms: Number(
(Number(process.hrtime.bigint() - startedAt) / 1e6).toFixed(2)
),
request_complete: req.complete,
outcome: res.statusCode < 400 ? 'success' : 'error',
failure_stage: failureStage,
upload: req.uploadDiagnostics || {
parser_status: 'not_reached'
}
});
});
res.once('close', () => {
if (responseFinished) {
return;
}
appendPostLog({
event: 'upload_request_interrupted',
...baseLogPayload,
status_code: res.statusCode,
duration_ms: Number(
(Number(process.hrtime.bigint() - startedAt) / 1e6).toFixed(2)
),
request_complete: req.complete,
outcome: 'interrupted',
failure_stage: 'transport_or_client_disconnect',
upload: req.uploadDiagnostics || {
parser_status: 'not_reached'
}
});
});
return next(); return next();
} }
@@ -107,6 +178,7 @@ app.use('/', authRoutes);
app.use('/', profileRoutes); app.use('/', profileRoutes);
app.use('/', require('./src/routes/locationRoutes')); app.use('/', require('./src/routes/locationRoutes'));
app.use('/api', require('./src/routes/stressRoutes')); // Stress Test Endpoint app.use('/api', require('./src/routes/stressRoutes')); // Stress Test Endpoint
app.use('/api', availabilityRoutes);
app.use('/api', tripsRoutes); app.use('/api', tripsRoutes);
app.use('/api', driverLicenseRoutes); app.use('/api', driverLicenseRoutes);
+102
View File
@@ -0,0 +1,102 @@
const db = require('../config/db');
const getCoordinatesFromBody = (body) => {
const lat = body?.latitud ?? body?.latitude;
const lng = body?.longitud ?? body?.longitude;
if (lat === undefined || lat === null || lng === undefined || lng === null) {
return null;
}
return { lat, lng };
};
const upsertOnlineAvailability = async (dni, lat, lng) => {
const [rows] = await db.query(
`SELECT id_usuario
FROM c_trazabilidad_online
WHERE id_usuario = ?
LIMIT 1`,
[dni]
);
if (rows.length > 0) {
await db.query(
`UPDATE c_trazabilidad_online
SET latitud = ?, longitud = ?, fecha = NOW()
WHERE id_usuario = ?`,
[String(lat), String(lng), dni]
);
return;
}
await db.query(
`INSERT INTO c_trazabilidad_online
(latitud, longitud, id_usuario, fecha)
VALUES (?, ?, ?, NOW())`,
[String(lat), String(lng), dni]
);
};
const getAvailability = async (req, res) => {
try {
const dni = String(req.user.dni);
const [rows] = await db.query(
`SELECT COUNT(*) AS total
FROM c_trazabilidad_online
WHERE id_usuario = ?`,
[dni]
);
return res.json({
success: true,
available: Number(rows[0]?.total || 0) > 0
});
} catch (error) {
console.error('Error getting availability:', error);
return res.status(500).json({ success: false, error: error.message });
}
};
const setAvailability = async (req, res) => {
try {
const coords = getCoordinatesFromBody(req.body);
if (!coords) {
return res.status(400).json({
success: false,
error: 'missing_coords',
message: 'latitud/longitud or latitude/longitude are required'
});
}
await upsertOnlineAvailability(String(req.user.dni), coords.lat, coords.lng);
return res.json({ success: true, available: true });
} catch (error) {
console.error('Error setting availability:', error);
return res.status(500).json({ success: false, error: error.message });
}
};
const deleteAvailability = async (req, res) => {
try {
await db.query(
`DELETE FROM c_trazabilidad_online
WHERE id_usuario = ?`,
[String(req.user.dni)]
);
return res.json({ success: true, available: false });
} catch (error) {
console.error('Error deleting availability:', error);
return res.status(500).json({ success: false, error: error.message });
}
};
module.exports = {
deleteAvailability,
getAvailability,
setAvailability,
upsertOnlineAvailability
};
+36 -1
View File
@@ -1,5 +1,6 @@
const db = require('../config/db'); const db = require('../config/db');
const agheeraPushClient = require('../services/agheeraPushClient'); const agheeraPushClient = require('../services/agheeraPushClient');
const { upsertOnlineAvailability } = require('./availabilityController');
const AGHEERA_CLIENT_ID = 532; const AGHEERA_CLIENT_ID = 532;
@@ -73,6 +74,22 @@ const getTripIdFromLocation = (locationData) => {
return null; return null;
}; };
const isAvailabilityModeEnabled = (value) =>
value === true || value === 'true' || value === 1 || value === '1';
const hasAvailabilityModeEnabled = (data, loc) => {
const candidates = [
data?.availability_mode,
data?.params?.availability_mode,
data?.extras?.availability_mode,
loc?.availability_mode,
loc?.params?.availability_mode,
loc?.extras?.availability_mode
];
return candidates.some(isAvailabilityModeEnabled);
};
const getRawTimestampFromLocation = (locationData) => { const getRawTimestampFromLocation = (locationData) => {
const candidates = [ const candidates = [
locationData?.timestamp, locationData?.timestamp,
@@ -167,7 +184,12 @@ const pushLocationToAgheera = async ({ latitude, longitude, dni, tripId, measure
longitude, longitude,
vehicleId: licensePlate, vehicleId: licensePlate,
licensePlate, licensePlate,
measurementTime measurementTime,
metadata: {
source: 'location',
trip_id: tripId,
dni
}
}); });
return { return {
@@ -236,6 +258,7 @@ const saveLocation = async (req, res) => {
const rowsToInsert = []; const rowsToInsert = [];
const locationsToPush = []; const locationsToPush = [];
const onlineAvailabilityUpdates = [];
for (const loc of locations) { for (const loc of locations) {
const coords = getCoordinatesFromLocation(loc); const coords = getCoordinatesFromLocation(loc);
@@ -269,6 +292,13 @@ const saveLocation = async (req, res) => {
tripId, tripId,
measurementTime: persistedTimestamp.value measurementTime: persistedTimestamp.value
}); });
if (dni && hasAvailabilityModeEnabled(data, loc)) {
onlineAvailabilityUpdates.push({
dni,
lat: coords.lat,
lng: coords.lng
});
}
} }
} }
@@ -288,6 +318,11 @@ const saveLocation = async (req, res) => {
); );
const agheeraResults = await pushLocationsToAgheera(locationsToPush); const agheeraResults = await pushLocationsToAgheera(locationsToPush);
for (const update of onlineAvailabilityUpdates) {
await upsertOnlineAvailability(update.dni, update.lat, update.lng);
}
const responseBody = { const responseBody = {
success: true, success: true,
count: rowsToInsert.length, count: rowsToInsert.length,
+162 -22
View File
@@ -19,6 +19,9 @@ const LEGACY_STATUS_PHOTO_FIELD_MAX_LENGTH = 100;
const LEGACY_INTERMEDIATE_POINT_VALUE_SEPARATOR = ':|:'; const LEGACY_INTERMEDIATE_POINT_VALUE_SEPARATOR = ':|:';
const LEGACY_INTERMEDIATE_POINT_REFERENCE_REGEX = /^[0-9]+$/; const LEGACY_INTERMEDIATE_POINT_REFERENCE_REGEX = /^[0-9]+$/;
const MOBILE_TRIPS_ALLOWED_STATES = [7, 8, 9, 1]; 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 CLEAR_STATUS_FALLBACK_STATE = 1;
const INTERMEDIATE_POINT_ALLOWED_STATES = [3, 4, 5]; const INTERMEDIATE_POINT_ALLOWED_STATES = [3, 4, 5];
const INTERMEDIATE_POINT_ALLOWED_STATES_SET = new Set(INTERMEDIATE_POINT_ALLOWED_STATES); 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 AGHEERA_CLIENT_ID = 532;
const INTERMEDIATE_POINT_STATUS_IDS = new Set([3, 4, 5]); 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 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([ const GLOBAL_STATUS_KEYS_BY_STATE_ID = new Map([
[1, 'assigned'], [1, 'assigned'],
[2, 'en_camino'], [2, 'en_camino'],
@@ -48,6 +52,59 @@ const appendTripStatusDebugLog = (payload) => {
console.info('[TripStatusDebug]', 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 = () => const getTripStatusUpdatesLogPath = () =>
process.env.TRIP_STATUS_UPDATES_LOG_PATH || process.env.TRIP_STATUS_UPDATES_LOG_PATH ||
'/var/log/status.log'; '/var/log/status.log';
@@ -284,7 +341,13 @@ const pushTripStatusPositionToAgheera = async ({
longitude: longitud, longitude: longitud,
vehicleId: licensePlate, vehicleId: licensePlate,
licensePlate, licensePlate,
measurementTime measurementTime,
metadata: {
source: 'trip_status',
request_id: requestId,
flow,
trip_id: tripId
}
}); });
appendTripStatusDebugLog({ appendTripStatusDebugLog({
@@ -3581,6 +3644,8 @@ const getActiveTrip = async (req, res) => {
}; };
const getTrips = async (req, res) => { const getTrips = async (req, res) => {
const startedAt = Date.now();
try { try {
const dni = req.user?.dni; const dni = req.user?.dni;
@@ -3588,6 +3653,79 @@ const getTrips = async (req, res) => {
return res.status(401).json({ error: 'Unauthorized' }); 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( const [rows] = await db.query(
`SELECT `SELECT
p.id_viaje AS id_viaje, p.id_viaje AS id_viaje,
@@ -3682,30 +3820,32 @@ const getTrips = async (req, res) => {
END AS fecha_llegada, END AS fecha_llegada,
NULLIF(TRIM(v.observaciones_mercancia), '') AS observaciones_mercancia, NULLIF(TRIM(v.observaciones_mercancia), '') AS observaciones_mercancia,
NULLIF(TRIM(v.observaciones_cliente), '') AS observaciones_cliente NULLIF(TRIM(v.observaciones_cliente), '') AS observaciones_cliente
FROM c_viajes_proveedor p ${fromAndWhereSql}
INNER JOIN c_viajes v ORDER BY COALESCE(p.fecha_salida, v.fecha_salida) DESC, p.id_viaje DESC
ON v.id_viaje = p.id_viaje LIMIT ? OFFSET ?`,
INNER JOIN m_proveedores_trasportistas t [...queryParams, limit, offset]
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]
]
); );
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({ return res.status(200).json({
trips: rows trips: rows,
page,
limit,
total,
has_more: offset + rows.length < total
}); });
} catch (error) { } catch (error) {
console.error('Error getting trips list:', { console.error('Error getting trips list:', {
+22
View File
@@ -1,5 +1,10 @@
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const {
beginUploadParsing,
markUploadParsed,
markUploadRejected
} = require('../utils/uploadDiagnostics');
const MAX_DRIVER_LICENSE_SIZE_BYTES = 5 * 1024 * 1024; const MAX_DRIVER_LICENSE_SIZE_BYTES = 5 * 1024 * 1024;
const FRONT_FILE_FIELD = 'carnet_conducir_frontal'; const FRONT_FILE_FIELD = 'carnet_conducir_frontal';
@@ -40,14 +45,31 @@ const internalUpload = multer({
}); });
const uploadDriverLicense = (req, res, next) => { const uploadDriverLicense = (req, res, next) => {
const flow = 'driver_license';
beginUploadParsing(req, flow);
internalUpload.fields([ internalUpload.fields([
{ name: FRONT_FILE_FIELD, maxCount: 1 }, { name: FRONT_FILE_FIELD, maxCount: 1 },
{ name: BACK_FILE_FIELD, maxCount: 1 } { name: BACK_FILE_FIELD, maxCount: 1 }
])(req, res, (error) => { ])(req, res, (error) => {
if (!error) { if (!error) {
const uploadedFiles = [
...(Array.isArray(req.files?.[FRONT_FILE_FIELD])
? req.files[FRONT_FILE_FIELD]
: []),
...(Array.isArray(req.files?.[BACK_FILE_FIELD])
? req.files[BACK_FILE_FIELD]
: [])
];
markUploadParsed(req, {
flow,
files: uploadedFiles
});
return next(); return next();
} }
markUploadRejected(req, { flow, error });
if (error instanceof multer.MulterError) { if (error instanceof multer.MulterError) {
if (error.code === 'LIMIT_FILE_SIZE') { if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ return res.status(400).json({
+14
View File
@@ -2,6 +2,11 @@ const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const {
beginUploadParsing,
markUploadParsed,
markUploadRejected
} = require('../utils/uploadDiagnostics');
const MAX_PROFILE_PHOTO_SIZE_BYTES = 5 * 1024 * 1024; const MAX_PROFILE_PHOTO_SIZE_BYTES = 5 * 1024 * 1024;
const PROFILE_UPLOADS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'profile'); const PROFILE_UPLOADS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'profile');
@@ -57,11 +62,20 @@ const internalUpload = multer({
}); });
const uploadProfilePhoto = (req, res, next) => { const uploadProfilePhoto = (req, res, next) => {
const flow = 'profile_photo';
beginUploadParsing(req, flow);
internalUpload.single('foto_perfil')(req, res, (error) => { internalUpload.single('foto_perfil')(req, res, (error) => {
if (!error) { if (!error) {
markUploadParsed(req, {
flow,
files: req.file ? [req.file] : []
});
return next(); return next();
} }
markUploadRejected(req, { flow, error });
if (error instanceof multer.MulterError) { if (error instanceof multer.MulterError) {
if (error.code === 'LIMIT_FILE_SIZE') { if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Archivo demasiado grande. Maximo 5MB.' }); return res.status(400).json({ error: 'Archivo demasiado grande. Maximo 5MB.' });
+26 -15
View File
@@ -4,11 +4,15 @@ const multer = require('multer');
const path = require('path'); const path = require('path');
const { const {
getTripStatusUploadsDir, getTripStatusUploadsDir,
getTripStatusFallbackUploadsDir,
replicateUploadedFilesToRemote, replicateUploadedFilesToRemote,
removeUploadedTripStatusFiles removeUploadedTripStatusFiles
} = require('../services/tripStatusPhotoStorage'); } = require('../services/tripStatusPhotoStorage');
const { appendPostLog } = require('../utils/postLog'); const { appendPostLog } = require('../utils/postLog');
const {
beginUploadParsing,
markUploadParsed,
markUploadRejected
} = require('../utils/uploadDiagnostics');
const MAX_TRIP_STATUS_PHOTO_SIZE_BYTES = 15 * 1024 * 1024; const MAX_TRIP_STATUS_PHOTO_SIZE_BYTES = 15 * 1024 * 1024;
const MAX_TRIP_STATUS_FILES = 5; const MAX_TRIP_STATUS_FILES = 5;
@@ -28,21 +32,9 @@ const getTripDirectorySegment = (req) => {
const getTripStatusUploadsTripDir = (req) => const getTripStatusUploadsTripDir = (req) =>
path.join(getTripStatusUploadsDir(), getTripDirectorySegment(req)); path.join(getTripStatusUploadsDir(), getTripDirectorySegment(req));
const getTripStatusFallbackUploadsTripDir = (req) =>
path.join(getTripStatusFallbackUploadsDir(), getTripDirectorySegment(req));
const ensureTripStatusUploadsDir = (req) => { const ensureTripStatusUploadsDir = (req) => {
const primaryTripDir = getTripStatusUploadsTripDir(req); fs.mkdirSync(getTripStatusUploadsTripDir(req), { recursive: true });
try {
fs.mkdirSync(primaryTripDir, { recursive: true });
return primaryTripDir;
} catch (primaryError) {
const fallbackTripDir = getTripStatusFallbackUploadsTripDir(req);
fs.mkdirSync(fallbackTripDir, { recursive: true });
return fallbackTripDir;
}
}; };
const getExtensionFromMimeType = (mimeType) => { const getExtensionFromMimeType = (mimeType) => {
@@ -68,7 +60,8 @@ const getExtensionFromMimeType = (mimeType) => {
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
try { try {
cb(null, ensureTripStatusUploadsDir(req)); ensureTripStatusUploadsDir(req);
cb(null, getTripStatusUploadsTripDir(req));
} catch (error) { } catch (error) {
cb(error); cb(error);
} }
@@ -96,6 +89,12 @@ const internalUpload = multer({
}); });
const uploadTripStatusPhotos = (req, res, next) => { const uploadTripStatusPhotos = (req, res, next) => {
const flow = 'trip_status_photos';
const storageMode = String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local')
.trim()
.toLowerCase();
beginUploadParsing(req, flow);
internalUpload.fields([ internalUpload.fields([
{ name: 'fotos', maxCount: MAX_TRIP_STATUS_FILES }, { name: 'fotos', maxCount: MAX_TRIP_STATUS_FILES },
{ name: 'fotos[]', maxCount: MAX_TRIP_STATUS_FILES } { name: 'fotos[]', maxCount: MAX_TRIP_STATUS_FILES }
@@ -104,6 +103,11 @@ const uploadTripStatusPhotos = (req, res, next) => {
const uploadedFiles = collectUploadedTripStatusFiles(req); const uploadedFiles = collectUploadedTripStatusFiles(req);
const authorizationHeader = req.get('authorization'); const authorizationHeader = req.get('authorization');
markUploadParsed(req, {
flow,
files: uploadedFiles,
storageMode
});
appendPostLog({ appendPostLog({
request_id: req.requestId || null, request_id: req.requestId || null,
method: req.method, method: req.method,
@@ -127,9 +131,16 @@ const uploadTripStatusPhotos = (req, res, next) => {
tripId: req.params?.id, tripId: req.params?.id,
files: uploadedFiles files: uploadedFiles
}); });
markUploadParsed(req, {
flow,
files: uploadedFiles,
storageMode
});
return next(); return next();
} }
markUploadRejected(req, { flow, error });
if (error instanceof multer.MulterError) { if (error instanceof multer.MulterError) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
+11
View File
@@ -0,0 +1,11 @@
const express = require('express');
const availabilityController = require('../controllers/availabilityController');
const authenticateDevice = require('../middleware/auth');
const router = express.Router();
router.get('/availability', authenticateDevice, availabilityController.getAvailability);
router.post('/availability', authenticateDevice, availabilityController.setAvailability);
router.delete('/availability', authenticateDevice, availabilityController.deleteAvailability);
module.exports = router;
+95 -20
View File
@@ -1,4 +1,8 @@
const DEFAULT_AGHEERA_PUSH_URL = 'https://push-test.agheera.com/Telematics/Positions'; const fs = require('fs');
const path = require('path');
const DEFAULT_AGHEERA_PUSH_URL = 'https://push-dhl.agheera.com/Telematics/positions';
const DEFAULT_AGHEERA_PUSH_LOG_PATH = '/var/log/agheera_push.log';
let httpClientOverride = null; let httpClientOverride = null;
@@ -8,6 +12,41 @@ const getPushUrl = () =>
const getApiKey = () => const getApiKey = () =>
String(process.env.AGHEERA_API_KEY || '').trim(); String(process.env.AGHEERA_API_KEY || '').trim();
const getPushLogPath = () =>
String(process.env.AGHEERA_PUSH_LOG_PATH || DEFAULT_AGHEERA_PUSH_LOG_PATH).trim();
const appendPushLog = async ({ metadata, url, payload, success, status, responseBody, error }) => {
if (process.env.AGHEERA_PUSH_LOGS === '0') {
return;
}
const firstVehicle = Array.isArray(payload?.Vehicles) ? payload.Vehicles[0] : null;
const entry = {
timestamp: new Date().toISOString(),
...(metadata || {}),
url,
vehicleId: firstVehicle?.vehicleId ?? null,
licensePlate: firstVehicle?.licensePlate ?? null,
latitude: firstVehicle?.latitude ?? null,
longitude: firstVehicle?.longitude ?? null,
measurementTime: firstVehicle?.measurementTime ?? null,
payload,
success,
http_status: status ?? null,
response_body: responseBody || '',
error: error || null
};
try {
const logPath = getPushLogPath();
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
await fs.promises.appendFile(logPath, `${JSON.stringify(entry)}
`);
} catch (logError) {
console.error('Failed to append Agheera push log:', { message: logError.message });
}
};
const formatMeasurementTime = (dateValue) => { const formatMeasurementTime = (dateValue) => {
const date = dateValue instanceof Date ? dateValue : new Date(dateValue); const date = dateValue instanceof Date ? dateValue : new Date(dateValue);
return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
@@ -53,18 +92,9 @@ const pushPosition = async ({
longitude, longitude,
vehicleId, vehicleId,
licensePlate, licensePlate,
measurementTime measurementTime,
metadata
}) => { }) => {
const httpClient = getHttpClient();
if (!httpClient) {
throw new Error('Agheera HTTP client unavailable');
}
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Agheera API key missing');
}
const url = getPushUrl(); const url = getPushUrl();
const payload = buildPositionPayload({ const payload = buildPositionPayload({
latitude, latitude,
@@ -74,23 +104,67 @@ const pushPosition = async ({
measurementTime measurementTime
}); });
const response = await httpClient(url, { const httpClient = getHttpClient();
method: 'POST', if (!httpClient) {
headers: { const message = 'Agheera HTTP client unavailable';
apiKey, await appendPushLog({ metadata, url, payload, success: false, error: message });
'Content-Type': 'application/json' throw new Error(message);
}, }
body: JSON.stringify(payload)
}); const apiKey = getApiKey();
if (!apiKey) {
const message = 'Agheera API key missing';
await appendPushLog({ metadata, url, payload, success: false, error: message });
throw new Error(message);
}
let response;
try {
response = await httpClient(url, {
method: 'POST',
headers: {
apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
} catch (error) {
await appendPushLog({
metadata,
url,
payload,
success: false,
error: error.message
});
throw error;
}
const responseBody = typeof response?.text === 'function' ? await response.text() : ''; const responseBody = typeof response?.text === 'function' ? await response.text() : '';
if (!response?.ok) { if (!response?.ok) {
const error = new Error('Agheera push failed'); const error = new Error('Agheera push failed');
error.status = response?.status || null; error.status = response?.status || null;
error.body = responseBody; error.body = responseBody;
await appendPushLog({
metadata,
url,
payload,
success: false,
status: error.status,
responseBody,
error: error.message
});
throw error; throw error;
} }
await appendPushLog({
metadata,
url,
payload,
success: true,
status: response.status,
responseBody
});
return { return {
status: response.status, status: response.status,
body: responseBody body: responseBody
@@ -107,6 +181,7 @@ const __resetHttpClientForTests = () => {
module.exports = { module.exports = {
DEFAULT_AGHEERA_PUSH_URL, DEFAULT_AGHEERA_PUSH_URL,
DEFAULT_AGHEERA_PUSH_LOG_PATH,
pushPosition, pushPosition,
__setHttpClientForTests, __setHttpClientForTests,
__resetHttpClientForTests __resetHttpClientForTests
+19 -10
View File
@@ -6,11 +6,6 @@ const DEFAULT_SFTP_PORT = 22;
let sftpClientFactoryOverride = null; let sftpClientFactoryOverride = null;
const PRIMARY_TRIP_STATUS_UPLOAD_DIR =
'/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native/trips/status';
const FALLBACK_TRIP_STATUS_UPLOAD_DIR =
path.resolve(__dirname, '..', '..', 'uploads', 'trips', 'status');
const resolveUploadDirCandidate = (uploadDir) => const resolveUploadDirCandidate = (uploadDir) =>
path.isAbsolute(uploadDir) path.isAbsolute(uploadDir)
? uploadDir ? uploadDir
@@ -42,10 +37,19 @@ const selectUploadDirCandidate = (uploadDirs) =>
.sort((left, right) => left.score - right.score || left.index - right.index)[0]?.path; .sort((left, right) => left.score - right.score || left.index - right.index)[0]?.path;
const getTripStatusUploadsDir = () => { const getTripStatusUploadsDir = () => {
return PRIMARY_TRIP_STATUS_UPLOAD_DIR; const configuredUploadDir = String(process.env.TRIP_STATUS_UPLOAD_DIR || '').trim();
};
const getTripStatusFallbackUploadsDir = () => FALLBACK_TRIP_STATUS_UPLOAD_DIR; if (configuredUploadDir) {
const configuredUploadDirs = configuredUploadDir
.split(';')
.map((uploadDir) => uploadDir.trim())
.filter(Boolean);
return selectUploadDirCandidate(configuredUploadDirs);
}
return path.resolve(__dirname, '..', '..', 'uploads', 'trips', 'status');
};
const getTripStatusPhotoStorageMode = () => const getTripStatusPhotoStorageMode = () =>
String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local') String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local')
@@ -205,7 +209,7 @@ const replicateUploadedFilesToRemote = async ({ tripId, files }) => {
file.tripStatusTripId = tripDirectorySegment; file.tripStatusTripId = tripDirectorySegment;
} }
await withSftpClient( const remoteOperationSucceeded = await withSftpClient(
async (client, sftpConfig) => { async (client, sftpConfig) => {
await ensureRemoteTripDirectory(client, { await ensureRemoteTripDirectory(client, {
remoteBaseDir: sftpConfig.remoteBaseDir, remoteBaseDir: sftpConfig.remoteBaseDir,
@@ -238,6 +242,12 @@ const replicateUploadedFilesToRemote = async ({ tripId, files }) => {
logContext: 'replicate_upload' logContext: 'replicate_upload'
} }
); );
if (shouldUseRemoteStorage() && !remoteOperationSucceeded) {
for (const file of normalizedFiles) {
file.tripStatusRemoteUploaded = false;
}
}
}; };
const removeRemoteFiles = async (remoteFilePaths, { logContext }) => { const removeRemoteFiles = async (remoteFilePaths, { logContext }) => {
@@ -389,7 +399,6 @@ const __resetSftpClientFactoryForTests = () => {
module.exports = { module.exports = {
getTripStatusUploadsDir, getTripStatusUploadsDir,
getTripStatusFallbackUploadsDir,
replicateUploadedFilesToRemote, replicateUploadedFilesToRemote,
removeUploadedTripStatusFiles, removeUploadedTripStatusFiles,
removeStatusPhotosByName, removeStatusPhotosByName,
+68
View File
@@ -0,0 +1,68 @@
const path = require('path');
const getBodyFieldNames = (req) =>
Object.keys(req.body || {}).sort();
const getFileMetadata = (file) => {
const originalExtension = path.extname(String(file?.originalname || '')).toLowerCase();
let remoteUploadStatus = null;
if (file?.tripStatusRemoteUploaded === true) {
remoteUploadStatus = 'success';
} else if (file?.tripStatusRemoteUploaded === false) {
remoteUploadStatus = 'failed';
}
return {
field_name: file?.fieldname || null,
original_extension: originalExtension || null,
mimetype: file?.mimetype || null,
size_bytes: Number.isFinite(file?.size) ? file.size : null,
stored_filename: file?.filename || null,
local_file_created: Boolean(file?.path),
remote_upload_status: remoteUploadStatus
};
};
const beginUploadParsing = (req, flow) => {
req.uploadDiagnostics = {
flow,
parser: 'multer',
parser_status: 'started'
};
};
const markUploadParsed = (req, { flow, files, storageMode = null }) => {
const normalizedFiles = Array.isArray(files) ? files : [];
req.uploadDiagnostics = {
flow,
parser: 'multer',
parser_status: 'parsed',
body_fields: getBodyFieldNames(req),
file_count: normalizedFiles.length,
files: normalizedFiles.map(getFileMetadata),
storage_mode: storageMode
};
};
const markUploadRejected = (req, { flow, error }) => {
req.uploadDiagnostics = {
flow,
parser: 'multer',
parser_status: 'rejected',
body_fields: getBodyFieldNames(req),
error: {
type: error?.constructor?.name || 'Error',
code: error?.code || error?.message || null,
field_name: error?.field || null,
message: String(error?.message || 'Unknown upload error').slice(0, 500)
}
};
};
module.exports = {
beginUploadParsing,
markUploadParsed,
markUploadRejected
};
+72
View File
@@ -0,0 +1,72 @@
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const test = require('node:test');
const agheeraPushClient = require('../src/services/agheeraPushClient');
let originalApiKey;
let originalPushLogPath;
let originalPushLogs;
test.before(() => {
originalApiKey = process.env.AGHEERA_API_KEY;
originalPushLogPath = process.env.AGHEERA_PUSH_LOG_PATH;
originalPushLogs = process.env.AGHEERA_PUSH_LOGS;
});
test.after(() => {
process.env.AGHEERA_API_KEY = originalApiKey;
process.env.AGHEERA_PUSH_LOG_PATH = originalPushLogPath;
process.env.AGHEERA_PUSH_LOGS = originalPushLogs;
agheeraPushClient.__resetHttpClientForTests();
});
test.afterEach(() => {
agheeraPushClient.__resetHttpClientForTests();
});
test('pushPosition escribe log dedicado sin apiKey', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agheera-log-'));
const logPath = path.join(tempDir, 'agheera_push.log');
process.env.AGHEERA_API_KEY = 'secret-api-key';
process.env.AGHEERA_PUSH_LOG_PATH = logPath;
delete process.env.AGHEERA_PUSH_LOGS;
agheeraPushClient.__setHttpClientForTests(async () => ({
ok: true,
status: 200,
text: async () => 'Messages received.'
}));
await agheeraPushClient.pushPosition({
latitude: '40.416775',
longitude: '-3.703790',
vehicleId: '6599LCN',
licensePlate: '6599LCN',
measurementTime: '2026-06-01T13:38:31Z',
metadata: {
source: 'test',
trip_id: 306075
}
});
const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n');
assert.equal(lines.length, 1);
const entry = JSON.parse(lines[0]);
assert.equal(entry.source, 'test');
assert.equal(entry.trip_id, 306075);
assert.equal(entry.vehicleId, '6599LCN');
assert.equal(entry.licensePlate, '6599LCN');
assert.equal(entry.latitude, 40.416775);
assert.equal(entry.longitude, -3.70379);
assert.equal(entry.measurementTime, '2026-06-01T13:38:31Z');
assert.equal(entry.success, true);
assert.equal(entry.http_status, 200);
assert.equal(entry.response_body, 'Messages received.');
assert.equal(entry.error, null);
assert.equal(JSON.stringify(entry).includes('secret-api-key'), false);
});
+360
View File
@@ -0,0 +1,360 @@
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 originalJwtSecret;
const createToken = (payload = {}) =>
jwt.sign(
{
id: 1,
dni: '58045340X',
id_proveedor: 675,
...payload
},
TEST_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 === undefined ? null : JSON.stringify(body);
const headers = {};
if (authorization) {
headers.authorization = authorization;
}
if (rawBody !== null) {
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(rawBody);
}
const req = http.request(
{
hostname: '127.0.0.1',
port,
method,
path,
headers
},
(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);
if (rawBody !== null) {
req.write(rawBody);
}
req.end();
});
test.before(() => {
originalQuery = db.query;
originalJwtSecret = process.env.JWT_SECRET;
});
test.after(() => {
db.query = originalQuery;
process.env.JWT_SECRET = originalJwtSecret;
});
test.afterEach(() => {
db.query = originalQuery;
});
test('GET /api/availability devuelve available false si no hay fila', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async (sql, params) => {
assert.match(sql, /COUNT\(\*\) AS total/);
assert.match(sql, /FROM c_trazabilidad_online/);
assert.deepEqual(params, ['58045340X']);
return [[{ total: 0 }]];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'GET',
path: '/api/availability',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { success: true, available: false });
});
test('GET /api/availability devuelve available true si hay fila', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async (sql, params) => {
assert.match(sql, /COUNT\(\*\) AS total/);
assert.match(sql, /FROM c_trazabilidad_online/);
assert.deepEqual(params, ['58045340X']);
return [[{ total: 1 }]];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'GET',
path: '/api/availability',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { success: true, available: true });
});
test('POST /api/availability hace INSERT si no existe', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /SELECT id_usuario/);
assert.match(sql, /FROM c_trazabilidad_online/);
assert.deepEqual(params, ['58045340X']);
return [[]];
}
assert.match(sql, /INSERT INTO c_trazabilidad_online/);
assert.deepEqual(params, ['40.416775', '-3.70379', '58045340X']);
return [{ affectedRows: 1 }];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/availability',
authorization: `Bearer ${createToken()}`,
body: {
latitud: 40.416775,
longitud: -3.70379,
usuario: 'OTHER'
}
})
);
assert.equal(step, 2);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { success: true, available: true });
});
test('POST /api/availability hace UPDATE si existe', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /SELECT id_usuario/);
assert.deepEqual(params, ['58045340X']);
return [[{ id_usuario: '58045340X' }]];
}
assert.match(sql, /UPDATE c_trazabilidad_online/);
assert.deepEqual(params, ['40.416775', '-3.70379', '58045340X']);
return [{ affectedRows: 1 }];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/availability',
authorization: `Bearer ${createToken()}`,
body: {
latitude: 40.416775,
longitude: -3.70379
}
})
);
assert.equal(step, 2);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { success: true, available: true });
});
test('DELETE /api/availability borra la fila', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async (sql, params) => {
assert.match(sql, /DELETE FROM c_trazabilidad_online/);
assert.deepEqual(params, ['58045340X']);
return [{ affectedRows: 1 }];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'DELETE',
path: '/api/availability',
authorization: `Bearer ${createToken()}`
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { success: true, available: false });
});
test('POST /api/locations con availability_mode true actualiza disponibilidad online', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /INSERT INTO c_trazabilidad_transportista/);
assert.equal(params[0].length, 1);
assert.deepEqual(params[0][0].slice(0, 3), ['40.416775', '-3.70379', '58045340X']);
return [{ affectedRows: 1 }];
}
if (step === 2) {
assert.match(sql, /SELECT id_usuario/);
assert.match(sql, /FROM c_trazabilidad_online/);
assert.deepEqual(params, ['58045340X']);
return [[{ id_usuario: '58045340X' }]];
}
assert.match(sql, /UPDATE c_trazabilidad_online/);
assert.deepEqual(params, ['40.416775', '-3.70379', '58045340X']);
return [{ affectedRows: 1 }];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/locations',
authorization: `Bearer ${createToken()}`,
body: {
location: [
{
coords: {
latitude: 40.416775,
longitude: -3.70379
},
params: {
availability_mode: 'true'
},
timestamp: '2026-06-01T13:20:00Z'
}
]
}
})
);
assert.equal(step, 3);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
count: 1,
message: 'Locations saved'
});
});
test('POST /api/locations sin availability_mode no toca disponibilidad online', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
let calls = 0;
db.query = async (sql, params) => {
calls += 1;
assert.match(sql, /INSERT INTO c_trazabilidad_transportista/);
assert.deepEqual(params[0][0].slice(0, 3), ['40.416775', '-3.70379', '58045340X']);
return [{ affectedRows: 1 }];
};
const response = await withServer((server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/locations',
authorization: `Bearer ${createToken()}`,
body: {
latitude: 40.416775,
longitude: -3.70379,
timestamp: '2026-06-01T13:20:00Z'
}
})
);
assert.equal(calls, 1);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
count: 1,
message: 'Locations saved'
});
});
test('todas las rutas nuevas requieren JWT valido', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
db.query = async () => {
throw new Error('db.query should not be called without token');
};
const responses = await withServer(async (server) => {
const port = server.address().port;
return Promise.all([
requestJson({ port, method: 'GET', path: '/api/availability' }),
requestJson({ port, method: 'POST', path: '/api/availability', body: { latitude: 1, longitude: 2 } }),
requestJson({ port, method: 'DELETE', path: '/api/availability' })
]);
});
assert.deepEqual(
responses.map((response) => response.statusCode),
[401, 401, 401]
);
});
+1 -1
View File
@@ -165,7 +165,7 @@ test('POST /api/locations envia posicion a Agheera para cliente 532', async () =
assert.equal(agheeraCalls.length, 1); assert.equal(agheeraCalls.length, 1);
const call = agheeraCalls[0]; const call = agheeraCalls[0];
assert.equal(call.url, 'https://push-test.agheera.com/Telematics/Positions'); assert.equal(call.url, 'https://push-dhl.agheera.com/Telematics/positions');
assert.equal(call.options.headers.apiKey, 'test-api-key'); assert.equal(call.options.headers.apiKey, 'test-api-key');
const payload = JSON.parse(call.options.body); const payload = JSON.parse(call.options.body);
+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'); 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 = [ const mockedTrips = [
{ {
id_viaje: 84919, id_viaje: 84919,
@@ -116,26 +116,36 @@ test('GET /api/trips devuelve viajes del transportista autenticado con aliases l
{ {
id_viaje: 84918, id_viaje: 84918,
cod_viaje: 'VIA-2026-0000', cod_viaje: 'VIA-2026-0000',
id_estado: 4, id_estado: 1,
nombrea: 'Madrid, ES', nombrea: 'Madrid, ES',
nombreb: 'Bilbao, ES', nombreb: 'Bilbao, ES',
fecha_salida: '2026-01-21', fecha_salida: '2026-01-21',
fecha_llegada: '2026-01-21' fecha_llegada: '2026-01-21'
} }
]; ];
let callCount = 0;
db.query = async (sql, params) => { db.query = async (sql, params) => {
callCount += 1;
assert.match(sql, /FROM c_viajes_proveedor p/); assert.match(sql, /FROM c_viajes_proveedor p/);
assert.match(sql, /INNER JOIN c_viajes v/); assert.match(sql, /INNER JOIN c_viajes v/);
assert.match(sql, /INNER JOIN m_proveedores_trasportistas t/); 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_viaje/);
assert.match(sql, /AS id_estado/); assert.match(sql, /AS id_estado/);
assert.match(sql, /AS nombrea/); assert.match(sql, /AS nombrea/);
assert.match(sql, /AS nombreb/); assert.match(sql, /AS nombreb/);
assert.match(sql, /AS fecha_salida/); assert.match(sql, /AS fecha_salida/);
assert.match(sql, /AS fecha_llegada/); 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]; return [mockedTrips];
}; };
@@ -149,13 +159,23 @@ test('GET /api/trips devuelve viajes del transportista autenticado con aliases l
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { 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[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 () => { 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) => const response = await withServer(async (server) =>
requestJson({ requestJson({
@@ -167,12 +187,16 @@ test('GET /api/trips devuelve lista vacia cuando no hay viajes', async () => {
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { 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 () => { test('GET /api/trips responde en menos de 1s para 25 viajes mockeados', async () => {
const mockedTrips = Array.from({ length: 500 }, (_, index) => ({ const mockedTrips = Array.from({ length: 25 }, (_, index) => ({
id_viaje: 100000 + index, id_viaje: 100000 + index,
cod_viaje: `VIA-2026-${String(index + 1).padStart(4, '0')}`, cod_viaje: `VIA-2026-${String(index + 1).padStart(4, '0')}`,
id_estado: index % 2 === 0 ? 7 : 4, 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' 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 startedAt = Date.now();
const response = await withServer(async (server) => 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; const elapsedMs = Date.now() - startedAt;
assert.equal(response.statusCode, 200); 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`); 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 () => { test('GET /api/trips devuelve 401 sin token', async () => {
db.query = async () => { db.query = async () => {
throw new Error('db.query should not be called without token'); throw new Error('db.query should not be called without token');
+5 -5
View File
@@ -719,7 +719,7 @@ test('POST /api/trips/:id/status modo dual replica foto a SFTP y mantiene local'
const recorder = createSftpRecorder(); const recorder = createSftpRecorder();
tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder)); tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder));
process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual';
process.env.TRIP_STATUS_SFTP_HOST = 'localhost'; process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51';
process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_PORT = '22';
process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado';
process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password';
@@ -796,7 +796,7 @@ test('POST /api/trips/:id/status modo dual con fallo SFTP mantiene fallback loca
createFakeSftpClientFactory(recorder, { failPut: true }) createFakeSftpClientFactory(recorder, { failPut: true })
); );
process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual';
process.env.TRIP_STATUS_SFTP_HOST = 'localhost'; process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51';
process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_PORT = '22';
process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado';
process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password';
@@ -862,7 +862,7 @@ test('POST /api/trips/:id/status payload inválido tras upload limpia remoto y l
const recorder = createSftpRecorder(); const recorder = createSftpRecorder();
tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder)); tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder));
process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual';
process.env.TRIP_STATUS_SFTP_HOST = 'localhost'; process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51';
process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_PORT = '22';
process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado';
process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password';
@@ -1204,7 +1204,7 @@ test('POST /api/trips/:id/status cliente 532 envia posicion a Agheera en estado
assert.equal(agheeraCalls.length, 1); assert.equal(agheeraCalls.length, 1);
const call = agheeraCalls[0]; const call = agheeraCalls[0];
assert.equal(call.url, 'https://push-test.agheera.com/Telematics/Positions'); assert.equal(call.url, 'https://push-dhl.agheera.com/Telematics/positions');
assert.equal(call.options.method, 'POST'); assert.equal(call.options.method, 'POST');
assert.equal(call.options.headers.apiKey, 'test-api-key'); assert.equal(call.options.headers.apiKey, 'test-api-key');
assert.equal(call.options.headers['Content-Type'], 'application/json'); assert.equal(call.options.headers['Content-Type'], 'application/json');
@@ -2795,7 +2795,7 @@ test('DELETE /api/trips/:id/status en modo dual no borra foto en remoto ni local
const recorder = createSftpRecorder(); const recorder = createSftpRecorder();
tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder)); tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder));
process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'; process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual';
process.env.TRIP_STATUS_SFTP_HOST = 'localhost'; process.env.TRIP_STATUS_SFTP_HOST = '194.164.175.51';
process.env.TRIP_STATUS_SFTP_PORT = '22'; process.env.TRIP_STATUS_SFTP_PORT = '22';
process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado'; process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado';
process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password'; process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password';