agmission/server/tests/test_pilot_dashboard_api.js

746 lines
26 KiB
JavaScript

'use strict';
/**
* Pilot Analytics Dashboard API — integration tests
*
* Run:
* npm run test:single tests/test_pilot_dashboard_api.js
*
* Env vars (set in environment.env or export before running):
* DASHBOARD_TEST_TOKEN — JWT for a Pilot user (required for live tests)
* PILOT_DASHBOARD_BASE_URL — override server URL (default: http://localhost:4100)
* RUN_SNAPSHOT_TESTS=0 — optionally skip snapshot endpoint tests (default: enabled)
* RUN_COMPLETE_TEST=1 — enable the complete-job test
* DASHBOARD_TEST_JOB_ID — numeric job ID in SPRAYED(3) status (required when RUN_COMPLETE_TEST=1)
*
* Environment is loaded by tests/setup.js (via --require in npm test scripts).
*/
const axios = require('axios');
const https = require('https');
const { expect } = require('chai');
const BASE_URL = process.env.PILOT_DASHBOARD_BASE_URL || process.env.API_BASE_URL || 'https://localhost:4100';
const TOKEN = process.env.DASHBOARD_TEST_TOKEN || process.env.AUTH_TOKEN || process.env.TEST_AUTH_TOKEN || '';
const RUN_SNAPSHOT_TESTS = process.env.RUN_SNAPSHOT_TESTS !== '0';
const RUN_COMPLETE_TEST = process.env.RUN_COMPLETE_TEST === '1';
const COMPLETE_JOB_ID = process.env.DASHBOARD_TEST_JOB_ID || '';
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
// Shared axios client — attaches JWT and returns { status, data } for every request
const client = axios.create({
baseURL: BASE_URL,
validateStatus: () => true, // never throw on non-2xx; assertions do that
httpsAgent,
headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}
});
async function get(path) {
try {
const res = await client.get(path);
return { status: res.status, data: res.data };
} catch (err) {
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message === 'socket hang up') {
throw new Error(`Server not reachable at ${BASE_URL} — start the server first (${err.code || err.message})`);
}
throw err;
}
}
async function patch(path) {
try {
const res = await client.patch(path);
return { status: res.status, data: res.data };
} catch (err) {
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message === 'socket hang up') {
throw new Error(`Server not reachable at ${BASE_URL} — start the server first (${err.code || err.message})`);
}
throw err;
}
}
async function put(path, body) {
try {
const res = await client.put(path, body);
return { status: res.status, data: res.data };
} catch (err) {
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message === 'socket hang up') {
throw new Error(`Server not reachable at ${BASE_URL} — start the server first (${err.code || err.message})`);
}
throw err;
}
}
async function ensureServerAvailable() {
try {
const res = await client.get('/api/health');
return res && typeof res.status === 'number';
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
describe('Pilot Analytics Dashboard API', function () {
this.timeout(15000);
before(async function () {
if (!TOKEN) {
console.log('\n ⚠ No token — skipping all live API tests.');
console.log(' Set DASHBOARD_TEST_TOKEN (or AUTH_TOKEN / TEST_AUTH_TOKEN) to run.\n');
return this.skip();
}
const isAvailable = await ensureServerAvailable();
if (!isAvailable) {
console.log(`\n ⚠ Server not reachable at ${BASE_URL}. Start the server first and rerun this test.\n`);
return this.skip();
}
});
// -------------------------------------------------------------------------
describe('GET /api/dashboard/pilot/kpi', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/kpi?tz=UTC'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('has operations and periods top-level keys', function () {
expect(data).to.include.keys('operations', 'periods');
});
it('operations has missionsFlown, distanceTravelledKm, distanceSprayedKm', function () {
expect(data.operations).to.be.an('object').and.include.keys('missionsFlown', 'distanceTravelledKm', 'distanceSprayedKm');
});
it('operations has sprayEfficiencyPct, ferryTimePct, flowAccuracyPct, avgHdop', function () {
expect(data.operations).to.include.keys('sprayEfficiencyPct', 'ferryTimePct', 'flowAccuracyPct', 'avgHdop');
});
it('operations sprayEfficiencyPct is a number or null', function () {
const v = data.operations.sprayEfficiencyPct;
expect(v === null || (typeof v === 'number' && v >= 0 && v <= 100)).to.equal(true);
});
it('operations ferryTimePct is a number or null', function () {
const v = data.operations.ferryTimePct;
expect(v === null || (typeof v === 'number' && v >= 0 && v <= 100)).to.equal(true);
});
it('sprayEfficiencyPct + ferryTimePct = 100 when both non-null', function () {
const s = data.operations.sprayEfficiencyPct;
const f = data.operations.ferryTimePct;
if (s !== null && f !== null) {
expect(Math.round(s + f)).to.equal(100);
}
});
it('operations flowAccuracyPct is a number or null', function () {
const v = data.operations.flowAccuracyPct;
expect(v === null || typeof v === 'number').to.equal(true);
});
it('operations avgHdop is a number or null', function () {
const v = data.operations.avgHdop;
expect(v === null || (typeof v === 'number' && v >= 0)).to.equal(true);
});
it('periods has day, week, month, year, all sub-objects', function () {
expect(data.periods).to.be.an('object').and.include.keys('day', 'week', 'month', 'year', 'all');
});
it('each period has assignedJobs, assignedHectares, sprayedHectares, flightHours, jobCounts', function () {
['day', 'week', 'month', 'year', 'all'].forEach((period) => {
expect(data.periods[period]).to.include.keys('assignedJobs', 'assignedHectares', 'sprayedHectares', 'flightHours', 'jobCounts');
expect(data.periods[period].jobCounts).to.include.keys('new', 'inProgress', 'completed');
});
});
it('each period has new efficiency and GPS metrics: sprayEfficiencyPct, ferryTimePct, flowAccuracyPct, avgHdop', function () {
['day', 'week', 'month', 'year', 'all'].forEach((period) => {
expect(data.periods[period]).to.include.keys('sprayEfficiencyPct', 'ferryTimePct', 'flowAccuracyPct', 'avgHdop');
});
});
it('each period sprayEfficiencyPct is a number or null, in range [0, 100]', function () {
['day', 'week', 'month', 'year', 'all'].forEach((period) => {
const v = data.periods[period].sprayEfficiencyPct;
expect(v === null || (typeof v === 'number' && v >= 0 && v <= 100)).to.equal(true);
});
});
it('each period ferryTimePct is a number or null, in range [0, 100]', function () {
['day', 'week', 'month', 'year', 'all'].forEach((period) => {
const v = data.periods[period].ferryTimePct;
expect(v === null || (typeof v === 'number' && v >= 0 && v <= 100)).to.equal(true);
});
});
it('each period: sprayEfficiencyPct + ferryTimePct = 100 when both non-null', function () {
['day', 'week', 'month', 'year', 'all'].forEach((period) => {
const s = data.periods[period].sprayEfficiencyPct;
const f = data.periods[period].ferryTimePct;
if (s !== null && f !== null) {
expect(Math.round(s + f)).to.equal(100);
}
});
});
it('each period flowAccuracyPct is a number or null', function () {
['day', 'week', 'month', 'year', 'all'].forEach((period) => {
const v = data.periods[period].flowAccuracyPct;
expect(v === null || (typeof v === 'number' && v >= 0 && v <= 100)).to.equal(true);
});
});
it('each period avgHdop is a number or null, non-negative', function () {
['day', 'week', 'month', 'year', 'all'].forEach((period) => {
const v = data.periods[period].avgHdop;
expect(v === null || (typeof v === 'number' && v >= 0)).to.equal(true);
});
});
});
// -------------------------------------------------------------------------
describe('GET /api/dashboard/pilot/summary', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/summary?tz=UTC'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('has today, yesterday and deltas', function () {
expect(data).to.include.keys('today', 'yesterday', 'deltas');
});
it('today has expected metric keys', function () {
expect(data.today).to.include.keys('hectares', 'flightHours', 'haPerHour', 'avgSpeedKmh', 'sprayVolumeLiters');
});
it('delta values are null or a number', function () {
Object.values(data.deltas).forEach((v) => {
expect(v === null || typeof v === 'number').to.be.true;
});
});
});
// -------------------------------------------------------------------------
describe('GET /api/dashboard/pilot/trend', function () {
describe('default (current week)', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/trend?tz=UTC'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('has labels, hoursFlown and hectaresPerDay arrays', function () {
expect(data.labels).to.be.an('array');
expect(data.hoursFlown).to.be.an('array');
expect(data.hectaresPerDay).to.be.an('array');
});
it('all three arrays have equal length', function () {
expect(data.hoursFlown).to.have.lengthOf(data.labels.length);
expect(data.hectaresPerDay).to.have.lengthOf(data.labels.length);
});
});
describe('custom date range (14 days)', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/trend?tz=UTC&startDate=2026-04-16&endDate=2026-04-29'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('returns exactly 14 data points', function () {
expect(data.labels).to.have.lengthOf(14);
});
});
describe('range exceeding 90-day cap', function () {
let status;
before(async function () {
({ status } = await get('/api/dashboard/pilot/trend?tz=UTC&startDate=2025-01-01&endDate=2026-04-29'));
});
it('returns HTTP 409', function () {
expect(status).to.equal(409);
});
});
});
// -------------------------------------------------------------------------
describe('GET /api/dashboard/pilot/activeJobs', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/activeJobs'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('has a jobs array', function () {
expect(data.jobs).to.be.an('array');
});
it('each job has required fields', function () {
data.jobs.forEach((j) => {
expect(j).to.include.keys('jobId', 'name', 'status', 'displayStatus', 'progressPct');
expect(j.jobId).to.be.a('number');
expect(['NEW', 'IN_PROGRESS', 'COMPLETED']).to.include(j.displayStatus);
expect(j.progressPct).to.be.within(0, 100);
});
});
});
// -------------------------------------------------------------------------
describe('UTC-backed dashboard windows', function () {
describe('GET /api/dashboard/pilot/activeJobs with period + tz', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/activeJobs?period=week&tz=America/Toronto'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('returns a jobs array for the tz-scoped period request', function () {
expect(data).to.have.property('jobs');
expect(data.jobs).to.be.an('array');
});
});
describe('GET /api/dashboard/pilot/snapshot with tz/date filters', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/snapshot?include=activeJobs,trend,performance&tz=America/Toronto&period=week&startDate=2026-04-16&endDate=2026-04-29'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('returns only the requested modules', function () {
expect(data).to.include.keys('activeJobs', 'trend', 'performance');
expect(data).to.not.have.property('kpi');
expect(data).to.not.have.property('summary');
});
it('trend arrays remain aligned for tz/date-scoped snapshot requests', function () {
expect(data.trend.labels).to.be.an('array');
expect(data.trend.hoursFlown).to.have.lengthOf(data.trend.labels.length);
expect(data.trend.hectaresPerDay).to.have.lengthOf(data.trend.labels.length);
});
});
});
// -------------------------------------------------------------------------
describe('GET /api/dashboard/pilot/performance', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/performance'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('has presence flags and threshold configs', function () {
expect(data).to.include.keys('hasXtData', 'hasAltitudeData', 'sampleSize', 'xtThreshold', 'altThreshold');
});
it('hasXtData and hasAltitudeData are booleans', function () {
expect(data.hasXtData).to.be.a('boolean');
expect(data.hasAltitudeData).to.be.a('boolean');
});
it('avgXtError is null when no XT data, number in metres otherwise', function () {
if (data.hasXtData) {
expect(data.avgXtError).to.be.a('number');
} else {
expect(data.avgXtError).to.be.null;
}
});
it('altitudeSource is sprayHeight or radarAlt when altitude data exists', function () {
if (data.hasAltitudeData) {
expect(['sprayHeight', 'radarAlt']).to.include(data.altitudeSource);
}
});
it('xtThreshold has good and monitor keys', function () {
expect(data.xtThreshold).to.include.keys('good', 'monitor');
});
it('altThreshold has target, goodBand and monitorBand keys', function () {
expect(data.altThreshold).to.include.keys('target', 'goodBand', 'monitorBand');
});
});
// -------------------------------------------------------------------------
describe('PUT /api/dashboard/pilot/performance/thresholds', function () {
describe('save custom thresholds', function () {
let status, data;
before(async function () {
({ status, data } = await put('/api/dashboard/pilot/performance/thresholds', {
xtGood: 1.5, xtMonitor: 4.0, altTarget: 4.0, altGoodBand: 0.2, altMonitorBand: 0.5
}));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('response has xtThreshold and altThreshold', function () {
expect(data).to.include.keys('xtThreshold', 'altThreshold');
});
it('xtThreshold reflects saved values', function () {
expect(data.xtThreshold.good).to.equal(1.5);
expect(data.xtThreshold.monitor).to.equal(4.0);
});
it('altThreshold reflects saved values', function () {
expect(data.altThreshold.target).to.equal(4.0);
expect(data.altThreshold.goodBand).to.equal(0.2);
expect(data.altThreshold.monitorBand).to.equal(0.5);
});
});
describe('partial update (only xtGood)', function () {
let status, data;
before(async function () {
// First set a known baseline
await put('/api/dashboard/pilot/performance/thresholds', {
xtGood: 1.0, xtMonitor: 3.0
});
// Now partial update just xtMonitor
({ status, data } = await put('/api/dashboard/pilot/performance/thresholds', {
xtMonitor: 5.0
}));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('updated field reflects new value', function () {
expect(data.xtThreshold.monitor).to.equal(5.0);
});
it('untouched field retains stored value', function () {
expect(data.xtThreshold.good).to.equal(1.0);
});
});
describe('reset a field to system default (null)', function () {
let status, data;
before(async function () {
// Set a custom value first
await put('/api/dashboard/pilot/performance/thresholds', { xtGood: 2.0, xtMonitor: 4.0 });
// Now reset xtGood to system default
({ status, data } = await put('/api/dashboard/pilot/performance/thresholds', {
xtGood: null
}));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('reset field returns system default (1.0)', function () {
expect(data.xtThreshold.good).to.equal(1.0);
});
});
describe('cross-field validation: xtMonitor must be > xtGood', function () {
let status;
before(async function () {
({ status } = await put('/api/dashboard/pilot/performance/thresholds', {
xtGood: 5.0, xtMonitor: 2.0
}));
});
it('returns HTTP 409', function () {
expect(status).to.equal(409);
});
});
describe('cross-field validation: altMonitorBand must be > altGoodBand', function () {
let status;
before(async function () {
({ status } = await put('/api/dashboard/pilot/performance/thresholds', {
altGoodBand: 0.5, altMonitorBand: 0.2
}));
});
it('returns HTTP 409', function () {
expect(status).to.equal(409);
});
});
describe('restore system defaults (cleanup)', function () {
let status;
before(async function () {
({ status } = await put('/api/dashboard/pilot/performance/thresholds', {
xtGood: null, xtMonitor: null, altTarget: null, altGoodBand: null, altMonitorBand: null
}));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
});
});
// -------------------------------------------------------------------------
describe('GET /api/dashboard/pilot/snapshot', function () {
before(function () {
if (!RUN_SNAPSHOT_TESTS) {
console.log('\n ⚠ RUN_SNAPSHOT_TESTS=0 — skipping snapshot endpoint tests.\n');
return this.skip();
}
});
describe('default (all modules)', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/snapshot?tz=UTC'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('includes all available modules', function () {
expect(data).to.include.keys('kpi', 'summary', 'activeJobs', 'performance', 'trend');
});
it('kpi module has operations and periods', function () {
expect(data.kpi).to.include.keys('operations', 'periods');
expect(data.kpi.periods).to.include.keys('day', 'week', 'month', 'year', 'all');
});
it('kpi operations has new metrics: sprayEfficiencyPct, ferryTimePct, flowAccuracyPct, avgHdop', function () {
expect(data.kpi.operations).to.include.keys('sprayEfficiencyPct', 'ferryTimePct', 'flowAccuracyPct', 'avgHdop');
});
it('kpi each period has new metrics: sprayEfficiencyPct, ferryTimePct, flowAccuracyPct, avgHdop', function () {
['day', 'week', 'month', 'year', 'all'].forEach((period) => {
expect(data.kpi.periods[period]).to.include.keys('sprayEfficiencyPct', 'ferryTimePct', 'flowAccuracyPct', 'avgHdop');
});
});
it('summary module has today, yesterday and deltas', function () {
expect(data.summary).to.include.keys('today', 'yesterday', 'deltas');
});
it('activeJobs module has jobs array', function () {
expect(data.activeJobs).to.include.keys('jobs');
expect(data.activeJobs.jobs).to.be.an('array');
});
it('performance module has xt and altitude data', function () {
expect(data.performance).to.include.keys('hasXtData', 'hasAltitudeData', 'xtThreshold', 'altThreshold');
});
it('trend module has labels and data arrays', function () {
expect(data.trend).to.include.keys('labels', 'hoursFlown', 'hectaresPerDay');
expect(data.trend.labels).to.be.an('array');
});
});
describe('selective modules (kpi only)', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/snapshot?include=kpi&tz=UTC'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('includes only kpi module', function () {
expect(data).to.include.keys('kpi');
expect(Object.keys(data)).to.have.lengthOf(1);
});
it('kpi data is complete', function () {
expect(data.kpi.operations).to.include.keys('missionsFlown', 'distanceTravelledKm', 'distanceSprayedKm',
'sprayEfficiencyPct', 'ferryTimePct', 'flowAccuracyPct', 'avgHdop');
expect(data.kpi.periods).to.include.keys('day', 'week', 'month', 'year', 'all');
});
});
describe('selective modules (multiple: performance + trend)', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/snapshot?include=performance,trend&tz=UTC'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('includes only performance and trend modules', function () {
expect(data).to.include.keys('performance', 'trend');
expect(Object.keys(data)).to.have.lengthOf(2);
});
});
describe('trend with custom date range', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/snapshot?include=trend&tz=UTC&startDate=2026-04-16&endDate=2026-04-29'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('trend data spans exactly 14 days', function () {
expect(data.trend.labels).to.have.lengthOf(14);
expect(data.trend.hoursFlown).to.have.lengthOf(14);
expect(data.trend.hectaresPerDay).to.have.lengthOf(14);
});
});
describe('range exceeding 90-day cap', function () {
let status;
before(async function () {
({ status } = await get('/api/dashboard/pilot/snapshot?include=trend&tz=UTC&startDate=2025-01-01&endDate=2026-04-29'));
});
it('returns HTTP 409', function () {
expect(status).to.equal(409);
});
});
describe('invalid include value (graceful degradation)', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/snapshot?include=kpi,invalid_module,performance&tz=UTC'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('includes only valid modules, silently ignores invalid ones', function () {
expect(data).to.include.keys('kpi', 'performance');
expect('invalid_module' in data).to.be.false;
});
});
describe('activeJobs with period filter (week)', function () {
let status, data;
before(async function () {
({ status, data } = await get('/api/dashboard/pilot/snapshot?include=activeJobs&tz=UTC&period=week'));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('includes only activeJobs module', function () {
expect(data).to.include.keys('activeJobs');
expect(Object.keys(data)).to.have.lengthOf(1);
});
it('activeJobs has jobs array (haSprayed scoped to current week)', function () {
expect(data.activeJobs).to.include.keys('jobs');
expect(data.activeJobs.jobs).to.be.an('array');
});
});
describe('activeJobs with invalid period value', function () {
let status;
before(async function () {
({ status } = await get('/api/dashboard/pilot/snapshot?include=activeJobs&tz=UTC&period=invalid'));
});
it('returns HTTP 409', function () {
expect(status).to.equal(409);
});
});
});
// -------------------------------------------------------------------------
describe('PATCH /api/jobs/:job_id/complete', function () {
before(function () {
if (!RUN_COMPLETE_TEST) return this.skip();
if (!COMPLETE_JOB_ID) return this.skip();
});
describe('happy path', function () {
let status, data;
before(async function () {
if (!RUN_COMPLETE_TEST || !COMPLETE_JOB_ID) return this.skip();
({ status, data } = await patch(`/api/jobs/${COMPLETE_JOB_ID}/complete`));
});
it('returns HTTP 200', function () {
expect(status).to.equal(200);
});
it('job status is now COMPLETED (4)', function () {
expect(data.status).to.equal(4);
});
});
describe('re-completing an already-completed job', function () {
let status;
before(async function () {
if (!RUN_COMPLETE_TEST || !COMPLETE_JOB_ID) return this.skip();
({ status } = await patch(`/api/jobs/${COMPLETE_JOB_ID}/complete`));
});
it('returns HTTP 409 (invalid status transition)', function () {
expect(status).to.equal(409);
});
});
});
});