const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'photobioreactor-')); const temporaryLogs = path.join(temporaryRoot, 'logs'); const temporaryData = path.join(temporaryRoot, 'data'); const temporaryRuntime = path.join(temporaryRoot, 'runtime.json'); const temporaryAlarms = path.join(temporaryRoot, 'alarms.json'); const temporaryNotifications = path.join(temporaryRoot, 'notifications.json'); fs.mkdirSync(temporaryLogs, { recursive: true }); fs.mkdirSync(temporaryData, { recursive: true }); fs.writeFileSync(temporaryAlarms, JSON.stringify({ temperature: { min: 20, max: 30 }, ph: { min: 6.8, max: 7.5 }, do: { min: 4, max: 12 }, ec: { min: 500, max: 2500 } })); fs.writeFileSync(temporaryNotifications, JSON.stringify({ enabled: false, minSeverity: 'CRITICAL', channels: { webhook: { enabled: false, url: '', headers: {} }, telegram: { enabled: false, botToken: '', chatId: '' } } })); process.env.LOGS_DIRECTORY = temporaryLogs; process.env.DATA_DIRECTORY = temporaryData; process.env.RUNTIME_CONFIG_FILE = temporaryRuntime; process.env.ALARM_CONFIG_FILE = temporaryAlarms; process.env.NOTIFICATION_CONFIG_FILE = temporaryNotifications; process.env.EZO_MODE = 'demo'; process.env.API_AUTH_TOKEN = 'test-token'; process.env.API_RATE_LIMIT_WINDOW_MS = '60000'; process.env.API_RATE_LIMIT_MAX = '1000'; const { app, HISTORY_FILES, resetRateLimits } = require('../api/server'); const { collectReadings, pruneCsvByRetention, publishReading } = require('../api/acquisition-service'); const { dispatchAlarmNotifications, readNotificationConfig, writeNotificationConfig } = require('../api/notification-service'); let server; let baseUrl; test.before(async () => { await new Promise((resolve) => { server = app.listen(0, '127.0.0.1', resolve); }); const address = server.address(); baseUrl = `http://127.0.0.1:${address.port}`; }); test.after(async () => { await new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); }); fs.rmSync(temporaryRoot, { recursive: true, force: true }); }); test('returns real CSV history for all sensors', async () => { fs.writeFileSync( path.join(temporaryLogs, 'temperature.csv'), 'timestamp,value\n2026-06-22T12:00:00.000Z,25.1\n' ); fs.writeFileSync( path.join(temporaryLogs, 'ph.csv'), 'timestamp,value\n2026-06-22T12:00:00.000Z,7.2\n' ); fs.writeFileSync(path.join(temporaryLogs, 'do.csv'), 'timestamp,value\n'); fs.writeFileSync(path.join(temporaryLogs, 'ec.csv'), 'timestamp,value\n'); const response = await fetch(`${baseUrl}/api/sensors/all`); const data = await response.json(); assert.equal(response.status, 200); assert.equal(data.length, 2); assert.ok(data[0].Timestamp); assert.ok(data[0].Sensor); assert.equal(typeof data[0].Valor, 'number'); }); test('all history export respects enabled sensors', async () => { fs.writeFileSync( path.join(temporaryLogs, 'temperature.csv'), 'timestamp,value\n2026-06-22T12:00:00.000Z,25.1\n' ); fs.writeFileSync( path.join(temporaryLogs, 'ph.csv'), 'timestamp,value\n2026-06-22T12:00:00.000Z,7.2\n' ); fs.writeFileSync(path.join(temporaryLogs, 'do.csv'), 'timestamp,value\n'); fs.writeFileSync(path.join(temporaryLogs, 'ec.csv'), 'timestamp,value\n'); await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ rate: 1, enabledSensors: ['temperature'] }) }); const response = await fetch(`${baseUrl}/api/sensors/all`); const data = await response.json(); assert.equal(response.status, 200); assert.equal(data.length, 1); assert.equal(data[0].Sensor, 'RTD'); }); test('serves the dashboard in local development mode', async () => { const response = await fetch(`${baseUrl}/`); const html = await response.text(); assert.equal(response.status, 200); assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); assert.equal(response.headers.get('x-frame-options'), 'DENY'); assert.match(html, /Panel del Fotobiorreactor/); }); test('protects critical API endpoints when token is configured', async () => { const response = await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rate: 1, enabledSensors: ['temperature'] }) }); const data = await response.json(); assert.equal(response.status, 401); assert.equal(data.success, false); const wrongTokenResponse = await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'wrong-token' }, body: JSON.stringify({ rate: 1, enabledSensors: ['temperature'] }) }); assert.equal(wrongTokenResponse.status, 401); }); test('validates sensor commands', async () => { const invalidResponse = await fetch(`${baseUrl}/api/sensors/invalid/command`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ command: 'r' }) }); assert.equal(invalidResponse.status, 400); const validResponse = await fetch(`${baseUrl}/api/sensors/ph/command`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ command: 'r' }) }); const validData = await validResponse.json(); assert.equal(validResponse.status, 200); assert.equal(validData.success, true); assert.equal(validData.mode, 'demo'); }); test('rejects undocumented calibration syntax', async () => { const response = await fetch(`${baseUrl}/api/sensors/do/command`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ command: 'Cal,atm' }) }); const data = await response.json(); assert.equal(response.status, 400); assert.match(data.error, /documentados/); }); test('persists logging rates used by acquisition', async () => { const invalidResponse = await fetch(`${baseUrl}/api/config/logging`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ rate: 3 }) }); assert.equal(invalidResponse.status, 400); const validResponse = await fetch(`${baseUrl}/api/config/logging`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ rate: 5 }) }); const validData = await validResponse.json(); assert.equal(validResponse.status, 200); assert.equal(validData.rate, 5); const readResponse = await fetch(`${baseUrl}/api/config/logging`); const readData = await readResponse.json(); assert.equal(readData.rate, 5); assert.equal(JSON.parse(fs.readFileSync(temporaryRuntime)).loggingRateSeconds, 5); }); test('persists enabled sensors used by acquisition', async () => { const response = await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ rate: 1, historyRetentionDays: 30, enabledSensors: ['temperature'] }) }); const data = await response.json(); assert.equal(response.status, 200); assert.deepEqual(data.enabledSensors, ['temperature']); const readResponse = await fetch(`${baseUrl}/api/config/runtime`); const readData = await readResponse.json(); assert.deepEqual(readData.enabledSensors, ['temperature']); assert.equal(readData.historyRetentionDays, 30); }); test('persists history retention configuration', async () => { const response = await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ rate: 1, historyRetentionDays: 7, enabledSensors: ['temperature'] }) }); const data = await response.json(); assert.equal(response.status, 200); assert.equal(data.historyRetentionDays, 7); assert.equal(JSON.parse(fs.readFileSync(temporaryRuntime)).historyRetentionDays, 7); }); test('rejects invalid history retention configuration', async () => { const response = await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ rate: 1, historyRetentionDays: 13, enabledSensors: ['temperature'] }) }); assert.equal(response.status, 400); }); test('rejects empty enabled sensor configuration', async () => { const response = await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ rate: 1, enabledSensors: [] }) }); assert.equal(response.status, 400); }); test('persists alarm threshold configuration with token protection', async () => { const unauthorizedResponse = await fetch(`${baseUrl}/api/config/alarms`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thresholds: { temperature: { min: 19, max: 29 }, ph: { min: 6.5, max: 7.8 }, do: { min: 3, max: 13 }, ec: { min: 400, max: 2600 } } }) }); assert.equal(unauthorizedResponse.status, 401); const validResponse = await fetch(`${baseUrl}/api/config/alarms`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ thresholds: { temperature: { min: 19, max: 29 }, ph: { min: 6.5, max: 7.8 }, do: { min: 3, max: 13 }, ec: { min: 400, max: 2600 } } }) }); const saved = await validResponse.json(); assert.equal(validResponse.status, 200); assert.equal(saved.thresholds.temperature.min, 19); const readResponse = await fetch(`${baseUrl}/api/config/alarms`); const read = await readResponse.json(); assert.equal(read.thresholds.ec.max, 2600); }); test('rejects invalid alarm thresholds', async () => { const response = await fetch(`${baseUrl}/api/config/alarms`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ thresholds: { temperature: { min: 30, max: 20 }, ph: { min: 6.5, max: 7.8 }, do: { min: 3, max: 13 }, ec: { min: 400, max: 2600 } } }) }); assert.equal(response.status, 400); }); test('persists notification configuration with redacted secrets', async () => { const response = await fetch(`${baseUrl}/api/config/notifications`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ config: { enabled: true, minSeverity: 'WARNING', channels: { webhook: { enabled: true, url: 'https://example.test/alarm', headers: { Authorization: 'Bearer secret' } }, telegram: { enabled: true, botToken: '123456:secret', chatId: '42' } } } }) }); const data = await response.json(); assert.equal(response.status, 200); assert.equal(data.config.enabled, true); assert.equal(data.config.channels.webhook.headers, '[configured]'); assert.equal(data.config.channels.telegram.botToken, '[configured]'); const readResponse = await fetch(`${baseUrl}/api/config/notifications`); const readData = await readResponse.json(); assert.equal(readData.config.channels.telegram.botToken, '[configured]'); }); test('dispatches webhook notifications for eligible alarm events', async () => { await writeNotificationConfig({ enabled: true, minSeverity: 'WARNING', channels: { webhook: { enabled: true, url: 'https://example.test/alarm', headers: { Authorization: 'Bearer secret' } }, telegram: { enabled: false, botToken: '', chatId: '' } } }); const originalFetch = global.fetch; const calls = []; global.fetch = async (url, options) => { calls.push({ url, options }); return { ok: true, status: 200 }; }; try { const result = await dispatchAlarmNotifications([{ timestamp: '2026-06-27T12:00:00.000Z', sensorId: 'temperature', state: 'CRITICAL', value: 31.5, message: 'Valor fuera de rango' }]); assert.equal(result.length, 1); assert.equal(result[0].success, true); assert.equal(calls.length, 1); assert.equal(calls[0].url, 'https://example.test/alarm'); assert.match(calls[0].options.body, /temperature/); } finally { global.fetch = originalFetch; await writeNotificationConfig({ enabled: false, minSeverity: 'CRITICAL', channels: { webhook: { enabled: false, url: '', headers: {} }, telegram: { enabled: false, botToken: '', chatId: '' } } }); } }); test('preserves existing Telegram token when requested by notification UI', async () => { await writeNotificationConfig({ enabled: true, minSeverity: 'CRITICAL', channels: { webhook: { enabled: false, url: '', headers: {} }, telegram: { enabled: true, botToken: '123456:secret', chatId: '42' } } }); await writeNotificationConfig({ enabled: true, minSeverity: 'WARNING', channels: { webhook: { enabled: false, url: '', headers: {} }, telegram: { enabled: true, botToken: '__KEEP__', chatId: '43' } } }); const config = await readNotificationConfig(); assert.equal(config.channels.telegram.botToken, '123456:secret'); assert.equal(config.channels.telegram.chatId, '43'); }); test('demo acquisition writes live JSON and CSV files', async () => { await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ rate: 1, enabledSensors: ['temperature', 'ph', 'do', 'ec'] }) }); const result = await collectReadings(); assert.equal(result.mode, 'demo'); for (const file of ['EZORTD.json', 'EZOPH.json', 'EZODO.json', 'EZOEC.json']) { const data = JSON.parse(fs.readFileSync(path.join(temporaryData, file))); assert.equal(data.online, true); assert.ok(data.timestamp); } for (const file of ['temperature.csv', 'ph.csv', 'do.csv', 'ec.csv']) { assert.match( fs.readFileSync(path.join(temporaryLogs, file), 'utf8'), /^timestamp,value/m ); } }); test('disabled sensors are published as disabled without CSV rows', async () => { await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ rate: 1, enabledSensors: ['temperature'] }) }); fs.rmSync(path.join(temporaryLogs, 'ph.csv'), { force: true }); const result = await collectReadings(); const phData = JSON.parse(fs.readFileSync(path.join(temporaryData, 'EZOPH.json'))); assert.equal(result.results.find((item) => item.sensorId === 'ph').disabled, true); assert.equal(phData.disabled, true); assert.equal(fs.existsSync(path.join(temporaryLogs, 'ph.csv')), false); }); test('acquisition persists alarm events and exposes them through the API', async () => { fs.writeFileSync(temporaryAlarms, JSON.stringify({ temperature: { min: 20, max: 24 }, ph: { min: 6.8, max: 7.5 }, do: { min: 4, max: 12 }, ec: { min: 500, max: 2500 } })); fs.rmSync(path.join(temporaryLogs, 'alarms.csv'), { force: true }); await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': 'test-token' }, body: JSON.stringify({ rate: 1, enabledSensors: ['temperature'] }) }); const result = await collectReadings(); const alarmsCsv = fs.readFileSync(path.join(temporaryLogs, 'alarms.csv'), 'utf8'); const response = await fetch(`${baseUrl}/api/alarms`); const alarmEvents = await response.json(); assert.equal(result.alarmEvents.length, 1); assert.match(alarmsCsv, /^timestamp,sensor,state,value,message/m); assert.match(alarmsCsv, /"temperature","CRITICAL"/); assert.equal(response.status, 200); assert.equal(alarmEvents[0].sensor, 'temperature'); assert.equal(alarmEvents[0].state, 'CRITICAL'); fs.writeFileSync(temporaryAlarms, JSON.stringify({ temperature: { min: 20, max: 30 }, ph: { min: 6.8, max: 7.5 }, do: { min: 4, max: 12 }, ec: { min: 500, max: 2500 } })); }); test('prunes historical CSV rows outside retention window', async () => { const csvPath = path.join(temporaryLogs, 'retention-test.csv'); fs.writeFileSync( csvPath, [ 'timestamp,value', '2026-01-01T00:00:00.000Z,10', '2026-06-20T00:00:00.000Z,20' ].join('\n') + '\n' ); const pruned = await pruneCsvByRetention( csvPath, 'timestamp,value\n', 30, new Date('2026-06-25T00:00:00.000Z') ); const content = fs.readFileSync(csvPath, 'utf8'); assert.equal(pruned, true); assert.doesNotMatch(content, /2026-01-01/); assert.match(content, /2026-06-20/); }); test('hardware null readings are marked offline and not appended as zero', async () => { const timestamp = new Date().toISOString(); const csvPath = path.join(temporaryLogs, 'ph.csv'); fs.rmSync(csvPath, { force: true }); const online = await publishReading('ph', null, timestamp, 'hardware'); const data = JSON.parse(fs.readFileSync(path.join(temporaryData, 'EZOPH.json'))); assert.equal(online, false); assert.equal(data.online, false); assert.equal(data.error, 'Lectura no disponible'); assert.equal(fs.existsSync(csvPath), false); }); test('clears historical CSV files and preserves headers', async () => { for (const name of Object.keys(HISTORY_FILES)) { fs.writeFileSync(path.join(temporaryLogs, `${name}.csv`), 'old,data\n1,2\n'); } const response = await fetch(`${baseUrl}/api/history/clear`, { method: 'POST', headers: { 'X-API-Token': 'test-token' } }); assert.equal(response.status, 200); for (const [name, header] of Object.entries(HISTORY_FILES)) { const content = fs.readFileSync( path.join(temporaryLogs, `${name}.csv`), 'utf8' ); assert.equal(content, header); } });