import { randomUUID } from 'node:crypto'; import argon2 from 'argon2'; import ExcelJS from 'exceljs'; import { afterEach, describe, expect, it } from 'vitest'; import { CONSENT_QUESTIONS } from '../../frontend/src/consentQuestions.js'; import { createServer } from '../src/app.js'; import { db } from '../src/db/knex.js'; import { CONSENT_STATEMENTS } from '../src/schemas/consent.js'; import { encryptField } from '../src/services/fieldCrypto.js'; import { seedSurveyDefinition } from '../src/survey/seed.js'; import { truncateTables } from './truncate.js'; const email = 'applications-export-admin@example.com'; const password = 'correct-password'; /** * @param {import('@hapi/hapi').ServerInjectResponse} response */ function sessionCookie(response) { const raw = response.headers['set-cookie']; const header = Array.isArray(raw) ? raw[0] : raw; return header.split(';')[0]; } describe('consent statements', () => { it('match the statements shown on the consent page', () => { expect(CONSENT_STATEMENTS).toEqual( CONSENT_QUESTIONS.map(({ id, label }) => ({ id, label })), ); }); }); describe('applications export', () => { /** @type {import('@hapi/hapi').Server | undefined} */ let server; afterEach(async () => { await server?.stop(); await truncateTables(); }); /** * One approved application inside the date range (with consent, signature * and an in-progress survey) and one draft outside it. * @returns {Promise} admin session cookie */ async function seedExport() { server = await createServer(); await db('admin_users').insert({ name: 'Administrator', email, password_hash: await argon2.hash(password, { type: argon2.argon2id }), is_active: true, }); const login = await server.inject({ method: 'POST', url: '/api/admin/auth/login', payload: { email, password }, }); const [userId] = await db('users').insert({ email: 'asha@example.com', name: 'Asha Rao' }); const [applicationId] = await db('applications').insert({ public_id: randomUUID(), user_id: userId, email: 'asha@example.com', status: 'APPROVED', full_name: 'Asha Rao', registration_no: 'MMC-100', pan: encryptField('ABCDE1234F'), account_number: encryptField('001234567890'), ifsc: 'HDFC0001234', cancelled_cheque_key: 'cheques/1.png', final_pdf_key: 'final/1.pdf', version: 1, created_at: '2026-10-10 09:00:00', }); await db('applications').insert({ public_id: randomUUID(), email: 'outside@example.com', status: 'DRAFT', version: 1, created_at: '2026-09-01 09:00:00', }); await db('signatures').insert({ application_id: applicationId, version: 1, type: 'TYPED', typed_text: 'Asha Rao', signed_at: new Date(), }); await db('consents').insert({ user_id: userId, application_id: applicationId, participant_name: 'Asha Rao', consent_date: '2026-10-10', answers: JSON.stringify( Object.fromEntries(CONSENT_STATEMENTS.map((statement) => [statement.id, true])), ), submitted_at: '2026-10-10 09:05:00', }); const definitionId = await seedSurveyDefinition(); await db('survey_responses').insert({ application_id: applicationId, survey_definition_id: definitionId, answers: JSON.stringify({}), status: 'IN_PROGRESS', started_at: new Date(), }); return sessionCookie(login); } /** * @param {string} cookie * @param {string} query */ async function exportSheet(cookie, query) { const response = await server.inject({ method: 'GET', url: `/api/admin/applications/export?${query}`, headers: { cookie }, }); expect(response.statusCode).toBe(200); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(response.rawPayload); const sheet = workbook.getWorksheet('Applications'); const headers = sheet.getRow(1).values.slice(1); const cell = (/** @type {string} */ header) => sheet.getRow(2).getCell(headers.indexOf(header) + 1).value; return { response, sheet, headers, cell }; } async function exportAudit() { const audit = await db('audit_log').where({ action: 'APPLICATIONS_EXPORTED' }).first(); return typeof audit.metadata === 'string' ? JSON.parse(audit.metadata) : audit.metadata; } it('writes only basic columns by default', async () => { const cookie = await seedExport(); const { response, sheet, headers, cell } = await exportSheet( cookie, 'from=2026-10-01&to=2026-10-31', ); expect(response.headers['content-disposition']).toContain('"applications-consent.xlsx"'); expect(headers).toEqual([ 'Application id', 'Status', 'Email', 'Full name', 'Age', 'Address', 'Created at', 'Agreement date', 'Signed', 'Consent date', 'Survey status', 'Survey completed at', ]); expect(sheet.rowCount).toBe(2); expect(cell('Status')).toBe('Approved'); expect(cell('Consent date')).toBe('2026-10-10'); expect(JSON.stringify(sheet.getSheetValues())).not.toContain('ABCDE1234F'); expect(JSON.stringify(sheet.getSheetValues())).not.toContain('001234567890'); expect(await exportAudit()).toMatchObject({ full: false }); }); it('writes every column for the full report', async () => { const cookie = await seedExport(); const { response, sheet, cell } = await exportSheet( cookie, 'from=2026-10-01&to=2026-10-31&full=true', ); expect(response.headers['content-disposition']).toContain('applications-consent-full.xlsx'); expect(sheet.rowCount).toBe(2); expect(sheet.getRow(1).getCell(1).font?.bold).toBe(true); expect(cell('Status')).toBe('Approved'); expect(cell('PAN')).toBe('ABCDE1234F'); expect(cell('Account number')).toBe('001234567890'); expect(cell('Cheque uploaded')).toBe('Yes'); expect(cell('Signed')).toBe('Yes'); expect(cell('Final PDF available')).toBe('Yes'); expect(cell('Consent date')).toBe('2026-10-10'); expect(cell(CONSENT_STATEMENTS[0].label)).toBe('Yes'); expect(cell('I agree and consent to participate in the survey.')).toBe('Yes'); expect(cell('Survey status')).toBe('In progress'); expect(await exportAudit()).toMatchObject({ full: true }); }); it('rejects malformed dates', async () => { server = await createServer(); await db('admin_users').insert({ name: 'Administrator', email, password_hash: await argon2.hash(password, { type: argon2.argon2id }), is_active: true, }); const login = await server.inject({ method: 'POST', url: '/api/admin/auth/login', payload: { email, password }, }); const response = await server.inject({ method: 'GET', url: '/api/admin/applications/export?from=10-10-2026', headers: { cookie: sessionCookie(login) }, }); expect(response.statusCode).toBe(400); }); });