From c2dbe773a4b75d02680d283d90805d0734668059 Mon Sep 17 00:00:00 2001 From: soporte roganet Date: Wed, 30 Sep 2026 11:48:32 +0200 Subject: [PATCH] chore: sincronizar cambios pendientes de desarrollo a produccion --- DEPLOY_PRODUCTION_TRIPS_PAGINATION.md | 276 ++++++++++++++++++ README.md | 23 ++ src/scripts/restartNodeApi.sh | 2 +- ...20260630_trips_list_pagination_indexes.sql | 64 ++++ test/trips.active.integration.test.js | 21 +- test/trips.status.integration.test.js | 12 +- test/upload-diagnostics.integration.test.js | 221 ++++++++++++++ 7 files changed, 609 insertions(+), 10 deletions(-) create mode 100644 DEPLOY_PRODUCTION_TRIPS_PAGINATION.md create mode 100644 src/sql/migrations/20260630_trips_list_pagination_indexes.sql create mode 100644 test/upload-diagnostics.integration.test.js diff --git a/DEPLOY_PRODUCTION_TRIPS_PAGINATION.md b/DEPLOY_PRODUCTION_TRIPS_PAGINATION.md new file mode 100644 index 0000000..cdb4edf --- /dev/null +++ b/DEPLOY_PRODUCTION_TRIPS_PAGINATION.md @@ -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="" + +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. diff --git a/README.md b/README.md index 360d53d..1dfa92e 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,15 @@ También puedes usar el script de npm: npm run notify:test -- --token "" --dry-run ``` +## API: Listado de viajes + +Endpoint: `GET /api/trips` +Auth: `Bearer ` 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) 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 ``` +## 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 Endpoint: `POST /api/trips/:tripId/incidencias` diff --git a/src/scripts/restartNodeApi.sh b/src/scripts/restartNodeApi.sh index 6415316..f305d0e 100755 --- a/src/scripts/restartNodeApi.sh +++ b/src/scripts/restartNodeApi.sh @@ -8,7 +8,7 @@ NODE_BIN="${NODE_BIN:-$(command -v node || true)}" HOST="${HOST:-127.0.0.1}" PORT="${PORT:-3001}" 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_EXPECTED_STATUS="${AUTH_EXPECTED_STATUS:-401}" STARTUP_TIMEOUT_SEC="${STARTUP_TIMEOUT_SEC:-30}" diff --git a/src/sql/migrations/20260630_trips_list_pagination_indexes.sql b/src/sql/migrations/20260630_trips_list_pagination_indexes.sql new file mode 100644 index 0000000..b83185b --- /dev/null +++ b/src/sql/migrations/20260630_trips_list_pagination_indexes.sql @@ -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; diff --git a/test/trips.active.integration.test.js b/test/trips.active.integration.test.js index daf0f08..28778c5 100644 --- a/test/trips.active.integration.test.js +++ b/test/trips.active.integration.test.js @@ -108,6 +108,7 @@ test('GET /api/trips/active devuelve viaje activo', async () => { cod_viaje: 'AB-2024-001', n_proveedor: 1, id_estado: 4, + statusId: 4, estado: 'CARGA DE MERCANCÍA', estado_en: 'CARGO LOADING', nombrea: 'ORIGEN', @@ -122,13 +123,19 @@ test('GET /api/trips/active devuelve viaje activo', async () => { }; db.query = async (sql, params) => { - assert.match(sql, /FROM c_viajes_proveedor p/); - assert.match(sql, /INNER JOIN t_viaje_estados e/); - assert.match(sql, /fecha_salida/); - assert.match(sql, /fecha_llegada/); - assert.match(sql, /INNER JOIN m_proveedores_trasportistas t/); - assert.deepEqual(params, ['58045340X', 2, 6]); - return [[mockedTrip]]; + if (/FROM c_viajes_proveedor p/.test(sql)) { + assert.match(sql, /INNER JOIN t_viaje_estados e/); + assert.match(sql, /fecha_salida/); + assert.match(sql, /fecha_llegada/); + assert.match(sql, /INNER JOIN m_proveedores_trasportistas t/); + assert.deepEqual(params, ['58045340X', 2, 6, '58045340X', 1]); + 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) => diff --git a/test/trips.status.integration.test.js b/test/trips.status.integration.test.js index 7320bce..e07d229 100644 --- a/test/trips.status.integration.test.js +++ b/test/trips.status.integration.test.js @@ -540,9 +540,17 @@ test('POST /api/trips/:id/status multipart registra body parseado y ficheros en ); 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.path, '/api/trips/248230/status'); assert.equal(entry.body.id_estado, '5'); diff --git a/test/upload-diagnostics.integration.test.js b/test/upload-diagnostics.integration.test.js new file mode 100644 index 0000000..08e8359 --- /dev/null +++ b/test/upload-diagnostics.integration.test.js @@ -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); +});