You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

643 lines
21 KiB
JavaScript

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