import { randomUUID } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createServer } from '../src/app.js'; import { db } from '../src/db/knex.js'; import { generateToken, hashToken } from '../src/services/tokens.js'; import { surveyDefinition } from '../src/survey/definition.js'; import { seedSurveyDefinition } from '../src/survey/seed.js'; import { knownAnswers, normalizeAnswers, validateAnswers } from '../src/survey/validate.js'; import { truncateTables } from './truncate.js'; /** * @param {Record} question */ function wrap(question) { return { sections: [{ id: 'section', title: 'Section', questions: [question] }] }; } /** * @param {{ sections: { questions: Record[] }[] }} definition */ function completeAnswers(definition) { /** @type {Record} */ const answers = {}; for (const section of definition.sections) { for (const question of section.questions) { if (question.type === 'help' || question.type === 'section_header' || !question.required) { continue; } answers[String(question.id)] = sample(question); } } return answers; } /** * @param {Record} question */ function sample(question) { if (question.type === 'short_text' || question.type === 'long_text') { return 'Noted during clinic'; } if (question.type === 'number') { return 10; } if (question.type === 'percentage') { return 40; } if (question.type === 'single_choice') { return { choice: question.options[0].id }; } if (question.type === 'multi_choice') { return { choices: [question.options[0].id] }; } if (question.type === 'grid_percentage' || question.type === 'grid_number') { /** @type {Record>} */ const grid = {}; for (const row of question.rows) { grid[row.id] = {}; for (const column of question.columns) { grid[row.id][column.id] = question.type === 'grid_percentage' ? 100 / question.rows.length : 50; } } return grid; } if (question.type === 'likert_matrix') { return Object.fromEntries(question.rows.map((row) => [row.id, question.scale[0].id])); } return null; } describe('survey answers', () => { it('validates each question type', () => { expect( validateAnswers( wrap({ id: 'name', type: 'short_text', label: 'Name', required: true }), {}, )[0].message, ).toBe('Name is required'); expect( validateAnswers(wrap({ id: 'name', type: 'short_text', label: 'Name', required: true }), { name: 'Asha Rao', }), ).toEqual([]); expect( validateAnswers( wrap({ id: 'advantages', type: 'long_text', label: 'Advantages', required: true }), { advantages: '' }, )[0].message, ).toBe('Advantages is required'); const choice = wrap({ id: 'qualification', type: 'single_choice', label: 'Qualification', required: true, other: true, options: [ { id: 'mbbs', label: 'MBBS' }, { id: 'other', label: 'Any other, please specify' }, ], }); expect( validateAnswers(choice, { qualification: { choice: 'other', other: ' ' } })[0].message, ).toMatch(/other text/); expect(validateAnswers(choice, { qualification: { choice: 'mbbs' } })).toEqual([]); const multi = wrap({ id: 'barriers', type: 'multi_choice', label: 'Barriers', required: true, other: true, options: [ { id: 'cost', label: 'Cost' }, { id: 'other', label: 'Other (Specify)' }, ], }); expect( validateAnswers(multi, { barriers: { choices: ['other'], other: '' } })[0].message, ).toMatch(/other text/); expect(validateAnswers(multi, { barriers: { choices: ['cost'] } })).toEqual([]); const years = wrap({ id: 'years', type: 'number', label: 'Years of Practice', required: true, integer: true, min: 0, max: 80, }); expect(validateAnswers(years, { years: 1.5 })[0].message).toMatch(/whole number/); expect(validateAnswers(years, { years: 81 })[0].message).toMatch(/at most 80/); expect(validateAnswers(years, { years: 12 })).toEqual([]); const percent = wrap({ id: 'share', type: 'percentage', label: 'Proportion', required: true, min: 0, max: 100, }); expect(validateAnswers(percent, { share: 101 })[0].message).toMatch(/at most 100/); const grid = { id: 'pack_split', type: 'grid_percentage', label: 'Pack type', required: true, min: 0, max: 100, sumTo: 100, rows: [ { id: 'regular_pmdi', label: 'Regular pMDI' }, { id: 'regular_rotacap', label: 'Regular Rotacap' }, ], columns: [{ id: 'asthma', label: 'Asthma' }], }; expect( validateAnswers(wrap(grid), { pack_split: { regular_pmdi: { asthma: 30 }, regular_rotacap: { asthma: 50 } }, })[0].message, ).toMatch(/Asthma total must equal 100/); const counts = { id: 'adherence_estimate', type: 'grid_number', label: 'Pack type', required: true, integer: true, min: 0, max: 100, rows: [{ id: 'regular_pmdi', label: 'Regular pMDI' }], columns: [{ id: 'asthma', label: 'Asthma' }], }; expect( validateAnswers(wrap(counts), { adherence_estimate: { regular_pmdi: { asthma: 101 } } })[0] .message, ).toMatch(/at most 100/); expect( validateAnswers(wrap(counts), { adherence_estimate: { regular_pmdi: { asthma: 40 } } }), ).toEqual([]); const likert = wrap({ id: 'likert', type: 'likert_matrix', label: 'Rating', required: true, scale: [{ id: 'agree', label: 'Agree' }], rows: [ { id: 'convenient', label: 'Using an adherence pack is more convenient for patients' }, ], }); expect(validateAnswers(likert, {})[0].message).toMatch(/required/); expect(validateAnswers(likert, { likert: { convenient: 'agree' } })).toEqual([]); expect(validateAnswers(wrap({ id: 'note', type: 'help', text: 'A note' }), {})).toEqual([]); expect( validateAnswers(wrap({ id: 'heading', type: 'section_header', label: 'Heading' }), {}), ).toEqual([]); expect(validateAnswers(surveyDefinition, completeAnswers(surveyDefinition))).toEqual([]); }); it('upgrades single-choice answers saved before a question became multi-choice', () => { const setting = wrap({ id: 'practice_setting', type: 'multi_choice', label: 'Practice setting', required: true, options: [ { id: 'private_clinic', label: 'Private Clinic' }, { id: 'govt_hospital', label: 'Govt. Hospital' }, ], }); const legacy = { practice_setting: { choice: 'private_clinic', other: '' } }; expect(normalizeAnswers(setting, legacy)).toEqual({ practice_setting: { choices: ['private_clinic'], other: '' }, }); expect(knownAnswers(setting, legacy)).toEqual({ practice_setting: { choices: ['private_clinic'], other: '' }, }); expect(validateAnswers(setting, knownAnswers(setting, legacy))).toEqual([]); }); it('upgrades a typed state to the matching dropdown option', () => { const state = wrap({ id: 'state', type: 'single_choice', display: 'dropdown', label: 'State', required: false, options: [ { id: 'maharashtra', label: 'Maharashtra' }, { id: 'tamil_nadu', label: 'Tamil Nadu' }, ], }); expect(knownAnswers(state, { state: ' tamil nadu ' })).toEqual({ state: { choice: 'tamil_nadu', other: '' }, }); expect(knownAnswers(state, { state: 'Bombay' })).toEqual({ state: 'Bombay' }); expect(validateAnswers(state, { state: 'Bombay' })).toEqual([]); }); }); describe('survey submission', () => { /** @type {import('@hapi/hapi').Server | undefined} */ let server; beforeEach(async () => { await seedSurveyDefinition(); }); afterEach(async () => { await server?.stop(); await truncateTables(); }); async function start() { server = await createServer(); return server; } async function issueToken() { const [applicationId] = await db('applications').insert({ public_id: randomUUID(), email: 'survey@example.com', status: 'APPROVED', full_name: 'Asha Rao', version: 1, }); const token = generateToken(); await db('survey_tokens').insert({ application_id: applicationId, token_hash: hashToken(token), created_at: new Date(), }); return { applicationId, token }; } it('saves progress on the original definition and refuses a second submit', async () => { const api = await start(); const { applicationId, token } = await issueToken(); const opened = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); expect(opened.statusCode).toBe(200); expect(opened.result.version).toBe(2); expect(opened.result.answers.name).toBe('Asha Rao'); const saved = await api.inject({ method: 'PUT', url: `/api/survey/${token}/progress`, payload: { answers: { city: 'Pune', ignored: 'no' } }, }); expect(saved.statusCode).toBe(200); expect(saved.result.answers.city).toBe('Pune'); expect(saved.result.answers.ignored).toBeUndefined(); await db('survey_definitions').where({ version: 2 }).update({ is_active: false }); await db('survey_definitions').insert({ version: 3, title: 'Replacement survey', definition: JSON.stringify({ sections: [] }), is_active: true, }); const pinned = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); expect(pinned.result.version).toBe(2); expect(pinned.result.answers.city).toBe('Pune'); const answers = completeAnswers(surveyDefinition); answers.pack_split.regular_pmdi.asthma = 10; const uneven = await api.inject({ method: 'POST', url: `/api/survey/${token}/submit`, payload: { answers }, }); expect(uneven.statusCode).toBe(400); expect(uneven.result.error.message).toMatch(/must equal 100/); answers.pack_split.regular_pmdi.asthma = 25; const submitted = await api.inject({ method: 'POST', url: `/api/survey/${token}/submit`, payload: { answers }, }); expect(submitted.statusCode).toBe(200); expect(submitted.result).toEqual({ status: 'COMPLETED' }); const row = await db('survey_responses').where({ application_id: applicationId }).first(); expect(row.status).toBe('COMPLETED'); expect( await db('audit_log').where({ action: 'SURVEY_SUBMITTED', entity_id: applicationId }).first(), ).toBeTruthy(); const again = await api.inject({ method: 'POST', url: `/api/survey/${token}/submit`, payload: { answers }, }); expect(again.statusCode).toBe(401); expect(again.result.error.message).toBe('This survey has already been submitted'); const closed = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); expect(closed.result.error.message).toBe('This survey has already been submitted'); }); it('rejects an unknown link and submits without a saved draft', async () => { const api = await start(); const missing = await api.inject({ method: 'GET', url: '/api/survey/not-a-token' }); expect(missing.statusCode).toBe(401); const progress = await api.inject({ method: 'PUT', url: '/api/survey/not-a-token/progress', payload: { answers: { city: 'Pune' } }, }); expect(progress.statusCode).toBe(401); const { token, applicationId } = await issueToken(); const answers = completeAnswers(surveyDefinition); answers.city = 'Pune'; const submitted = await api.inject({ method: 'POST', url: `/api/survey/${token}/submit`, payload: { answers }, }); expect(submitted.statusCode).toBe(200); const row = await db('survey_responses').where({ application_id: applicationId }).first(); const stored = typeof row.answers === 'string' ? JSON.parse(row.answers) : row.answers; expect(stored.city).toBe('Pune'); }); it('rejects a revoked link and a survey that is not configured', async () => { const api = await start(); const revoked = await issueToken(); await db('survey_tokens').where({ application_id: revoked.applicationId }).update({ revoked_at: new Date(), }); const closed = await api.inject({ method: 'GET', url: `/api/survey/${revoked.token}` }); expect(closed.statusCode).toBe(401); await db('survey_definitions').update({ is_active: false }); const { token } = await issueToken(); const opened = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); expect(opened.statusCode).toBe(500); const progress = await api.inject({ method: 'PUT', url: `/api/survey/${token}/progress`, payload: { answers: { city: 'Pune' } }, }); expect(progress.statusCode).toBe(500); const submitted = await api.inject({ method: 'POST', url: `/api/survey/${token}/submit`, payload: { answers: { city: 'Pune' } }, }); expect(submitted.statusCode).toBe(500); }); });