chore: sincronizar cambios pendientes de desarrollo a produccion
This commit is contained in:
@@ -0,0 +1,276 @@
|
|||||||
|
# Manual de despliegue a produccion: paginacion de GET /api/trips
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
|
||||||
|
Este despliegue publica el cambio del backend para que `GET /api/trips` aplique paginacion y filtros en SQL.
|
||||||
|
|
||||||
|
Incluye:
|
||||||
|
|
||||||
|
- Cambios en `src/controllers/tripsController.js`.
|
||||||
|
- Tests de integracion en `test/trips.list.integration.test.js`.
|
||||||
|
- Documentacion en `README.md`.
|
||||||
|
- Migracion de indices: `src/sql/migrations/20260630_trips_list_pagination_indexes.sql`.
|
||||||
|
|
||||||
|
No incluye cambios en la app React Native.
|
||||||
|
|
||||||
|
## Supuestos
|
||||||
|
|
||||||
|
- El servidor Node de produccion usa este proyecto: `/var/www/node.gestion.abianservice.com`.
|
||||||
|
- La API escucha en `127.0.0.1:3001` salvo que se sobreescriba `PORT`.
|
||||||
|
- El restart operativo se hace con `src/scripts/restartNodeApi.sh`.
|
||||||
|
- Las variables `DB_HOST`, `DB_USER`, `DB_PASSWORD` y `DB_NAME` de produccion estan configuradas en `.env` o disponibles en shell.
|
||||||
|
- Las migraciones SQL se aplican manualmente con `mysql`.
|
||||||
|
|
||||||
|
Si alguno de estos puntos no coincide con produccion, parar y ajustar el manual antes de ejecutar.
|
||||||
|
|
||||||
|
## Criterios de exito
|
||||||
|
|
||||||
|
- `GET /api/trips` sin parametros responde `200` con maximo 25 viajes por defecto.
|
||||||
|
- La respuesta incluye `trips`, `page`, `limit`, `total` y `has_more`.
|
||||||
|
- Las llamadas con `page`, `limit`, `status_ids`, `date_from` y `date_to` filtran en base de datos.
|
||||||
|
- La API arranca correctamente y `/health` responde `200`.
|
||||||
|
- `/api/trips/active` sin token responde `401`.
|
||||||
|
- No aparecen errores nuevos en el log de la API.
|
||||||
|
|
||||||
|
## 1. Precheck antes de tocar produccion
|
||||||
|
|
||||||
|
Desde la raiz del proyecto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/node.gestion.abianservice.com
|
||||||
|
|
||||||
|
git status --short
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Revisar que no haya cambios locales no esperados. Si hay cambios ajenos, no sobrescribirlos.
|
||||||
|
|
||||||
|
Ejecutar tests antes de desplegar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Tambien se puede ejecutar solo la cobertura del endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test test/trips.list.integration.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Backup rapido de base de datos
|
||||||
|
|
||||||
|
Antes de aplicar indices, sacar al menos backup de las tablas tocadas por la migracion:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/abian-db-backups
|
||||||
|
|
||||||
|
mysqldump -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" \
|
||||||
|
c_viajes c_viajes_proveedor c_cambios_estado \
|
||||||
|
> /tmp/abian-db-backups/trips_indexes_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Si `DB_PASSWORD` no esta exportada en shell, cargar `.env` de forma controlada o introducir la password manualmente con `-p`.
|
||||||
|
|
||||||
|
## 3. Aplicar codigo
|
||||||
|
|
||||||
|
Actualizar el codigo de produccion con el mecanismo habitual del equipo. Si se usa Git en el servidor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/node.gestion.abianservice.com
|
||||||
|
|
||||||
|
git fetch --all --prune
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Antes de cambiar de revision, confirmar que `git status --short` no muestra cambios locales que no deban pisarse.
|
||||||
|
|
||||||
|
Despues de actualizar la rama o revision objetivo, instalar dependencias solo si `package-lock.json` cambio:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --omit=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Para este cambio no deberia ser necesario si solo se despliega la paginacion de viajes.
|
||||||
|
|
||||||
|
## 4. Aplicar migracion de indices
|
||||||
|
|
||||||
|
La migracion es idempotente: comprueba `information_schema.statistics` antes de crear cada indice.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/node.gestion.abianservice.com
|
||||||
|
|
||||||
|
mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" \
|
||||||
|
< src/sql/migrations/20260630_trips_list_pagination_indexes.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Validar que los indices existen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" -e "
|
||||||
|
SHOW INDEX FROM c_viajes WHERE Key_name = 'idx_c_viajes_id_estado';
|
||||||
|
SHOW INDEX FROM c_viajes_proveedor WHERE Key_name IN (
|
||||||
|
'idx_c_viajes_proveedor_fecha_salida',
|
||||||
|
'idx_c_viajes_proveedor_fecha_llegada',
|
||||||
|
'idx_c_viajes_proveedor_dni_estado_dates'
|
||||||
|
);
|
||||||
|
SHOW INDEX FROM c_cambios_estado WHERE Key_name = 'idx_c_cambios_estado_viaje_estado_fecha';
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Si la tabla es grande, esta fase puede tardar. No reiniciar la API hasta que termine correctamente.
|
||||||
|
|
||||||
|
## 5. Reiniciar API
|
||||||
|
|
||||||
|
Usar el script incluido en el repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/node.gestion.abianservice.com
|
||||||
|
|
||||||
|
bash src/scripts/restartNodeApi.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
El script valida:
|
||||||
|
|
||||||
|
- Proceso Node arrancado.
|
||||||
|
- Puerto `127.0.0.1:3001` escuchando.
|
||||||
|
- `/health` responde `200`.
|
||||||
|
- `/api/trips/active` sin token responde `401`.
|
||||||
|
|
||||||
|
Si produccion usa otro puerto o ruta, exportar variables antes de ejecutar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3001 HOST=127.0.0.1 bash src/scripts/restartNodeApi.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Smoke tests post-deploy
|
||||||
|
|
||||||
|
### Sin token
|
||||||
|
|
||||||
|
Debe devolver `401`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i "http://127.0.0.1:3001/api/trips"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Con token real de conductor
|
||||||
|
|
||||||
|
Usar un JWT valido de la app o de una sesion de pruebas.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="<JWT_VALIDO>"
|
||||||
|
|
||||||
|
curl -sS "http://127.0.0.1:3001/api/trips?page=1&limit=25" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Comprobar que la respuesta contiene:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trips": [],
|
||||||
|
"page": 1,
|
||||||
|
"limit": 25,
|
||||||
|
"total": 0,
|
||||||
|
"has_more": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`trips` puede traer datos reales, pero `page`, `limit`, `total` y `has_more` deben existir.
|
||||||
|
|
||||||
|
Probar historico:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "http://127.0.0.1:3001/api/trips?page=1&limit=25&status_ids=7,8,9" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Probar proximos desde una fecha:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "http://127.0.0.1:3001/api/trips?page=1&limit=25&date_from=2026-06-30" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Probar rango de fechas:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "http://127.0.0.1:3001/api/trips?page=1&limit=25&date_from=2026-06-01&date_to=2026-06-30" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validaciones negativas
|
||||||
|
|
||||||
|
Deben devolver `400`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i "http://127.0.0.1:3001/api/trips?page=0" -H "Authorization: Bearer $TOKEN"
|
||||||
|
curl -i "http://127.0.0.1:3001/api/trips?limit=101" -H "Authorization: Bearer $TOKEN"
|
||||||
|
curl -i "http://127.0.0.1:3001/api/trips?status_ids=7,x" -H "Authorization: Bearer $TOKEN"
|
||||||
|
curl -i "http://127.0.0.1:3001/api/trips?date_from=2026-02-30" -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Logs a revisar
|
||||||
|
|
||||||
|
Log por defecto del script de restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -n 100 /tmp/node-gestion-api.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Buscar entradas del listado:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "\[TripsList\]" /tmp/node-gestion-api.log | tail -n 20
|
||||||
|
```
|
||||||
|
|
||||||
|
Cada entrada debe mostrar `page`, `limit`, `rows`, `total` y `elapsed_ms`.
|
||||||
|
|
||||||
|
Senales de problema:
|
||||||
|
|
||||||
|
- `Error getting trips list`.
|
||||||
|
- `ER_PARSE_ERROR`.
|
||||||
|
- `ER_BAD_FIELD_ERROR`.
|
||||||
|
- Tiempos `elapsed_ms` altos de forma sostenida.
|
||||||
|
- Respuestas sin `page`, `limit`, `total` o `has_more`.
|
||||||
|
|
||||||
|
## 8. Rollback
|
||||||
|
|
||||||
|
### Rollback de codigo
|
||||||
|
|
||||||
|
Volver a la revision anterior con el mecanismo habitual del equipo y reiniciar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/node.gestion.abianservice.com
|
||||||
|
|
||||||
|
bash src/scripts/restartNodeApi.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback de indices
|
||||||
|
|
||||||
|
Normalmente no hace falta retirar indices: son compatibles y no cambian datos. Solo retirarlos si se confirma impacto operativo.
|
||||||
|
|
||||||
|
Comandos de retirada, si fueran necesarios:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE c_viajes DROP INDEX idx_c_viajes_id_estado;
|
||||||
|
ALTER TABLE c_viajes_proveedor DROP INDEX idx_c_viajes_proveedor_fecha_salida;
|
||||||
|
ALTER TABLE c_viajes_proveedor DROP INDEX idx_c_viajes_proveedor_fecha_llegada;
|
||||||
|
ALTER TABLE c_viajes_proveedor DROP INDEX idx_c_viajes_proveedor_dni_estado_dates;
|
||||||
|
ALTER TABLE c_cambios_estado DROP INDEX idx_c_cambios_estado_viaje_estado_fecha;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejecutarlos solo tras confirmar que esos indices no existian antes del despliegue.
|
||||||
|
|
||||||
|
## 9. Checklist final
|
||||||
|
|
||||||
|
- [ ] Tests ejecutados antes del despliegue.
|
||||||
|
- [ ] Backup de tablas creado.
|
||||||
|
- [ ] Codigo actualizado.
|
||||||
|
- [ ] Migracion de indices aplicada sin errores.
|
||||||
|
- [ ] API reiniciada con `restartNodeApi.sh`.
|
||||||
|
- [ ] `/health` responde `200`.
|
||||||
|
- [ ] `/api/trips/active` sin token responde `401`.
|
||||||
|
- [ ] `GET /api/trips?page=1&limit=25` con token responde metadata de paginacion.
|
||||||
|
- [ ] Filtros `status_ids`, `date_from` y `date_to` verificados.
|
||||||
|
- [ ] Logs sin errores nuevos.
|
||||||
@@ -77,6 +77,15 @@ También puedes usar el script de npm:
|
|||||||
npm run notify:test -- --token "<FCM_DEVICE_TOKEN>" --dry-run
|
npm run notify:test -- --token "<FCM_DEVICE_TOKEN>" --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## API: Listado de viajes
|
||||||
|
|
||||||
|
Endpoint: `GET /api/trips`
|
||||||
|
Auth: `Bearer <JWT>` obligatorio.
|
||||||
|
|
||||||
|
Parametros opcionales: `page` (default `1`), `limit` (default `25`, maximo `100`), `status_ids` separado por comas, `date_from` y `date_to` en formato `YYYY-MM-DD`.
|
||||||
|
|
||||||
|
La respuesta mantiene `trips` con los campos legacy de cada viaje y añade `page`, `limit`, `total` y `has_more`. La paginacion y filtros se aplican en SQL. El orden estable del listado es `COALESCE(p.fecha_salida, v.fecha_salida) DESC, p.id_viaje DESC`.
|
||||||
|
|
||||||
## Fotos de estado de viaje (almacenamiento temporal dual)
|
## Fotos de estado de viaje (almacenamiento temporal dual)
|
||||||
|
|
||||||
Para `POST /api/trips/:id/status`, las fotos pueden guardarse en local y replicarse por SFTP en paralelo.
|
Para `POST /api/trips/:id/status`, las fotos pueden guardarse en local y replicarse por SFTP en paralelo.
|
||||||
@@ -103,6 +112,20 @@ TRIP_STATUS_SFTP_PASSWORD=********
|
|||||||
TRIP_STATUS_SFTP_REMOTE_BASE_DIR=/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native
|
TRIP_STATUS_SFTP_REMOTE_BASE_DIR=/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Diagnostico de subidas desde la app
|
||||||
|
|
||||||
|
Las peticiones `POST` multipart dejan eventos JSON en `POSTS_LOG_PATH` (por defecto,
|
||||||
|
`/var/log/posts.log`):
|
||||||
|
|
||||||
|
- `upload_request_started`: la API recibio la peticion.
|
||||||
|
- `upload_request_finished`: incluye estado HTTP, duracion, fase del posible fallo y
|
||||||
|
resultado del parser multipart.
|
||||||
|
- `upload_request_interrupted`: el cliente o la conexion se cerro antes de terminar.
|
||||||
|
|
||||||
|
Todos los eventos de una peticion comparten `request_id`, que tambien se devuelve en
|
||||||
|
la cabecera `x-request-id`. No se registran el token, los valores del formulario, el
|
||||||
|
contenido del archivo ni su nombre original.
|
||||||
|
|
||||||
## API: Crear incidencia de viaje
|
## API: Crear incidencia de viaje
|
||||||
|
|
||||||
Endpoint: `POST /api/trips/:tripId/incidencias`
|
Endpoint: `POST /api/trips/:tripId/incidencias`
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
|||||||
HOST="${HOST:-127.0.0.1}"
|
HOST="${HOST:-127.0.0.1}"
|
||||||
PORT="${PORT:-3001}"
|
PORT="${PORT:-3001}"
|
||||||
LOG_FILE="${LOG_FILE:-/tmp/node-gestion-api.log}"
|
LOG_FILE="${LOG_FILE:-/tmp/node-gestion-api.log}"
|
||||||
HEALTH_PATH="${HEALTH_PATH:-/}"
|
HEALTH_PATH="${HEALTH_PATH:-/health}"
|
||||||
AUTH_CHECK_PATH="${AUTH_CHECK_PATH:-/api/trips/active}"
|
AUTH_CHECK_PATH="${AUTH_CHECK_PATH:-/api/trips/active}"
|
||||||
AUTH_EXPECTED_STATUS="${AUTH_EXPECTED_STATUS:-401}"
|
AUTH_EXPECTED_STATUS="${AUTH_EXPECTED_STATUS:-401}"
|
||||||
STARTUP_TIMEOUT_SEC="${STARTUP_TIMEOUT_SEC:-30}"
|
STARTUP_TIMEOUT_SEC="${STARTUP_TIMEOUT_SEC:-30}"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
SET @sql := IF(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'c_viajes'
|
||||||
|
AND index_name = 'idx_c_viajes_id_estado') = 0,
|
||||||
|
'ALTER TABLE c_viajes ADD KEY idx_c_viajes_id_estado (id_estado)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'c_viajes_proveedor'
|
||||||
|
AND index_name = 'idx_c_viajes_proveedor_fecha_salida') = 0,
|
||||||
|
'ALTER TABLE c_viajes_proveedor ADD KEY idx_c_viajes_proveedor_fecha_salida (fecha_salida)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'c_viajes_proveedor'
|
||||||
|
AND index_name = 'idx_c_viajes_proveedor_fecha_llegada') = 0,
|
||||||
|
'ALTER TABLE c_viajes_proveedor ADD KEY idx_c_viajes_proveedor_fecha_llegada (fecha_llegada)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'c_viajes_proveedor'
|
||||||
|
AND index_name = 'idx_c_viajes_proveedor_dni_estado_dates') = 0,
|
||||||
|
'ALTER TABLE c_viajes_proveedor ADD KEY idx_c_viajes_proveedor_dni_estado_dates (dni, id_viaje, fecha_salida, fecha_llegada)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'c_cambios_estado'
|
||||||
|
AND index_name = 'idx_c_cambios_estado_viaje_estado_fecha') = 0,
|
||||||
|
'ALTER TABLE c_cambios_estado ADD KEY idx_c_cambios_estado_viaje_estado_fecha (id_viaje, id_estado, fecha_y_hora)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@@ -108,6 +108,7 @@ test('GET /api/trips/active devuelve viaje activo', async () => {
|
|||||||
cod_viaje: 'AB-2024-001',
|
cod_viaje: 'AB-2024-001',
|
||||||
n_proveedor: 1,
|
n_proveedor: 1,
|
||||||
id_estado: 4,
|
id_estado: 4,
|
||||||
|
statusId: 4,
|
||||||
estado: 'CARGA DE MERCANCÍA',
|
estado: 'CARGA DE MERCANCÍA',
|
||||||
estado_en: 'CARGO LOADING',
|
estado_en: 'CARGO LOADING',
|
||||||
nombrea: 'ORIGEN',
|
nombrea: 'ORIGEN',
|
||||||
@@ -122,13 +123,19 @@ test('GET /api/trips/active devuelve viaje activo', async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.query = async (sql, params) => {
|
db.query = async (sql, params) => {
|
||||||
assert.match(sql, /FROM c_viajes_proveedor p/);
|
if (/FROM c_viajes_proveedor p/.test(sql)) {
|
||||||
assert.match(sql, /INNER JOIN t_viaje_estados e/);
|
assert.match(sql, /INNER JOIN t_viaje_estados e/);
|
||||||
assert.match(sql, /fecha_salida/);
|
assert.match(sql, /fecha_salida/);
|
||||||
assert.match(sql, /fecha_llegada/);
|
assert.match(sql, /fecha_llegada/);
|
||||||
assert.match(sql, /INNER JOIN m_proveedores_trasportistas t/);
|
assert.match(sql, /INNER JOIN m_proveedores_trasportistas t/);
|
||||||
assert.deepEqual(params, ['58045340X', 2, 6]);
|
assert.deepEqual(params, ['58045340X', 2, 6, '58045340X', 1]);
|
||||||
return [[mockedTrip]];
|
return [[mockedTrip]];
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(sql, /FROM c_cambios_estado c/);
|
||||||
|
assert.match(sql, /FROM c_viajes_puntos vp/);
|
||||||
|
assert.deepEqual(params, [123, 123, 3, 4, 5]);
|
||||||
|
return [[]];
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await withServer(async (server) =>
|
const response = await withServer(async (server) =>
|
||||||
|
|||||||
@@ -540,9 +540,17 @@ test('POST /api/trips/:id/status multipart registra body parseado y ficheros en
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
await waitFor(() => fs.existsSync(TEST_POSTS_LOG_PATH));
|
await waitFor(() =>
|
||||||
|
fs.existsSync(TEST_POSTS_LOG_PATH) &&
|
||||||
|
readJsonLines(TEST_POSTS_LOG_PATH).some(
|
||||||
|
(logEntry) => logEntry.body?.id_estado === '5'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const [entry] = readJsonLines(TEST_POSTS_LOG_PATH);
|
const entry = readJsonLines(TEST_POSTS_LOG_PATH).find(
|
||||||
|
(logEntry) => logEntry.body?.id_estado === '5'
|
||||||
|
);
|
||||||
|
assert.ok(entry, 'Expected parsed multipart payload log entry');
|
||||||
assert.equal(entry.method, 'POST');
|
assert.equal(entry.method, 'POST');
|
||||||
assert.equal(entry.path, '/api/trips/248230/status');
|
assert.equal(entry.path, '/api/trips/248230/status');
|
||||||
assert.equal(entry.body.id_estado, '5');
|
assert.equal(entry.body.id_estado, '5');
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const http = require('node:http');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const TEST_LOG_PATH = path.resolve(__dirname, '..', 'tmp', 'test-upload-diagnostics.log');
|
||||||
|
process.env.POSTS_LOG_PATH = TEST_LOG_PATH;
|
||||||
|
|
||||||
|
const app = require('../app');
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
|
||||||
|
process.env.JWT_SECRET = JWT_SECRET;
|
||||||
|
|
||||||
|
const createToken = () =>
|
||||||
|
jwt.sign(
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
dni: '58045340X',
|
||||||
|
id_proveedor: 675
|
||||||
|
},
|
||||||
|
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 requestMultipart = async ({ port, authorization, fields = {}, file }) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const boundary = `----UploadDiagnostics${Date.now().toString(16)}`;
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
chunks.push(Buffer.from(`--${boundary}\r\n`));
|
||||||
|
chunks.push(
|
||||||
|
Buffer.from(`Content-Disposition: form-data; name="${key}"\r\n\r\n${String(value)}\r\n`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
chunks.push(Buffer.from(`--${boundary}\r\n`));
|
||||||
|
chunks.push(
|
||||||
|
Buffer.from(
|
||||||
|
`Content-Disposition: form-data; name="${file.fieldName}"; filename="${file.filename}"\r\n` +
|
||||||
|
`Content-Type: ${file.contentType}\r\n\r\n`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
chunks.push(file.content);
|
||||||
|
chunks.push(Buffer.from('\r\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(Buffer.from(`--${boundary}--\r\n`));
|
||||||
|
const bodyBuffer = Buffer.concat(chunks);
|
||||||
|
const request = http.request(
|
||||||
|
{
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/update_profile_photo',
|
||||||
|
headers: {
|
||||||
|
...(authorization ? { authorization } : {}),
|
||||||
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
'Content-Length': bodyBuffer.length
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
let responseBody = '';
|
||||||
|
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
responseBody += chunk;
|
||||||
|
});
|
||||||
|
response.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
headers: response.headers,
|
||||||
|
body: responseBody ? JSON.parse(responseBody) : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
request.on('error', reject);
|
||||||
|
request.end(bodyBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
const readLogEntries = () => {
|
||||||
|
if (!fs.existsSync(TEST_LOG_PATH)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
.readFileSync(TEST_LOG_PATH, 'utf8')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line));
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForFinishedLog = async () => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startedAt < 1500) {
|
||||||
|
const finished = readLogEntries().find(
|
||||||
|
(entry) => entry.event === 'upload_request_finished'
|
||||||
|
);
|
||||||
|
if (finished) {
|
||||||
|
return finished;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Timed out waiting for upload diagnostics log');
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
fs.rmSync(TEST_LOG_PATH, { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
delete process.env.POSTS_LOG_PATH;
|
||||||
|
fs.rmSync(TEST_LOG_PATH, { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multipart sin autenticacion identifica fallo antes del parser', async () => {
|
||||||
|
const response = await withServer((server) =>
|
||||||
|
requestMultipart({
|
||||||
|
port: server.address().port,
|
||||||
|
file: {
|
||||||
|
fieldName: 'foto_perfil',
|
||||||
|
filename: 'profile.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
content: Buffer.from('jpeg')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
const finished = await waitForFinishedLog();
|
||||||
|
const started = readLogEntries().find(
|
||||||
|
(entry) => entry.event === 'upload_request_started'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(finished.request_id, response.headers['x-request-id']);
|
||||||
|
assert.equal(started.request_id, finished.request_id);
|
||||||
|
assert.equal(finished.failure_stage, 'authentication_rate_limit_or_route');
|
||||||
|
assert.equal(finished.upload.parser_status, 'not_reached');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tipo de archivo invalido identifica rechazo del parser multipart', async () => {
|
||||||
|
const token = createToken();
|
||||||
|
const response = await withServer((server) =>
|
||||||
|
requestMultipart({
|
||||||
|
port: server.address().port,
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
file: {
|
||||||
|
fieldName: 'foto_perfil',
|
||||||
|
filename: 'profile.txt',
|
||||||
|
contentType: 'text/plain',
|
||||||
|
content: Buffer.from('not-an-image')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 400);
|
||||||
|
const finished = await waitForFinishedLog();
|
||||||
|
|
||||||
|
assert.equal(finished.failure_stage, 'multipart_parser');
|
||||||
|
assert.equal(finished.upload.flow, 'profile_photo');
|
||||||
|
assert.equal(finished.upload.parser_status, 'rejected');
|
||||||
|
assert.equal(finished.upload.error.code, 'INVALID_FILE_TYPE');
|
||||||
|
assert.equal(JSON.stringify(readLogEntries()).includes(token), false);
|
||||||
|
assert.equal(JSON.stringify(readLogEntries()).includes('profile.txt'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payload invalido tras guardar el archivo identifica fallo posterior al parser', async () => {
|
||||||
|
const response = await withServer((server) =>
|
||||||
|
requestMultipart({
|
||||||
|
port: server.address().port,
|
||||||
|
authorization: `Bearer ${createToken()}`,
|
||||||
|
fields: {
|
||||||
|
id_proveedor: 675
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
fieldName: 'foto_perfil',
|
||||||
|
filename: 'profile.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
content: Buffer.from('jpeg')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 400);
|
||||||
|
const finished = await waitForFinishedLog();
|
||||||
|
|
||||||
|
assert.equal(finished.failure_stage, 'controller_or_persistence');
|
||||||
|
assert.equal(finished.upload.parser_status, 'parsed');
|
||||||
|
assert.deepEqual(finished.upload.body_fields, ['id_proveedor']);
|
||||||
|
assert.equal(finished.upload.file_count, 1);
|
||||||
|
assert.equal(finished.upload.files[0].original_extension, '.jpg');
|
||||||
|
assert.equal(finished.upload.files[0].local_file_created, true);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user