Compare commits

...

3 Commits

Author SHA1 Message Date
abiandev
12364bcb44 Update SFTP host configuration to use localhost for local development 2026-06-01 16:12:51 +02:00
abiandev
6b7c7ec462 cambios de desarrollo 2026-06-01 16:11:02 +02:00
abiandev
48b349d68b Update SFTP host configuration to use localhost for local development 2026-06-01 16:02:13 +02:00
12 changed files with 1284 additions and 25 deletions

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

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=********

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=********

12
app.js
View File

@ -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

Binary file not shown.

191
docu/cambiar_estado.php Normal file
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
docu/correo.txt Normal file
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

View File

@ -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,11 +287,18 @@ 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 });

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,
@ -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);

View File

@ -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
};

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-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'
}
});
});

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 });
});
@ -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 = '194.164.175.51';
process.env.TRIP_STATUS_SFTP_HOST = 'localhost';
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 = '194.164.175.51';
process.env.TRIP_STATUS_SFTP_HOST = 'localhost';
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 = '194.164.175.51';
process.env.TRIP_STATUS_SFTP_HOST = 'localhost';
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 = '194.164.175.51';
process.env.TRIP_STATUS_SFTP_HOST = 'localhost';
process.env.TRIP_STATUS_SFTP_PORT = '22';
process.env.TRIP_STATUS_SFTP_USERNAME = 'ssh_fotos_estado';
process.env.TRIP_STATUS_SFTP_PASSWORD = 'test-password';