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