'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); }); }); });