Compare commits

...
8 Commits
22 changed files with 2435 additions and 78 deletions
+3 -3
View File
@@ -1,11 +1,11 @@
PORT=3001
DB_HOST=194.164.175.51
DB_HOST=localhost
DB_USER=roganet
DB_PASSWORD=bdIRGLnet2905*/
DB_NAME=abian_app_produccion
JWT_SECRET=9c64f2727d53bfefaaa17a5fda5009ffe93cae904860c659bd18d2d14ad6b467
DB_HOST_P = 194.164.175.51
DB_HOST_P = localhost
DB_USER_P = roganet
DB_PASSWORD_P = bdIRGLnet2905*/
DB_NAME_P = abian_app_produccion
@@ -26,7 +26,7 @@ DRIVER_LICENSE_RETENTION_DAYS=365
#Carga de fotos dual
TRIP_STATUS_PHOTO_STORAGE_MODE=local
TRIP_STATUS_SFTP_HOST=194.164.175.51
TRIP_STATUS_SFTP_HOST=localhost
TRIP_STATUS_SFTP_PORT=22
TRIP_STATUS_SFTP_USERNAME=ssh_fotos_estado
TRIP_STATUS_SFTP_PASSWORD=IZYj%c0FiIlCc@rI%W0Z
+1 -1
View File
@@ -96,7 +96,7 @@ Ejemplo de modo temporal dual:
```bash
TRIP_STATUS_PHOTO_STORAGE_MODE=dual
TRIP_STATUS_SFTP_HOST=194.164.175.51
TRIP_STATUS_SFTP_HOST=localhost
TRIP_STATUS_SFTP_PORT=22
TRIP_STATUS_SFTP_USERNAME=ssh_fotos_estado
TRIP_STATUS_SFTP_PASSWORD=********
+1 -1
View File
@@ -96,7 +96,7 @@ Ejemplo de modo temporal dual:
```bash
TRIP_STATUS_PHOTO_STORAGE_MODE=dual
TRIP_STATUS_SFTP_HOST=194.164.175.51
TRIP_STATUS_SFTP_HOST=localhost
TRIP_STATUS_SFTP_PORT=22
TRIP_STATUS_SFTP_USERNAME=ssh_fotos_estado
TRIP_STATUS_SFTP_PASSWORD=********
+84
View File
@@ -7,12 +7,14 @@ const authRoutes = require('./src/routes/authRoutes');
const profileRoutes = require('./src/routes/profileRoutes');
const tripsRoutes = require('./src/routes/tripsRoutes');
const driverLicenseRoutes = require('./src/routes/driverLicenseRoutes');
const availabilityRoutes = require('./src/routes/availabilityRoutes');
const { appendPostLog } = require('./src/utils/postLog');
dotenv.config({ path: path.resolve(__dirname, '.env'), override: true });
const app = express();
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || "127.0.0.1";
const uploadsDir = path.resolve(__dirname, 'uploads');
app.set('trust proxy', 1);
@@ -53,6 +55,76 @@ app.use((req, res, next) => {
contentType.toLowerCase().startsWith('multipart/form-data');
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();
}
@@ -88,6 +160,17 @@ const limiter = rateLimit({
legacyHeaders: false,
message: "Too many requests from this IP, please try again after 15 minutes"
});
app.get(["/health", "/healthcheck"], (req, res) => {
res.set("Cache-Control", "no-store");
res.status(200).json({
status: "ok",
service: "node-gestion-api",
pid: process.pid,
uptime_seconds: Math.floor(process.uptime()),
timestamp_utc: new Date().toISOString()
});
});
app.use(limiter);
// Routes
@@ -95,6 +178,7 @@ app.use('/', authRoutes);
app.use('/', profileRoutes);
app.use('/', require('./src/routes/locationRoutes'));
app.use('/api', require('./src/routes/stressRoutes')); // Stress Test Endpoint
app.use('/api', availabilityRoutes);
app.use('/api', tripsRoutes);
app.use('/api', driverLicenseRoutes);
Binary file not shown.
+191
View File
@@ -0,0 +1,191 @@
<?php
header('Access-Control-Allow-Origin: *');
include("mysql.php");
$id_viaje = $_POST['id_viaje'];
$n_proveedor = $_POST['n_proveedor'];
$usuario = $_POST['usuario'];
$id_estado = $_POST['id_estado'];
$incidencia = $_POST['incidencia'];
$latitud = $_POST['latitud'];
$longitud = $_POST['longitud'];
$nombre = $_POST['nombre'] . '.jpg';
if ($nombre == "vacio.jpg") {
$nombre = NULL;
}
$consulta = "SELECT count(*) from c_cambios_estado where id_viaje='" . $id_viaje . "' and id_estado='" . $id_estado . "' and foto='" . $nombre . "'";
$rResult2 = mysqli_query($gaSql['link'], $consulta) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link']));
$fila = mysqli_fetch_row($rResult2);
$duplicado = $fila[0];
if ($duplicado > 0) {
echo json_encode("0");
die();
}
$sQuery2 = "UPDATE c_viajes SET id_estado = $id_estado, ind_edi_app = 1 WHERE id_viaje = $id_viaje";
$rResult2 = mysqli_query($gaSql['link'], $sQuery2) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link']));
$sQuery2 = "INSERT INTO c_cambios_estado (id_viaje, n_proveedor, id_transportista, id_estado, incidencia, latitud, longitud, fecha_y_hora, foto) VALUES ('" . $id_viaje . "','" . $n_proveedor . "','" . $usuario . "','" . $id_estado . "','" . $incidencia . "','" . $latitud . "','" . $longitud . "',NOW(),'" . $nombre . "')";
$rResult2 = mysqli_query($gaSql['link'], $sQuery2) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link']));
$consulta = "SELECT id_viaje_padre, id_cliente, cod_viaje from c_viajes where id_viaje='" . $id_viaje . "'";
$rResult2 = mysqli_query($gaSql['link'], $consulta) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link']));
$fila = mysqli_fetch_row($rResult2);
$id_viaje_padre = $fila[0];
$id_cliente = $fila[1];
$cod_viaje = $fila[2];
while ($id_viaje_padre > 0) {
$consulta = "UPDATE `c_viajes` SET id_estado='" . $id_estado . "', ind_edi_app = 1 where id_viaje='" . $id_viaje_padre . "'";
$query_res = mysqli_query($gaSql['link'], $consulta);
$consulta = "INSERT INTO c_cambios_estado (id_viaje, n_proveedor, id_transportista, id_estado, incidencia, latitud, longitud, fecha_y_hora, foto) VALUES ('" . $id_viaje . "','" . $n_proveedor . "','" . $usuario . "','" . $id_estado . "','" . $incidencia . "','" . $latitud . "','" . $longitud . "',NOW(),'" . $nombre . "')";
$query_res = mysqli_query($gaSql['link'], $consulta);
//vover consultar para ver si se sale del bucle
$consulta = "SELECT id_viaje_padre, id_cr from c_viajes where id_viaje='" . $id_viaje_padre . "'";
$rResult2 = mysqli_query($gaSql['link'], $consulta) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link']));
$fila = mysqli_fetch_row($rResult2);
$id_viaje_padre = $fila[0];
}
$resultado = "0";
$consulta = "SELECT html FROM html_correo WHERE id_mensaje = 34";
$rResult2 = mysqli_query($gaSql['link'], $consulta);
$fila = mysqli_fetch_row($rResult2);
$mensaje = $fila[0];
$consulta = "SELECT user_smtp_admin, user_smtp_admin, pass_smtp_admin, host_smtp, puerto_smtp, date_format(NOW(), '%Y-%m-%d_%H_%i_%s') as ahora, email_operaciones FROM m_cr WHERE id_cr = $cr";
$rResult2 = mysqli_query($gaSql['link'], $consulta);
$fila = mysqli_fetch_row($rResult2);
$email_operaciones = $fila[0];
$user_smtp = $fila[1];
$pass_smtp = $fila[2];
$host_smtp = $fila[3];
$puerto_smtp = $fila[4];
$ahora = $fila[5];
$email_operaciones = $fila[6];
$consulta = "SELECT estado FROM t_viaje_estados WHERE id_estado = $id_estado";
$rResult2 = mysqli_query($gaSql['link'], $consulta);
$fila = mysqli_fetch_row($rResult2);
$estado = $fila[0];
if (strlen($user_smtp) > 4 && strlen($pass_smtp) > 4 && strlen($host_smtp) > 4) {
$mail = new PHPMailer();
$mail->IsSMTP();
$mail->From = $user_smtp;
$mail->FromName = "Abian Service";
$html = '<br>Se ha cambiado el estado del viaje ' . $cod_viaje . ' a ' . $estado . ' mediante la APP';
$mail->AddAddress($email_operaciones);
$mail->SMTPAuth = true;
$mail->SMTPSecure = 'tls';
$mail->Host = $host_smtp;
$mail->Port = $puerto_smtp;
$mail->Username = $user_smtp;
$mail->Password = $pass_smtp;
$mail->CharSet = 'UTF-8';
$mail->Subject = "Facturas pendientes Abian Service";
$mail->Body = $mensaje . "<br>" . $html;
$mail->IsHTML(true);
$mail->SMTPDebug = 0;
if ($mail->Send()) {
echo json_encode("0");
} else {
echo json_encode("Error al enviar el correo");
}
} else {
echo json_encode("Error al enviar el correo: fallo con usuario/contraseña");
}
if ($id_cliente == 532) {
$consulta = "SELECT id_tipovehiculo AS matricula FROM c_viajes_proveedor WHERE id_viaje = $id_viaje AND n_proveedor = $n_proveedor";
$rResult2 = mysqli_query($gaSql['link'], $consulta) or fatal_error('MySQL Error: ' . mysqli_errno($gaSql['link']));
$fila = mysqli_fetch_row($rResult2);
$matricula = $fila[0];
$xml = '
<S:Envelope
xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
<SOAP-ENV:Header/>
<S:Body>
<ns2:SubmitPosition xmlns="http://schemas.datacontract.org/2004/07/QESE.QFV.Classes.QAP"
xmlns:ns2="QESE.QFV.QAP" xmlns:ns3="http://schemas.datacontract.org/2004/07/System"
xmlns:ns4="http://schemas.microsoft.com/2003/10/Serialization/Arrays"
xmlns:ns5="http://schemas.datacontract.org/2004/07/QESE.QFV.Classes"
xmlns:ns6="http://schemas.datacontract.org/2004/07/FV.Enums"
xmlns:ns7="http://schemas.datacontract.org/2004/07/FV.Enums.DriverHours"
xmlns:ns8="http://schemas.microsoft.com/2003/10/Serialization/">
<ns2:credentials>
<Client>abian</Client>
<Password>TVwuW57u0^</Password>
<UserName>abian-5567</UserName>
<Version>1</Version>
</ns2:credentials>
<ns2:positions>
<Position>
<AssetCategory>1</AssetCategory>
<CID>5567</CID>
<CustomerName/>
<DT>
<ns3:DateTime>?fecha_hora?</ns3:DateTime>
<ns3:OffsetMinutes>0</ns3:OffsetMinutes>
</DT>
<DeviceType>14</DeviceType>
<From>' . $matricula . '</From>
<GatewayMsgId/>
<IdentifierType>12</IdentifierType>
<MSISDN>' . $matricula . '</MSISDN>
<RequestId>1</RequestId>
<Version>1</Version>
<Latitude>?latitud?</Latitude>
<Longitude>?longitud?</Longitude>
</Position>
</ns2:positions>
</ns2:SubmitPosition>
</S:Body>
</S:Envelope>';
$url = 'https://export.fleetvisor.eu/wsQAP/Positions.svc/ssl';
$str = date("Y-m-d");
$strb = date("H:i:s");
$fecha_hora = $str . "T" . $strb . "Z";
$xml = str_replace("?fecha_hora?", $fecha_hora, $xml);
$xml = str_replace("?latitud?", $latitud, $xml);
$xml = str_replace("?longitud?", $longitud, $xml);
//echo $xml;
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
$headers = array(
"Content-Type: text/xml",
"SOAPAction: QESE.QFV.QAP/PositionsService/SubmitPosition"
);
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
$data = $xml;
curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
$resp = curl_exec($curl);
curl_close($curl);
}
echo json_encode($resultado);
?>
+109
View File
@@ -0,0 +1,109 @@
Dear Gorka,
thanks for your reply!
We have a standard API that we provide openly - please see the attached documentation for more details.
The attached document also includes the URL and apikey for our TEST instance.
- endpoint URL for test and production
PROD URL: https://push-dhl.agheera.com/Telematics/positions
apiKey for Abian: 2NL7G-0QTMP-T0N54-7ERGY-S7U6U-SXKWF
TEST URL: https://push-test.agheera.com/Telematics/Positions
apiKey for testing purposes: 0eec610a-aad5-4f60-91c7-7f9a4089d5b5
- authentication method and credentials process
Authentication is done by sending an apikey in a HTTP header "apiKey" with each request.
There is no further handshake (oauth flow or similar). If the apikey is correct and accepted, data will be assigned to the customer (in this case Abian)
Apikey is provided by Agheera on request.
1 Apikey is bound to 1 customer and 1 environment (prod/test).
- required payload format
Please see attached documentation for more details.
We encourage to send data batches whenever possible.
- required vehicle identifier: license plate, device ID, MSISDN, etc.
Device ID and License Plate shall both be provided
- timestamp format/timezone
Must be in UTC timezone
Format: yyyy-MM-ddTHH:mm:ssZ
- required fields for position updates
Minimum required are: latitude, longitude, vehicleid(deviceid), licensePlate, measurementTime
We appreciate the following fields to have better Trip ETA calculation: speed, direction, assetType
- expected response codes
200 - OK
If data was accepted and was syntactically correct
400 - Bad request
If data was syntactically wrong/malformed
401 - Unauthorized
Wrong apikey or apikey header missing
405 - Method not available
If request method was not POST
- retry/error handling recommendations
We are not validating the semantics of position data at the point of response generation, so we return 200 - OK on almost all requests unless the request is badly malformed or unauthorized.
This also means a retry is not very likely needed, unless the Agheera server is down.
For sending position data to us, it is fine to "fire and forget" and not schedule a retry.
In case you notice any errors, please contact ops@agheera.com and we will investigate.
- whether positions should be sent only for active DHL trips/customer id 532 or for all authorized vehicles
Agheera does already filter the data so that DHL can only see data that is relevant for DHL trips.
So as far as we are concerned, you can send for all vehicles, and we will filter it anyway.
But I think this should be decided by ABIAN if they want this.
- any IP allowlist requirements
No IP whitelist in place. We do not need to know your IPs upfront.
Let me know if you have any open questions!
Kind Regards / Freundliche Grüße
Stephan Wahlen
Head of IoT Hardware
Agheera GmbH a DHL Group company
stephan.wahlen@agheera.com
+49 2203 29757-23
Office: August-Horch-Straße 5, 51149 Köln
Warehouse: Kasinostraße 24, 53840 Troisdorf, Deutschland
Registered office Cologne; Register court Bonn; HRB 18111
VAT ID no. DE 273 231 181
Managing Directors: Pierre Lynch, Sven Kefferpütz
-----Ursprüngliche Nachricht-----
Von: Gorka Leceta <gleceta@roganet.es>
Gesendet: Donnerstag, 28. Mai 2026 11:52
An: Operations <ops@agheera.com>; Stephan Wahlen <stephan.wahlen@agheera.com>
Betreff: ABIAN SERVICE - Agheera Push API documentation request for GPS position integration
Hello Stephan / Agheera team,
We are Roganet, GPS provider for ABIAN SERVICE.
ABIAN has asked us to integrate active position pushing from their mobile app backend to Agheera for DHL transports.
Could you please send us the technical documentation for Agheera's push API?
We need:
- endpoint URL for test and production
- authentication method and credentials process
- required payload format
- required vehicle identifier: license plate, device ID, MSISDN, etc.
- timestamp format/timezone
- required fields for position updates
- expected response codes
- retry/error handling recommendations
- whether positions should be sent only for active DHL trips/customer id
532 or for all authorized vehicles
- any IP allowlist requirements
ABIAN registration was submitted on 2024-01-23 and the authorization document is AFTemplateV2-ABIAN SERVICE_2024-01-23T11_55_17Z.PDF.
Kind regards
+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
};
+199 -4
View File
@@ -1,4 +1,8 @@
const db = require('../config/db');
const agheeraPushClient = require('../services/agheeraPushClient');
const { upsertOnlineAvailability } = require('./availabilityController');
const AGHEERA_CLIENT_ID = 532;
const getDniFromLocation = (locationData) => {
if (locationData?.extras?.alias) {
@@ -70,6 +74,158 @@ const getTripIdFromLocation = (locationData) => {
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 candidates = [
locationData?.timestamp,
locationData?.location?.timestamp
];
for (const value of candidates) {
if (value !== undefined && value !== null && String(value).trim() !== '') {
return String(value).trim();
}
}
return null;
};
const getPersistedTimestampFromLocation = (locationData) => {
const rawTimestamp = getRawTimestampFromLocation(locationData);
if (rawTimestamp === null) {
return {
value: new Date(),
usedFallback: true,
reason: 'missing'
};
}
const parsedTimestamp = new Date(rawTimestamp);
if (Number.isNaN(parsedTimestamp.getTime())) {
return {
value: new Date(),
usedFallback: true,
reason: 'invalid',
rawTimestamp
};
}
return {
value: parsedTimestamp,
usedFallback: false,
reason: null,
rawTimestamp
};
};
const pushLocationToAgheera = async ({ latitude, longitude, dni, tripId, measurementTime }) => {
if (!tripId || !dni) {
return null;
}
const [tripRows] = await db.query(
`SELECT id_cliente
FROM c_viajes
WHERE id_viaje = ?
LIMIT 1`,
[tripId]
);
if (Number.parseInt(tripRows[0]?.id_cliente, 10) !== AGHEERA_CLIENT_ID) {
return null;
}
const baseResult = {
trip_id: tripId,
attempted: true,
success: false,
http_status: null,
error: null
};
const [authorizationRows] = await db.query(
`SELECT id_tipovehiculo AS matricula
FROM c_viajes_proveedor
WHERE id_viaje = ?
AND dni = ?
ORDER BY n_proveedor ASC
LIMIT 1`,
[tripId, dni]
);
const licensePlate = String(authorizationRows[0]?.matricula || '').trim();
if (!licensePlate) {
return {
...baseResult,
error: 'LICENSE_PLATE_NOT_FOUND'
};
}
try {
const result = await agheeraPushClient.pushPosition({
latitude,
longitude,
vehicleId: licensePlate,
licensePlate,
measurementTime,
metadata: {
source: 'location',
trip_id: tripId,
dni
}
});
return {
...baseResult,
success: true,
http_status: result?.status ?? null
};
} catch (error) {
console.error('Agheera push failed after location update:', {
tripId,
dni,
message: error.message,
status: error.status || null
});
return {
...baseResult,
http_status: error.status || null,
error: error.message
};
}
};
const pushLocationsToAgheera = async (locationsToPush) => {
const results = [];
for (const location of locationsToPush) {
const result = await pushLocationToAgheera(location);
if (result) {
results.push(result);
}
}
return results;
};
const saveLocation = async (req, res) => {
try {
const data = req.body;
@@ -100,8 +256,9 @@ const saveLocation = async (req, res) => {
globalTripId = getTripIdFromLocation(data);
}
const now = new Date();
const rowsToInsert = [];
const locationsToPush = [];
const onlineAvailabilityUpdates = [];
for (const loc of locations) {
const coords = getCoordinatesFromLocation(loc);
@@ -109,13 +266,39 @@ const saveLocation = async (req, res) => {
const tripId = globalTripId !== null ? globalTripId : getTripIdFromLocation(loc);
if (coords && coords.lat !== undefined && coords.lat !== null && coords.lng !== undefined && coords.lng !== null) {
const persistedTimestamp = getPersistedTimestampFromLocation(loc);
if (persistedTimestamp.usedFallback) {
console.warn('Location timestamp fallback applied:', {
reason: persistedTimestamp.reason,
rawTimestamp: persistedTimestamp.rawTimestamp || null,
uuid: loc?.uuid || loc?.location?.uuid || null,
tripId,
dni: dni || null
});
}
rowsToInsert.push([
String(coords.lat),
String(coords.lng),
dni || null,
now,
persistedTimestamp.value,
tripId
]);
locationsToPush.push({
latitude: coords.lat,
longitude: coords.lng,
dni,
tripId,
measurementTime: persistedTimestamp.value
});
if (dni && hasAvailabilityModeEnabled(data, loc)) {
onlineAvailabilityUpdates.push({
dni,
lat: coords.lat,
lng: coords.lng
});
}
}
}
@@ -134,11 +317,23 @@ const saveLocation = async (req, res) => {
[rowsToInsert]
);
return res.json({
const agheeraResults = await pushLocationsToAgheera(locationsToPush);
for (const update of onlineAvailabilityUpdates) {
await upsertOnlineAvailability(update.dni, update.lat, update.lng);
}
const responseBody = {
success: true,
count: rowsToInsert.length,
message: 'Locations saved'
});
};
if (agheeraResults.length > 0) {
responseBody.agheera_push = agheeraResults.length === 1 ? agheeraResults[0] : agheeraResults;
}
return res.json(responseBody);
} catch (error) {
console.error('Error saving location:', error);
return res.status(500).json({ success: false, error: error.message });
+282 -29
View File
@@ -2,6 +2,7 @@ const db = require('../config/db');
const fs = require('fs');
const path = require('path');
const tripIncidenceMailer = require('../services/tripIncidenceMailer');
const agheeraPushClient = require('../services/agheeraPushClient');
const {
collectUploadedTripStatusFiles,
removeUploadedTripStatusFiles,
@@ -18,13 +19,18 @@ 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);
const SQL_DATETIME_REGEX = /^(\d{4})-(\d{2})-(\d{2}) ([0-2]\d):([0-5]\d):([0-5]\d)$/;
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'],
@@ -46,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';
@@ -238,6 +297,91 @@ const normalizeCoordinateValue = (rawValue) => {
return String(parsedNumericValue);
};
const pushTripStatusPositionToAgheera = async ({
tripRow,
authorizationRow,
latitud,
longitud,
measurementTime,
tripId,
requestId,
flow
}) => {
const clientId = Number.parseInt(tripRow?.id_cliente, 10);
if (clientId !== AGHEERA_CLIENT_ID) {
return null;
}
const baseResult = {
trip_id: tripId,
attempted: false,
success: false,
http_status: null,
error: null
};
if (latitud === null || longitud === null) {
return {
...baseResult,
error: 'COORDINATES_MISSING'
};
}
const licensePlate = String(authorizationRow?.matricula || '').trim();
if (!licensePlate) {
return {
...baseResult,
error: 'LICENSE_PLATE_NOT_FOUND'
};
}
try {
const result = await agheeraPushClient.pushPosition({
latitude: latitud,
longitude: longitud,
vehicleId: licensePlate,
licensePlate,
measurementTime,
metadata: {
source: 'trip_status',
request_id: requestId,
flow,
trip_id: tripId
}
});
appendTripStatusDebugLog({
stage: 'update_trip_status:agheera_push_success',
request_id: requestId,
flow,
trip_id: tripId,
http_status: result?.status ?? null
});
return {
...baseResult,
attempted: true,
success: true,
http_status: result?.status ?? null
};
} catch (error) {
console.error('Agheera push failed after trip status update:', {
tripId,
requestId,
flow,
message: error.message,
status: error.status || null
});
return {
...baseResult,
attempted: true,
http_status: error.status || null,
error: error.message
};
}
};
const normalizeSqlDateTimeValue = (rawValue) => {
if (typeof rawValue !== 'string') {
return null;
@@ -1420,7 +1564,7 @@ const updateTripStatus = async (req, res) => {
await connection.beginTransaction();
const [tripRows] = await connection.query(
`SELECT id_viaje, id_estado, id_viaje_padre
`SELECT id_viaje, id_estado, id_viaje_padre, id_cliente
FROM c_viajes
WHERE id_viaje = ?
LIMIT 1
@@ -1453,7 +1597,7 @@ const updateTripStatus = async (req, res) => {
}
const [authorizationRows] = await connection.query(
`SELECT n_proveedor, id_proveedor
`SELECT n_proveedor, id_proveedor, id_tipovehiculo AS matricula
FROM c_viajes_proveedor
WHERE id_viaje = ?
AND dni = ?
@@ -1716,8 +1860,18 @@ const updateTripStatus = async (req, res) => {
idEstadoLogged: FAILED_TRIP_STATE,
idPuntoLogged: idPunto
});
const agheeraPushResult = await pushTripStatusPositionToAgheera({
tripRow: tripRows[0],
authorizationRow: authorizationRows[0],
latitud,
longitud,
measurementTime: now,
tripId,
requestId,
flow: 'failed_branch_manual'
});
return res.status(200).json({
const responseBody = {
success: true,
trip_id: tripId,
updated_status_id: idEstado,
@@ -1729,7 +1883,13 @@ const updateTripStatus = async (req, res) => {
failed_marked: true,
fotos_concat: fotosConcat || '',
updated_at: now.toISOString()
});
};
if (agheeraPushResult) {
responseBody.agheera_push = agheeraPushResult;
}
return res.status(200).json(responseBody);
} catch (error) {
if (connection) {
try {
@@ -1752,7 +1912,7 @@ const updateTripStatus = async (req, res) => {
}
const [tripRows] = await db.query(
`SELECT id_viaje, id_viaje_padre
`SELECT id_viaje, id_viaje_padre, id_cliente
FROM c_viajes
WHERE id_viaje = ?
LIMIT 1`,
@@ -1783,7 +1943,7 @@ const updateTripStatus = async (req, res) => {
}
const [authorizationRows] = await db.query(
`SELECT n_proveedor, id_proveedor
`SELECT n_proveedor, id_proveedor, id_tipovehiculo AS matricula
FROM c_viajes_proveedor
WHERE id_viaje = ?
AND dni = ?
@@ -1912,8 +2072,18 @@ const updateTripStatus = async (req, res) => {
idEstadoLogged: idEstado,
idPuntoLogged: idPunto
});
const agheeraPushResult = await pushTripStatusPositionToAgheera({
tripRow: tripRows[0],
authorizationRow: authorizationRows[0],
latitud,
longitud,
measurementTime: now,
tripId,
requestId,
flow: 'normal_manual'
});
return res.status(200).json({
const responseBody = {
success: true,
trip_id: tripId,
updated_status_id: idEstado,
@@ -1925,7 +2095,13 @@ const updateTripStatus = async (req, res) => {
failed_marked: false,
fotos_concat: fotosConcat || '',
updated_at: now.toISOString()
});
};
if (agheeraPushResult) {
responseBody.agheera_push = agheeraPushResult;
}
return res.status(200).json(responseBody);
} catch (error) {
const parsedTripId = Number.parseInt(req.params?.id, 10);
const parsedStatusId = Number.parseInt(req.body?.id_estado, 10);
@@ -3468,6 +3644,8 @@ const getActiveTrip = async (req, res) => {
};
const getTrips = async (req, res) => {
const startedAt = Date.now();
try {
const dni = req.user?.dni;
@@ -3475,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,
@@ -3569,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:', {
+22
View File
@@ -1,5 +1,10 @@
const multer = require('multer');
const path = require('path');
const {
beginUploadParsing,
markUploadParsed,
markUploadRejected
} = require('../utils/uploadDiagnostics');
const MAX_DRIVER_LICENSE_SIZE_BYTES = 5 * 1024 * 1024;
const FRONT_FILE_FIELD = 'carnet_conducir_frontal';
@@ -40,14 +45,31 @@ const internalUpload = multer({
});
const uploadDriverLicense = (req, res, next) => {
const flow = 'driver_license';
beginUploadParsing(req, flow);
internalUpload.fields([
{ name: FRONT_FILE_FIELD, maxCount: 1 },
{ name: BACK_FILE_FIELD, maxCount: 1 }
])(req, res, (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();
}
markUploadRejected(req, { flow, error });
if (error instanceof multer.MulterError) {
if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
+14
View File
@@ -2,6 +2,11 @@ const crypto = require('crypto');
const fs = require('fs');
const multer = require('multer');
const path = require('path');
const {
beginUploadParsing,
markUploadParsed,
markUploadRejected
} = require('../utils/uploadDiagnostics');
const MAX_PROFILE_PHOTO_SIZE_BYTES = 5 * 1024 * 1024;
const PROFILE_UPLOADS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'profile');
@@ -57,11 +62,20 @@ const internalUpload = multer({
});
const uploadProfilePhoto = (req, res, next) => {
const flow = 'profile_photo';
beginUploadParsing(req, flow);
internalUpload.single('foto_perfil')(req, res, (error) => {
if (!error) {
markUploadParsed(req, {
flow,
files: req.file ? [req.file] : []
});
return next();
}
markUploadRejected(req, { flow, error });
if (error instanceof multer.MulterError) {
if (error.code === 'LIMIT_FILE_SIZE') {
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 {
getTripStatusUploadsDir,
getTripStatusFallbackUploadsDir,
replicateUploadedFilesToRemote,
removeUploadedTripStatusFiles
} = require('../services/tripStatusPhotoStorage');
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_FILES = 5;
@@ -28,21 +32,9 @@ const getTripDirectorySegment = (req) => {
const getTripStatusUploadsTripDir = (req) =>
path.join(getTripStatusUploadsDir(), getTripDirectorySegment(req));
const getTripStatusFallbackUploadsTripDir = (req) =>
path.join(getTripStatusFallbackUploadsDir(), getTripDirectorySegment(req));
const ensureTripStatusUploadsDir = (req) => {
const primaryTripDir = getTripStatusUploadsTripDir(req);
try {
fs.mkdirSync(primaryTripDir, { recursive: true });
return primaryTripDir;
} catch (primaryError) {
const fallbackTripDir = getTripStatusFallbackUploadsTripDir(req);
fs.mkdirSync(fallbackTripDir, { recursive: true });
return fallbackTripDir;
}
fs.mkdirSync(getTripStatusUploadsTripDir(req), { recursive: true });
};
const getExtensionFromMimeType = (mimeType) => {
@@ -68,7 +60,8 @@ const getExtensionFromMimeType = (mimeType) => {
const storage = multer.diskStorage({
destination: (req, file, cb) => {
try {
cb(null, ensureTripStatusUploadsDir(req));
ensureTripStatusUploadsDir(req);
cb(null, getTripStatusUploadsTripDir(req));
} catch (error) {
cb(error);
}
@@ -96,6 +89,12 @@ const internalUpload = multer({
});
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([
{ 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 authorizationHeader = req.get('authorization');
markUploadParsed(req, {
flow,
files: uploadedFiles,
storageMode
});
appendPostLog({
request_id: req.requestId || null,
method: req.method,
@@ -127,9 +131,16 @@ const uploadTripStatusPhotos = (req, res, next) => {
tripId: req.params?.id,
files: uploadedFiles
});
markUploadParsed(req, {
flow,
files: uploadedFiles,
storageMode
});
return next();
}
markUploadRejected(req, { flow, error });
if (error instanceof multer.MulterError) {
return res.status(400).json({
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;
+188
View File
@@ -0,0 +1,188 @@
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;
const getPushUrl = () =>
String(process.env.AGHEERA_PUSH_URL || DEFAULT_AGHEERA_PUSH_URL).trim();
const getApiKey = () =>
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 date = dateValue instanceof Date ? dateValue : new Date(dateValue);
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
};
const normalizeNumber = (rawValue) => {
const numericValue = Number(rawValue);
return Number.isFinite(numericValue) ? numericValue : null;
};
const getHttpClient = () => {
if (typeof httpClientOverride === 'function') {
return httpClientOverride;
}
if (typeof fetch === 'function') {
return fetch;
}
return null;
};
const buildPositionPayload = ({
latitude,
longitude,
vehicleId,
licensePlate,
measurementTime
}) => ({
Vehicles: [
{
latitude: normalizeNumber(latitude),
longitude: normalizeNumber(longitude),
vehicleId: String(vehicleId || '').trim(),
licensePlate: String(licensePlate || '').trim(),
measurementTime: formatMeasurementTime(measurementTime)
}
]
});
const pushPosition = async ({
latitude,
longitude,
vehicleId,
licensePlate,
measurementTime,
metadata
}) => {
const url = getPushUrl();
const payload = buildPositionPayload({
latitude,
longitude,
vehicleId,
licensePlate,
measurementTime
});
const httpClient = getHttpClient();
if (!httpClient) {
const message = 'Agheera HTTP client unavailable';
await appendPushLog({ metadata, url, payload, success: false, error: message });
throw new Error(message);
}
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() : '';
if (!response?.ok) {
const error = new Error('Agheera push failed');
error.status = response?.status || null;
error.body = responseBody;
await appendPushLog({
metadata,
url,
payload,
success: false,
status: error.status,
responseBody,
error: error.message
});
throw error;
}
await appendPushLog({
metadata,
url,
payload,
success: true,
status: response.status,
responseBody
});
return {
status: response.status,
body: responseBody
};
};
const __setHttpClientForTests = (httpClient) => {
httpClientOverride = httpClient;
};
const __resetHttpClientForTests = () => {
httpClientOverride = null;
};
module.exports = {
DEFAULT_AGHEERA_PUSH_URL,
DEFAULT_AGHEERA_PUSH_LOG_PATH,
pushPosition,
__setHttpClientForTests,
__resetHttpClientForTests
};
+19 -10
View File
@@ -6,11 +6,6 @@ const DEFAULT_SFTP_PORT = 22;
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) =>
path.isAbsolute(uploadDir)
? uploadDir
@@ -42,10 +37,19 @@ const selectUploadDirCandidate = (uploadDirs) =>
.sort((left, right) => left.score - right.score || left.index - right.index)[0]?.path;
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 = () =>
String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local')
@@ -205,7 +209,7 @@ const replicateUploadedFilesToRemote = async ({ tripId, files }) => {
file.tripStatusTripId = tripDirectorySegment;
}
await withSftpClient(
const remoteOperationSucceeded = await withSftpClient(
async (client, sftpConfig) => {
await ensureRemoteTripDirectory(client, {
remoteBaseDir: sftpConfig.remoteBaseDir,
@@ -238,6 +242,12 @@ const replicateUploadedFilesToRemote = async ({ tripId, files }) => {
logContext: 'replicate_upload'
}
);
if (shouldUseRemoteStorage() && !remoteOperationSucceeded) {
for (const file of normalizedFiles) {
file.tripStatusRemoteUploaded = false;
}
}
};
const removeRemoteFiles = async (remoteFilePaths, { logContext }) => {
@@ -389,7 +399,6 @@ const __resetSftpClientFactoryForTests = () => {
module.exports = {
getTripStatusUploadsDir,
getTripStatusFallbackUploadsDir,
replicateUploadedFilesToRemote,
removeUploadedTripStatusFiles,
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]
);
});
+289
View File
@@ -0,0 +1,289 @@
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 agheeraPushClient = require('../src/services/agheeraPushClient');
const TEST_JWT_SECRET = 'test-jwt-secret';
let originalQuery;
let originalJwtSecret;
let originalAgheeraApiKey;
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 postJson = async ({ port, path, authorization, body }) =>
new Promise((resolve, reject) => {
const rawBody = JSON.stringify(body);
const req = http.request(
{
hostname: '127.0.0.1',
port,
method: 'POST',
path,
headers: {
authorization,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(rawBody)
}
},
(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);
req.write(rawBody);
req.end();
});
test.before(() => {
originalQuery = db.query;
originalJwtSecret = process.env.JWT_SECRET;
originalAgheeraApiKey = process.env.AGHEERA_API_KEY;
});
test.after(() => {
db.query = originalQuery;
process.env.JWT_SECRET = originalJwtSecret;
process.env.AGHEERA_API_KEY = originalAgheeraApiKey;
agheeraPushClient.__resetHttpClientForTests();
});
test.afterEach(() => {
db.query = originalQuery;
agheeraPushClient.__resetHttpClientForTests();
});
test('POST /api/locations envia posicion a Agheera para cliente 532', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
process.env.AGHEERA_API_KEY = 'test-api-key';
const agheeraCalls = [];
agheeraPushClient.__setHttpClientForTests(async (url, options) => {
agheeraCalls.push({ url, options });
return { ok: true, status: 200, text: async () => 'Messages received.' };
});
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']);
assert.equal(params[0][0][4], 248230);
return [{ affectedRows: 1 }];
}
if (step === 2) {
assert.match(sql, /FROM c_viajes/);
assert.deepEqual(params, [248230]);
return [[{ id_cliente: 532 }]];
}
assert.match(sql, /FROM c_viajes_proveedor/);
assert.deepEqual(params, [248230, '58045340X']);
return [[{ matricula: '6599LCN' }]];
};
const response = await withServer(async (server) =>
postJson({
port: server.address().port,
path: '/api/locations',
authorization: `Bearer ${createToken()}`,
body: {
latitude: 40.416775,
longitude: -3.70379,
id_viaje: 248230,
timestamp: '2026-06-01T13:20:00Z'
}
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
count: 1,
message: 'Locations saved',
agheera_push: {
trip_id: 248230,
attempted: true,
success: true,
http_status: 200,
error: null
}
});
assert.equal(agheeraCalls.length, 1);
const call = agheeraCalls[0];
assert.equal(call.url, 'https://push-dhl.agheera.com/Telematics/positions');
assert.equal(call.options.headers.apiKey, 'test-api-key');
const payload = JSON.parse(call.options.body);
assert.deepEqual(payload, {
Vehicles: [
{
latitude: 40.416775,
longitude: -3.70379,
vehicleId: '6599LCN',
licensePlate: '6599LCN',
measurementTime: '2026-06-01T13:20:00Z'
}
]
});
});
test('POST /api/locations no envia a Agheera para clientes distintos de 532', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
process.env.AGHEERA_API_KEY = 'test-api-key';
const agheeraCalls = [];
agheeraPushClient.__setHttpClientForTests(async (url, options) => {
agheeraCalls.push({ url, options });
return { ok: true, status: 200, text: async () => 'Messages received.' };
});
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /INSERT INTO c_trazabilidad_transportista/);
return [{ affectedRows: 1 }];
}
assert.match(sql, /FROM c_viajes/);
assert.deepEqual(params, [248230]);
return [[{ id_cliente: 700 }]];
};
const response = await withServer(async (server) =>
postJson({
port: server.address().port,
path: '/api/locations',
authorization: `Bearer ${createToken()}`,
body: {
latitude: 40.416775,
longitude: -3.70379,
id_viaje: 248230,
timestamp: '2026-06-01T13:20:00Z'
}
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
count: 1,
message: 'Locations saved'
});
assert.equal(agheeraCalls.length, 0);
});
test('POST /api/locations devuelve error de Agheera sin romper guardado local', async () => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
process.env.AGHEERA_API_KEY = 'test-api-key';
agheeraPushClient.__setHttpClientForTests(async () => ({
ok: false,
status: 401,
text: async () => 'Unauthorized'
}));
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /INSERT INTO c_trazabilidad_transportista/);
return [{ affectedRows: 1 }];
}
if (step === 2) {
assert.match(sql, /FROM c_viajes/);
assert.deepEqual(params, [248230]);
return [[{ id_cliente: 532 }]];
}
assert.match(sql, /FROM c_viajes_proveedor/);
assert.deepEqual(params, [248230, '58045340X']);
return [[{ matricula: '6599LCN' }]];
};
const response = await withServer(async (server) =>
postJson({
port: server.address().port,
path: '/api/locations',
authorization: `Bearer ${createToken()}`,
body: {
latitude: 40.416775,
longitude: -3.70379,
id_viaje: 248230,
timestamp: '2026-06-01T13:20:00Z'
}
})
);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
success: true,
count: 1,
message: 'Locations saved',
agheera_push: {
trip_id: 248230,
attempted: true,
success: false,
http_status: 401,
error: 'Agheera push failed'
}
});
});
+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');
+272
View File
@@ -12,8 +12,11 @@ process.env.TRIP_STATUS_UPLOAD_DIR = TEST_UPLOAD_DIR;
process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual'
const app = require('../app');
process.env.TRIP_STATUS_UPLOAD_DIR = TEST_UPLOAD_DIR;
process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual';
const db = require('../src/config/db');
const tripStatusPhotoStorage = require('../src/services/tripStatusPhotoStorage');
const agheeraPushClient = require('../src/services/agheeraPushClient');
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.JWT_SECRET = JWT_SECRET;
@@ -233,6 +236,7 @@ test.after(() => {
db.query = originalQuery;
db.getConnection = originalGetConnection;
tripStatusPhotoStorage.__resetSftpClientFactoryForTests();
agheeraPushClient.__resetHttpClientForTests();
process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual';
delete process.env.TRIP_STATUS_SFTP_HOST;
delete process.env.TRIP_STATUS_SFTP_PORT;
@@ -241,6 +245,8 @@ test.after(() => {
delete process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR;
delete process.env.POSTS_LOG_PATH;
delete process.env.TRIP_STATUS_UPDATES_LOG_PATH;
delete process.env.AGHEERA_PUSH_URL;
delete process.env.AGHEERA_API_KEY;
fs.rmSync(TEST_UPLOAD_DIR, { recursive: true, force: true });
fs.rmSync(TEST_POSTS_LOG_PATH, { force: true });
fs.rmSync(TEST_STATUS_LOG_PATH, { force: true });
@@ -250,6 +256,7 @@ test.afterEach(() => {
db.query = originalQuery;
db.getConnection = originalGetConnection;
tripStatusPhotoStorage.__resetSftpClientFactoryForTests();
agheeraPushClient.__resetHttpClientForTests();
process.env.TRIP_STATUS_PHOTO_STORAGE_MODE = 'dual';
delete process.env.TRIP_STATUS_SFTP_HOST;
delete process.env.TRIP_STATUS_SFTP_PORT;
@@ -258,6 +265,8 @@ test.afterEach(() => {
delete process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR;
delete process.env.POSTS_LOG_PATH;
delete process.env.TRIP_STATUS_UPDATES_LOG_PATH;
delete process.env.AGHEERA_PUSH_URL;
delete process.env.AGHEERA_API_KEY;
fs.rmSync(TEST_POSTS_LOG_PATH, { force: true });
fs.rmSync(TEST_STATUS_LOG_PATH, { force: true });
});
@@ -1121,6 +1130,269 @@ test('POST /api/trips/:id/status propaga estado global al viaje padre', async ()
assert.equal(step, 7);
});
test('POST /api/trips/:id/status cliente 532 envia posicion a Agheera en estado global', async () => {
process.env.AGHEERA_API_KEY = 'test-api-key';
const agheeraCalls = [];
agheeraPushClient.__setHttpClientForTests(async (url, options) => {
agheeraCalls.push({ url, options });
return {
ok: true,
status: 200,
text: async () => 'Messages received.'
};
});
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /FROM t_viaje_estados/);
assert.deepEqual(params, [6]);
return [[{ id_estado: 6 }]];
}
if (step === 2) {
assert.match(sql, /FROM c_viajes/);
assert.match(sql, /id_cliente/);
assert.deepEqual(params, [248230]);
return [[{ id_viaje: 248230, id_viaje_padre: 0, id_cliente: 532 }]];
}
if (step === 3) {
assert.match(sql, /FROM c_viajes_proveedor/);
assert.match(sql, /id_tipovehiculo AS matricula/);
assert.deepEqual(params, [248230, '58045340X']);
return [[{ n_proveedor: 1, id_proveedor: 675, matricula: '1234ABC' }]];
}
if (step === 4) {
assert.match(sql, /UPDATE c_viajes/);
assert.deepEqual(params, [6, 1, 248230]);
return [{ affectedRows: 1 }];
}
assert.match(sql, /INSERT INTO c_cambios_estado/);
assert.equal(params[6], '40.416775');
assert.equal(params[7], '-3.70379');
return [{ insertId: 6, affectedRows: 1 }];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 6,
latitud: '40,416775',
longitud: '-3.703790'
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.deepEqual(response.body.agheera_push, {
trip_id: 248230,
attempted: true,
success: true,
http_status: 200,
error: null
});
assert.equal(agheeraCalls.length, 1);
const call = agheeraCalls[0];
assert.equal(call.url, 'https://push-dhl.agheera.com/Telematics/positions');
assert.equal(call.options.method, 'POST');
assert.equal(call.options.headers.apiKey, 'test-api-key');
assert.equal(call.options.headers['Content-Type'], 'application/json');
const payload = JSON.parse(call.options.body);
assert.deepEqual(Object.keys(payload), ['Vehicles']);
assert.equal(payload.Vehicles.length, 1);
assert.equal(payload.Vehicles[0].latitude, 40.416775);
assert.equal(payload.Vehicles[0].longitude, -3.70379);
assert.equal(payload.Vehicles[0].vehicleId, '1234ABC');
assert.equal(payload.Vehicles[0].licensePlate, '1234ABC');
assert.match(payload.Vehicles[0].measurementTime, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
});
test('POST /api/trips/:id/status cliente distinto de 532 no envia a Agheera', async () => {
process.env.AGHEERA_API_KEY = 'test-api-key';
const agheeraCalls = [];
agheeraPushClient.__setHttpClientForTests(async (url, options) => {
agheeraCalls.push({ url, options });
return { ok: true, status: 200, text: async () => 'Messages received.' };
});
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
return [[{ id_estado: 6 }]];
}
if (step === 2) {
return [[{ id_viaje: 248230, id_viaje_padre: 0, id_cliente: 700 }]];
}
if (step === 3) {
return [[{ n_proveedor: 1, id_proveedor: 675, matricula: '1234ABC' }]];
}
if (step === 4) {
assert.match(sql, /UPDATE c_viajes/);
return [{ affectedRows: 1 }];
}
assert.match(sql, /INSERT INTO c_cambios_estado/);
return [{ insertId: 6, affectedRows: 1 }];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 6,
latitud: '40.416775',
longitud: '-3.703790'
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.equal(response.body.agheera_push, undefined);
assert.equal(agheeraCalls.length, 0);
});
test('POST /api/trips/:id/status estado intermedio con id_punto no envia a Agheera', async () => {
process.env.AGHEERA_API_KEY = 'test-api-key';
const agheeraCalls = [];
agheeraPushClient.__setHttpClientForTests(async (url, options) => {
agheeraCalls.push({ url, options });
return { ok: true, status: 200, text: async () => 'Messages received.' };
});
let step = 0;
db.query = async (sql, params) => {
step += 1;
if (step === 1) {
assert.match(sql, /FROM t_viaje_estados/);
return [[{ id_estado: 5 }]];
}
if (step === 2) {
assert.match(sql, /FROM c_viajes/);
return [[{ id_viaje: 248230 }]];
}
if (step === 3) {
assert.match(sql, /FROM c_viajes_proveedor/);
return [[{ n_proveedor: 1 }]];
}
if (step === 4) {
assert.match(sql, /FROM c_viajes_puntos/);
assert.deepEqual(params, [8123, 248230]);
return [[{ id_punto: 8123, id_estado_intermedio: 4, valor: null, foto: null }]];
}
assert.match(sql, /UPDATE c_viajes_puntos/);
return [{ affectedRows: 1 }];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 5,
id_punto: 8123,
latitud: '40.416775',
longitud: '-3.703790'
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.equal(response.body.agheera_push, undefined);
assert.equal(agheeraCalls.length, 0);
});
test('POST /api/trips/:id/status fallo de Agheera mantiene respuesta 200', async () => {
process.env.AGHEERA_API_KEY = 'test-api-key';
const agheeraCalls = [];
agheeraPushClient.__setHttpClientForTests(async (url, options) => {
agheeraCalls.push({ url, options });
return {
ok: false,
status: 500,
text: async () => 'temporary error'
};
});
let step = 0;
db.query = async (sql) => {
step += 1;
if (step === 1) {
return [[{ id_estado: 6 }]];
}
if (step === 2) {
return [[{ id_viaje: 248230, id_viaje_padre: 0, id_cliente: 532 }]];
}
if (step === 3) {
return [[{ n_proveedor: 1, id_proveedor: 675, matricula: '1234ABC' }]];
}
if (step === 4) {
assert.match(sql, /UPDATE c_viajes/);
return [{ affectedRows: 1 }];
}
assert.match(sql, /INSERT INTO c_cambios_estado/);
return [{ insertId: 6, affectedRows: 1 }];
};
const response = await withServer(async (server) =>
requestJson({
port: server.address().port,
method: 'POST',
path: '/api/trips/248230/status',
authorization: `Bearer ${createToken()}`,
body: {
id_estado: 6,
latitud: '40.416775',
longitud: '-3.703790'
}
})
);
assert.equal(response.statusCode, 200);
assert.equal(response.body.success, true);
assert.deepEqual(response.body.agheera_push, {
trip_id: 248230,
attempted: true,
success: false,
http_status: 500,
error: 'Agheera push failed'
});
assert.equal(agheeraCalls.length, 1);
});
test('POST /api/trips/:id/status estado intermedio con id_punto inválido => 400', async () => {
db.query = async () => {
throw new Error('db.query should not run for invalid id_punto');