import { afterEach, describe, expect, it, vi } from 'vitest'; import { createServer } from '../src/app.js'; import { db } from '../src/db/knex.js'; import { clearOutbox, outbox } from '../src/services/mail.js'; import { truncateTables } from './truncate.js'; vi.mock('../src/pdf/render.js', () => ({ renderPdf: vi.fn(async () => Buffer.from('%PDF-1.4')), })); const email = 'physician@example.com'; const complete = { agreement_date: '2026-09-30', full_name: 'Asha Rao', age: 42, address: 'Pune', registration_no: 'MMC-12345', medical_council: 'Maharashtra Medical Council', pan: 'ABCDE1234F', bank_name: 'State Bank of India', account_holder: 'Asha Rao', account_number: '123456789012', ifsc: 'SBIN0001234', accepted: true, }; /** * @param {string} url */ function tokenFrom(url) { return url.split('/resume/')[1]; } describe('applications', () => { /** @type {import('@hapi/hapi').Server | undefined} */ let server; afterEach(async () => { await server?.stop(); clearOutbox(); await truncateTables(); }); async function start() { server = await createServer(); return server; } async function postEmail(api, address = email) { return api.inject({ method: 'POST', url: '/api/applications', payload: { email: address }, }); } async function postEmailWithCookie(api) { return api.inject({ method: 'POST', url: '/api/applications', headers: { cookie: 'admin_token=not-a-valid-iron-cookie' }, payload: { email }, }); } it('creates a draft when the browser sends an invalid admin cookie', async () => { const api = await start(); const created = await postEmailWithCookie(api); expect(created.statusCode).toBe(200); expect(created.result).toMatchObject({ status: 'DRAFT' }); }); it('creates a draft and resumes with the emailed token', async () => { const api = await start(); const created = await postEmail(api); expect(created.statusCode).toBe(200); expect(created.result).toMatchObject({ status: 'DRAFT' }); expect(JSON.stringify(created.result)).not.toContain('/resume/'); const token = tokenFrom(outbox[0].url); expect(created.result.token).toBe(token); const me = await api.inject({ method: 'GET', url: '/api/applications/me', headers: { authorization: `Bearer ${token}` }, }); expect(me.statusCode).toBe(200); expect(me.result).toMatchObject({ email, status: 'DRAFT' }); const audit = await db('audit_log').where({ action: 'APPLICATION_CREATED' }).first(); expect(audit.actor_type).toBe('PHYSICIAN'); }); it('reuses a draft email and revokes the previous token', async () => { const api = await start(); await postEmail(api); const firstToken = tokenFrom(outbox[0].url); await postEmail(api); const secondToken = tokenFrom(outbox[1].url); expect(await db('applications').count({ count: '*' }).first()).toMatchObject({ count: 1 }); const oldLink = await api.inject({ method: 'GET', url: '/api/applications/me', headers: { authorization: `Bearer ${firstToken}` }, }); const newLink = await api.inject({ method: 'GET', url: '/api/applications/me', headers: { authorization: `Bearer ${secondToken}` }, }); expect(oldLink.statusCode).toBe(401); expect(newLink.statusCode).toBe(200); }); it('saves a partial draft and rejects an incomplete finalize', async () => { const api = await start(); await postEmail(api); const token = tokenFrom(outbox[0].url); const headers = { authorization: `Bearer ${token}` }; const saved = await api.inject({ method: 'PUT', url: '/api/applications/me', headers, payload: { full_name: 'Asha Rao' }, }); expect(saved.statusCode).toBe(200); expect(saved.result).toMatchObject({ full_name: 'Asha Rao' }); const finalize = await api.inject({ method: 'PUT', url: '/api/applications/me?finalize=true', headers, payload: { full_name: 'Asha Rao', accepted: true }, }); expect(finalize.statusCode).toBe(400); }); it('accepts a complete finalize and blocks a duplicate registration number', async () => { const api = await start(); await postEmail(api); const token = tokenFrom(outbox[0].url); const saved = await api.inject({ method: 'PUT', url: '/api/applications/me?finalize=true', headers: { authorization: `Bearer ${token}` }, payload: { ...complete, pan: 'abcde1234f' }, }); expect(saved.statusCode).toBe(200); expect(saved.result).toMatchObject({ pan: 'ABCDE1234F', accepted: true }); const stored = await db('applications').where({ email }).first(); expect(String(stored.pan).startsWith('enc:v1:')).toBe(true); expect(String(stored.account_number).startsWith('enc:v1:')).toBe(true); expect(stored.pan_lookup).toHaveLength(64); await postEmail(api, 'other@example.com'); const otherToken = tokenFrom(outbox[1].url); const duplicate = await api.inject({ method: 'PUT', url: '/api/applications/me', headers: { authorization: `Bearer ${otherToken}` }, payload: { registration_no: 'MMC-12345' }, }); expect(duplicate.statusCode).toBe(409); expect(duplicate.result).toEqual({ error: { code: 'CONFLICT', message: 'This registration number is already in use', }, }); }); it('returns a rejected application to draft and voids its signature', async () => { const api = await start(); await postEmail(api); const application = await db('applications').where({ email }).first(); await db('applications').where({ id: application.id }).update({ status: 'REJECTED', rejection_reason: 'Registration number could not be confirmed', }); await db('signatures').insert({ application_id: application.id, version: 1, type: 'TYPED', typed_text: 'Asha Rao', signed_at: new Date(), }); const response = await api.inject({ method: 'PUT', url: '/api/applications/me', headers: { authorization: `Bearer ${tokenFrom(outbox[0].url)}` }, payload: { full_name: 'Asha Rao' }, }); expect(response.statusCode).toBe(200); expect(response.result).toMatchObject({ status: 'DRAFT', version: 2, rejection_reason: 'Registration number could not be confirmed', }); const signature = await db('signatures').where({ application_id: application.id }).first(); expect(signature.voided_at).toBeTruthy(); }); it('rejects edits after submission and unknown tokens', async () => { const api = await start(); await postEmail(api); const application = await db('applications').where({ email }).first(); await db('applications') .where({ id: application.id }) .update({ status: 'SUBMITTED', draft_pdf_key: 'drafts/one.pdf' }); const submitted = await api.inject({ method: 'PUT', url: '/api/applications/me', headers: { authorization: `Bearer ${tokenFrom(outbox[0].url)}` }, payload: { full_name: 'Asha Rao' }, }); const missing = await api.inject({ method: 'GET', url: '/api/applications/me', headers: { authorization: 'Bearer not-a-real-token' }, }); expect(submitted.statusCode).toBe(409); expect(missing.statusCode).toBe(401); expect(missing.result).toEqual({ error: { code: 'UNAUTHORIZED', message: 'This resume link is invalid or has expired', }, }); }); it('rejects a signature when the application or consent is incomplete', async () => { const api = await start(); await postEmail(api); const headers = { authorization: `Bearer ${tokenFrom(outbox[0].url)}` }; const incomplete = await api.inject({ method: 'POST', url: '/api/applications/me/sign', headers, payload: { type: 'TYPED', typedName: 'Asha Rao' }, }); expect(incomplete.statusCode).toBe(400); await api.inject({ method: 'PUT', url: '/api/applications/me?finalize=true', headers, payload: complete, }); const missingConsent = await api.inject({ method: 'POST', url: '/api/applications/me/sign', headers, payload: { type: 'TYPED', typedName: 'Asha Rao' }, }); expect(missingConsent.statusCode).toBe(400); // Ensure auto-approve cannot run so this case stays SUBMITTED. await db('settings') .where({ key: 'signatory_signature_key' }) .update({ value: JSON.stringify('') }); const signed = await api.inject({ method: 'POST', url: '/api/applications/me/sign', headers, payload: { type: 'TYPED', typedName: 'Asha Rao' }, }); expect(signed.statusCode).toBe(200); // Without a CREST signatory image, auto-approve fails and the row stays SUBMITTED. expect(signed.result).toMatchObject({ status: 'SUBMITTED' }); expect(signed.result.signedPdfUrl).toEqual(expect.any(String)); const row = await db('applications').where({ email }).first(); expect(row.signed_pdf_sha256).toHaveLength(64); expect(row.submitted_at).toBeTruthy(); }); it('auto-approves on sign when the CREST signature is configured', async () => { const { putObject } = await import('../src/services/storage.js'); const api = await start(); await postEmail(api); const headers = { authorization: `Bearer ${tokenFrom(outbox[0].url)}` }; await api.inject({ method: 'PUT', url: '/api/applications/me?finalize=true', headers, payload: complete, }); const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ); await putObject('settings/signatory-signature.png', png, 'image/png'); await db('settings') .where({ key: 'signatory_signature_key' }) .update({ value: JSON.stringify('settings/signatory-signature.png') }); const signed = await api.inject({ method: 'POST', url: '/api/applications/me/sign', headers, payload: { type: 'TYPED', typedName: 'Asha Rao' }, }); expect(signed.statusCode).toBe(200); expect(signed.result).toMatchObject({ status: 'APPROVED' }); expect(signed.result.surveyToken).toEqual(expect.any(String)); const row = await db('applications').where({ email }).first(); expect(row.status).toBe('APPROVED'); expect(row.final_pdf_key).toBeTruthy(); expect(row.decided_by).toBeNull(); expect( await db('audit_log').where({ action: 'APPLICATION_AUTO_APPROVED' }).first(), ).toBeTruthy(); expect(await db('survey_tokens').where({ application_id: row.id }).first()).toBeTruthy(); }); it('returns a rejected application to draft from edit and keeps the reason', async () => { const api = await start(); await postEmail(api); const application = await db('applications').where({ email }).first(); await db('applications').where({ id: application.id }).update({ status: 'REJECTED', rejection_reason: 'Please correct the bank details', }); await db('signatures').insert({ application_id: application.id, version: 1, type: 'TYPED', typed_text: 'Asha Rao', signed_at: new Date(), }); const response = await api.inject({ method: 'POST', url: '/api/applications/me/edit', headers: { authorization: `Bearer ${tokenFrom(outbox[0].url)}` }, }); expect(response.statusCode).toBe(200); expect(response.result).toMatchObject({ status: 'DRAFT', version: 2, rejection_reason: 'Please correct the bank details', }); const signature = await db('signatures').where({ application_id: application.id }).first(); expect(signature.voided_at).toBeTruthy(); }); });