PPPAIMT-Crest/backend/test/survey.test.js

426 lines
14 KiB
JavaScript

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<string, unknown>} question
*/
function wrap(question) {
return { sections: [{ id: 'section', title: 'Section', questions: [question] }] };
}
/**
* @param {{ sections: { questions: Record<string, unknown>[] }[] }} definition
*/
function completeAnswers(definition) {
/** @type {Record<string, unknown>} */
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<string, any>} 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<string, Record<string, number>>} */
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 Extended-Dose 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('requires the text when practice setting Other is ticked', () => {
const settingErrors = (/** @type {Record<string, unknown>} */ practice) =>
validateAnswers(surveyDefinition, { practice_setting: practice }).filter(
(error) => error.id === 'practice_setting',
);
expect(settingErrors({ choices: ['other'], other: '' })[0].message).toBe(
'Practice setting requires the other text',
);
expect(settingErrors({ choices: ['private_clinic', 'other'], other: 'Nursing home' })).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: '<b>Pune</b>' } },
});
expect(progress.statusCode).toBe(401);
const { token, applicationId } = await issueToken();
const answers = completeAnswers(surveyDefinition);
answers.city = '<i>Pune</i>';
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);
});
});