'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_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_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 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 top-level KPI fields', function () { expect(data).to.include.keys('assignedJobs', 'assignedHectares', 'sprayedToday', 'flightHoursToday'); }); it('assignedJobs is a number', function () { expect(data.assignedJobs).to.be.a('number'); }); it('assignedHectares is a number', function () { expect(data.assignedHectares).to.be.a('number'); }); it('has operations block with distanceKm and sprayVolumeLiters', function () { expect(data.operations).to.be.an('object').and.include.keys('distanceKm', 'sprayVolumeLiters'); }); it('has historical block with year/month/week/day sub-objects', function () { expect(data.historical).to.be.an('object').and.include.keys('jobs', 'hectares', 'flightHours'); ['jobs', 'hectares', 'flightHours'].forEach((k) => { expect(data.historical[k]).to.include.keys('year', 'month', 'week', 'day'); }); }); }); // ------------------------------------------------------------------------- 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('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('avgXtErrorMeters is null when no XT data, number otherwise', function () { if (data.hasXtData) { expect(data.avgXtErrorMeters).to.be.a('number'); } else { expect(data.avgXtErrorMeters).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('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); }); }); }); });