256 lines
7.7 KiB
JavaScript
256 lines
7.7 KiB
JavaScript
import argon2 from 'argon2';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { createServer } from '../src/app.js';
|
|
import { db } from '../src/db/knex.js';
|
|
import { renderPdf } from '../src/pdf/render.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 consent')),
|
|
}));
|
|
|
|
const email = 'consent-doctor@example.com';
|
|
const adminEmail = 'consent-admin@example.com';
|
|
const adminPassword = 'correct-password';
|
|
|
|
const answers = {
|
|
voluntary_withdraw: true,
|
|
mou_required: true,
|
|
eligibility_six_months: true,
|
|
confidential_research: true,
|
|
web_questionnaire: true,
|
|
no_intervention: true,
|
|
aggregated_reporting: true,
|
|
honorarium: true,
|
|
agree_participate: true,
|
|
};
|
|
|
|
/**
|
|
* @param {import('@hapi/hapi').ServerInjectResponse} response
|
|
*/
|
|
function userCookie(response) {
|
|
const raw = response.headers['set-cookie'];
|
|
const headers = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
const match = headers.find((header) => header.startsWith('user_token='));
|
|
return match ? match.split(';')[0] : null;
|
|
}
|
|
|
|
describe('consent', () => {
|
|
/** @type {import('@hapi/hapi').Server | undefined} */
|
|
let server;
|
|
|
|
afterEach(async () => {
|
|
clearOutbox();
|
|
vi.mocked(renderPdf).mockClear();
|
|
await server?.stop();
|
|
await truncateTables();
|
|
});
|
|
|
|
async function start() {
|
|
await db('pppaimt_doctors').insert({ email, hcp_name: 'Dr Consent Test' });
|
|
server = await createServer();
|
|
return server;
|
|
}
|
|
|
|
async function login(api) {
|
|
await api.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/otp/request',
|
|
payload: { email },
|
|
});
|
|
|
|
const verified = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/otp/verify',
|
|
payload: { email, otp: outbox[0].otp },
|
|
});
|
|
|
|
return userCookie(verified);
|
|
}
|
|
|
|
it('submits consent and advances nextStep to agreement', async () => {
|
|
const api = await start();
|
|
const cookie = await login(api);
|
|
|
|
const before = await api.inject({
|
|
method: 'GET',
|
|
url: '/api/me',
|
|
headers: { cookie },
|
|
});
|
|
expect(before.result.nextStep).toBe('consent');
|
|
|
|
const submitted = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/consent',
|
|
headers: { cookie },
|
|
payload: {
|
|
participant_name: 'Dr Consent Test',
|
|
consent_date: '2026-10-16',
|
|
answers,
|
|
},
|
|
});
|
|
|
|
expect(submitted.statusCode).toBe(200);
|
|
expect(submitted.result).toMatchObject({
|
|
participant_name: 'Dr Consent Test',
|
|
consent_date: '2026-10-16',
|
|
});
|
|
|
|
const me = await api.inject({
|
|
method: 'GET',
|
|
url: '/api/me',
|
|
headers: { cookie },
|
|
});
|
|
expect(me.result.steps.consent.done).toBe(true);
|
|
expect(me.result.nextStep).toBe('agreement');
|
|
expect(me.result.user.name).toBe('Dr Consent Test');
|
|
|
|
const again = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/consent',
|
|
headers: { cookie },
|
|
payload: {
|
|
participant_name: 'Dr Consent Test',
|
|
consent_date: '2026-10-16',
|
|
answers,
|
|
},
|
|
});
|
|
expect(again.statusCode).toBe(409);
|
|
|
|
const row = await db('consents').where({ participant_name: 'Dr Consent Test' }).first();
|
|
expect(row).toBeTruthy();
|
|
});
|
|
|
|
it('rejects incomplete consent answers', async () => {
|
|
const api = await start();
|
|
const cookie = await login(api);
|
|
|
|
const response = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/consent',
|
|
headers: { cookie },
|
|
payload: {
|
|
participant_name: 'Dr Consent Test',
|
|
consent_date: '2026-10-16',
|
|
answers: { ...answers, eligibility_six_months: false },
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
});
|
|
|
|
async function adminCookie(api) {
|
|
await db('admin_users').insert({
|
|
name: 'Administrator',
|
|
email: adminEmail,
|
|
password_hash: await argon2.hash(adminPassword, { type: argon2.argon2id }),
|
|
is_active: true,
|
|
});
|
|
const response = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/auth/login',
|
|
payload: { email: adminEmail, password: adminPassword },
|
|
});
|
|
const raw = response.headers['set-cookie'];
|
|
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
return header.split(';')[0];
|
|
}
|
|
|
|
async function submit(api, cookie) {
|
|
return api.inject({
|
|
method: 'POST',
|
|
url: '/api/consent',
|
|
headers: { cookie },
|
|
payload: { participant_name: 'Dr Consent Test', consent_date: '2026-10-16', answers },
|
|
});
|
|
}
|
|
|
|
it('stores a consent PDF on submission and lets the physician download it', async () => {
|
|
const api = await start();
|
|
const cookie = await login(api);
|
|
|
|
const missing = await api.inject({
|
|
method: 'GET',
|
|
url: '/api/consent/me/pdf',
|
|
headers: { cookie },
|
|
});
|
|
expect(missing.statusCode).toBe(404);
|
|
|
|
expect((await submit(api, cookie)).statusCode).toBe(200);
|
|
const row = await db('consents').where({ participant_name: 'Dr Consent Test' }).first();
|
|
expect(row.consent_pdf_key).toMatch(/\/consent\.pdf$/);
|
|
expect(renderPdf).toHaveBeenCalledTimes(1);
|
|
|
|
const html = String(vi.mocked(renderPdf).mock.calls[0][0]);
|
|
expect(html).toContain('Dr Consent Test');
|
|
expect(html).toContain('16/10/2026');
|
|
expect(html).toContain('INFORMED CONSENT FORM (ICF)');
|
|
expect(html).not.toContain('Electronic Signature');
|
|
|
|
const download = await api.inject({
|
|
method: 'GET',
|
|
url: '/api/consent/me/pdf',
|
|
headers: { cookie },
|
|
});
|
|
expect(download.statusCode).toBe(200);
|
|
expect(download.headers['content-type']).toContain('application/pdf');
|
|
expect(download.headers['content-disposition']).toMatch(/attachment; filename="consent-.+\.pdf"/);
|
|
expect(download.rawPayload.toString()).toBe('%PDF-1.4 consent');
|
|
expect(renderPdf).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('requires a signed-in physician to download a consent', async () => {
|
|
const api = await start();
|
|
const response = await api.inject({ method: 'GET', url: '/api/consent/me/pdf' });
|
|
expect(response.statusCode).toBe(401);
|
|
});
|
|
|
|
it('lets an admin download a consent and records it', async () => {
|
|
const api = await start();
|
|
const cookie = await login(api);
|
|
await submit(api, cookie);
|
|
const row = await db('consents').where({ participant_name: 'Dr Consent Test' }).first();
|
|
await db('consents').where({ id: row.id }).update({ consent_pdf_key: null });
|
|
const admin = await adminCookie(api);
|
|
|
|
const detail = await api.inject({
|
|
method: 'GET',
|
|
url: `/api/admin/applications/${row.application_id}`,
|
|
headers: { cookie: admin },
|
|
});
|
|
expect(detail.result.consent).toMatchObject({
|
|
participantName: 'Dr Consent Test',
|
|
consentDate: '2026-10-16',
|
|
});
|
|
|
|
const download = await api.inject({
|
|
method: 'GET',
|
|
url: `/api/admin/applications/${row.application_id}/consent-pdf`,
|
|
headers: { cookie: admin },
|
|
});
|
|
expect(download.statusCode).toBe(200);
|
|
expect(download.headers['content-type']).toContain('application/pdf');
|
|
expect((await db('consents').where({ id: row.id }).first()).consent_pdf_key).toBeTruthy();
|
|
expect(
|
|
await db('audit_log')
|
|
.where({ action: 'CONSENT_PDF_DOWNLOADED', entity_id: row.application_id })
|
|
.first(),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('returns 404 to an admin when consent has not been submitted', async () => {
|
|
const api = await start();
|
|
await login(api);
|
|
const application = await db('applications').where({ email }).first();
|
|
const admin = await adminCookie(api);
|
|
|
|
const response = await api.inject({
|
|
method: 'GET',
|
|
url: `/api/admin/applications/${application.id}/consent-pdf`,
|
|
headers: { cookie: admin },
|
|
});
|
|
expect(response.statusCode).toBe(404);
|
|
});
|
|
});
|