372 lines
12 KiB
JavaScript
372 lines
12 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 { 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 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([]);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|