All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 42s
+ Added public data export API enhancements, tests, and customer documentation + Extended /api/v1 data export endpoints with richer session, records, area, and async export output + Added confirmed/fallback report values, client metadata, mapped area, over-spray, volume/apprate (string) units, and weather blocks + Normalized flowController to "No FC" and align record field names with playback output + Converted record wind speed output to knots, add Fligh Mater only record/export fields behind fm=true, and persist fm on export jobs + Added export status/area constants, HTTP 202 support, route-level API docs, and per-account export rate limiting support + Added comprehensive endpoint, format, and verification test coverage plus test-suite README + Added customer-facing data export design, integration, rate-limit, and documentation index guides + Updated README/DLQ docs and related documentation links to current HTTPS dashboard paths
340 lines
10 KiB
JavaScript
340 lines
10 KiB
JavaScript
/**
|
||
* Simple Data Export Verification Test
|
||
*
|
||
* Tests all data export API endpoints with real database data
|
||
* Run: mocha tests/test_export_verify_endpoints.js --timeout 60000
|
||
*/
|
||
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
|
||
const args = process.argv.slice(2);
|
||
let envFile = './environment.env';
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === '--env' && args[i + 1]) {
|
||
envFile = args[i + 1];
|
||
i++;
|
||
}
|
||
}
|
||
|
||
require('dotenv').config({ path: path.resolve(process.cwd(), envFile) });
|
||
|
||
const { expect } = require('chai');
|
||
const axios = require('axios');
|
||
const bcrypt = require('bcryptjs');
|
||
const https = require('https');
|
||
const { ObjectId } = require('mongodb');
|
||
const moment = require('moment');
|
||
|
||
const { Job, App, AppFile, AppDetail, User, Pilot, Vehicle } = require('../model');
|
||
const ApiKey = require('../model/api_key');
|
||
const { ApiKeyServices, ExportUnits } = require('../helpers/constants');
|
||
const dbConnect = require('../helpers/db/connect');
|
||
|
||
const BASE_URL = `https://localhost:${process.env.AGM_PORT || process.env.PORT || 4100}`;
|
||
const httpClient = axios.create({
|
||
baseURL: BASE_URL,
|
||
httpsAgent: new https.Agent({ rejectUnauthorized: false })
|
||
});
|
||
|
||
describe('Data Export API - Endpoint Verification', function() {
|
||
this.timeout(60000);
|
||
|
||
let testUserId, testJobId, testAppId, testFileId, testApiKey;
|
||
let testClientId;
|
||
|
||
before(async function() {
|
||
console.log('\n🔧 Setting up test data...');
|
||
await dbConnect();
|
||
|
||
// Create users
|
||
const adminUser = new User({
|
||
username: `admin_${Date.now()}`,
|
||
email: `admin_${Date.now()}@test.com`,
|
||
passwordHash: 'hash',
|
||
status: 'active',
|
||
role: 'admin',
|
||
kind: 'REGULAR'
|
||
});
|
||
await adminUser.save();
|
||
testUserId = adminUser._id;
|
||
|
||
const clientUser = new User({
|
||
username: `client_${Date.now()}`,
|
||
email: `client_${Date.now()}@test.com`,
|
||
passwordHash: 'hash',
|
||
status: '3',
|
||
role: 'client',
|
||
kind: 'REGULAR'
|
||
});
|
||
await clientUser.save();
|
||
testClientId = clientUser._id;
|
||
|
||
// Create pilot and vehicle
|
||
const pilot = new Pilot({ name: `Pilot${Date.now()}`, licenseNum: 'TEST001', active: true });
|
||
await pilot.save();
|
||
|
||
const vehicle = new Vehicle({ name: `Aircraft${Date.now()}`, tailNumber: `N${Math.random().toString().slice(2,7)}`, active: true });
|
||
await vehicle.save();
|
||
|
||
// Create Job
|
||
const job = new Job({
|
||
_id: Math.floor(Math.random() * 900000) + 100000,
|
||
name: `Job_${Date.now()}`,
|
||
orderNumber: String(Math.floor(Math.random() * 10000)),
|
||
byPuid: testUserId,
|
||
client: testClientId,
|
||
operator: pilot._id,
|
||
vehicle: vehicle._id,
|
||
status: 0,
|
||
swathWidth: 12,
|
||
measureUnit: false,
|
||
sprayAreas: [{
|
||
properties: { name: 'Area1', appRate: 50, area: 10 },
|
||
geometry: { type: 'Polygon', coordinates: [[[-50, -30], [-50, -20], [-40, -20], [-40, -30], [-50, -30]]] }
|
||
}]
|
||
});
|
||
await job.save();
|
||
testJobId = job._id;
|
||
|
||
// Create App (session)
|
||
const app = new App({
|
||
jobId: testJobId,
|
||
fileName: `session_${Date.now()}.log`,
|
||
fileSize: 2048,
|
||
status: 3,
|
||
totalFlightTime: 3600,
|
||
totalSprayTime: 2400,
|
||
totalTurnTime: 1200,
|
||
totalSprayed: 5.0,
|
||
totalSprayMat: 250,
|
||
totalSprayMatUnit: 1,
|
||
avgSpraySpeed: 40,
|
||
markedDelete: false
|
||
});
|
||
await app.save();
|
||
testAppId = app._id;
|
||
|
||
// Create AppFile
|
||
const appFile = new AppFile({
|
||
appId: testAppId,
|
||
name: `file_${Date.now()}.log`,
|
||
agn: 1,
|
||
meta: {
|
||
areaOrZone: 'Main Area',
|
||
sprCoverage: [100, 5.0],
|
||
appRate: 50,
|
||
appRateUnitStr: 'L/ha',
|
||
fcName: 'Controller1',
|
||
sprOnLag: 0.5,
|
||
sprOffLag: 0.3,
|
||
pulsesPerLit: 10,
|
||
operator: 'Test Pilot',
|
||
matType: 'wet'
|
||
}
|
||
});
|
||
await appFile.save();
|
||
testFileId = appFile._id;
|
||
|
||
// Create AppDetail records
|
||
const baseTime = moment().unix();
|
||
const records = [];
|
||
for (let i = 0; i < 10; i++) {
|
||
records.push({
|
||
fileId: testFileId,
|
||
gpsTime: baseTime + (i * 10),
|
||
lat: 40.71 + (i * 0.0001),
|
||
lon: -74.00 + (i * 0.0001),
|
||
utmX: 583960 + (i * 10),
|
||
utmY: 4506721 + (i * 10),
|
||
alt: 100 + (i * 2),
|
||
grSpeed: 35 + (i * 0.5),
|
||
head: 45,
|
||
xTrack: 0.5,
|
||
llnum: 1,
|
||
stdHdop: 0.8,
|
||
satsIn: 12,
|
||
tslu: 0,
|
||
calcodeFreq: 0,
|
||
sprayStat: i === 0 ? 3 : (i % 2 === 0 ? 0 : 1),
|
||
lminApp: i % 2 === 0 ? 0 : 45,
|
||
lminReq: 45,
|
||
lhaReq: 50,
|
||
swath: 12,
|
||
psi: 2.5,
|
||
rpm: 1800,
|
||
windSpd: 2.5,
|
||
windDir: 180,
|
||
temp: 22,
|
||
humid: 65
|
||
});
|
||
}
|
||
await AppDetail.insertMany(records);
|
||
|
||
// Create API key
|
||
const plainApiKey = crypto.randomBytes(32).toString('hex');
|
||
const prefix = plainApiKey.substring(0, 8);
|
||
const keyHash = await bcrypt.hash(plainApiKey, 10);
|
||
|
||
const apiKey = new ApiKey({
|
||
owner: testUserId,
|
||
label: `key_${Date.now()}`,
|
||
prefix,
|
||
keyHash,
|
||
service: ApiKeyServices.DATA_EXPORT,
|
||
active: true
|
||
});
|
||
await apiKey.save();
|
||
testApiKey = plainApiKey;
|
||
|
||
console.log('✅ Test data ready\n');
|
||
});
|
||
|
||
after(async function() {
|
||
console.log('\n🧹 Cleaning up...');
|
||
try {
|
||
await AppDetail.deleteMany({ fileId: testFileId });
|
||
await AppFile.deleteOne({ _id: testFileId });
|
||
await App.deleteOne({ _id: testAppId });
|
||
await Job.deleteOne({ _id: testJobId });
|
||
await User.deleteMany({ _id: { $in: [testUserId, testClientId] } });
|
||
console.log('✅ Cleaned up\n');
|
||
} catch (err) {
|
||
console.error('Cleanup error:', err.message);
|
||
}
|
||
});
|
||
|
||
it('✅ GET /api/v1/jobs/:jobId/sessions - returns session summary', async function() {
|
||
const res = await httpClient.get(`/api/v1/jobs/${testJobId}/sessions`, {
|
||
headers: { 'X-API-Key': testApiKey }
|
||
});
|
||
|
||
expect(res.status).to.equal(200);
|
||
expect(res.data.data).to.be.an('array').with.length.greaterThan(0);
|
||
|
||
const session = res.data.data[0];
|
||
expect(session.totalFlightTime_s).to.exist;
|
||
expect(session.totalSprayed_ha).to.exist;
|
||
expect(session.avgSpraySpeed_ms).to.exist;
|
||
|
||
console.log(` ✅ Sessions endpoint: ${res.data.data.length} session(s)`);
|
||
console.log(` - totalFlightTime_s: ${session.totalFlightTime_s}s`);
|
||
console.log(` - avgSpraySpeed_ms: ${session.avgSpraySpeed_ms} m/s`);
|
||
});
|
||
|
||
it('✅ GET /api/v1/jobs/:jobId/sessions/:fileId/records - returns GPS trace', async function() {
|
||
const res = await httpClient.get(
|
||
`/api/v1/jobs/${testJobId}/sessions/${testFileId}/records`,
|
||
{ headers: { 'X-API-Key': testApiKey }, params: { limit: 100 } }
|
||
);
|
||
|
||
expect(res.status).to.equal(200);
|
||
expect(res.data.data).to.be.an('array').with.length.greaterThan(0);
|
||
|
||
const record = res.data.data[0];
|
||
expect(record.gpsTime).to.exist;
|
||
expect(record.lat).to.exist;
|
||
expect(record.lon).to.exist;
|
||
|
||
// Verify sprayStat=3 is excluded
|
||
const hasSprying3 = res.data.data.some(r => r.sprayStat === 3);
|
||
expect(hasSprying3).to.be.false;
|
||
|
||
console.log(` ✅ Records endpoint: ${res.data.data.length} records`);
|
||
console.log(` - sprayStat=3 properly filtered`);
|
||
});
|
||
|
||
it('✅ GET /api/v1/jobs/:jobId/areas - returns GeoJSON areas', async function() {
|
||
const res = await httpClient.get(`/api/v1/jobs/${testJobId}/areas`, {
|
||
headers: { 'X-API-Key': testApiKey }
|
||
});
|
||
|
||
expect(res.status).to.equal(200);
|
||
expect(res.data.type).to.equal('FeatureCollection');
|
||
expect(res.data.features).to.be.an('array').with.length.greaterThan(0);
|
||
|
||
const feature = res.data.features[0];
|
||
expect(feature.type).to.equal('Feature');
|
||
expect(feature.properties).to.exist;
|
||
|
||
console.log(` ✅ Areas endpoint: ${res.data.features.length} features`);
|
||
});
|
||
|
||
it('✅ POST /api/v1/jobs/:jobId/export - triggers export', async function() {
|
||
const res = await httpClient.post(
|
||
`/api/v1/jobs/${testJobId}/export`,
|
||
{ format: 'csv', interval: null, units: ExportUnits.METRIC },
|
||
{ headers: { 'X-API-Key': testApiKey } }
|
||
);
|
||
|
||
expect(res.status).to.equal(202);
|
||
expect(res.data.exportId).to.exist;
|
||
expect(res.data.status).to.equal('pending');
|
||
expect(res.data.units).to.equal(ExportUnits.METRIC);
|
||
|
||
this.exportId = res.data.exportId;
|
||
console.log(` ✅ Export triggered: ${res.data.exportId}`);
|
||
});
|
||
|
||
it('✅ GET /api/v1/exports/:exportId - polls status', async function() {
|
||
const exportId = this.exportId;
|
||
if (!exportId) { console.log(' ⏭️ Skipping (no exportId)'); this.skip(); }
|
||
|
||
let status = 'pending';
|
||
for (let i = 0; i < 30 && ['pending', 'processing'].includes(status); i++) {
|
||
const res = await httpClient.get(
|
||
`/api/v1/exports/${exportId}`,
|
||
{ headers: { 'X-API-Key': testApiKey } }
|
||
);
|
||
|
||
expect(res.data.status).to.be.oneOf(['pending', 'processing', 'ready', 'error']);
|
||
status = res.data.status;
|
||
if (['pending', 'processing'].includes(status)) await new Promise(r => setTimeout(r, 1000));
|
||
}
|
||
|
||
expect(status).to.be.oneOf(['ready', 'error']);
|
||
this.exportId = exportId;
|
||
console.log(` ✅ Export status: ${status}`);
|
||
});
|
||
|
||
it('✅ GET /api/v1/exports/:exportId/download - downloads file', async function() {
|
||
const exportId = this.exportId;
|
||
if (!exportId) { console.log(' ⏭️ Skipping'); this.skip(); }
|
||
|
||
try {
|
||
const res = await httpClient.get(
|
||
`/api/v1/exports/${exportId}/download`,
|
||
{ headers: { 'X-API-Key': testApiKey }, responseType: 'text' }
|
||
);
|
||
|
||
expect(res.status).to.equal(200);
|
||
expect(res.data).to.be.a('string').with.length.greaterThan(0);
|
||
|
||
const lines = res.data.split('\n');
|
||
const headers = lines[0].split(',');
|
||
expect(headers.length).to.be.greaterThan(0);
|
||
|
||
console.log(` ✅ Downloaded: ${lines.length} lines, ${headers.length} columns`);
|
||
} catch (err) {
|
||
if (err.response?.status === 404) {
|
||
console.log(' ℹ️ Export not ready');
|
||
this.skip();
|
||
} else {
|
||
throw err;
|
||
}
|
||
}
|
||
});
|
||
|
||
it('✅ Authorization - rejects invalid key', async function() {
|
||
try {
|
||
await httpClient.get(`/api/v1/jobs/${testJobId}/sessions`, {
|
||
headers: { 'X-API-Key': 'invalid' }
|
||
});
|
||
expect.fail('Should reject invalid key');
|
||
} catch (err) {
|
||
expect(err.response.status).to.equal(401);
|
||
console.log(` ✅ Invalid key rejected (401)`);
|
||
}
|
||
});
|
||
});
|