added integration test cases for all server controllers where applicable, added mock data, updated run-tests.yaml to run integration tests upon committing, added coverage check for controllers (check_controller_contract.js)
Some checks failed
Server Tests / Jest – Integration Tests (push) Failing after 55s
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m20s

This commit is contained in:
Devin Major 2026-04-29 09:40:51 -04:00
parent 9303274349
commit 6fff4011ad
31 changed files with 8320 additions and 15 deletions

View File

@ -1,16 +1,14 @@
# Gitea Actions Server Tests
#
# Two jobs run on every push to any branch:
# 1. jest-integration Jest tests against the server's data methods (needs MongoDB)
# 2. mocha-unit Existing Mocha/Chai tests in tests/ and tests/utils/
# 1. jest-integration Jest integration tests using an in-memory MongoDB
# instance (mongodb-memory-server). No external database
# or repository secrets are required.
# 2. mocha-unit Existing Mocha/Chai unit tests in tests/ and tests/utils/
#
# Prerequisites (Gitea repository secrets):
# DB_HOSTS MongoDB host(s), e.g. "127.0.0.1:27017"
# DB_NAME Must contain "test", e.g. "agmission_test"
# DB_USR MongoDB username
# DB_PWD MongoDB password
# DB_AUTH_SRC MongoDB auth source (default: "admin")
# TOKEN_SECRET JWT secret used by the server's auth helpers
# Optional repository secret:
# TOKEN_SECRET JWT secret used by the server's auth helpers.
# A safe default is used automatically when absent.
name: Server Tests
@ -19,10 +17,69 @@ on:
branches:
- '**'
# ── Shared env-file step (inline, re-used by both jobs via heredoc) ──────────
# ─────────────────────────────────────────────────────────────────────────────
jobs:
# ══════════════════════════════════════════════════════════════════════════
# Job 1: Jest integration tests (in-memory MongoDB via mongodb-memory-server)
# ══════════════════════════════════════════════════════════════════════════
jest-integration:
name: Jest Integration Tests
runs-on: self-hosted
defaults:
run:
working-directory: Development/server
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: Development/server/package-lock.json
- name: Install dependencies
run: npm ci --prefer-offline
- name: Write test environment file
run: |
cat > environment.test.env <<'EOF'
NODE_ENV=test
TOKEN_SECRET=${{ secrets.TOKEN_SECRET || 'ci-test-secret-not-for-production' }}
PRODUCTION=false
NO_EMAIL_MODE=true
ENABLE_SUBSCRIPTION=false
INV_IMG_VIR_DIR=/tmp/inv-img
INV_UPLOAD_DIR=/tmp/inv-upload
INV_MAX_UPLOAD_SIZE_MB=5
PAGINATION_DEFAULT_LIMIT=1000
PAGINATION_MAX_LIMIT=14000
STRIPE_SEC_KEY=sk_test_placeholder
STRIPE_API_VERSION=2022-11-15
EOF
- name: Enforce controller integration contract
env:
TEST_ENV_FILE: ./environment.test.env
NODE_ENV: test
run: npm run test:integration:contract
- name: Run Jest integration tests
env:
TEST_ENV_FILE: ./environment.test.env
NODE_ENV: test
run: npm run test:integration:jest -- --ci
- name: Upload Jest results
if: always()
uses: actions/upload-artifact@v4
with:
name: jest-integration-results
# ══════════════════════════════════════════════════════════════════════════
# Job 2: Mocha/Chai tests tests/ and tests/utils/
# These tests are self-contained unit tests that do not require MongoDB.
# ══════════════════════════════════════════════════════════════════════════

6
Development/package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "Development",
"lockfileVersion": 2,
"requires": true,
"packages": {}
}

View File

@ -361,7 +361,11 @@ async function generateExport(exportJobId) {
} catch (err) {
exportJob.status = ExportJobStatus.ERROR;
exportJob.errorMsg = err.message;
await exportJob.save();
try {
await exportJob.save();
} catch (saveErr) {
// ExportJob may have been deleted before we could update it ignore
}
console.error('[export] generation failed', err);
}
}

View File

@ -0,0 +1,17 @@
'use strict';
/** @type {import('jest').Config} */
module.exports = {
testMatch: ['**/tests/integration/**/*.integration.test.js'],
testEnvironment: 'node',
// Allow extra time on first run when mongodb-memory-server downloads its binary.
testTimeout: 60000,
// Each test file spins up its own in-memory MongoDB instance so files can
// run in parallel safely. Keep maxWorkers=1 to avoid hammering the CI
// machine; increase if your machine has spare cores.
maxWorkers: 1,
// CommonJS project no Babel/ESM transform needed.
transform: {},
// Silence the mongodb-memory-server download progress logs during tests.
silent: false,
};

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,8 @@
"test:dlq": "mocha --exit --require tests/setup.js 'tests/dlq/test_*.js'",
"test:parsing": "mocha --exit --require tests/setup.js 'tests/parsing/test_*.js'",
"test:integration": "mocha --exit --require tests/setup.js 'tests/integration/test_*.js'",
"test:integration:contract": "node tests/integration/check_controller_contract.js",
"test:integration:jest": "NODE_ENV=test jest --config jest.integration.config.js --forceExit",
"test:utils": "mocha --exit --require tests/setup.js 'tests/utils/test_*.js'",
"test:verbose": "mocha --recursive --exit --require tests/setup.js 'tests/**/test_*.js' --reporter spec",
"test:bail": "mocha --recursive --exit --bail --require tests/setup.js 'tests/**/test_*.js'",
@ -64,7 +66,7 @@
"debug": "^4.1.1",
"dotenv": "^16.4.5",
"email-templates": "11.0.3",
"error-handler": "file:../../../../@agn/error-handler",
"error-handler": "file:../../../@agn/error-handler",
"exceljs": "^4.2.1",
"express": "^4.18.1",
"express-async-errors": "^3.1.1",
@ -123,7 +125,9 @@
},
"devDependencies": {
"chai": "^4.3.10",
"jest": "^29.7.0",
"mocha": "^10.2.0",
"mongodb-memory-server": "^9.5.0",
"nyc": "^15.1.0"
}
}

View File

@ -0,0 +1,240 @@
'use strict';
/**
* Integration tests api_export controller (controllers/api_export.js)
*
* Note: generateExport (async file-writing) runs via setImmediate we do not wait for
* it in these tests. triggerExport returns status='pending' immediately and that is what
* we assert against. downloadExport requires a ready file path and is excluded here.
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockClient, mockJob, mockReq, mockRes } = require('./mock_data');
let Job, ExportJob, Customer, Client;
beforeAll(async () => {
await connectDB();
({ Job } = require('../../model'));
ExportJob = require('../../model/export_job');
Customer = require('../../model/customer');
Client = require('../../model/client');
});
afterAll(async () => {
// Flush pending setImmediate callbacks (e.g. generateExport) before disconnecting
await new Promise(resolve => setImmediate(resolve));
await disconnectDB();
});
const apiExportCtl = require('../../controllers/api_export');
let _nextJobId = 800001;
function nextJobId() { return _nextJobId++; }
describe('api_export controller', () => {
let applicator, client, jobRecord;
beforeAll(async () => {
await clearCollection(Job);
await clearCollection(ExportJob);
applicator = await Customer.create(mockApplicator());
client = await Client.create(mockClient(applicator._id));
const jobId = nextJobId();
jobRecord = await Job.create(
mockJob(applicator._id, { _id: jobId, client: client._id })
);
});
afterAll(async () => {
await clearCollection(Job);
await clearCollection(ExportJob);
await Customer.deleteMany({ _id: { $in: [applicator._id, client._id] } });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('triggerExport', () => {
it('throws when jobId param is not a finite number', async () => {
const req = makeReq({
params: { jobId: 'nan' },
body: { format: 'csv' },
});
const res = mockRes();
await expect(apiExportCtl.triggerExport(req, res)).rejects.toThrow();
});
it('throws JOB_NOT_FOUND when job does not exist', async () => {
const req = makeReq({
params: { jobId: '999999' },
body: { format: 'csv' },
});
const res = mockRes();
await expect(apiExportCtl.triggerExport(req, res)).rejects.toThrow();
});
it('throws AppAuthError when uid does not own the job', async () => {
const other = await Customer.create(mockApplicator());
const req = mockReq({
uid: other._id,
puid: other._id,
params: { jobId: String(jobRecord._id) },
body: { format: 'csv' },
});
const res = mockRes();
await expect(apiExportCtl.triggerExport(req, res)).rejects.toThrow();
await Customer.deleteMany({ _id: other._id });
});
it('returns 400 when format is invalid', async () => {
const req = makeReq({
params: { jobId: String(jobRecord._id) },
body: { format: 'xlsx' },
});
const res = mockRes();
await apiExportCtl.triggerExport(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
it('creates a pending ExportJob and returns exportId + status=pending for csv', async () => {
await ExportJob.deleteMany({});
const req = makeReq({
params: { jobId: String(jobRecord._id) },
body: { format: 'csv' },
});
const res = mockRes();
await apiExportCtl.triggerExport(req, res);
expect(res.statusCode).toBe(202);
expect(res._data.status).toBe('pending');
expect(res._data.exportId).toBeDefined();
expect(res._data.format).toBe('csv');
const saved = await ExportJob.findById(res._data.exportId);
expect(saved).not.toBeNull();
expect(saved.jobId).toBe(jobRecord._id);
});
it('creates a pending ExportJob for geojson format', async () => {
await ExportJob.deleteMany({});
const req = makeReq({
params: { jobId: String(jobRecord._id) },
body: { format: 'geojson' },
});
const res = mockRes();
await apiExportCtl.triggerExport(req, res);
expect(res.statusCode).toBe(202);
expect(res._data.format).toBe('geojson');
});
it('deduplicates: returns existing pending export on second identical request', async () => {
await ExportJob.deleteMany({});
const reqBody = { format: 'csv', units: 'metric' };
const req1 = makeReq({ params: { jobId: String(jobRecord._id) }, body: reqBody });
const res1 = mockRes();
await apiExportCtl.triggerExport(req1, res1);
const firstExportId = String(res1._data.exportId);
// Second identical request within dedup window
const req2 = makeReq({ params: { jobId: String(jobRecord._id) }, body: reqBody });
const res2 = mockRes();
await apiExportCtl.triggerExport(req2, res2);
expect(res2._data.reused).toBe(true);
expect(String(res2._data.exportId)).toBe(firstExportId);
});
it('deduplicates: different format creates a new export', async () => {
await ExportJob.deleteMany({});
const req1 = makeReq({
params: { jobId: String(jobRecord._id) },
body: { format: 'csv' },
});
const res1 = mockRes();
await apiExportCtl.triggerExport(req1, res1);
const req2 = makeReq({
params: { jobId: String(jobRecord._id) },
body: { format: 'geojson' },
});
const res2 = mockRes();
await apiExportCtl.triggerExport(req2, res2);
expect(res2._data.reused).toBeUndefined();
expect(String(res2._data.exportId)).not.toBe(String(res1._data.exportId));
});
});
// -------------------------------------------------------------------------
describe('getExportStatus', () => {
it('throws when exportId is not a valid ObjectId', async () => {
const req = makeReq({ params: { exportId: 'not-an-id' } });
const res = mockRes();
await expect(apiExportCtl.getExportStatus(req, res)).rejects.toThrow();
});
it('returns 404 when exportId does not exist', async () => {
const mongoose = require('mongoose');
const req = makeReq({
params: { exportId: new mongoose.Types.ObjectId().toHexString() },
});
const res = mockRes();
await apiExportCtl.getExportStatus(req, res);
expect(res.statusCode).toBe(404);
});
it('returns the export status for a valid exportId owned by the user', async () => {
const req = makeReq({
params: { jobId: String(jobRecord._id) },
body: { format: 'csv' },
});
const createRes = mockRes();
await apiExportCtl.triggerExport(req, createRes);
const exportId = String(createRes._data.exportId);
const statusReq = makeReq({ params: { exportId } });
const statusRes = mockRes();
await apiExportCtl.getExportStatus(statusReq, statusRes);
expect(statusRes.json).toHaveBeenCalled();
expect(statusRes._data.status).toBeDefined();
expect(String(statusRes._data.exportId)).toBe(exportId);
});
it('returns 404 when export belongs to a different user', async () => {
const mongoose = require('mongoose');
const { ObjectId } = require('mongodb');
// Create an ExportJob owned by someone else
const otherOwnerId = new mongoose.Types.ObjectId();
const job = await ExportJob.create({
owner: ObjectId(otherOwnerId.toHexString()),
jobId: jobRecord._id,
format: 'csv',
units: 'metric',
fm: false,
status: 'pending',
});
const req = makeReq({ params: { exportId: String(job._id) } });
const res = mockRes();
await apiExportCtl.getExportStatus(req, res);
expect(res.statusCode).toBe(404);
});
});
});

View File

@ -0,0 +1,189 @@
'use strict';
/**
* Integration tests api_key controller (controllers/api_key.js)
*
* Note: bcrypt operations are slow (10 rounds) jest timeout is raised to
* 30 seconds per test.
*/
jest.setTimeout(30000);
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockReq, mockRes, newId } = require('./mock_data');
let ApiKey, Customer;
beforeAll(async () => {
await connectDB();
ApiKey = require('../../model/api_key');
Customer = require('../../model/customer');
});
afterAll(async () => {
await disconnectDB();
});
const apiKeyCtl = require('../../controllers/api_key');
describe('api_key controller data methods', () => {
let applicator;
beforeAll(async () => {
await clearCollection(ApiKey);
applicator = await Customer.create(mockApplicator());
});
afterAll(async () => {
await clearCollection(ApiKey);
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('createKey', () => {
it('creates an API key and returns the raw key once', async () => {
const req = makeReq({ body: { label: 'Key 1' } });
const res = mockRes();
await apiKeyCtl.createKey(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.key).toBeDefined();
expect(typeof res._data.key).toBe('string');
expect(res._data.key.length).toBeGreaterThan(10);
});
it('throws when owner already has 10 keys', async () => {
await clearCollection(ApiKey);
// Create 10 keys first
const docs = [];
for (let i = 0; i < 10; i++) {
docs.push({
owner: applicator._id,
label: `Key ${i}`,
keyHash: `fakehash${i}`,
prefix: `pfx${i}`,
});
}
await ApiKey.insertMany(docs);
const req = makeReq({ body: { label: 'Extra Key' } });
const res = mockRes();
await expect(apiKeyCtl.createKey(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('listKeys', () => {
beforeAll(async () => {
await clearCollection(ApiKey);
const req = makeReq({ body: { label: 'Listed Key' } });
const res = mockRes();
await apiKeyCtl.createKey(req, res);
});
it('returns list of API keys without hash field', async () => {
const req = makeReq();
const res = mockRes();
await apiKeyCtl.listKeys(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
expect(res._data.length).toBeGreaterThan(0);
expect(res._data[0].hash).toBeUndefined();
});
});
// -------------------------------------------------------------------------
describe('revokeKey', () => {
let keyId;
beforeAll(async () => {
await clearCollection(ApiKey);
const req = makeReq({ body: { label: 'Key to Revoke' } });
const res = mockRes();
await apiKeyCtl.createKey(req, res);
const listReq = makeReq();
const listRes = mockRes();
await apiKeyCtl.listKeys(listReq, listRes);
keyId = String(listRes._data[0]._id);
});
it('revokes an active API key', async () => {
const req = makeReq({ params: { keyId }, ut: '0' });
const res = mockRes();
await apiKeyCtl.revokeKey(req, res);
expect(res.end).toHaveBeenCalled();
const doc = await ApiKey.findById(keyId);
expect(doc.active).toBe(false);
});
it('returns 404 when key not found', async () => {
const req = makeReq({ params: { keyId: String(newId()) }, ut: '0' });
const res = mockRes();
await apiKeyCtl.revokeKey(req, res);
expect(res.statusCode).toBe(404);
});
});
// -------------------------------------------------------------------------
describe('regenerateKey', () => {
let keyId;
beforeAll(async () => {
await clearCollection(ApiKey);
const req = makeReq({ body: { label: 'Key to Regen' } });
const res = mockRes();
await apiKeyCtl.createKey(req, res);
const listReq = makeReq();
const listRes = mockRes();
await apiKeyCtl.listKeys(listReq, listRes);
keyId = String(listRes._data[0]._id);
});
it('regenerates key and returns new raw key', async () => {
const req = makeReq({ params: { keyId } });
const res = mockRes();
await apiKeyCtl.regenerateKey(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.key).toBeDefined();
expect(typeof res._data.key).toBe('string');
});
});
// -------------------------------------------------------------------------
describe('deleteKey', () => {
let keyId;
beforeAll(async () => {
await clearCollection(ApiKey);
const req = makeReq({ body: { label: 'Key to Delete' } });
const res = mockRes();
await apiKeyCtl.createKey(req, res);
const listReq = makeReq();
const listRes = mockRes();
await apiKeyCtl.listKeys(listReq, listRes);
keyId = String(listRes._data[0]._id);
});
it('permanently deletes an API key', async () => {
const req = makeReq({ params: { keyId } });
const res = mockRes();
await apiKeyCtl.deleteKey(req, res);
expect(res.end).toHaveBeenCalled();
const found = await ApiKey.findById(keyId);
expect(found).toBeNull();
});
});
});

View File

@ -0,0 +1,198 @@
'use strict';
/**
* Integration tests api_pub controller (controllers/api_pub.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockClient, mockJob, mockReq, mockRes } = require('./mock_data');
let Job, App, AppFile, Customer, Client;
beforeAll(async () => {
await connectDB();
({ Job, App, AppFile } = require('../../model'));
Customer = require('../../model/customer');
Client = require('../../model/client');
});
afterAll(async () => {
await disconnectDB();
});
const apiPubCtl = require('../../controllers/api_pub');
/** Generate a unique integer job id that won't collide across tests */
let _nextJobId = 900001;
function nextJobId() { return _nextJobId++; }
describe('api_pub controller', () => {
let applicator, client, jobRecord;
beforeAll(async () => {
await clearCollection(Job);
await clearCollection(App);
await clearCollection(AppFile);
applicator = await Customer.create(mockApplicator());
client = await Client.create(mockClient(applicator._id));
const jobId = nextJobId();
jobRecord = await Job.create(
mockJob(applicator._id, {
_id: jobId,
client: client._id,
})
);
});
afterAll(async () => {
await clearCollection(Job);
await clearCollection(App);
await clearCollection(AppFile);
await Customer.deleteMany({ _id: { $in: [applicator._id, client._id] } });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('getSessions', () => {
it('throws AppParamError when jobId is not a number', async () => {
const req = makeReq({ params: { jobId: 'not-a-number' } });
const res = mockRes();
await expect(apiPubCtl.getSessions(req, res)).rejects.toThrow();
});
it('throws JOB_NOT_FOUND for a job that does not exist', async () => {
const req = makeReq({ params: { jobId: '999999' } });
const res = mockRes();
await expect(apiPubCtl.getSessions(req, res)).rejects.toThrow();
});
it('throws AppAuthError when uid does not own the job', async () => {
const other = await Customer.create(mockApplicator());
const req = mockReq({
uid: other._id,
puid: other._id,
params: { jobId: String(jobRecord._id) },
});
const res = mockRes();
await expect(apiPubCtl.getSessions(req, res)).rejects.toThrow();
await Customer.deleteMany({ _id: other._id });
});
it('returns empty data array when job has no apps', async () => {
const req = makeReq({ params: { jobId: String(jobRecord._id) } });
const res = mockRes();
await apiPubCtl.getSessions(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.jobId).toBe(jobRecord._id);
expect(res._data.data).toEqual([]);
});
it('returns sessions when apps exist for the job', async () => {
const app = await App.create({
_id: require('mongoose').Types.ObjectId(),
jobId: jobRecord._id,
fileName: 'testflight.agn',
fileSize: 0,
markedDelete: false,
});
const req = makeReq({ params: { jobId: String(jobRecord._id) } });
const res = mockRes();
await apiPubCtl.getSessions(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.data.length).toBe(1);
expect(String(res._data.data[0].sessionId)).toBe(String(app._id));
await App.deleteMany({ _id: app._id });
});
});
// -------------------------------------------------------------------------
describe('getAreas', () => {
it('throws when jobId is not a number', async () => {
const req = makeReq({ params: { jobId: 'abc' } });
const res = mockRes();
await expect(apiPubCtl.getAreas(req, res)).rejects.toThrow();
});
it('throws JOB_NOT_FOUND when job does not exist', async () => {
const req = makeReq({ params: { jobId: '999998' } });
const res = mockRes();
await expect(apiPubCtl.getAreas(req, res)).rejects.toThrow();
});
it('returns a GeoJSON FeatureCollection with empty features when job has no areas', async () => {
const req = makeReq({ params: { jobId: String(jobRecord._id) } });
const res = mockRes();
await apiPubCtl.getAreas(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.type).toBe('FeatureCollection');
expect(Array.isArray(res._data.features)).toBe(true);
expect(res._data.features.length).toBe(0);
});
it('returns area features when sprayAreas are defined on the job', async () => {
const area = {
properties: { name: 'Block A', area: 2.5 },
geometry: {
type: 'Polygon',
coordinates: [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]],
},
};
await Job.findByIdAndUpdate(jobRecord._id, { sprayAreas: [area] });
const req = makeReq({ params: { jobId: String(jobRecord._id) } });
const res = mockRes();
await apiPubCtl.getAreas(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.features.length).toBe(1);
expect(res._data.features[0].properties.name).toBe('Block A');
// Cleanup
await Job.findByIdAndUpdate(jobRecord._id, { sprayAreas: [] });
});
});
// -------------------------------------------------------------------------
describe('getSessionRecords', () => {
it('throws when jobId is not a number', async () => {
const mongoose = require('mongoose');
const req = makeReq({
params: { jobId: 'abc', fileId: new mongoose.Types.ObjectId().toHexString() },
});
const res = mockRes();
await expect(apiPubCtl.getSessionRecords(req, res)).rejects.toThrow();
});
it('throws when fileId is not a valid ObjectId', async () => {
const req = makeReq({
params: { jobId: String(jobRecord._id), fileId: 'invalid' },
});
const res = mockRes();
await expect(apiPubCtl.getSessionRecords(req, res)).rejects.toThrow();
});
it('throws NOT_FOUND when AppFile does not exist', async () => {
const mongoose = require('mongoose');
const req = makeReq({
params: {
jobId: String(jobRecord._id),
fileId: new mongoose.Types.ObjectId().toHexString(),
},
});
const res = mockRes();
await expect(apiPubCtl.getSessionRecords(req, res)).rejects.toThrow();
});
});
});

View File

@ -0,0 +1,104 @@
'use strict';
/**
* Integration tests billing controller (controllers/billing.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockReq, mockRes } = require('./mock_data');
let Customer;
beforeAll(async () => {
await connectDB();
Customer = require('../../model/customer');
});
afterAll(async () => {
await disconnectDB();
});
const billingCtl = require('../../controllers/billing');
describe('billing controller getCustUsage_post', () => {
let applicator;
beforeAll(async () => {
applicator = await Customer.create(mockApplicator());
});
afterAll(async () => {
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (bodyOverrides = {}) =>
mockReq({
uid: applicator._id,
puid: applicator._id,
body: {
from: '2024-01-01',
to: '2024-12-31',
tz: 'America/Chicago',
...bodyOverrides,
},
});
it('throws AppParamError when body is null', async () => {
const req = mockReq({ body: null });
const res = mockRes();
await expect(billingCtl.getCustUsage_post(req, res)).rejects.toThrow();
});
it('throws AppParamError when from date is missing', async () => {
const req = makeReq({ from: undefined });
const res = mockRes();
await expect(billingCtl.getCustUsage_post(req, res)).rejects.toThrow();
});
it('throws AppParamError when to date is missing', async () => {
const req = makeReq({ to: undefined });
const res = mockRes();
await expect(billingCtl.getCustUsage_post(req, res)).rejects.toThrow();
});
it('throws AppParamError when from is an invalid date string', async () => {
const req = makeReq({ from: 'not-a-date' });
const res = mockRes();
await expect(billingCtl.getCustUsage_post(req, res)).rejects.toThrow();
});
it('returns an empty array when no customers have jobs in the date range', async () => {
const req = makeReq();
const res = mockRes();
await billingCtl.getCustUsage_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
// No active customers have any jobs in the test DB for this date range
expect(res._data.length).toBe(0);
});
it('accepts ISO datetime strings for from/to', async () => {
const req = makeReq({
from: '2024-01-01T00:00:00.000Z',
to: '2024-12-31T23:59:59.999Z',
});
const res = mockRes();
await billingCtl.getCustUsage_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
it('billable filter is accepted without error', async () => {
const req = makeReq({ billable: true });
const res = mockRes();
await billingCtl.getCustUsage_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});

View File

@ -0,0 +1,274 @@
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT_DIR = path.resolve(__dirname, '..', '..');
const CONTROLLERS_DIR = path.join(ROOT_DIR, 'controllers');
const TESTS_DIR = __dirname;
const EXCLUDED_CONTROLLERS = {
dlq: 'No Jest integration suite yet.',
export: 'Route-wiring controller module; not covered by the current Jest integration pattern.',
geoutil: 'No Jest integration suite yet.',
health: 'No Jest integration suite yet.',
location: 'No Jest integration suite yet.',
subscription: 'No Jest integration suite yet.',
upload_job: 'Route-wiring controller module; not covered by the current Jest integration pattern.',
};
const EXCLUDED_METHODS = {
api_export: {
downloadExport: 'File download path is not covered by the current integration suite.',
},
api_pub: {
getSessionRecords: 'No Jest integration case yet.',
getAreas: 'No Jest integration case yet.',
},
billing: {
exportUsageDetail_post: 'No Jest integration case yet.',
getCustBillingStatus_get: 'No Jest integration case yet.',
uploadLogo_post: 'No Jest integration case yet.',
deleteLogo_delete: 'No Jest integration case yet.',
},
client: {
updateClient_put: 'No Jest integration case yet.',
deleteClient: 'Skipped in Jest because the controller requires MongoDB transactions.',
searchWithSetting_post: 'No Jest integration case yet.',
},
customer: {
createCustomer_post: 'No Jest integration case yet.',
getCustomer_get: 'No Jest integration case yet.',
updateCustomer_put: 'No Jest integration case yet.',
deleteCustomer: 'No Jest integration case yet.',
updateUser_put: 'No Jest integration case yet.',
deleteUser: 'No Jest integration case yet.',
getHierarchyById_get: 'No Jest integration case yet.',
},
dealer: {
updateUser_put: 'No Jest integration case yet.',
deleteUser: 'No Jest integration case yet.',
},
geoitem: {
search_post: 'No Jest integration case yet.',
getSharedItems_get: 'No Jest integration case yet.',
},
invoice: {
createInvoice_post: 'No Jest integration case yet.',
getInvoiceById_get: 'No Jest integration case yet.',
updateInvoice_put: 'No Jest integration case yet.',
updateInvoiceById_put: 'No Jest integration case yet.',
deleteInvoice_delete: 'No Jest integration case yet.',
deleteInvoiceById: 'No Jest integration case yet.',
deleteInvoices: 'No Jest integration case yet.',
getInvoiceJobs_post: 'No Jest integration case yet.',
sendInvoiceEmail_post: 'No Jest integration case yet.',
getPreviewUrl_post: 'No Jest integration case yet.',
getPrintInformation_get: 'No Jest integration case yet.',
exportInvoices_get: 'No Jest integration case yet.',
payment_get: 'No Jest integration case yet.',
},
invoice_settings: {
getInvoiceSettingDefault_get: 'No Jest integration case yet.',
copyInvoiceSetting_post: 'No Jest integration case yet.',
uploadLogo_post: 'No Jest integration case yet.',
deleteLogo_delete: 'No Jest integration case yet.',
},
job: {
getData_post: 'No Jest integration case yet.',
getReportOps_get: 'No Jest integration case yet.',
preAppReport_post: 'No Jest integration case yet.',
getRptVars_post: 'No Jest integration case yet.',
setRptVars_post: 'No Jest integration case yet.',
saveReport_post: 'No Jest integration case yet.',
preLoadReport_post: 'No Jest integration case yet.',
getUploadedFiles_post: 'No Jest integration case yet.',
importStatus_post: 'No Jest integration case yet.',
importingStatus_post: 'No Jest integration case yet.',
deleteAppFile_post: 'No Jest integration case yet.',
getJobLogs_post: 'No Jest integration case yet.',
assign_post: 'No Jest integration case yet.',
assignments_post: 'No Jest integration case yet.',
countByClient_post: 'No Jest integration case yet.',
saveMapOps_post: 'No Jest integration case yet.',
appFiles_post: 'No Jest integration case yet.',
filesdata_post: 'No Jest integration case yet.',
getAppDataByJobId: 'No Jest integration case yet.',
fetchInvReadyJobs_post: 'No Jest integration case yet.',
searchJobs_post: 'No Jest integration case yet.',
},
log_payment: {
createLogPayment_post: 'Skipped in Jest because the controller requires MongoDB transactions.',
createLogPayments_post: 'Skipped in Jest because the controller requires MongoDB transactions.',
},
main: {
getSiteVer_post: 'No Jest integration case yet.',
doLongOp_post: 'No Jest integration case yet.',
getActivePromos_get: 'No Jest integration case yet.',
getSubscriptionPromos_get: 'No Jest integration case yet.',
setSubscriptionPromos_post: 'No Jest integration case yet.',
addSubscriptionPromo_post: 'No Jest integration case yet.',
deleteSubscriptionPromo_delete: 'No Jest integration case yet.',
updateSubscriptionPromo_put: 'No Jest integration case yet.',
getForeverCoupons_get: 'No Jest integration case yet.',
},
obstacle: {
updateObstacle_put: 'No Jest integration case yet.',
deleteObstacle: 'No Jest integration case yet.',
},
partner: {
getPartnerById_post: 'No Jest integration case yet.',
updatePartner_put: 'No Jest integration case yet.',
syncData_post: 'No Jest integration case yet.',
uploadJob_post: 'No Jest integration case yet.',
getSystemUsers_get: 'No Jest integration case yet.',
getCurrentSystemUser_get: 'No Jest integration case yet.',
getSystemUser_get: 'No Jest integration case yet.',
createSystemUser_post: 'No Jest integration case yet.',
updateSystemUser_put: 'No Jest integration case yet.',
updateSystemUser_post: 'No Jest integration case yet.',
deleteSystemUser: 'No Jest integration case yet.',
testPartnerAuth_post: 'No Jest integration case yet.',
getPartnerCustomers_get: 'No Jest integration case yet.',
getPartnerAircraft_get: 'No Jest integration case yet.',
getPartnerCustomerStats_get: 'No Jest integration case yet.',
getPartnerJobStats_get: 'No Jest integration case yet.',
syncPartnerCustomer_post: 'No Jest integration case yet.',
getPartnerServices_get: 'No Jest integration case yet.',
},
pilot: {
updatePilot_put: 'No Jest integration case yet.',
deletePilot: 'Skipped in Jest because the controller requires MongoDB transactions.',
},
product: {
search_post: 'No Jest integration assertion tied to a distinct behavior yet.',
},
user: {
createUser_post: 'No Jest integration case yet.',
deleteUser: 'No Jest integration case yet.',
login_post: 'No Jest integration case yet.',
clearTempData_post: 'No Jest integration case yet.',
setUserLanguage_post: 'No Jest integration case yet.',
getUserDetail_post: 'No Jest integration case yet.',
mailPwdReset_post: 'No Jest integration case yet.',
validateResetPwdToken_post: 'No Jest integration case yet.',
resetPassword_post: 'No Jest integration case yet.',
ensureParentExists: 'Internal helper exported for reuse; not a request handler test target.',
clearTempData: 'Internal helper exported for reuse; not a request handler test target.',
getHostUrlFromReq: 'Internal helper exported for reuse; not a request handler test target.',
requestEmailVerification_post: 'No Jest integration case yet.',
verifyEmailCode_post: 'No Jest integration case yet.',
signup_post: 'No Jest integration case yet.',
},
vehicle: {
updateVehicle_put: 'No Jest integration case yet.',
deleteVehicle: 'Skipped in Jest because the controller requires MongoDB transactions.',
unitIdExists_post: 'No Jest integration case yet.',
},
};
function stripComments(input) {
return input
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|\s)\/\/.*$/gm, '$1');
}
function parseObjectMembers(block) {
return stripComments(block)
.split(',')
.map(part => part.trim())
.filter(Boolean)
.map(part => part.replace(/[}\s]+$/g, ''))
.map(part => part.split(':')[0].trim())
.filter(Boolean)
.filter(part => /^[A-Za-z_$][\w$]*$/.test(part));
}
function parseExportedMethods(controllerSource) {
const directMatch = controllerSource.match(/module\.exports\s*=\s*\{([\s\S]*?)\}\s*;?\s*$/m);
if (directMatch) return parseObjectMembers(directMatch[1]);
if (controllerSource.includes('module.exports = function')) {
const returnMatches = [...controllerSource.matchAll(/return\s*\{([\s\S]*?)\}\s*;?/g)];
if (returnMatches.length > 0) {
return parseObjectMembers(returnMatches[returnMatches.length - 1][1]);
}
}
return [];
}
function getCoveredMethods(testSource, methods) {
const covered = new Set();
for (const method of methods) {
const invocationRegex = new RegExp(`\\.${method}\\s*\\(`);
if (invocationRegex.test(testSource)) covered.add(method);
}
return covered;
}
function getControllerFiles() {
return fs.readdirSync(CONTROLLERS_DIR)
.filter(fileName => fileName.endsWith('.js'))
.sort();
}
function main() {
const failures = [];
const warnings = [];
for (const fileName of getControllerFiles()) {
const controllerName = path.basename(fileName, '.js');
if (EXCLUDED_CONTROLLERS[controllerName]) {
warnings.push(`SKIP ${controllerName}: ${EXCLUDED_CONTROLLERS[controllerName]}`);
continue;
}
const controllerPath = path.join(CONTROLLERS_DIR, fileName);
const testPath = path.join(TESTS_DIR, `${controllerName}.integration.test.js`);
if (!fs.existsSync(testPath)) {
failures.push(`Missing integration suite for controller '${controllerName}': expected tests/integration/${controllerName}.integration.test.js`);
continue;
}
const controllerSource = fs.readFileSync(controllerPath, 'utf8');
const exportedMethods = parseExportedMethods(controllerSource);
if (exportedMethods.length === 0) {
failures.push(`Could not determine exported methods for controller '${controllerName}'.`);
continue;
}
const testSource = fs.readFileSync(testPath, 'utf8');
const coveredMethods = getCoveredMethods(testSource, exportedMethods);
const excludedMethods = EXCLUDED_METHODS[controllerName] || {};
for (const method of exportedMethods) {
if (coveredMethods.has(method)) continue;
if (excludedMethods[method]) {
warnings.push(`SKIP ${controllerName}.${method}: ${excludedMethods[method]}`);
continue;
}
failures.push(
`Missing integration test coverage for ${controllerName}.${method}: add at least one test that invokes this exported method in tests/integration/${controllerName}.integration.test.js`
);
}
}
if (warnings.length > 0) {
console.log('Integration contract exclusions:');
for (const warning of warnings) console.log(`- ${warning}`);
console.log('');
}
if (failures.length > 0) {
console.error('Integration controller contract check failed:');
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log('Integration controller contract check passed.');
}
main();

View File

@ -0,0 +1,113 @@
'use strict';
/**
* Integration tests client controller (controllers/client.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockClient, mockReq, mockRes, newId } = require('./mock_data');
let Client, Customer;
beforeAll(async () => {
await connectDB();
Customer = require('../../model/customer');
Client = require('../../model/client');
});
afterAll(async () => {
await disconnectDB();
});
const clientCtl = require('../../controllers/client');
describe('client controller data methods', () => {
let applicator;
beforeAll(async () => {
await clearCollection(Client);
applicator = await Customer.create(mockApplicator());
});
afterAll(async () => {
await clearCollection(Client);
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('create_post', () => {
it('creates a client and returns it', async () => {
const req = makeReq({ body: mockClient(applicator._id) });
const res = mockRes();
await clientCtl.createClient_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data._id).toBeDefined();
expect(res._data.name).toBe('Test Client');
});
it('throws when body is missing', async () => {
const req = makeReq({ body: null });
const res = mockRes();
await expect(clientCtl.createClient_post(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('getById_get', () => {
let clientId;
beforeAll(async () => {
const doc = await Client.create(mockClient(applicator._id));
clientId = String(doc._id);
});
it('returns client by id', async () => {
const req = makeReq({ params: { id: clientId } });
const res = mockRes();
await clientCtl.getClient_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(String(res._data._id)).toBe(clientId);
});
});
// -------------------------------------------------------------------------
describe('search_post', () => {
it('returns matching clients', async () => {
const req = makeReq({ body: { byPuid: applicator._id } });
const res = mockRes();
await clientCtl.search_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});
// -------------------------------------------------------------------------
describe('delete_delete', () => {
let clientId;
beforeAll(async () => {
const doc = await Client.create(mockClient(applicator._id));
clientId = String(doc._id);
});
it.skip('soft-deletes a client (skipped: requires MongoDB replica-set for transactions)', async () => {
const req = makeReq({ params: { id: clientId } });
const res = mockRes();
await clientCtl.deleteClient(req, res);
expect(res.json).toHaveBeenCalled();
const found = await Client.findById(clientId);
expect(!found || found.markedDelete).toBeTruthy();
});
});
});

View File

@ -0,0 +1,67 @@
'use strict';
/**
* Integration tests common controller (controllers/common.js)
*
* Covers: getCountries_get
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockReq, mockRes } = require('./mock_data');
let Country;
beforeAll(async () => {
await connectDB();
Country = require('../../model/country');
});
afterAll(async () => {
await disconnectDB();
});
const commonCtl = require('../../controllers/common');
describe('common controller data methods', () => {
beforeAll(async () => {
await clearCollection(Country);
await Country.create([
{ code: 'US', name: 'United States' },
{ code: 'CA', name: 'Canada' },
]);
});
afterAll(async () => {
await clearCollection(Country);
});
describe('getCountries_get', () => {
it('returns an array of countries with code and name', async () => {
const req = mockReq();
const res = mockRes();
await commonCtl.getCountries_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
expect(res._data.length).toBeGreaterThanOrEqual(2);
const us = res._data.find(c => c.code === 'US');
expect(us).toBeDefined();
expect(us.name).toBe('United States');
// _id should be excluded per the controller projection
expect(us._id).toBeUndefined();
});
it('returns an empty array when no countries exist', async () => {
await clearCollection(Country);
const req = mockReq();
const res = mockRes();
await commonCtl.getCountries_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data).toEqual([]);
});
});
});

View File

@ -0,0 +1,143 @@
'use strict';
/**
* Integration tests costing_items controller (controllers/costing_items.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockCostingItem, mockReq, mockRes, newId } = require('./mock_data');
let CostingItem, Customer;
beforeAll(async () => {
await connectDB();
CostingItem = require('../../model/costing_items');
Customer = require('../../model/customer');
});
afterAll(async () => {
await disconnectDB();
});
const costingCtl = require('../../controllers/costing_items');
describe('costing_items controller data methods', () => {
let applicator;
beforeAll(async () => {
await clearCollection(CostingItem);
applicator = await Customer.create(mockApplicator());
});
afterAll(async () => {
await clearCollection(CostingItem);
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('create_post', () => {
it('creates a costing item and returns it', async () => {
const req = makeReq({ body: mockCostingItem(applicator._id, applicator._id) });
const res = mockRes();
await costingCtl.createCostingItem_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data._id).toBeDefined();
expect(res._data.name).toMatch(/CostItem-/);
});
it('throws when body is missing', async () => {
const req = makeReq({ body: null });
const res = mockRes();
await expect(costingCtl.createCostingItem_post(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('list_get', () => {
it('returns costing items for the applicator', async () => {
const req = makeReq({ query: {} });
const res = mockRes();
await costingCtl.getCostingItems_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
expect(res._data.length).toBeGreaterThan(0);
});
});
// -------------------------------------------------------------------------
describe('getById_get', () => {
let itemId;
beforeAll(async () => {
const doc = await CostingItem.create(mockCostingItem(applicator._id, applicator._id));
itemId = String(doc._id);
});
it('returns costing item by id', async () => {
const req = makeReq({ params: { costingItemId: itemId } });
const res = mockRes();
await costingCtl.getCostingItemDetail_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(String(res._data._id)).toBe(itemId);
});
it('throws when id is invalid', async () => {
const req = makeReq({ params: { costingItemId: 'bad-id' } });
const res = mockRes();
await expect(costingCtl.getCostingItemDetail_get(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('update_put', () => {
let itemId;
beforeAll(async () => {
const doc = await CostingItem.create(mockCostingItem(applicator._id, applicator._id));
itemId = String(doc._id);
});
it('updates a costing item', async () => {
const req = makeReq({
params: { costingItemId: itemId },
body: { name: 'Updated Cost Item', price: 20.0 },
});
const res = mockRes();
await costingCtl.updateCostingItem_put(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.name).toBe('Updated Cost Item');
});
});
// -------------------------------------------------------------------------
describe('delete_delete', () => {
let itemId;
beforeAll(async () => {
const doc = await CostingItem.create(mockCostingItem(applicator._id, applicator._id));
itemId = String(doc._id);
});
it('deletes a costing item', async () => {
const req = makeReq({ params: { costingItemId: itemId } });
const res = mockRes();
await costingCtl.deleteCostingItem(req, res);
expect(res.json).toHaveBeenCalled();
const found = await CostingItem.findById(itemId);
expect(found).toBeNull();
});
});
});

View File

@ -0,0 +1,155 @@
'use strict';
/**
* Integration tests crop controller (controllers/crop.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockCrop, mockReq, mockRes, newId } = require('./mock_data');
let Crop, Customer;
beforeAll(async () => {
await connectDB();
Crop = require('../../model/crop');
Customer = require('../../model/customer');
});
afterAll(async () => {
await disconnectDB();
});
const cropCtl = require('../../controllers/crop');
describe('crop controller data methods', () => {
let applicator;
beforeAll(async () => {
await clearCollection(Crop);
applicator = await Customer.create(mockApplicator());
});
afterAll(async () => {
await clearCollection(Crop);
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('create_post', () => {
it('creates a crop and returns it', async () => {
const req = makeReq({ body: mockCrop(applicator._id) });
const res = mockRes();
await cropCtl.createCrop_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data._id).toBeDefined();
expect(res._data.name).toMatch(/Crop-/);
});
it('throws when body is missing', async () => {
const req = makeReq({ body: null });
const res = mockRes();
await expect(cropCtl.createCrop_post(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('getAll_get', () => {
it('returns crops for the applicator', async () => {
const req = makeReq({ query: {} });
const res = mockRes();
await cropCtl.getCrops_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});
// -------------------------------------------------------------------------
describe('getById_get', () => {
let cropId;
beforeAll(async () => {
const doc = await Crop.create(mockCrop(applicator._id));
cropId = String(doc._id);
});
it('returns a crop by id', async () => {
const req = makeReq({ params: { crop_id: cropId } });
const res = mockRes();
await cropCtl.getCrop_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(String(res._data._id)).toBe(cropId);
});
it('throws when id is invalid', async () => {
const req = makeReq({ params: { crop_id: 'bad-id' } });
const res = mockRes();
await expect(cropCtl.getCrop_get(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('update_put', () => {
let cropId;
beforeAll(async () => {
const doc = await Crop.create(mockCrop(applicator._id));
cropId = String(doc._id);
});
it('updates a crop', async () => {
const req = makeReq({
params: { crop_id: cropId },
body: { name: 'Updated Crop' },
});
const res = mockRes();
await cropCtl.updateCrop_put(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.name).toBe('Updated Crop');
});
});
// -------------------------------------------------------------------------
describe('search_post', () => {
it('returns matching crops', async () => {
const req = makeReq({ body: { byUserId: applicator._id } });
const res = mockRes();
await cropCtl.search_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});
// -------------------------------------------------------------------------
describe('delete_delete', () => {
let cropId;
beforeAll(async () => {
const doc = await Crop.create(mockCrop(applicator._id));
cropId = String(doc._id);
});
it('deletes a crop', async () => {
const req = makeReq({ params: { crop_id: cropId } });
const res = mockRes();
await cropCtl.deleteCrop(req, res);
expect(res.json).toHaveBeenCalled();
const found = await Crop.findById(cropId);
expect(found).toBeNull();
});
});
});

View File

@ -0,0 +1,75 @@
'use strict';
/**
* Integration tests customer controller (controllers/customer.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockClient, mockReq, mockRes, newId } = require('./mock_data');
let Customer, Client;
beforeAll(async () => {
await connectDB();
Customer = require('../../model/customer');
Client = require('../../model/client');
});
afterAll(async () => {
await disconnectDB();
});
const customerCtl = require('../../controllers/customer');
describe('customer controller data methods', () => {
let applicator, client;
beforeAll(async () => {
await clearCollection(Client);
applicator = await Customer.create(mockApplicator());
client = await Client.create(mockClient(applicator._id));
});
afterAll(async () => {
await clearCollection(Client);
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('getCustomers_get', () => {
it('returns aggregated customer list for applicator', async () => {
const req = makeReq({ query: {} });
const res = mockRes();
await customerCtl.getCustomers_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
it('filters by name when provided', async () => {
const filters = JSON.stringify({ name: { value: 'Test Applicator', valueOperator: 'contains' } });
const req = makeReq({ query: { filters } });
const res = mockRes();
await customerCtl.getCustomers_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.length).toBeGreaterThan(0);
});
it('returns empty when name does not match', async () => {
const filters = JSON.stringify({ name: { value: 'NONEXISTENT_CLIENT_XYZ', valueOperator: 'contains' } });
const req = makeReq({ query: { filters } });
const res = mockRes();
await customerCtl.getCustomers_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.length).toBe(0);
});
});
});

View File

@ -0,0 +1,202 @@
'use strict';
/**
* Integration tests dealer controller (controllers/dealer.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockReq, mockRes } = require('./mock_data');
let Dealer;
beforeAll(async () => {
await connectDB();
Dealer = require('../../model/dealer');
});
afterAll(async () => {
await disconnectDB();
});
const dealerCtl = require('../../controllers/dealer');
function mockDealer(overrides = {}) {
return {
companyName: `Dealer-${Date.now()}`,
country: 'US',
contactName: 'John Smith',
address: '789 Dealer Rd',
phone: '+15550003000',
email: `dealer_${Date.now()}@test.com`,
...overrides,
};
}
describe('dealer controller data methods', () => {
beforeAll(async () => {
await clearCollection(Dealer);
});
afterAll(async () => {
await clearCollection(Dealer);
});
// -------------------------------------------------------------------------
describe('getDealers_get', () => {
it('returns an empty array when no dealers exist', async () => {
const req = mockReq();
const res = mockRes();
await dealerCtl.getDealers_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
expect(res._data.length).toBe(0);
});
it('returns dealers sorted by country then companyName', async () => {
await Dealer.create([
mockDealer({ companyName: 'Zulu Ag', country: 'CA' }),
mockDealer({ companyName: 'Alpha Ag', country: 'US' }),
mockDealer({ companyName: 'Beta Ag', country: 'CA' }),
]);
const req = mockReq();
const res = mockRes();
await dealerCtl.getDealers_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.length).toBe(3);
// First two entries should be CA (sorted by country first)
expect(res._data[0].country).toBe('CA');
expect(res._data[1].country).toBe('CA');
// Within CA, sorted by companyName: Beta < Zulu
expect(res._data[0].companyName).toBe('Beta Ag');
expect(res._data[1].companyName).toBe('Zulu Ag');
});
});
// -------------------------------------------------------------------------
describe('createDealer_post', () => {
it('creates a dealer and returns the saved document', async () => {
const req = mockReq({ body: mockDealer() });
const res = mockRes();
await dealerCtl.createDealer_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data._id).toBeDefined();
expect(res._data.country).toBe('US');
});
it('strips the _id from input so Mongo assigns its own', async () => {
const mongoose = require('mongoose');
const injectedId = new mongoose.Types.ObjectId();
const req = mockReq({ body: mockDealer({ _id: injectedId }) });
const res = mockRes();
await dealerCtl.createDealer_post(req, res);
expect(res.json).toHaveBeenCalled();
// The returned _id must differ from the injected one because createDealer_post deletes it
expect(String(res._data._id)).not.toBe(String(injectedId));
});
it('throws when body is null', async () => {
const req = mockReq({ body: null });
const res = mockRes();
await expect(dealerCtl.createDealer_post(req, res)).rejects.toThrow();
});
it('throws when companyName is missing', async () => {
const req = mockReq({ body: { country: 'US' } });
const res = mockRes();
await expect(dealerCtl.createDealer_post(req, res)).rejects.toThrow();
});
it('throws when country is missing', async () => {
const req = mockReq({ body: { companyName: 'Test Dealer' } });
const res = mockRes();
await expect(dealerCtl.createDealer_post(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('updateDealer_put', () => {
let dealerId;
beforeAll(async () => {
const doc = await Dealer.create(mockDealer());
dealerId = String(doc._id);
});
it('updates a dealer and returns the updated document', async () => {
const req = mockReq({
params: { id: dealerId },
body: { contactName: 'Jane Doe' },
});
const res = mockRes();
await dealerCtl.updateDealer_put(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.contactName).toBe('Jane Doe');
expect(String(res._data._id)).toBe(dealerId);
});
it('throws when id param is missing', async () => {
const req = mockReq({ params: {}, body: { contactName: 'Jane Doe' } });
const res = mockRes();
await expect(dealerCtl.updateDealer_put(req, res)).rejects.toThrow();
});
it('throws when id does not match any dealer', async () => {
const mongoose = require('mongoose');
const req = mockReq({
params: { id: new mongoose.Types.ObjectId().toHexString() },
body: { contactName: 'Nobody' },
});
const res = mockRes();
await expect(dealerCtl.updateDealer_put(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('deleteDealer_delete', () => {
let dealerId;
beforeAll(async () => {
const doc = await Dealer.create(mockDealer());
dealerId = String(doc._id);
});
it('deletes a dealer and returns { ok: true }', async () => {
const req = mockReq({ params: { id: dealerId } });
const res = mockRes();
await dealerCtl.deleteDealer_delete(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data).toEqual({ ok: true });
const found = await Dealer.findById(dealerId);
expect(found).toBeNull();
});
it('throws when id param is missing', async () => {
const req = mockReq({ params: {} });
const res = mockRes();
await expect(dealerCtl.deleteDealer_delete(req, res)).rejects.toThrow();
});
it('throws when id does not match any dealer', async () => {
const mongoose = require('mongoose');
const req = mockReq({
params: { id: new mongoose.Types.ObjectId().toHexString() },
});
const res = mockRes();
await expect(dealerCtl.deleteDealer_delete(req, res)).rejects.toThrow();
});
});
});

View File

@ -0,0 +1,189 @@
'use strict';
/**
* Integration tests geoitem controller (controllers/geoitem.js)
*/
const mongoose = require('mongoose');
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockClient, mockReq, mockRes, newId } = require('./mock_data');
let Area, Customer, Client;
beforeAll(async () => {
await connectDB();
Area = require('../../model/area');
Customer = require('../../model/customer');
Client = require('../../model/client');
});
afterAll(async () => {
await disconnectDB();
});
const geoitemCtl = require('../../controllers/geoitem');
/** Minimal valid GeoJSON Polygon coordinates (a small square) */
function squareCoords() {
return [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]];
}
function mockArea(clientId, overrides = {}) {
return {
properties: {
name: `Area-${Date.now()}`,
type: 0,
color: '#FF0000',
area: 1.5,
},
geometry: {
type: 'Polygon',
coordinates: squareCoords(),
},
client: clientId,
...overrides,
};
}
describe('geoitem controller data methods', () => {
let applicator, client;
beforeAll(async () => {
await clearCollection(Area);
applicator = await Customer.create(mockApplicator());
client = await Client.create(mockClient(applicator._id));
});
afterAll(async () => {
await clearCollection(Area);
await Customer.deleteMany({ _id: { $in: [applicator._id, client._id] } });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('findAreas_post', () => {
it('throws when clientId is missing from body', async () => {
const req = makeReq({ body: {} });
const res = mockRes();
await expect(geoitemCtl.findAreas_post(req, res)).rejects.toThrow();
});
it('throws when clientId is not a valid ObjectId', async () => {
const req = makeReq({ body: { clientId: 'not-an-id' } });
const res = mockRes();
await expect(geoitemCtl.findAreas_post(req, res)).rejects.toThrow();
});
it('returns an empty array when no areas exist for the client', async () => {
const req = makeReq({ body: { clientId: client._id.toHexString() } });
const res = mockRes();
await geoitemCtl.findAreas_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
expect(res._data.length).toBe(0);
});
it('returns areas belonging to the specified client', async () => {
await Area.create(mockArea(client._id));
await Area.create(mockArea(client._id));
const req = makeReq({ body: { clientId: client._id.toHexString() } });
const res = mockRes();
await geoitemCtl.findAreas_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.length).toBe(2);
// toGeoItems output shape: { _id, properties, geometry, client }
expect(res._data[0]).toHaveProperty('_id');
expect(res._data[0]).toHaveProperty('geometry');
});
it('does not return areas belonging to a different client', async () => {
const otherClient = await Client.create(mockClient(applicator._id));
await Area.create(mockArea(otherClient._id));
const req = makeReq({ body: { clientId: client._id.toHexString() } });
const res = mockRes();
await geoitemCtl.findAreas_post(req, res);
expect(res.json).toHaveBeenCalled();
// Should still only see 2 (created in previous test)
const clientIds = res._data.map(f => String(f.properties?.client ?? ''));
expect(clientIds.every(id => id !== String(otherClient._id) || id === '')).toBe(true);
await Client.deleteMany({ _id: otherClient._id });
});
});
// -------------------------------------------------------------------------
describe('update_post', () => {
it('returns immediately when all update arrays are empty', async () => {
const req = makeReq({ body: { c: [], u: [], r: [] } });
const res = mockRes();
await geoitemCtl.update_post(req, res);
expect(res.end).toHaveBeenCalled();
});
it('returns immediately when body is null', async () => {
const req = makeReq({ body: null });
const res = mockRes();
await geoitemCtl.update_post(req, res);
expect(res.end).toHaveBeenCalled();
});
it('inserts new areas via the c (create) array', async () => {
const area = mockArea(client._id);
const req = makeReq({ body: { c: [area] } });
const res = mockRes();
await geoitemCtl.update_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data).toEqual({ ok: true });
const found = await Area.find({ client: client._id });
expect(found.length).toBeGreaterThan(0);
});
it('removes areas via the r (remove) array', async () => {
const doc = await Area.create(mockArea(client._id));
const countBefore = await Area.countDocuments({ client: client._id });
const req = makeReq({ body: { r: [String(doc._id)] } });
const res = mockRes();
await geoitemCtl.update_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data).toEqual({ ok: true });
const countAfter = await Area.countDocuments({ client: client._id });
expect(countAfter).toBe(countBefore - 1);
});
});
// -------------------------------------------------------------------------
describe('addToLibrary_post', () => {
it('throws when areas array is empty', async () => {
const req = makeReq({ body: { areas: [] } });
const res = mockRes();
await expect(geoitemCtl.addToLibrary_post(req, res)).rejects.toThrow();
});
it('throws when body has no areas field', async () => {
const req = makeReq({ body: {} });
const res = mockRes();
await expect(geoitemCtl.addToLibrary_post(req, res)).rejects.toThrow();
});
});
});

View File

@ -0,0 +1,128 @@
'use strict';
/**
* Integration tests Invoice controller (controllers/invoice.js)
*
* We focus on the read path (getInvoices_get) which does not require the full
* complex invoice-creation pipeline (jobs, clients, Stripe, etc.).
* A seeded Invoice document is created directly via the model for query tests.
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockClient, mockReq, mockRes, newId } = require('./mock_data');
let Invoice, Customer, Client;
beforeAll(async () => {
await connectDB();
Invoice = require('../../model/invoice');
Customer = require('../../model/customer');
Client = require('../../model/client');
});
afterAll(async () => {
await disconnectDB();
});
const invoiceCtl = require('../../controllers/invoice');
describe('Invoice controller data methods', () => {
let applicator, client;
function makeInvoiceDoc(puid, clientId) {
return {
code: `INV-${Date.now()}`,
companyName: 'Test Applicator Co.',
address: '123 Main St',
currency: 'USD',
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
openDate: new Date(),
status: 'open',
paymentTerm: 30,
byPuid: puid,
clients: [
{
billTo: clientId,
code: `INV-${Date.now()}-1`,
split: '100',
subTotal: '100.00',
},
],
jobs: [],
};
}
beforeAll(async () => {
await clearCollection(Invoice);
applicator = await Customer.create(mockApplicator());
client = await Client.create(mockClient(applicator._id));
});
afterAll(async () => {
await clearCollection(Invoice);
await Client.deleteMany({ _id: client._id });
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({
uid: applicator._id,
puid: applicator._id,
ut: '1',
userInfo: {
puid: applicator._id,
kind: '1',
premium: 0,
membership: null,
markedDelete: false,
},
...extra,
});
// -------------------------------------------------------------------------
describe('getInvoices_get', () => {
beforeAll(async () => {
await Invoice.create(makeInvoiceDoc(applicator._id, client._id));
});
it('returns invoices for the applicator', async () => {
const req = makeReq({ query: {} });
const res = mockRes();
await invoiceCtl.getInvoices_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
expect(res._data.length).toBeGreaterThan(0);
});
it('returns empty array when no invoices match filter', async () => {
const filters = JSON.stringify({ code: { value: 'NONEXISTENT_CODE_12345', valueOperator: 'contains' } });
const req = makeReq({ query: { filters } });
const res = mockRes();
await invoiceCtl.getInvoices_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.length).toBe(0);
});
it('throws AppParamError when applicator puid is invalid', async () => {
const req = mockReq({
uid: newId(),
puid: 'invalid-puid',
ut: '1',
userInfo: {
puid: 'invalid-puid',
kind: '1',
premium: 0,
membership: null,
markedDelete: false,
},
});
const res = mockRes();
await expect(invoiceCtl.getInvoices_get(req, res)).rejects.toThrow();
});
});
});

View File

@ -0,0 +1,143 @@
'use strict';
/**
* Integration tests invoice_settings controller (controllers/invoice_settings.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockClient, mockInvoiceSetting, mockReq, mockRes, newId } = require('./mock_data');
let InvoiceSetting, Customer, Client;
beforeAll(async () => {
await connectDB();
InvoiceSetting = require('../../model/invoice_settings');
Customer = require('../../model/customer');
Client = require('../../model/client');
});
afterAll(async () => {
await disconnectDB();
});
const invSettingsCtl = require('../../controllers/invoice_settings');
describe('invoice_settings controller data methods', () => {
let applicator, client;
beforeAll(async () => {
await clearCollection(InvoiceSetting);
applicator = await Customer.create(mockApplicator());
client = await Client.create(mockClient(applicator._id));
});
afterAll(async () => {
await clearCollection(InvoiceSetting);
await Client.deleteMany({ _id: client._id });
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('createInvoiceSetting_post', () => {
it('creates an invoice setting and returns it', async () => {
const req = makeReq({ body: mockInvoiceSetting(applicator._id, client._id) });
const res = mockRes();
await invSettingsCtl.createInvoiceSetting_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data._id).toBeDefined();
expect(res._data.companyName).toBe('Test Applicator Co.');
});
});
// -------------------------------------------------------------------------
describe('getInvoiceSettings_get', () => {
it('returns invoice settings for the applicator', async () => {
const req = makeReq({ query: {} });
const res = mockRes();
await invSettingsCtl.getInvoiceSettings_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
expect(res._data.length).toBeGreaterThan(0);
});
});
// -------------------------------------------------------------------------
describe('getInvoiceSettingDetail_get', () => {
let settingId;
beforeAll(async () => {
await clearCollection(InvoiceSetting);
const doc = await InvoiceSetting.create(mockInvoiceSetting(applicator._id, client._id));
settingId = String(doc._id);
});
it('returns invoice setting by id', async () => {
const req = makeReq({ params: { invoiceSettingId: settingId } });
const res = mockRes();
await invSettingsCtl.getInvoiceSettingDetail_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(String(res._data._id)).toBe(settingId);
});
it('throws when id is invalid', async () => {
const req = makeReq({ params: { invoiceSettingId: 'bad-id' } });
const res = mockRes();
await expect(invSettingsCtl.getInvoiceSettingDetail_get(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('updateInvoiceSetting_put', () => {
let settingId;
beforeAll(async () => {
await clearCollection(InvoiceSetting);
const doc = await InvoiceSetting.create(mockInvoiceSetting(applicator._id, client._id));
settingId = String(doc._id);
});
it('updates an invoice setting', async () => {
const req = makeReq({
params: { invoiceSettingId: settingId },
body: { companyName: 'Updated Co.', paymentTerm: 45 },
});
const res = mockRes();
await invSettingsCtl.updateInvoiceSetting_put(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.companyName).toBe('Updated Co.');
});
});
// -------------------------------------------------------------------------
describe('deleteInvoiceSetting', () => {
let settingId;
beforeAll(async () => {
await clearCollection(InvoiceSetting);
const doc = await InvoiceSetting.create(mockInvoiceSetting(applicator._id, client._id));
settingId = String(doc._id);
});
it('deletes an invoice setting', async () => {
const req = makeReq({ params: { invoiceSettingId: settingId } });
const res = mockRes();
await invSettingsCtl.deleteInvoiceSetting(req, res);
expect(res.json).toHaveBeenCalled();
const found = await InvoiceSetting.findById(settingId);
expect(found).toBeNull();
});
});
});

View File

@ -0,0 +1,65 @@
'use strict';
/**
* Jest integration test setup.
* Uses an in-memory MongoDB instance (mongodb-memory-server) so tests run
* without any external database credentials or infrastructure.
*
* TOKEN_SECRET may optionally be set in the environment; a safe default
* is used when it is absent (tests only, never production).
*/
// Ensure NODE_ENV=test so application code skips production-only paths.
process.env.NODE_ENV = 'test';
// Provide a default token secret for JWT signing in tests.
if (!process.env.TOKEN_SECRET) {
process.env.TOKEN_SECRET = 'test-secret-not-for-production';
}
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
let mongod = null;
/**
* Start the in-memory MongoDB server and connect mongoose.
* Call this inside beforeAll() in each test file.
*/
async function connectDB() {
if (mongoose.connection.readyState !== 0) return; // already connected
mongod = await MongoMemoryServer.create();
const uri = mongod.getUri();
mongoose.set('strictPopulate', false);
await mongoose.connect(uri, {
connectTimeoutMS: 15000,
socketTimeoutMS: 30000,
});
// Register all discriminators in the correct order (user → sub-types).
// Require-ordering matters base model first.
require('../../model');
}
/**
* Disconnect mongoose and stop the in-memory server.
* Call this inside afterAll() in each test file.
*/
async function disconnectDB() {
await mongoose.disconnect();
if (mongod) {
await mongod.stop();
mongod = null;
}
}
/**
* Remove all documents from a collection.
* Call this inside beforeEach() / afterEach() to keep tests isolated.
*/
async function clearCollection(model) {
await model.deleteMany({});
}
module.exports = { connectDB, disconnectDB, clearCollection };

View File

@ -0,0 +1,213 @@
'use strict';
/**
* Integration tests job controller (controllers/job.js)
*
* The job controller is a factory: require('../../controllers/job')({})
* Job._id is an auto-increment numeric field.
* deleteJob wraps transaction errors with try/catch so it works on
* standalone MongoDB (no replica-set needed).
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const {
mockApplicator,
mockClient,
mockPilot,
mockVehicle,
mockJob,
mockReq,
mockRes,
newId,
} = require('./mock_data');
let Job, Customer, Client, Pilot, Vehicle;
beforeAll(async () => {
await connectDB();
Customer = require('../../model/customer');
Client = require('../../model/client');
Pilot = require('../../model/pilot');
Vehicle = require('../../model/vehicle');
Job = require('../../model/job');
});
afterAll(async () => {
await disconnectDB();
});
// Job controller is a factory
const jobCtl = require('../../controllers/job')({});
describe('job controller data methods', () => {
let applicator, client, pilot, vehicle;
beforeAll(async () => {
await clearCollection(Job);
applicator = await Customer.create(mockApplicator());
client = await Client.create(mockClient(applicator._id));
pilot = await Pilot.create(mockPilot(applicator._id));
vehicle = await Vehicle.create(mockVehicle(applicator._id));
});
afterAll(async () => {
await clearCollection(Job);
await Client.deleteMany({ _id: client._id });
await Pilot.deleteMany({ _id: pilot._id });
await Vehicle.deleteMany({ _id: vehicle._id });
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({
uid: applicator._id,
puid: applicator._id,
ut: '1',
userInfo: {
puid: applicator._id,
kind: '1',
premium: 0,
membership: null,
markedDelete: false,
},
...extra,
});
// -------------------------------------------------------------------------
describe('createJob_post', () => {
it('creates a job and returns it', async () => {
const body = mockJob(applicator._id);
body.client = client._id;
body.pilot = pilot._id;
body.vehicle = vehicle._id;
const req = makeReq({ body });
const res = mockRes();
await jobCtl.createJob_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data._id).toBeDefined();
expect(typeof res._data._id).toBe('number');
});
it('throws when puid is missing', async () => {
const req = makeReq({ puid: undefined, body: mockJob(applicator._id) });
const res = mockRes();
await expect(jobCtl.createJob_post(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('getJobs_get', () => {
it('returns jobs for the applicator', async () => {
const req = makeReq({ query: {} });
const res = mockRes();
await jobCtl.getJobs_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
expect(res._data.length).toBeGreaterThan(0);
});
it('returns empty array when puid has no jobs', async () => {
const unknownId = newId();
const req = mockReq({
uid: unknownId,
puid: unknownId,
ut: '1',
userInfo: { puid: unknownId, kind: '1', premium: 0, membership: null, markedDelete: false },
query: {},
});
const res = mockRes();
await jobCtl.getJobs_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.length).toBe(0);
});
});
// -------------------------------------------------------------------------
describe('getJob_get', () => {
let jobId;
beforeAll(async () => {
const body = mockJob(applicator._id);
body.client = client._id;
body.pilot = pilot._id;
body.vehicle = vehicle._id;
const req = makeReq({ body });
const res = mockRes();
await jobCtl.createJob_post(req, res);
jobId = res._data._id;
});
it('returns a job by numeric id', async () => {
const req = makeReq({ params: { job_id: String(jobId) } });
const res = mockRes();
await jobCtl.getJob_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data._id).toBe(jobId);
});
});
// -------------------------------------------------------------------------
describe('updateJob_put', () => {
let jobId;
beforeAll(async () => {
const body = mockJob(applicator._id);
body.client = client._id;
body.pilot = pilot._id;
body.vehicle = vehicle._id;
const req = makeReq({ body });
const res = mockRes();
await jobCtl.createJob_post(req, res);
jobId = res._data._id;
});
it('updates a job field', async () => {
const req = makeReq({
params: { job_id: String(jobId) },
body: { job: { name: 'Updated Job Name' } },
});
const res = mockRes();
await jobCtl.updateJob_put(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.name).toBe('Updated Job Name');
});
});
// -------------------------------------------------------------------------
describe('deleteJob', () => {
let jobId;
beforeAll(async () => {
const body = mockJob(applicator._id);
body.client = client._id;
body.pilot = pilot._id;
body.vehicle = vehicle._id;
const req = makeReq({ body });
const res = mockRes();
await jobCtl.createJob_post(req, res);
jobId = res._data._id;
});
it('deletes a job (transaction error handled on standalone MongoDB)', async () => {
const req = makeReq({ params: { id: String(jobId) } });
const res = mockRes();
// deleteJob wraps the transaction with try/catch it either succeeds
// fully or returns a soft-success; either way res.json is called.
await jobCtl.deleteJob(req, res);
expect(res.json).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,108 @@
'use strict';
/**
* Integration tests log_payment controller (controllers/log_payment.js)
*
* createLogPayment_post and createLogPayments_post are skipped because they
* use MongoDB transactions which require a replica-set. Only the read path
* (getLogPayments_get) is tested here.
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockClient, mockReq, mockRes, newId } = require('./mock_data');
let LogPayment, Invoice, Customer, Client;
beforeAll(async () => {
await connectDB();
Customer = require('../../model/customer');
Client = require('../../model/client');
LogPayment = require('../../model/log_payment');
({ Invoice } = require('../../model'));
});
afterAll(async () => {
await disconnectDB();
});
const logPaymentCtl = require('../../controllers/log_payment');
describe('log_payment controller data methods', () => {
let applicator, client, invoice;
function makeInvoiceDoc(puid, clientId) {
return {
code: `INV-TEST-${Date.now()}`,
currency: 'USD',
dueDate: new Date(),
openDate: new Date(),
byPuid: puid,
clients: [{ billTo: clientId, split: '100', subTotal: '0.00' }],
jobs: [{ totalAmount: '0.00' }],
};
}
function makeLogPaymentDoc(puid, clientId, invoiceId) {
return {
byPuid: puid,
invoice: invoiceId,
client: clientId,
amount: '100.00',
paymentDate: new Date(),
paymentMethod: 'cash',
};
}
beforeAll(async () => {
await clearCollection(LogPayment);
applicator = await Customer.create(mockApplicator());
client = await Client.create(mockClient(applicator._id));
invoice = await Invoice.create(makeInvoiceDoc(applicator._id, client._id));
await LogPayment.create(makeLogPaymentDoc(applicator._id, client._id, invoice._id));
});
afterAll(async () => {
await clearCollection(LogPayment);
await Invoice.deleteMany({ _id: invoice._id });
await Client.deleteMany({ _id: client._id });
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('getLogPayments_get', () => {
it('returns log payments for the applicator', async () => {
const req = makeReq({ query: { invoiceId: String(invoice._id) } });
const res = mockRes();
await logPaymentCtl.getLogPayments_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
expect(res._data.length).toBeGreaterThan(0);
});
it('returns empty array when invoice has no log payments', async () => {
const emptyInvoice = await Invoice.create(makeInvoiceDoc(applicator._id, client._id));
const req = makeReq({ query: { invoiceId: String(emptyInvoice._id) } });
const res = mockRes();
await logPaymentCtl.getLogPayments_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.length).toBe(0);
await Invoice.deleteMany({ _id: emptyInvoice._id });
});
});
// -------------------------------------------------------------------------
describe('createLogPayment_post (transaction skipped)', () => {
it.skip('skipped: MongoDB transactions require a replica-set', () => {});
});
describe('createLogPayments_post (transaction skipped)', () => {
it.skip('skipped: MongoDB transactions require a replica-set', () => {});
});
});

View File

@ -0,0 +1,76 @@
'use strict';
/**
* Integration tests main controller (controllers/main.js)
*
* Covers: pingAPI_get, getAppConfig_get, setAppConfig_post
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockReq, mockRes, newId } = require('./mock_data');
let Settings;
beforeAll(async () => {
await connectDB();
Settings = require('../../model/setting');
});
afterAll(async () => {
await disconnectDB();
});
const mainCtl = require('../../controllers/main');
describe('main controller data methods', () => {
afterAll(async () => {
await clearCollection(Settings);
});
// -------------------------------------------------------------------------
describe('pingAPI_get', () => {
it('responds with a pong / alive message', async () => {
const req = mockReq();
const res = mockRes();
await mainCtl.pingAPI_get(req, res);
// pingAPI_get uses res.send() or res.json()
const called = res.json.mock.calls.length > 0 || res.send.mock.calls.length > 0;
expect(called).toBe(true);
});
});
// -------------------------------------------------------------------------
describe('getAppConfig_get', () => {
it('returns an app config object', async () => {
const req = mockReq({ query: {} });
const res = mockRes();
await mainCtl.getAppConfig_get(req, res);
const called = res.json.mock.calls.length > 0 || res.send.mock.calls.length > 0;
expect(called).toBe(true);
expect(typeof res._data).toBe('object');
});
});
// -------------------------------------------------------------------------
describe('setAppConfig_post', () => {
it('saves an app config value and returns the saved document', async () => {
await clearCollection(Settings);
const req = mockReq({
uid: newId(),
ut: '1',
body: { key: 'testKey', value: 'testValue' },
});
const res = mockRes();
await mainCtl.setAppConfig_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data).toBeTruthy();
});
});
});

View File

@ -0,0 +1,225 @@
'use strict';
/**
* Shared mock data factories for integration tests.
* All ids are fresh ObjectIds so tests are fully isolated.
*/
const mongoose = require('mongoose');
const newId = () => new mongoose.Types.ObjectId();
// ---------------------------------------------------------------------------
// Applicator / Customer (kind = "1")
// ---------------------------------------------------------------------------
function mockApplicator(overrides = {}) {
return {
_id: newId(),
kind: '1',
username: `applicator_${Date.now()}@test.com`,
password: '$2a$10$hashedpassword',
name: 'Test Applicator Co.',
email: `applicator_${Date.now()}@test.com`,
phone: '+15550001000',
address: '123 Main St',
country: 'US',
active: true,
billable: true,
premium: 0,
selfSignup: false,
markedDelete: false,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Client (kind = "3")
// ---------------------------------------------------------------------------
function mockClient(parentId, overrides = {}) {
return {
_id: newId(),
kind: '3',
username: `client_${Date.now()}@test.com`,
name: 'Test Client',
email: `client_${Date.now()}@test.com`,
phone: '+15550002000',
address: '456 Oak Ave',
country: 'US',
active: true,
parent: parentId,
markedDelete: false,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Pilot (kind = "5")
// ---------------------------------------------------------------------------
function mockPilot(parentId, overrides = {}) {
return {
_id: newId(),
kind: '5',
username: `pilot_${Date.now()}@test.com`,
name: 'Test Pilot',
email: `pilot_${Date.now()}@test.com`,
active: true,
parent: parentId,
markedDelete: false,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Vehicle / Device (kind = "9")
// ---------------------------------------------------------------------------
function mockVehicle(parentId, overrides = {}) {
return {
_id: newId(),
kind: '9',
name: `Aircraft-${Date.now()}`,
model: 'AgNav TestBird',
color: 'yellow',
vehicleType: 0,
active: true,
tracking: false,
pkgActive: false,
parent: parentId,
markedDelete: false,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Product
// ---------------------------------------------------------------------------
function mockProduct(puid, overrides = {}) {
return {
name: `Product-${Date.now()}`,
type: 1, // ACTIVE
restricted: false,
epaReg: `EPA-${Date.now()}`,
desc: 'Integration test product',
rate: { value: 2.5, unit: 1 }, // GAL
byPuid: puid,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Crop
// ---------------------------------------------------------------------------
function mockCrop(puid, overrides = {}) {
return {
name: `Crop-${Date.now()}`,
color: '#00AA00',
desc: 'Integration test crop',
byPuid: puid,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Job
// ---------------------------------------------------------------------------
function mockJob(puid, overrides = {}) {
return {
name: `Job-${Date.now()}`,
orderNumber: `ORD-${Date.now()}`,
measureUnit: false, // false = acres
swathWidth: 60,
byPuid: puid,
status: 0, // PENDING
markedDelete: false,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Costing Item
// ---------------------------------------------------------------------------
function mockCostingItem(puid, userId, overrides = {}) {
return {
name: `CostItem-${Date.now()}`,
type: 0,
unit: 1, // GAL
price: 10.5,
byPuid: puid,
createdBy: userId,
updatedBy: userId,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Invoice Setting
// ---------------------------------------------------------------------------
function mockInvoiceSetting(puid, userId, overrides = {}) {
return {
byPuid: puid,
userId,
companyName: 'Test Applicator Co.',
address: '123 Main St, Testville, TX',
taxValue: 0,
discount: 0,
termOpts: [15, 30, 60],
paymentTerm: 30,
currency: 'USD',
note: 'Integration test invoice setting',
logo: '',
createdBy: userId,
updatedBy: userId,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Request / response mocks used for controller unit-style tests
// ---------------------------------------------------------------------------
/**
* Build a minimal Express-like req object for an authenticated applicator.
*/
function mockReq({ uid, puid, ut = '1', body = {}, params = {}, query = {}, userInfo = null } = {}) {
const _uid = uid ? String(uid) : String(new mongoose.Types.ObjectId());
const _puid = puid ? String(puid) : _uid;
return {
uid: _uid,
ut,
userInfo: userInfo || { puid: _puid, kind: ut, premium: 0, membership: null, markedDelete: false },
body,
params,
query,
headers: {},
file: undefined,
};
}
/**
* Build a minimal Express-like res object with jest spy functions.
*/
function mockRes() {
const res = {
statusCode: 200,
_data: undefined,
};
res.json = jest.fn((data) => { res._data = data; return res; });
res.send = jest.fn((data) => { res._data = data; return res; });
res.status = jest.fn((code) => { res.statusCode = code; return res; });
res.end = jest.fn(() => res);
return res;
}
module.exports = {
newId,
mockApplicator,
mockClient,
mockPilot,
mockVehicle,
mockProduct,
mockCrop,
mockJob,
mockCostingItem,
mockInvoiceSetting,
mockReq,
mockRes,
};

View File

@ -0,0 +1,187 @@
'use strict';
/**
* Integration tests obstacle controller (controllers/obstacle.js)
*
* Covers: createObstacle_post, updateObstacle_put, deleteObstacle, near_post
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockReq, mockRes, newId } = require('./mock_data');
let Obstacle;
beforeAll(async () => {
await connectDB();
Obstacle = require('../../model/obstacles');
});
afterAll(async () => {
await disconnectDB();
});
const obstacleCtl = require('../../controllers/obstacle');
describe('obstacle controller data methods', () => {
afterAll(async () => {
await clearCollection(Obstacle);
});
// -------------------------------------------------------------------------
describe('createObstacle_post', () => {
it('creates an obstacle and returns it as a plain object', async () => {
const userId = newId();
const req = mockReq({
uid: userId,
body: {
item: {
name: 'Test Tower',
type: 'USER',
agl: 150,
amsl: 500,
coors: [-80.1234, 25.7617],
},
pUser: String(userId),
},
});
const res = mockRes();
await obstacleCtl.createObstacle_post(req, res);
expect(res.json).toHaveBeenCalled();
const obstacle = res._data;
expect(obstacle._id).toBeDefined();
expect(obstacle.name).toBe('Test Tower');
expect(obstacle.agl).toBe(150);
expect(obstacle.amsl).toBe(500);
});
it('throws AppParamError when body is missing', async () => {
const req = mockReq({ body: null });
const res = mockRes();
await expect(obstacleCtl.createObstacle_post(req, res)).rejects.toThrow();
});
it('throws AppParamError when coordinates are missing', async () => {
const req = mockReq({
body: {
item: { name: 'Bad Obs', agl: 100, coors: [] },
pUser: String(newId()),
},
});
const res = mockRes();
await expect(obstacleCtl.createObstacle_post(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('updateObstacle_put', () => {
let obstacleId;
beforeAll(async () => {
const obs = await Obstacle.create({
properties: { name: 'Original', type: 'USER', agl: 100, amsl: 300 },
geometry: { type: 'Point', coordinates: [-80.0, 25.0] },
});
obstacleId = String(obs._id);
});
it('updates name and agl and returns updated obstacle', async () => {
const req = mockReq({
params: { ob_id: obstacleId },
body: { name: 'Updated Tower', agl: 200, amsl: 400 },
});
const res = mockRes();
await obstacleCtl.updateObstacle_put(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data).toBeTruthy();
expect(res._data.name).toBe('Updated Tower');
expect(res._data.agl).toBe(200);
});
});
// -------------------------------------------------------------------------
describe('deleteObstacle', () => {
let obstacleId;
beforeAll(async () => {
const obs = await Obstacle.create({
properties: { name: 'To Delete', type: 'USER', agl: 50, amsl: 200 },
geometry: { type: 'Point', coordinates: [-79.0, 26.0] },
});
obstacleId = String(obs._id);
});
it('deletes obstacle and returns success message', async () => {
const req = mockReq({ params: { ob_id: obstacleId } });
const res = mockRes();
await obstacleCtl.deleteObstacle(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.message).toMatch(/deleted/i);
const found = await Obstacle.findById(obstacleId);
expect(found).toBeNull();
});
it('returns success even when obstacle does not exist', async () => {
const req = mockReq({ params: { ob_id: String(newId()) } });
const res = mockRes();
await obstacleCtl.deleteObstacle(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.message).toMatch(/deleted/i);
});
});
// -------------------------------------------------------------------------
describe('near_post', () => {
beforeAll(async () => {
await Obstacle.ensureIndexes();
await Obstacle.create({
properties: { name: 'Miami Tower', type: 'USER', agl: 200, amsl: 600 },
geometry: { type: 'Point', coordinates: [-80.1918, 25.7617] },
});
});
it('returns obstacles near the given center within radius', async () => {
const req = mockReq({
body: { center: { lng: -80.1918, lat: 25.7617 }, min: 0 },
});
const res = mockRes();
await obstacleCtl.near_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
expect(res._data.length).toBeGreaterThan(0);
});
it('returns empty array when no obstacles are in the vicinity', async () => {
const req = mockReq({
body: { center: { lng: -140.0, lat: 0.0 }, min: 0 },
});
const res = mockRes();
await obstacleCtl.near_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.length).toBe(0);
});
it('filters by minimum AGL height', async () => {
const req = mockReq({
body: { center: { lng: -80.1918, lat: 25.7617 }, min: 999 },
});
const res = mockRes();
await obstacleCtl.near_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.length).toBe(0);
});
});
});

View File

@ -0,0 +1,180 @@
'use strict';
/**
* Integration tests partner controller (controllers/partner.js)
*
* Covers: createPartner_post, getPartners_get, getPartners_post,
* getPartnerById_get, updatePartner_put, deletePartner
*
* Skipped: syncData_post, uploadJob_post, testPartnerAuth_post
* (require external partner service / RabbitMQ).
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockReq, mockRes, newId } = require('./mock_data');
let Partner, Customer;
beforeAll(async () => {
await connectDB();
Customer = require('../../model/customer');
// Ensure partner discriminator is registered
const partnerModel = require('../../model/partner');
Partner = partnerModel.Partner || partnerModel;
});
afterAll(async () => {
await disconnectDB();
});
const partnerCtl = require('../../controllers/partner');
describe('partner controller data methods', () => {
let admin;
function makePartnerBody() {
return {
name: `Partner-${Date.now()}`,
username: `partner_${Date.now()}@test.com`,
password: 'Test@123',
active: true,
kind: '20',
partnerCode: `TESTCODE-${Date.now()}`,
};
}
beforeAll(async () => {
await clearCollection(Partner);
admin = await Customer.create(mockApplicator());
});
afterAll(async () => {
await clearCollection(Partner);
await Customer.deleteMany({ _id: admin._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: admin._id, puid: admin._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('createPartner_post', () => {
it('creates a partner and returns it', async () => {
const req = makeReq({ body: makePartnerBody() });
const res = mockRes();
await partnerCtl.createPartner_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data._id).toBeDefined();
expect(res._data.kind).toBe('20');
});
it('throws when body is missing', async () => {
const req = makeReq({ body: null });
const res = mockRes();
await expect(partnerCtl.createPartner_post(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('getPartners_get', () => {
it('returns array of partners', async () => {
const req = makeReq({ query: {} });
const res = mockRes();
await partnerCtl.getPartners_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});
// -------------------------------------------------------------------------
describe('getPartners_post', () => {
it('returns partners matching search criteria', async () => {
const req = makeReq({ body: { name: 'Partner' } });
const res = mockRes();
await partnerCtl.getPartners_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});
// -------------------------------------------------------------------------
describe('getPartnerById_get', () => {
let partnerId;
beforeAll(async () => {
const req = makeReq({ body: makePartnerBody() });
const res = mockRes();
await partnerCtl.createPartner_post(req, res);
partnerId = String(res._data._id);
});
it('returns partner by id', async () => {
const req = makeReq({ params: { id: partnerId } });
const res = mockRes();
await partnerCtl.getPartnerById_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(String(res._data._id)).toBe(partnerId);
});
it('throws when partner id is invalid', async () => {
const req = makeReq({ params: { id: 'invalid-id' } });
const res = mockRes();
await expect(partnerCtl.getPartnerById_get(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('updatePartner_put', () => {
let partnerId;
beforeAll(async () => {
const req = makeReq({ body: makePartnerBody() });
const res = mockRes();
await partnerCtl.createPartner_post(req, res);
partnerId = String(res._data._id);
});
it('updates a partner field', async () => {
const req = makeReq({
params: { id: partnerId },
body: { name: 'Updated Partner Name', kind: '20' },
});
const res = mockRes();
await partnerCtl.updatePartner_put(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.name).toBe('Updated Partner Name');
});
});
// -------------------------------------------------------------------------
describe('deletePartner', () => {
let partnerId;
beforeAll(async () => {
const req = makeReq({ body: makePartnerBody() });
const res = mockRes();
await partnerCtl.createPartner_post(req, res);
partnerId = String(res._data._id);
});
it('soft-deletes a partner', async () => {
const req = makeReq({ params: { id: partnerId } });
const res = mockRes();
await partnerCtl.deletePartner(req, res);
expect(res.json).toHaveBeenCalled();
const found = await Partner.findById(partnerId);
expect(!found || found.active === false).toBeTruthy();
});
});
});

View File

@ -0,0 +1,113 @@
'use strict';
/**
* Integration tests pilot controller (controllers/pilot.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockPilot, mockReq, mockRes, newId } = require('./mock_data');
let Pilot, Customer;
beforeAll(async () => {
await connectDB();
Customer = require('../../model/customer');
Pilot = require('../../model/pilot');
});
afterAll(async () => {
await disconnectDB();
});
const pilotCtl = require('../../controllers/pilot');
describe('pilot controller data methods', () => {
let applicator;
beforeAll(async () => {
await clearCollection(Pilot);
applicator = await Customer.create(mockApplicator());
});
afterAll(async () => {
await clearCollection(Pilot);
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('create_post', () => {
it('creates a pilot and returns it', async () => {
const req = makeReq({ body: mockPilot(applicator._id) });
const res = mockRes();
await pilotCtl.createPilot_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data._id).toBeDefined();
expect(res._data.name).toBe('Test Pilot');
});
it('throws when body is missing', async () => {
const req = makeReq({ body: null });
const res = mockRes();
await expect(pilotCtl.createPilot_post(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('getById_get', () => {
let pilotId;
beforeAll(async () => {
const doc = await Pilot.create(mockPilot(applicator._id));
pilotId = String(doc._id);
});
it('returns pilot by id', async () => {
const req = makeReq({ params: { id: pilotId } });
const res = mockRes();
await pilotCtl.getPilot_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(String(res._data._id)).toBe(pilotId);
});
});
// -------------------------------------------------------------------------
describe('search_post', () => {
it('returns matching pilots', async () => {
const req = makeReq({ body: { byUserId: applicator._id } });
const res = mockRes();
await pilotCtl.search_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});
// -------------------------------------------------------------------------
describe('delete_delete', () => {
let pilotId;
beforeAll(async () => {
const doc = await Pilot.create(mockPilot(applicator._id));
pilotId = String(doc._id);
});
it.skip('soft-deletes a pilot (skipped: requires MongoDB replica-set for transactions)', async () => {
const req = makeReq({ params: { id: pilotId } });
const res = mockRes();
await pilotCtl.deletePilot(req, res);
expect(res.json).toHaveBeenCalled();
const found = await Pilot.findById(pilotId);
expect(!found || found.markedDelete).toBeTruthy();
});
});
});

View File

@ -0,0 +1,156 @@
'use strict';
/**
* Integration tests product controller (controllers/product.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockProduct, mockReq, mockRes, newId } = require('./mock_data');
let Product, Customer;
beforeAll(async () => {
await connectDB();
Product = require('../../model/product');
Customer = require('../../model/customer');
});
afterAll(async () => {
await disconnectDB();
});
const productCtl = require('../../controllers/product');
describe('product controller data methods', () => {
let applicator;
beforeAll(async () => {
await clearCollection(Product);
applicator = await Customer.create(mockApplicator());
});
afterAll(async () => {
await clearCollection(Product);
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('create_post', () => {
it('creates a product and returns it', async () => {
const req = makeReq({ body: mockProduct(applicator._id) });
const res = mockRes();
await productCtl.createProduct_post(req, res);
expect(res.json).toHaveBeenCalled();
const p = res._data;
expect(p._id).toBeDefined();
expect(p.name).toMatch(/Product-/);
});
it('throws when body is missing', async () => {
const req = makeReq({ body: null });
const res = mockRes();
await expect(productCtl.createProduct_post(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('getAll_get', () => {
it('returns products for the applicator', async () => {
const req = makeReq({ query: {} });
const res = mockRes();
await productCtl.getProducts_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});
// -------------------------------------------------------------------------
describe('getById_get', () => {
let productId;
beforeAll(async () => {
const doc = await Product.create(mockProduct(applicator._id));
productId = String(doc._id);
});
it('returns a product by id', async () => {
const req = makeReq({ params: { product_id: productId } });
const res = mockRes();
await productCtl.getProduct_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(String(res._data._id)).toBe(productId);
});
it('throws when id is invalid', async () => {
const req = makeReq({ params: { product_id: 'bad-id' } });
const res = mockRes();
await expect(productCtl.getProduct_get(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('update_put', () => {
let productId;
beforeAll(async () => {
const doc = await Product.create(mockProduct(applicator._id));
productId = String(doc._id);
});
it('updates a product', async () => {
const req = makeReq({
params: { product_id: productId },
body: { name: 'Updated Product' },
});
const res = mockRes();
await productCtl.updateProduct_put(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.name).toBe('Updated Product');
});
});
// -------------------------------------------------------------------------
describe('search_post', () => {
it('returns matching products', async () => {
const req = makeReq({ body: { byUserId: applicator._id } });
const res = mockRes();
await productCtl.search_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});
// -------------------------------------------------------------------------
describe('delete_delete', () => {
let productId;
beforeAll(async () => {
const doc = await Product.create(mockProduct(applicator._id));
productId = String(doc._id);
});
it('deletes a product', async () => {
const req = makeReq({ params: { product_id: productId } });
const res = mockRes();
await productCtl.deleteProduct(req, res);
expect(res.json).toHaveBeenCalled();
const found = await Product.findById(productId);
expect(found).toBeNull();
});
});
});

View File

@ -0,0 +1,112 @@
'use strict';
/**
* Integration tests user controller (controllers/user.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockReq, mockRes, newId } = require('./mock_data');
let User, Customer;
beforeAll(async () => {
await connectDB();
User = require('../../model/user');
Customer = require('../../model/customer');
});
afterAll(async () => {
await disconnectDB();
});
const userCtl = require('../../controllers/user');
describe('user controller data methods', () => {
let applicator;
beforeAll(async () => {
applicator = await Customer.create(mockApplicator());
});
afterAll(async () => {
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('getUser_get', () => {
it('returns a user by id', async () => {
const req = makeReq({ params: { id: String(applicator._id) }, query: {} });
const res = mockRes();
await userCtl.getUser_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(String(res._data._id)).toBe(String(applicator._id));
});
it('returns null when user not found', async () => {
const req = makeReq({ params: { id: String(newId()) }, query: {} });
const res = mockRes();
await userCtl.getUser_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data).toBeNull();
});
});
// -------------------------------------------------------------------------
describe('updateUser_put', () => {
it('updates a user field', async () => {
const req = makeReq({
params: { id: String(applicator._id) },
body: { name: 'Updated Applicator Name', kind: '1' },
});
const res = mockRes();
await userCtl.updateUser_put(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data.name).toBe('Updated Applicator Name');
});
});
// -------------------------------------------------------------------------
describe('usernameExists_post', () => {
it('returns true when username exists', async () => {
const req = makeReq({ body: { username: applicator.username } });
const res = mockRes();
await userCtl.isUserNameExists_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data).toBe(1);
});
it('returns false when username does not exist', async () => {
const req = makeReq({ body: { username: `nonexistent_${Date.now()}@test.com` } });
const res = mockRes();
await userCtl.isUserNameExists_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data).toBe(0);
});
});
// -------------------------------------------------------------------------
describe('searchUsers_post', () => {
it('returns matching users', async () => {
const req = makeReq({ body: { byPuid: applicator._id } });
const res = mockRes();
await userCtl.search_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});
});

View File

@ -0,0 +1,137 @@
'use strict';
/**
* Integration tests vehicle controller (controllers/vehicle.js)
*/
const { connectDB, disconnectDB, clearCollection } = require('./jest.setup');
const { mockApplicator, mockVehicle, mockReq, mockRes, newId } = require('./mock_data');
let Vehicle, Customer;
beforeAll(async () => {
await connectDB();
Customer = require('../../model/customer');
Vehicle = require('../../model/vehicle');
});
afterAll(async () => {
await disconnectDB();
});
const vehicleCtl = require('../../controllers/vehicle');
describe('vehicle controller data methods', () => {
let applicator;
beforeAll(async () => {
await clearCollection(Vehicle);
applicator = await Customer.create(mockApplicator());
});
afterAll(async () => {
await clearCollection(Vehicle);
await Customer.deleteMany({ _id: applicator._id });
});
const makeReq = (extra = {}) =>
mockReq({ uid: applicator._id, puid: applicator._id, ut: '1', ...extra });
// -------------------------------------------------------------------------
describe('create_post', () => {
it('creates a vehicle and returns it', async () => {
const req = makeReq({ body: mockVehicle(applicator._id) });
const res = mockRes();
await vehicleCtl.createVehicle_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(res._data._id).toBeDefined();
expect(res._data.name).toMatch(/Aircraft-/);
});
it('throws when body is missing', async () => {
const req = makeReq({ body: null });
const res = mockRes();
await expect(vehicleCtl.createVehicle_post(req, res)).rejects.toThrow();
});
});
// -------------------------------------------------------------------------
describe('getById_get', () => {
let vehicleId;
beforeAll(async () => {
const doc = await Vehicle.create(mockVehicle(applicator._id));
vehicleId = String(doc._id);
});
it('returns vehicle by id', async () => {
const req = makeReq({ params: { id: vehicleId } });
const res = mockRes();
await vehicleCtl.getVehicle_get(req, res);
expect(res.json).toHaveBeenCalled();
expect(String(res._data._id)).toBe(vehicleId);
});
});
// -------------------------------------------------------------------------
describe('search_post', () => {
it('returns matching vehicles', async () => {
const req = makeReq({ body: { byUserId: applicator._id } });
const res = mockRes();
await vehicleCtl.search_post(req, res);
expect(res.json).toHaveBeenCalled();
expect(Array.isArray(res._data)).toBe(true);
});
});
// -------------------------------------------------------------------------
describe('bulkUpdate_post', () => {
let vehicleIds;
beforeAll(async () => {
const docs = await Vehicle.create([
mockVehicle(applicator._id),
mockVehicle(applicator._id),
]);
vehicleIds = docs.map(d => String(d._id));
});
it('bulk-updates multiple vehicles', async () => {
const req = makeReq({
body: vehicleIds.map(id => ({ _id: id, active: false })),
});
const res = mockRes();
await vehicleCtl.updateVehicles_post(req, res);
expect(res.json).toHaveBeenCalled();
});
});
// -------------------------------------------------------------------------
describe('delete_delete', () => {
let vehicleId;
beforeAll(async () => {
const doc = await Vehicle.create(mockVehicle(applicator._id));
vehicleId = String(doc._id);
});
it.skip('soft-deletes a vehicle (skipped: requires MongoDB replica-set for transactions)', async () => {
const req = makeReq({ params: { id: vehicleId } });
const res = mockRes();
await vehicleCtl.deleteVehicle(req, res);
expect(res.json).toHaveBeenCalled();
const found = await Vehicle.findById(vehicleId);
expect(!found || found.markedDelete).toBeTruthy();
});
});
});