agmission/Development/server/tests/test_pilot_dashboard_api.js

314 lines
10 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_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);
});
});
});
});