diff --git a/.env_sin_ruta_imagenes_api b/.env_sin_ruta_imagenes_api
index 4eb2066..ecf5f20 100644
--- a/.env_sin_ruta_imagenes_api
+++ b/.env_sin_ruta_imagenes_api
@@ -1,11 +1,11 @@
PORT=3001
-DB_HOST=localhost
+DB_HOST=194.164.175.51
DB_USER=roganet
DB_PASSWORD=bdIRGLnet2905*/
DB_NAME=abian_app_produccion
JWT_SECRET=9c64f2727d53bfefaaa17a5fda5009ffe93cae904860c659bd18d2d14ad6b467
-DB_HOST_P = localhost
+DB_HOST_P = 194.164.175.51
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=localhost
+TRIP_STATUS_SFTP_HOST=194.164.175.51
TRIP_STATUS_SFTP_PORT=22
TRIP_STATUS_SFTP_USERNAME=ssh_fotos_estado
TRIP_STATUS_SFTP_PASSWORD=IZYj%c0FiIlCc@rI%W0Z
diff --git a/app.js b/app.js
index c9fa6d8..9e6b70e 100644
--- a/app.js
+++ b/app.js
@@ -13,6 +13,7 @@ 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);
@@ -88,6 +89,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
diff --git a/docu/PUSH-API example 1.8.pdf b/docu/PUSH-API example 1.8.pdf
new file mode 100644
index 0000000..6a5b209
Binary files /dev/null and b/docu/PUSH-API example 1.8.pdf differ
diff --git a/docu/cambiar_estado.php b/docu/cambiar_estado.php
new file mode 100644
index 0000000..737230d
--- /dev/null
+++ b/docu/cambiar_estado.php
@@ -0,0 +1,191 @@
+ 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 = '
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 . "
" . $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 = '
+
+
+
+
+
+ abian
+ TVwuW57u0^
+ abian-5567
+ 1
+
+
+
+ 1
+ 5567
+
+
+ ?fecha_hora?
+ 0
+
+ 14
+ ' . $matricula . '
+
+ 12
+ ' . $matricula . '
+ 1
+ 1
+ ?latitud?
+ ?longitud?
+
+
+
+
+ ';
+
+ $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);
+?>
\ No newline at end of file
diff --git a/docu/correo.txt b/docu/correo.txt
new file mode 100644
index 0000000..5d10f33
--- /dev/null
+++ b/docu/correo.txt
@@ -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
+Gesendet: Donnerstag, 28. Mai 2026 11:52
+An: Operations ; Stephan Wahlen
+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
+
diff --git a/src/controllers/locationController.js b/src/controllers/locationController.js
index 4d28570..7bdaf2b 100644
--- a/src/controllers/locationController.js
+++ b/src/controllers/locationController.js
@@ -1,4 +1,7 @@
const db = require('../config/db');
+const agheeraPushClient = require('../services/agheeraPushClient');
+
+const AGHEERA_CLIENT_ID = 532;
const getDniFromLocation = (locationData) => {
if (locationData?.extras?.alias) {
@@ -70,6 +73,137 @@ const getTripIdFromLocation = (locationData) => {
return null;
};
+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
+ });
+
+ 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 +234,8 @@ const saveLocation = async (req, res) => {
globalTripId = getTripIdFromLocation(data);
}
- const now = new Date();
const rowsToInsert = [];
+ const locationsToPush = [];
for (const loc of locations) {
const coords = getCoordinatesFromLocation(loc);
@@ -109,13 +243,32 @@ 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
+ });
}
}
@@ -134,17 +287,24 @@ const saveLocation = async (req, res) => {
[rowsToInsert]
);
- return res.json({
+ const agheeraResults = await pushLocationsToAgheera(locationsToPush);
+ 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 });
}
};
-
-module.exports = {
- saveLocation
-};
+
+module.exports = {
+ saveLocation
+};
diff --git a/src/controllers/tripsController.js b/src/controllers/tripsController.js
index 6801e53..d191aa8 100644
--- a/src/controllers/tripsController.js
+++ b/src/controllers/tripsController.js
@@ -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,
@@ -23,6 +24,7 @@ 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 GLOBAL_STATUS_KEYS_BY_STATE_ID = new Map([
@@ -238,6 +240,85 @@ 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
+ });
+
+ 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 +1501,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 +1534,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 +1797,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 +1820,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 +1849,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 +1880,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 +2009,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 +2032,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);
diff --git a/src/services/agheeraPushClient.js b/src/services/agheeraPushClient.js
new file mode 100644
index 0000000..eae14ff
--- /dev/null
+++ b/src/services/agheeraPushClient.js
@@ -0,0 +1,113 @@
+const DEFAULT_AGHEERA_PUSH_URL = 'https://push-test.agheera.com/Telematics/Positions';
+
+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 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
+}) => {
+ 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 payload = buildPositionPayload({
+ latitude,
+ longitude,
+ vehicleId,
+ licensePlate,
+ measurementTime
+ });
+
+ const response = await httpClient(url, {
+ method: 'POST',
+ headers: {
+ apiKey,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(payload)
+ });
+
+ 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;
+ throw error;
+ }
+
+ return {
+ status: response.status,
+ body: responseBody
+ };
+};
+
+const __setHttpClientForTests = (httpClient) => {
+ httpClientOverride = httpClient;
+};
+
+const __resetHttpClientForTests = () => {
+ httpClientOverride = null;
+};
+
+module.exports = {
+ DEFAULT_AGHEERA_PUSH_URL,
+ pushPosition,
+ __setHttpClientForTests,
+ __resetHttpClientForTests
+};
diff --git a/test/location.agheera.integration.test.js b/test/location.agheera.integration.test.js
new file mode 100644
index 0000000..02c905d
--- /dev/null
+++ b/test/location.agheera.integration.test.js
@@ -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-test.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'
+ }
+ });
+});
diff --git a/test/trips.status.integration.test.js b/test/trips.status.integration.test.js
index 1f893e9..227ab27 100644
--- a/test/trips.status.integration.test.js
+++ b/test/trips.status.integration.test.js
@@ -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 });
});
@@ -710,7 +719,7 @@ test('POST /api/trips/:id/status modo dual replica foto a SFTP y mantiene local'
const recorder = createSftpRecorder();
tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder));
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_USERNAME = 'ssh_fotos_estado';
process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password';
@@ -787,7 +796,7 @@ test('POST /api/trips/:id/status modo dual con fallo SFTP mantiene fallback loca
createFakeSftpClientFactory(recorder, { failPut: true })
);
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_USERNAME = 'ssh_fotos_estado';
process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password';
@@ -853,7 +862,7 @@ test('POST /api/trips/:id/status payload inválido tras upload limpia remoto y l
const recorder = createSftpRecorder();
tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder));
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_USERNAME = 'ssh_fotos_estado';
process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password';
@@ -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-test.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');
@@ -2523,7 +2795,7 @@ test('DELETE /api/trips/:id/status en modo dual no borra foto en remoto ni local
const recorder = createSftpRecorder();
tripStatusPhotoStorage.__setSftpClientFactoryForTests(createFakeSftpClientFactory(recorder));
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_USERNAME = 'ssh_fotos_estado';
process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password';