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