|
|
|
|
@ -8,15 +8,28 @@ 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');
|
|
|
|
|
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 }
|
|
|
|
|
}));
|
|
|
|
|
process.env.LOGS_DIRECTORY = temporaryLogs;
|
|
|
|
|
process.env.DATA_DIRECTORY = temporaryData;
|
|
|
|
|
process.env.RUNTIME_CONFIG_FILE = temporaryRuntime;
|
|
|
|
|
process.env.ALARM_CONFIG_FILE = temporaryAlarms;
|
|
|
|
|
process.env.EZO_MODE = 'demo';
|
|
|
|
|
process.env.API_AUTH_TOKEN = 'test-token';
|
|
|
|
|
|
|
|
|
|
const { app, HISTORY_FILES } = require('../api/server');
|
|
|
|
|
const { collectReadings, publishReading } = require('../api/acquisition-service');
|
|
|
|
|
const {
|
|
|
|
|
collectReadings,
|
|
|
|
|
pruneCsvByRetention,
|
|
|
|
|
publishReading
|
|
|
|
|
} = require('../api/acquisition-service');
|
|
|
|
|
|
|
|
|
|
let server;
|
|
|
|
|
let baseUrl;
|
|
|
|
|
@ -73,7 +86,10 @@ test('all history export respects enabled sensors', async () => {
|
|
|
|
|
|
|
|
|
|
await fetch(`${baseUrl}/api/config/runtime`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'X-API-Token': 'test-token'
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
rate: 1,
|
|
|
|
|
enabledSensors: ['temperature']
|
|
|
|
|
@ -96,17 +112,38 @@ test('serves the dashboard in local development mode', async () => {
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('validates sensor commands', async () => {
|
|
|
|
|
const invalidResponse = await fetch(`${baseUrl}/api/sensors/invalid/command`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
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' },
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'X-API-Token': 'test-token'
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ command: 'r' })
|
|
|
|
|
});
|
|
|
|
|
const validData = await validResponse.json();
|
|
|
|
|
@ -119,7 +156,10 @@ test('validates sensor commands', async () => {
|
|
|
|
|
test('rejects undocumented calibration syntax', async () => {
|
|
|
|
|
const response = await fetch(`${baseUrl}/api/sensors/do/command`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'X-API-Token': 'test-token'
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ command: 'Cal,atm' })
|
|
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
@ -131,14 +171,20 @@ test('rejects undocumented calibration syntax', async () => {
|
|
|
|
|
test('persists logging rates used by acquisition', async () => {
|
|
|
|
|
const invalidResponse = await fetch(`${baseUrl}/api/config/logging`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
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' },
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'X-API-Token': 'test-token'
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ rate: 5 })
|
|
|
|
|
});
|
|
|
|
|
const validData = await validResponse.json();
|
|
|
|
|
@ -155,9 +201,13 @@ test('persists logging rates used by acquisition', async () => {
|
|
|
|
|
test('persists enabled sensors used by acquisition', async () => {
|
|
|
|
|
const response = await fetch(`${baseUrl}/api/config/runtime`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'X-API-Token': 'test-token'
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
rate: 1,
|
|
|
|
|
historyRetentionDays: 30,
|
|
|
|
|
enabledSensors: ['temperature']
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
@ -169,12 +219,53 @@ test('persists enabled sensors used by acquisition', async () => {
|
|
|
|
|
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' },
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'X-API-Token': 'test-token'
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
rate: 1,
|
|
|
|
|
enabledSensors: []
|
|
|
|
|
@ -184,10 +275,73 @@ test('rejects empty enabled sensor configuration', async () => {
|
|
|
|
|
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('demo acquisition writes live JSON and CSV files', async () => {
|
|
|
|
|
await fetch(`${baseUrl}/api/config/runtime`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'X-API-Token': 'test-token'
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
rate: 1,
|
|
|
|
|
enabledSensors: ['temperature', 'ph', 'do', 'ec']
|
|
|
|
|
@ -212,7 +366,10 @@ test('demo acquisition writes live JSON and CSV files', async () => {
|
|
|
|
|
test('disabled sensors are published as disabled without CSV rows', async () => {
|
|
|
|
|
await fetch(`${baseUrl}/api/config/runtime`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'X-API-Token': 'test-token'
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
rate: 1,
|
|
|
|
|
enabledSensors: ['temperature']
|
|
|
|
|
@ -228,6 +385,71 @@ test('disabled sensors are published as disabled without CSV rows', async () =>
|
|
|
|
|
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');
|
|
|
|
|
@ -248,7 +470,10 @@ test('clears historical CSV files and preserves headers', async () => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`${baseUrl}/api/history/clear`, {
|
|
|
|
|
method: 'POST'
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'X-API-Token': 'test-token'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.equal(response.status, 200);
|
|
|
|
|
|