diff --git a/app.js b/app.js index 9e6b70e..17ce22a 100644 --- a/app.js +++ b/app.js @@ -7,6 +7,7 @@ const authRoutes = require('./src/routes/authRoutes'); const profileRoutes = require('./src/routes/profileRoutes'); const tripsRoutes = require('./src/routes/tripsRoutes'); const driverLicenseRoutes = require('./src/routes/driverLicenseRoutes'); +const availabilityRoutes = require('./src/routes/availabilityRoutes'); const { appendPostLog } = require('./src/utils/postLog'); dotenv.config({ path: path.resolve(__dirname, '.env'), override: true }); @@ -107,6 +108,7 @@ app.use('/', authRoutes); app.use('/', profileRoutes); app.use('/', require('./src/routes/locationRoutes')); app.use('/api', require('./src/routes/stressRoutes')); // Stress Test Endpoint +app.use('/api', availabilityRoutes); app.use('/api', tripsRoutes); app.use('/api', driverLicenseRoutes); diff --git a/src/controllers/availabilityController.js b/src/controllers/availabilityController.js new file mode 100644 index 0000000..ab40d48 --- /dev/null +++ b/src/controllers/availabilityController.js @@ -0,0 +1,102 @@ +const db = require('../config/db'); + +const getCoordinatesFromBody = (body) => { + const lat = body?.latitud ?? body?.latitude; + const lng = body?.longitud ?? body?.longitude; + + if (lat === undefined || lat === null || lng === undefined || lng === null) { + return null; + } + + return { lat, lng }; +}; + +const upsertOnlineAvailability = async (dni, lat, lng) => { + const [rows] = await db.query( + `SELECT id_usuario + FROM c_trazabilidad_online + WHERE id_usuario = ? + LIMIT 1`, + [dni] + ); + + if (rows.length > 0) { + await db.query( + `UPDATE c_trazabilidad_online + SET latitud = ?, longitud = ?, fecha = NOW() + WHERE id_usuario = ?`, + [String(lat), String(lng), dni] + ); + return; + } + + await db.query( + `INSERT INTO c_trazabilidad_online + (latitud, longitud, id_usuario, fecha) + VALUES (?, ?, ?, NOW())`, + [String(lat), String(lng), dni] + ); +}; + +const getAvailability = async (req, res) => { + try { + const dni = String(req.user.dni); + const [rows] = await db.query( + `SELECT COUNT(*) AS total + FROM c_trazabilidad_online + WHERE id_usuario = ?`, + [dni] + ); + + return res.json({ + success: true, + available: Number(rows[0]?.total || 0) > 0 + }); + } catch (error) { + console.error('Error getting availability:', error); + return res.status(500).json({ success: false, error: error.message }); + } +}; + +const setAvailability = async (req, res) => { + try { + const coords = getCoordinatesFromBody(req.body); + + if (!coords) { + return res.status(400).json({ + success: false, + error: 'missing_coords', + message: 'latitud/longitud or latitude/longitude are required' + }); + } + + await upsertOnlineAvailability(String(req.user.dni), coords.lat, coords.lng); + + return res.json({ success: true, available: true }); + } catch (error) { + console.error('Error setting availability:', error); + return res.status(500).json({ success: false, error: error.message }); + } +}; + +const deleteAvailability = async (req, res) => { + try { + await db.query( + `DELETE FROM c_trazabilidad_online + WHERE id_usuario = ?`, + [String(req.user.dni)] + ); + + return res.json({ success: true, available: false }); + } catch (error) { + console.error('Error deleting availability:', error); + return res.status(500).json({ success: false, error: error.message }); + } +}; + +module.exports = { + deleteAvailability, + getAvailability, + setAvailability, + upsertOnlineAvailability +}; diff --git a/src/controllers/locationController.js b/src/controllers/locationController.js index 3174ad8..c4652f9 100644 --- a/src/controllers/locationController.js +++ b/src/controllers/locationController.js @@ -1,5 +1,6 @@ const db = require('../config/db'); const agheeraPushClient = require('../services/agheeraPushClient'); +const { upsertOnlineAvailability } = require('./availabilityController'); const AGHEERA_CLIENT_ID = 532; @@ -73,6 +74,22 @@ const getTripIdFromLocation = (locationData) => { return null; }; +const isAvailabilityModeEnabled = (value) => + value === true || value === 'true' || value === 1 || value === '1'; + +const hasAvailabilityModeEnabled = (data, loc) => { + const candidates = [ + data?.availability_mode, + data?.params?.availability_mode, + data?.extras?.availability_mode, + loc?.availability_mode, + loc?.params?.availability_mode, + loc?.extras?.availability_mode + ]; + + return candidates.some(isAvailabilityModeEnabled); +}; + const getRawTimestampFromLocation = (locationData) => { const candidates = [ locationData?.timestamp, @@ -241,6 +258,7 @@ const saveLocation = async (req, res) => { const rowsToInsert = []; const locationsToPush = []; + const onlineAvailabilityUpdates = []; for (const loc of locations) { const coords = getCoordinatesFromLocation(loc); @@ -274,6 +292,13 @@ const saveLocation = async (req, res) => { tripId, measurementTime: persistedTimestamp.value }); + if (dni && hasAvailabilityModeEnabled(data, loc)) { + onlineAvailabilityUpdates.push({ + dni, + lat: coords.lat, + lng: coords.lng + }); + } } } @@ -293,6 +318,11 @@ const saveLocation = async (req, res) => { ); const agheeraResults = await pushLocationsToAgheera(locationsToPush); + + for (const update of onlineAvailabilityUpdates) { + await upsertOnlineAvailability(update.dni, update.lat, update.lng); + } + const responseBody = { success: true, count: rowsToInsert.length, diff --git a/src/routes/availabilityRoutes.js b/src/routes/availabilityRoutes.js new file mode 100644 index 0000000..42b0c80 --- /dev/null +++ b/src/routes/availabilityRoutes.js @@ -0,0 +1,11 @@ +const express = require('express'); +const availabilityController = require('../controllers/availabilityController'); +const authenticateDevice = require('../middleware/auth'); + +const router = express.Router(); + +router.get('/availability', authenticateDevice, availabilityController.getAvailability); +router.post('/availability', authenticateDevice, availabilityController.setAvailability); +router.delete('/availability', authenticateDevice, availabilityController.deleteAvailability); + +module.exports = router; diff --git a/test/availability.integration.test.js b/test/availability.integration.test.js new file mode 100644 index 0000000..d2ed0d0 --- /dev/null +++ b/test/availability.integration.test.js @@ -0,0 +1,360 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const test = require('node:test'); +const jwt = require('jsonwebtoken'); + +const app = require('../app'); +const db = require('../src/config/db'); + +const TEST_JWT_SECRET = 'test-jwt-secret'; + +let originalQuery; +let originalJwtSecret; + +const createToken = (payload = {}) => + jwt.sign( + { + id: 1, + dni: '58045340X', + id_proveedor: 675, + ...payload + }, + TEST_JWT_SECRET, + { expiresIn: '1h' } + ); + +const withServer = async (callback) => + new Promise((resolve, reject) => { + const server = app.listen(0, '127.0.0.1'); + + server.on('error', reject); + server.on('listening', async () => { + try { + const result = await callback(server); + server.close((closeError) => { + if (closeError) { + reject(closeError); + return; + } + resolve(result); + }); + } catch (error) { + server.close(() => reject(error)); + } + }); + }); + +const requestJson = async ({ port, method, path, authorization, body }) => + new Promise((resolve, reject) => { + const rawBody = body === undefined ? null : JSON.stringify(body); + const headers = {}; + + if (authorization) { + headers.authorization = authorization; + } + + if (rawBody !== null) { + headers['Content-Type'] = 'application/json'; + headers['Content-Length'] = Buffer.byteLength(rawBody); + } + + const req = http.request( + { + hostname: '127.0.0.1', + port, + method, + path, + headers + }, + (res) => { + let responseBody = ''; + + res.on('data', (chunk) => { + responseBody += chunk; + }); + + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + body: responseBody ? JSON.parse(responseBody) : null + }); + }); + } + ); + + req.on('error', reject); + if (rawBody !== null) { + req.write(rawBody); + } + req.end(); + }); + +test.before(() => { + originalQuery = db.query; + originalJwtSecret = process.env.JWT_SECRET; +}); + +test.after(() => { + db.query = originalQuery; + process.env.JWT_SECRET = originalJwtSecret; +}); + +test.afterEach(() => { + db.query = originalQuery; +}); + +test('GET /api/availability devuelve available false si no hay fila', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + + db.query = async (sql, params) => { + assert.match(sql, /COUNT\(\*\) AS total/); + assert.match(sql, /FROM c_trazabilidad_online/); + assert.deepEqual(params, ['58045340X']); + return [[{ total: 0 }]]; + }; + + const response = await withServer((server) => + requestJson({ + port: server.address().port, + method: 'GET', + path: '/api/availability', + authorization: `Bearer ${createToken()}` + }) + ); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { success: true, available: false }); +}); + +test('GET /api/availability devuelve available true si hay fila', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + + db.query = async (sql, params) => { + assert.match(sql, /COUNT\(\*\) AS total/); + assert.match(sql, /FROM c_trazabilidad_online/); + assert.deepEqual(params, ['58045340X']); + return [[{ total: 1 }]]; + }; + + const response = await withServer((server) => + requestJson({ + port: server.address().port, + method: 'GET', + path: '/api/availability', + authorization: `Bearer ${createToken()}` + }) + ); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { success: true, available: true }); +}); + +test('POST /api/availability hace INSERT si no existe', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + + let step = 0; + db.query = async (sql, params) => { + step += 1; + + if (step === 1) { + assert.match(sql, /SELECT id_usuario/); + assert.match(sql, /FROM c_trazabilidad_online/); + assert.deepEqual(params, ['58045340X']); + return [[]]; + } + + assert.match(sql, /INSERT INTO c_trazabilidad_online/); + assert.deepEqual(params, ['40.416775', '-3.70379', '58045340X']); + return [{ affectedRows: 1 }]; + }; + + const response = await withServer((server) => + requestJson({ + port: server.address().port, + method: 'POST', + path: '/api/availability', + authorization: `Bearer ${createToken()}`, + body: { + latitud: 40.416775, + longitud: -3.70379, + usuario: 'OTHER' + } + }) + ); + + assert.equal(step, 2); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { success: true, available: true }); +}); + +test('POST /api/availability hace UPDATE si existe', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + + let step = 0; + db.query = async (sql, params) => { + step += 1; + + if (step === 1) { + assert.match(sql, /SELECT id_usuario/); + assert.deepEqual(params, ['58045340X']); + return [[{ id_usuario: '58045340X' }]]; + } + + assert.match(sql, /UPDATE c_trazabilidad_online/); + assert.deepEqual(params, ['40.416775', '-3.70379', '58045340X']); + return [{ affectedRows: 1 }]; + }; + + const response = await withServer((server) => + requestJson({ + port: server.address().port, + method: 'POST', + path: '/api/availability', + authorization: `Bearer ${createToken()}`, + body: { + latitude: 40.416775, + longitude: -3.70379 + } + }) + ); + + assert.equal(step, 2); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { success: true, available: true }); +}); + +test('DELETE /api/availability borra la fila', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + + db.query = async (sql, params) => { + assert.match(sql, /DELETE FROM c_trazabilidad_online/); + assert.deepEqual(params, ['58045340X']); + return [{ affectedRows: 1 }]; + }; + + const response = await withServer((server) => + requestJson({ + port: server.address().port, + method: 'DELETE', + path: '/api/availability', + authorization: `Bearer ${createToken()}` + }) + ); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { success: true, available: false }); +}); + +test('POST /api/locations con availability_mode true actualiza disponibilidad online', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + + let step = 0; + db.query = async (sql, params) => { + step += 1; + + if (step === 1) { + assert.match(sql, /INSERT INTO c_trazabilidad_transportista/); + assert.equal(params[0].length, 1); + assert.deepEqual(params[0][0].slice(0, 3), ['40.416775', '-3.70379', '58045340X']); + return [{ affectedRows: 1 }]; + } + + if (step === 2) { + assert.match(sql, /SELECT id_usuario/); + assert.match(sql, /FROM c_trazabilidad_online/); + assert.deepEqual(params, ['58045340X']); + return [[{ id_usuario: '58045340X' }]]; + } + + assert.match(sql, /UPDATE c_trazabilidad_online/); + assert.deepEqual(params, ['40.416775', '-3.70379', '58045340X']); + return [{ affectedRows: 1 }]; + }; + + const response = await withServer((server) => + requestJson({ + port: server.address().port, + method: 'POST', + path: '/api/locations', + authorization: `Bearer ${createToken()}`, + body: { + location: [ + { + coords: { + latitude: 40.416775, + longitude: -3.70379 + }, + params: { + availability_mode: 'true' + }, + timestamp: '2026-06-01T13:20:00Z' + } + ] + } + }) + ); + + assert.equal(step, 3); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { + success: true, + count: 1, + message: 'Locations saved' + }); +}); + +test('POST /api/locations sin availability_mode no toca disponibilidad online', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + + let calls = 0; + db.query = async (sql, params) => { + calls += 1; + assert.match(sql, /INSERT INTO c_trazabilidad_transportista/); + assert.deepEqual(params[0][0].slice(0, 3), ['40.416775', '-3.70379', '58045340X']); + return [{ affectedRows: 1 }]; + }; + + const response = await withServer((server) => + requestJson({ + port: server.address().port, + method: 'POST', + path: '/api/locations', + authorization: `Bearer ${createToken()}`, + body: { + latitude: 40.416775, + longitude: -3.70379, + timestamp: '2026-06-01T13:20:00Z' + } + }) + ); + + assert.equal(calls, 1); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { + success: true, + count: 1, + message: 'Locations saved' + }); +}); + +test('todas las rutas nuevas requieren JWT valido', async () => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + + db.query = async () => { + throw new Error('db.query should not be called without token'); + }; + + const responses = await withServer(async (server) => { + const port = server.address().port; + return Promise.all([ + requestJson({ port, method: 'GET', path: '/api/availability' }), + requestJson({ port, method: 'POST', path: '/api/availability', body: { latitude: 1, longitude: 2 } }), + requestJson({ port, method: 'DELETE', path: '/api/availability' }) + ]); + }); + + assert.deepEqual( + responses.map((response) => response.statusCode), + [401, 401, 401] + ); +});