diff --git a/.github/workflows/1.yml b/.github/workflows/1.yml index eccab56c..e1dd37f8 100644 --- a/.github/workflows/1.yml +++ b/.github/workflows/1.yml @@ -54,7 +54,28 @@ jobs: run: npm ci --prefer-offline --no-audit --no-fund - name: 4. Unit Tests - run: npm run test:ci + run: npm run test:unit:ci + + integration-tests: + if: github.event.pull_request.draft == false + name: Integration Tests + runs-on: ubuntu-latest + + steps: + - name: 1. Checkout Repository + uses: actions/checkout@v4 + + - name: 2. Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.22' + cache: npm + + - name: 3. Install Dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: 4. Integration Tests + run: npm run test:integration:ci typecheck: if: github.event.pull_request.draft == false diff --git a/package.json b/package.json index dcc880f0..146f18cd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,10 @@ "fixtures:check": "echo \"No fixtures drift check configured for this repo\"", "test": "vitest", "test:ci": "vitest run --coverage", + "test:unit": "vitest run test/unit", + "test:unit:ci": "vitest run test/unit --coverage", + "test:integration": "vitest run test/integration", + "test:integration:ci": "vitest run test/integration --coverage", "test:run": "vitest run", "test:watch": "vitest --watch", "test:coverage": "vitest run --coverage" diff --git a/src/api/api.jsx b/src/api/api.jsx index 30a73129..8593d48f 100644 --- a/src/api/api.jsx +++ b/src/api/api.jsx @@ -1,6 +1,15 @@ -import { createPatient } from './patientsApi' -import { submitPatientForm } from './formsApi' -import { toFormKey } from '../forms/formKeys' +export { + calculateBMI, + calculateSppbScore, + formatBmi, + formatGeriVision, + formatWceStation, + parseGeriVision, + parseWceStation, + regexPasswordPattern, + submitForm, +} from './formHelpers' + export { generateDoctorPdf } from '../reports/doctorPdf' export { generateFormAPdf } from '../reports/formAPdf' export { @@ -26,421 +35,3 @@ export { recommendationSection, temperatureSection, } from '../reports/patientReportPdfUpdated' - -// export async function preRegister(preRegArgs) { -// let gender = preRegArgs.gender -// let initials = preRegArgs.initials.trim() -// let age = preRegArgs.age -// let preferredLanguage = preRegArgs.preferredLanguage.trim() -// let goingForPhlebotomy = preRegArgs.goingForPhlebotomy -// // validate params -// if ( -// gender == null || -// initials == null || -// age == null || -// preferredLanguage == null || -// goingForPhlebotomy == null -// ) { -// return { result: false, error: 'Function Arguments canot be undefined.' } -// } -// if ( -// typeof goingForPhlebotomy === 'string' && -// goingForPhlebotomy !== 'Y' && -// goingForPhlebotomy !== 'N' -// ) { -// return { result: false, error: 'The value of goingForPhlebotomy must either be "T" or "F"' } -// } -// // TODO: more exhaustive error handling. consider abstracting it in a validation function, and using schema validation -// let data = { -// gender: gender, -// initials: initials, -// age: age, -// preferredLanguage: preferredLanguage, -// goingForPhlebotomy: goingForPhlebotomy, -// } -// let isSuccess = false -// let errorMsg = '' -// try { -// const mongoConnection = mongoDB.currentUser.mongoClient('mongodb-atlas') -// const patientsRecord = mongoConnection.db('phs').collection('patients') -// const qNum = await mongoDB.currentUser.functions.getNextQueueNo() -// await patientsRecord.insertOne({ queueNo: qNum, ...data }) -// data = { patientId: qNum, ...data } -// isSuccess = true -// } catch (err) { -// // TODO: more granular error handling -// return { result: false, error: err } -// } -// return { result: isSuccess, data: data, error: errorMsg } -// } - -// export async function submitForm(args, patientId, formCollection) { -// try { -// const mongoConnection = mongoDB.currentUser.mongoClient('mongodb-atlas') -// const patientsRecord = mongoConnection.db('phs').collection('patients') -// const registrationForms = mongoConnection.db('phs').collection(formCollection) -// const record2 = await patientsRecord.findOne({ queueNo: patientId }) - -// let qNum = 0 - -// let gender = args.registrationQ5 -// let initials = args.registrationQ2 -// let age = args.registrationQ4 -// let preferredLanguage = args.registrationQ14 -// let goingForPhlebotomy = args.registrationQ15 - -// let data = { -// gender: gender, -// initials: initials, -// age: age, -// preferredLanguage: preferredLanguage, -// goingForPhlebotomy: goingForPhlebotomy, -// } - -// console.log('patient id: ' + record2) - -// if (record2 == null) { -// qNum = await mongoDB.currentUser.functions.getNextQueueNo() -// await patientsRecord.insertOne({ queueNo: qNum, ...data }) -// patientId = qNum -// } - -// const record = await patientsRecord.findOne({ queueNo: patientId }) - -// if (record) { -// // Adds a key-value pair for each form submitted for the first time to the patient's document in the patients collection -// // in MongoDB to track which forms have been successfully submitted -// if (record[formCollection] === undefined) { -// await patientsRecord.updateOne( -// { queueNo: patientId }, -// { $set: { [formCollection]: patientId } }, -// ) - -// await registrationForms.insertOne({ _id: patientId, ...args }) - -// await updateAllStationCounts(patientId) - -// await updateGeriGraceEligibility(args, patientId, formCollection) - -// return { result: true, data: data, qNum: patientId } -// } else { -// if (await isAdmin()) { -// args.lastEdited = new Date() -// args.lastEditedBy = getName() -// await registrationForms.updateOne({ _id: patientId }, { $set: { ...args } }) -// if (formCollection == 'registrationForm') { -// await patientsRecord.updateOne( -// { queueNo: patientId }, -// { $set: { initials: args.registrationQ2 } }, -// ) -// await patientsRecord.updateOne( -// { queueNo: patientId }, -// { $set: { age: args.registrationQ4 } }, -// ) -// } -// await updateAllStationCounts(patientId) -// await updateGeriGraceEligibility(args, patientId, formCollection, patientsRecord) -// // replace form -// // registrationForms.findOneAndReplace({_id: record[formCollection]}, args); -// // throw error message -// // const errorMsg = "This form has already been submitted. If you need to make " -// // + "any changes, please contact the admin." -// return { result: true, data: data, qNum: patientId } -// } else { -// const errorMsg = -// 'This form has already been submitted. If you need to make ' + -// 'any changes, please contact the admin.' -// return { result: false, error: errorMsg } -// } -// } -// } else { -// // TODO: throw error, not possible that no document is found -// // unless malicious user tries to change link to directly access reg page -// // Can check in every form page if there is valid patientId instead -// // cannot use useEffect since the form component is class component -// const errorMsg = 'An error has occurred.' -// console.log('There is an error here') -// // You will be directed to the registration page." logic not done -// return { result: false, error: errorMsg } -// } -// } catch (err) { -// return { result: false, error: err } -// } -// } - -const inFlightFormSubmissions = new Map() - -function getFormSubmissionKey(patientId, formCollection) { - return `${formCollection}:${patientId ?? 'new'}` -} - -async function submitFormOnce(args, patientId, formCollection) { - try { - // Registers the patient in the patients collection if they do not exist yet - let effectiveId = patientId - let patientData = {} - - if (effectiveId === -1 || effectiveId == null) { - const payload = { - gender: args.registrationQ5, - initials: (args.registrationQ2 || '').trim(), - age: Number(args.registrationQ4 ?? 0), - preferredLanguage: (args.registrationQ14 || '').trim(), - } - const created = await createPatient(payload) - if (!created?.result) return { result: false, error: 'Failed to create patient' } - effectiveId = created.data.queueNo - patientData = payload - } else { - patientData = { - gender: args.registrationQ5, - initials: args.registrationQ2, - age: args.registrationQ4, - preferredLanguage: args.registrationQ14, - } - } - - // Upsert form data - const upsert = await submitPatientForm(effectiveId, toFormKey(formCollection), args) - if (!upsert?.result) return { result: false, error: 'Failed to save form' } - - // Return same shape expected by frontend logic - return { - result: true, - data: patientData, - qNum: effectiveId, - } - } catch (err) { - return { result: false, error: err.message || String(err) } - } -} - -export async function submitForm(args, patientId, formCollection) { - const submissionKey = getFormSubmissionKey(patientId, formCollection) - const inFlightSubmission = inFlightFormSubmissions.get(submissionKey) - - if (inFlightSubmission) { - return inFlightSubmission - } - - const submission = submitFormOnce(args, patientId, formCollection).finally(() => { - inFlightFormSubmissions.delete(submissionKey) - }) - - inFlightFormSubmissions.set(submissionKey, submission) - return submission -} - -// Calcuates the BMI -export function formatBmi(heightInCm, weightInKg) { - const bmi = calculateBMI(heightInCm, weightInKg) - - if (bmi > 27.5) { - return ( -

- {bmi} -
- BMI is obese -

- ) - } else if (bmi >= 23.0) { - return ( -

- {bmi} -
- BMI is overweight -

- ) - } else if (bmi < 18.5) { - return ( -

- {bmi} -
- BMI is underweight -

- ) - } else { - return

{bmi}

- } -} - -export function calculateBMI(heightInCm, weightInKg) { - const height = heightInCm / 100 - const bmi = (weightInKg / height / height).toFixed(1) - - return bmi -} - -// Formats the response for the geri vision section -export const formatGeriVision = (acuityString, questionNo) => { - const acuity = parseInt(acuityString) - if (acuity >= 6) { - return

{parseGeriVision(acuity, questionNo)}

- } - if (questionNo === 6) { - return

{parseGeriVision(acuity, questionNo)}

- } - return

{parseGeriVision(acuity, questionNo)}

-} -export function parseGeriVision(acuity, questionNo) { - var result - var additionalInfo - - switch (questionNo) { - case 3: - case 4: - if (acuity >= 6) { - additionalInfo = '\nSee VA with pinhole' - result = 'Visual acuity (w/o pinhole occluder) - Right Eye 6/' + acuity + additionalInfo - } else { - result = 'Visual acuity (w/o pinhole occluder) - Left Eye 6/' + acuity - } - return result - case 5: - case 6: - if (acuity >= 6) { - result = 'Visual acuity (with pinhole occluder) - Right Eye 6/' + acuity - additionalInfo = '\nNon-refractive error, participant should have consulted on-site doctor' - } else { - result = 'Visual acuity (with pinhole occluder) - Left Eye 6/' + acuity - additionalInfo = - '\nRefractive error, participant can opt to apply for Senior Mobility Fund (SMF)' - } - result = result + additionalInfo - return result - } -} - -export const formatWceStation = (gender, question, answer) => { - if (gender == 'Male' || gender == 'Not Applicable') { - return '-' - } - return ( -
-

{parseWceStation(question, answer).result}

-

{parseWceStation(question, answer).additionalInfo}

-
- ) -} -export function parseWceStation(question, answer) { - var result = { result: answer, additionalInfo: null } - var additionalInfo - switch (question) { - case 2: - case 3: - additionalInfo = - 'If participant is interested in WCE, check whether they have' + - 'completed the station. Referring to the responses below, please check with them if the relevant appointments have been made based on their indicated interests.' - break - case 4: - if (answer == 'Yes') { - additionalInfo = 'Kindly remind participant that SCS will be contacting them.' - } - break - case 5: - if (answer == 'Yes') { - additionalInfo = 'Kindly remind participant that SCS will be contacting them.' - } - break - case 6: - if (answer == 'Yes') { - additionalInfo = 'Kindly remind participant that NHGD will be contacting them.' - } - break - } - result.additionalInfo = additionalInfo - - return result -} - -export function calculateSppbScore(q2, q6, q8) { - let score = 0 - if (q2 !== undefined) { - score += parseInt(q2.slice(0)) - } - if (q6 !== undefined) { - const num = parseInt(q6.slice(0)) - if (!Number.isNaN(num)) { - score += num - } - } - if (q8 !== undefined) { - score += parseInt(q8.slice(0)) - } - return score -} - -export const regexPasswordPattern = - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{12,}$/ - -// export const deleteFromAllDatabase = async () => { -// const mongoConnection = mongoDB.currentUser.mongoClient('mongodb-atlas') -// const mongoDBConnection = mongoConnection.db('phs') - -// console.log(await mongoDBConnection.collection("patients").deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriPtConsultForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.dietitiansConsultForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.doctorConsultForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.fitForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriAmtForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriEbasDepForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriFrailScaleForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriGeriApptForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriOtConsultForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriOtQuestionnaireForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriParQForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriMmseForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriPhysicalActivityLevelForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriAudiometryForm).deleteMany({})) -// console.log("half") -// console.log(await mongoDBConnection.collection(forms.geriSppbForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.phlebotomyForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriTugForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriVisionForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.hxCancerForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.hxHcsrForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.hxNssForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.hxSocialForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.phleboForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.registrationForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.oralHealthForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.socialServiceForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.wceForm).deleteMany({})) -// console.log('done') -// deletes volunteer accounts -// console.log(await mongoDBConnection.collection("profiles").deleteMany({is_admin:{$eq : undefined}})) - -// console.log(await mongoDBConnection.collection("patients").deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriPtConsultForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.dietitiansConsultForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.doctorConsultForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.fitForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriAmtForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriEbasDepForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriFrailScaleForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriGeriApptForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriOtConsultForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriOtQuestionnaireForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriParQForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriMmseForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriPhysicalActivityLevelForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriAudiometryForm).deleteMany({})) -// console.log("half") -// console.log(await mongoDBConnection.collection(forms.geriSppbForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.phlebotomyForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriTugForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.geriVisionForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.hxCancerForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.hxHcsrForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.hxNssForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.hxSocialForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.phleboForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.registrationForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.oralHealthForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.socialServiceForm).deleteMany({})) -// console.log(await mongoDBConnection.collection(forms.wceForm).deleteMany({})) -// console.log('done') -// deletes volunteer accounts -// console.log(await mongoDBConnection.collection("profiles").deleteMany({is_admin:{$eq : undefined}})) -// } - diff --git a/src/api/formHelpers.jsx b/src/api/formHelpers.jsx index 352362d5..2f832765 100644 --- a/src/api/formHelpers.jsx +++ b/src/api/formHelpers.jsx @@ -3,7 +3,13 @@ import { createPatient } from './patientsApi' import { submitPatientForm } from './formsApi' import { toFormKey } from '../forms/formKeys' -export async function submitForm(args, patientId, formCollection) { +const inFlightFormSubmissions = new Map() + +function getFormSubmissionKey(patientId, formCollection) { + return `${formCollection}:${patientId ?? 'new'}` +} + +async function submitFormOnce(args, patientId, formCollection) { try { let effectiveId = patientId let patientData = {} @@ -41,6 +47,22 @@ export async function submitForm(args, patientId, formCollection) { } } +export async function submitForm(args, patientId, formCollection) { + const submissionKey = getFormSubmissionKey(patientId, formCollection) + const inFlightSubmission = inFlightFormSubmissions.get(submissionKey) + + if (inFlightSubmission) { + return inFlightSubmission + } + + const submission = submitFormOnce(args, patientId, formCollection).finally(() => { + inFlightFormSubmissions.delete(submissionKey) + }) + + inFlightFormSubmissions.set(submissionKey, submission) + return submission +} + export function formatBmi(heightInCm, weightInKg) { const bmi = calculateBMI(heightInCm, weightInKg) diff --git a/test/integration/appShell.test.jsx b/test/integration/appShell.test.jsx new file mode 100644 index 00000000..6381c0ba --- /dev/null +++ b/test/integration/appShell.test.jsx @@ -0,0 +1,99 @@ +import React, { useContext } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HelmetProvider } from 'react-helmet-async' +import { MemoryRouter } from 'react-router-dom' +import App, { LoginContext } from '../../src/App' +import { FormContext } from '../../src/api/utils' +import { FORM_SUBMIT_STATUS_EVENT } from '../../src/components/form-components/FormSubmitStatusHost' + +const mockRoutes = vi.hoisted(() => []) + +vi.mock('../../src/routes', () => ({ + default: mockRoutes, +})) + +function ProviderProbe() { + const { login, profile } = useContext(LoginContext) + const { patientId, patientInfo, updatePatientInfo, clearPatient } = useContext(FormContext) + + return ( +
+
{login ? 'logged-in' : 'logged-out'}
+
{profile?.name || 'no-profile'}
+
{patientId}
+
{patientInfo.name || 'no-patient'}
+ + +
+ ) +} + +function renderApp() { + mockRoutes.splice(0, mockRoutes.length, { + path: '/', + element: , + }) + + return render( + + + + + + ) +} + +describe('App shell integration', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('initializes and provides login and patient context state', async () => { + const user = userEvent.setup() + localStorage.setItem('authToken', 'token-123') + localStorage.setItem('profile', JSON.stringify({ name: 'Admin User' })) + + renderApp() + + expect(screen.getByTestId('login-state')).toHaveTextContent('logged-in') + expect(screen.getByTestId('profile-name')).toHaveTextContent('Admin User') + expect(screen.getByTestId('patient-id')).toHaveTextContent('-1') + expect(screen.getByTestId('patient-name')).toHaveTextContent('no-patient') + + await user.click(screen.getByRole('button', { name: 'Load Patient' })) + + expect(screen.getByTestId('patient-id')).toHaveTextContent('42') + expect(screen.getByTestId('patient-name')).toHaveTextContent('Ada Lovelace') + + await user.click(screen.getByRole('button', { name: 'Clear Patient' })) + + expect(screen.getByTestId('patient-id')).toHaveTextContent('-1') + expect(screen.getByTestId('patient-name')).toHaveTextContent('no-patient') + }) + + it('renders the global form submit status host', async () => { + renderApp() + + act(() => { + window.dispatchEvent( + new CustomEvent(FORM_SUBMIT_STATUS_EVENT, { + detail: { + message: 'Saved from App shell.', + severity: 'success', + }, + }) + ) + }) + + expect(await screen.findByText('Saved from App shell.')).toBeInTheDocument() + }) +}) diff --git a/test/integration/authRoutes.test.jsx b/test/integration/authRoutes.test.jsx new file mode 100644 index 00000000..d1f23662 --- /dev/null +++ b/test/integration/authRoutes.test.jsx @@ -0,0 +1,118 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { HelmetProvider } from 'react-helmet-async' +import { MemoryRouter, useLocation } from 'react-router-dom' +import App from '../../src/App' +import { getProfile } from '../../src/services/authSession' + +vi.mock('../../src/services/authSession', async () => { + const actual = await vi.importActual('../../src/services/authSession') + + return { + ...actual, + getProfile: vi.fn(), + } +}) + +function MockPage({ name }) { + const location = useLocation() + + return ( +
+
{location.pathname}
+
{name}
+
+ ) +} + +vi.mock('../../src/pages/Login', () => ({ + default: () => , +})) + +vi.mock('../../src/pages/Registration', () => ({ + default: () => , +})) + +vi.mock('../../src/pages/Dashboard', () => ({ + default: () => , +})) + +vi.mock('../../src/pages/ManageVolunteers', () => ({ + default: () => , +})) + +function renderApp(initialPath) { + return render( + + + + + + ) +} + +describe('auth route access integration', () => { + beforeEach(() => { + window.scrollTo = vi.fn() + localStorage.clear() + vi.clearAllMocks() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('redirects logged-out users away from protected app routes', async () => { + renderApp('/app/dashboard') + + expect(await screen.findByTestId('login-page')).toBeInTheDocument() + expect(screen.getByTestId('current-path')).toHaveTextContent('/login') + expect(screen.queryByTestId('dashboard-page')).not.toBeInTheDocument() + }) + + it('redirects logged-in users away from guest-only login route', async () => { + localStorage.setItem('authToken', 'token-123') + + renderApp('/login') + + expect(await screen.findByTestId('registration-page')).toBeInTheDocument() + expect(screen.getByTestId('current-path')).toHaveTextContent('/app/registration') + expect(screen.queryByTestId('login-page')).not.toBeInTheDocument() + }) + + it('sends logged-in users from the default route to registration', async () => { + localStorage.setItem('authToken', 'token-123') + + renderApp('/') + + expect(await screen.findByTestId('registration-page')).toBeInTheDocument() + expect(screen.getByTestId('current-path')).toHaveTextContent('/app/registration') + }) + + it('allows admin users through admin app routes', async () => { + localStorage.setItem('authToken', 'token-123') + getProfile.mockResolvedValue({ username: 'admin@example.com', is_admin: true }) + + renderApp('/app/manage') + + expect(await screen.findByTestId('manage-volunteers-page')).toBeInTheDocument() + expect(screen.getByTestId('current-path')).toHaveTextContent('/app/manage') + expect(localStorage.getItem('profile')).toBe( + JSON.stringify({ username: 'admin@example.com', is_admin: true }) + ) + }) + + it('redirects non-admin users away from admin app routes', async () => { + localStorage.setItem('authToken', 'token-123') + getProfile.mockResolvedValue({ username: 'guest@example.com', is_admin: false }) + + renderApp('/app/manage') + + await waitFor(() => { + expect(screen.getByTestId('registration-page')).toBeInTheDocument() + }) + expect(screen.getByTestId('current-path')).toHaveTextContent('/app/registration') + expect(screen.queryByTestId('manage-volunteers-page')).not.toBeInTheDocument() + }) +}) diff --git a/test/integration/eventDashboardFlow.test.jsx b/test/integration/eventDashboardFlow.test.jsx new file mode 100644 index 00000000..b9a91ca2 --- /dev/null +++ b/test/integration/eventDashboardFlow.test.jsx @@ -0,0 +1,121 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HelmetProvider } from 'react-helmet-async' +import EventDashboard from '../../src/pages/EventDashboard' +import { + getEventDashboardSummary, + getIncompletePatients, +} from '../../src/api/eventDashboardApi' + +vi.mock('../../src/api/eventDashboardApi', () => ({ + getEventDashboardSummary: vi.fn(), + getIncompletePatients: vi.fn(), +})) + +const summary = { + registeredPatients: 120, + screeningPatients: 34, + completedPatients: 86, + refreshedAt: '2026-06-28T04:00:00.000Z', + stationQueues: [ + { stationName: 'Triage', count: 8 }, + { stationName: 'Doctor Consult', count: 3 }, + ], + printQueues: [{ queueName: 'Form A', count: 5 }], + bottleneckStation: { stationName: 'Triage', count: 8 }, +} + +function patientResponse(data, pagination = { page: 1, totalPages: 1, total: data.length }) { + return { data, pagination } +} + +function renderDashboard() { + return render( + + + + ) +} + +describe('EventDashboard data flow integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads and renders dashboard summary and incomplete patients', async () => { + getEventDashboardSummary.mockResolvedValue({ data: summary }) + getIncompletePatients.mockResolvedValue( + patientResponse([ + { + queueNo: 17, + initials: 'AL', + age: 67, + currentQueue: { stationName: 'Triage', position: 2 }, + }, + ]) + ) + + renderDashboard() + + expect(await screen.findByText('Event Dashboard')).toBeInTheDocument() + expect(screen.getByText('Registered Patients')).toBeInTheDocument() + expect(screen.getByText('120')).toBeInTheDocument() + expect(screen.getByText('Still Screening')).toBeInTheDocument() + expect(screen.getByText('34')).toBeInTheDocument() + expect(screen.getByText('Completed')).toBeInTheDocument() + expect(screen.getByText('86')).toBeInTheDocument() + expect(screen.getByText('Bottleneck: Triage (8)')).toBeInTheDocument() + + const row = screen.getByRole('row', { name: /17 AL 67 Triage #2/i }) + expect(within(row).getByText('17')).toBeInTheDocument() + expect(within(row).getByText('AL')).toBeInTheDocument() + + expect(getEventDashboardSummary).toHaveBeenCalledTimes(1) + expect(getIncompletePatients).toHaveBeenCalledWith({ q: '', page: 1, limit: 25 }) + }) + + it('searches incomplete patients with the submitted query', async () => { + const user = userEvent.setup() + getEventDashboardSummary.mockResolvedValue({ data: summary }) + getIncompletePatients + .mockResolvedValueOnce(patientResponse([])) + .mockResolvedValueOnce( + patientResponse([ + { + queueNo: 22, + initials: 'BT', + age: 58, + currentQueue: null, + }, + ]) + ) + + renderDashboard() + + expect(await screen.findByText('No incomplete patients found.')).toBeInTheDocument() + + await user.type(screen.getByLabelText('Search name or ID'), ' BT ') + await user.click(screen.getByRole('button', { name: 'Search' })) + + expect(await screen.findByRole('row', { name: /22 BT 58 Not in queue/i })).toBeInTheDocument() + expect(getIncompletePatients).toHaveBeenLastCalledWith({ + q: 'BT', + page: 1, + limit: 25, + }) + }) + + it('shows an error message when dashboard data fails to load', async () => { + getEventDashboardSummary.mockRejectedValue(new Error('Dashboard unavailable')) + getIncompletePatients.mockResolvedValue(patientResponse([])) + + renderDashboard() + + await waitFor(() => { + expect(screen.getByText('Dashboard unavailable')).toBeInTheDocument() + }) + expect(screen.getByText('No incomplete patients found.')).toBeInTheDocument() + }) +}) diff --git a/test/integration/patientLookupFlow.test.jsx b/test/integration/patientLookupFlow.test.jsx new file mode 100644 index 00000000..93df2031 --- /dev/null +++ b/test/integration/patientLookupFlow.test.jsx @@ -0,0 +1,118 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import RegisterPatient from '../../src/components/RegisterPatient' +import { FormContext } from '../../src/api/utils' +import { + getAllPatientNamesStrict, + getPatientNameMatchesStrict, + getPreRegDataByIdStrict, +} from '../../src/services/patientData' +import { updateAllStationCounts } from '../../src/services/stationCounts' + +vi.mock('../../src/services/patientData', () => ({ + getAllPatientNamesStrict: vi.fn(), + getPatientNameMatchesStrict: vi.fn(), + getPreRegDataByIdStrict: vi.fn(), +})) + +vi.mock('../../src/services/stationCounts', () => ({ + updateAllStationCounts: vi.fn(), +})) + +function DashboardTarget() { + return
Dashboard route
+} + +function renderLookup(contextOverrides = {}) { + const context = { + updatePatientInfo: vi.fn(), + clearPatient: vi.fn(), + ...contextOverrides, + } + + render( + + + + } /> + } /> + + + + ) + + return context +} + +describe('Patient lookup data flow integration', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + window.alert = vi.fn() + getAllPatientNamesStrict.mockResolvedValue([]) + updateAllStationCounts.mockResolvedValue({}) + }) + + it('loads patient lookup options without opening the registration form', async () => { + renderLookup() + + expect(await screen.findByText('Patient Lookup')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Register New Patient' })).toBeInTheDocument() + expect(getAllPatientNamesStrict).toHaveBeenCalledWith('patients', { + q: '', + page: 1, + limit: 20, + }) + }) + + it('looks up a patient by queue number and navigates to the dashboard', async () => { + const user = userEvent.setup() + const patient = { queueNo: 42, initials: 'AL', age: 67 } + const context = renderLookup() + getPreRegDataByIdStrict.mockResolvedValue(patient) + + await user.type(screen.getByPlaceholderText('Queue number'), '42') + await user.click(screen.getByRole('button', { name: 'Search by Queue Number' })) + + await waitFor(() => { + expect(context.updatePatientInfo).toHaveBeenCalledWith(patient) + }) + expect(updateAllStationCounts).toHaveBeenCalledWith(42) + expect(await screen.findByTestId('dashboard-route')).toBeInTheDocument() + }) + + it('searches patients by exact name and selects a returned match', async () => { + const user = userEvent.setup() + const patient = { + queueNo: 55, + initials: 'BT', + birthday: '1958-02-03T00:00:00.000Z', + } + const context = renderLookup() + getPatientNameMatchesStrict.mockResolvedValue({ data: [patient], pagination: null }) + + await user.type(screen.getByLabelText('Patient name'), 'BT') + await user.click(screen.getByRole('button', { name: 'Search by Name' })) + + expect(await screen.findByText('Select the matching patient by birthday.')).toBeInTheDocument() + expect(screen.getByText('55')).toBeInTheDocument() + expect(screen.getByText('BT')).toBeInTheDocument() + expect(getPatientNameMatchesStrict).toHaveBeenCalledWith({ + initials: 'BT', + page: 1, + limit: 10, + }) + + await user.click(screen.getByRole('button', { name: 'Select' })) + + await waitFor(() => { + expect(context.updatePatientInfo).toHaveBeenCalledWith(patient) + }) + expect(updateAllStationCounts).toHaveBeenCalledWith(55) + expect(await screen.findByTestId('dashboard-route')).toBeInTheDocument() + }) +}) diff --git a/test/integration/stationQueueFlow.test.jsx b/test/integration/stationQueueFlow.test.jsx new file mode 100644 index 00000000..ce8a9a7f --- /dev/null +++ b/test/integration/stationQueueFlow.test.jsx @@ -0,0 +1,107 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import StationQueue from '../../src/components/StationQueue' +import { + addPatientsToStationQueue, + getQueueEntries, + removePatientsFromStationQueue, +} from '../../src/api/queuesApi' +import { getProfile } from '../../src/services/authSession' +import { getPreRegDataByIdStrict, getSavedData } from '../../src/services/patientData' + +vi.mock('../../src/api/queuesApi', () => ({ + addPatientsToStationQueue: vi.fn(), + createStationQueue: vi.fn(), + deleteStationQueue: vi.fn(), + getQueueEntries: vi.fn(), + removeFirstPatientFromStationQueue: vi.fn(), + removePatientsFromStationQueue: vi.fn(), + restoreLastRemovedToFront: vi.fn(), +})) + +vi.mock('../../src/services/authSession', () => ({ + getProfile: vi.fn(), +})) + +vi.mock('../../src/services/patientData', () => ({ + getPreRegDataByIdStrict: vi.fn(), + getSavedData: vi.fn(), +})) + +const queueResponse = { + data: [ + { + stationName: 'Triage', + queueItems: ['5: Mr BO'], + lastRemoved: { queueItems: ['3: Ms CY'] }, + }, + ], +} + +describe('StationQueue data flow integration', () => { + beforeEach(() => { + vi.clearAllMocks() + window.alert = vi.fn() + getProfile.mockResolvedValue({ username: 'admin@example.com', is_admin: true }) + getQueueEntries.mockResolvedValue(queueResponse) + }) + + it('loads and renders station queue data', async () => { + render() + + expect(await screen.findByText('Station Queue Management')).toBeInTheDocument() + expect(screen.getByText('Triage')).toBeInTheDocument() + expect(screen.getByText('Mr BO')).toBeInTheDocument() + expect(screen.getByText('id: 5')).toBeInTheDocument() + expect(screen.getByText('3: Ms CY')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add Station' })).toBeInTheDocument() + expect(getQueueEntries).toHaveBeenCalledTimes(1) + }) + + it('adds an existing patient to a station queue using the displayed patient string', async () => { + const user = userEvent.setup() + getPreRegDataByIdStrict.mockResolvedValue({ queueNo: 11, initials: 'AL' }) + getSavedData.mockResolvedValue({ registrationQ1: 'Ms' }) + + render() + + await screen.findByText('Triage') + await user.type(screen.getByLabelText('Add patient IDs'), '11') + await user.click(screen.getByRole('button', { name: 'Add to Back' })) + + await waitFor(() => { + expect(addPatientsToStationQueue).toHaveBeenCalledWith('Triage', ['11: Ms AL']) + }) + expect(getPreRegDataByIdStrict).toHaveBeenCalledWith(11, 'patients') + expect(getSavedData).toHaveBeenCalledWith(11, 'registrationForm') + }) + + it('removes selected patients from a station queue by matching displayed queue items', async () => { + const user = userEvent.setup() + + render() + + await screen.findByText('Triage') + await user.type(screen.getByLabelText('Remove patient IDs'), '5') + await user.click(screen.getByRole('button', { name: 'Remove' })) + + await waitFor(() => { + expect(removePatientsFromStationQueue).toHaveBeenCalledWith('Triage', ['5: Mr BO']) + }) + }) + + it('shows a load error when station queues fail to load', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + getQueueEntries.mockRejectedValue(new Error('Queue backend unavailable')) + + render() + + expect( + await screen.findByText( + 'Unable to load station queues. Details: Queue backend unavailable' + ) + ).toBeInTheDocument() + }) +}) diff --git a/test/unit/api.test.jsx b/test/unit/api.test.jsx new file mode 100644 index 00000000..b00ae4b9 --- /dev/null +++ b/test/unit/api.test.jsx @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPatient } from '../../src/api/patientsApi' +import { submitPatientForm } from '../../src/api/formsApi' +import { submitForm } from '../../src/api/formHelpers' + +vi.mock('../../src/api/patientsApi', () => ({ + createPatient: vi.fn(), +})) + +vi.mock('../../src/api/formsApi', () => ({ + submitPatientForm: vi.fn(), +})) + +const registrationArgs = { + registrationQ2: ' AL ', + registrationQ4: '42', + registrationQ5: 'Female', + registrationQ14: ' Mandarin ', +} + +describe('formHelpers submitForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('creates a patient before submitting a form when patient id is -1', async () => { + createPatient.mockResolvedValue({ + result: true, + data: { queueNo: 17 }, + }) + submitPatientForm.mockResolvedValue({ result: true }) + + await expect(submitForm(registrationArgs, -1, 'registrationForm')).resolves.toEqual({ + result: true, + data: { + gender: 'Female', + initials: 'AL', + age: 42, + preferredLanguage: 'Mandarin', + }, + qNum: 17, + }) + + expect(createPatient).toHaveBeenCalledWith({ + gender: 'Female', + initials: 'AL', + age: 42, + preferredLanguage: 'Mandarin', + }) + expect(submitPatientForm).toHaveBeenCalledWith(17, 'registration', registrationArgs) + }) + + it('creates a patient before submitting a form when patient id is null', async () => { + createPatient.mockResolvedValue({ + result: true, + data: { queueNo: 23 }, + }) + submitPatientForm.mockResolvedValue({ result: true }) + + await expect(submitForm(registrationArgs, null, 'triageForm')).resolves.toMatchObject({ + result: true, + qNum: 23, + }) + + expect(createPatient).toHaveBeenCalledTimes(1) + expect(submitPatientForm).toHaveBeenCalledWith(23, 'triage', registrationArgs) + }) + + it('submits a form for an existing patient without creating a patient', async () => { + submitPatientForm.mockResolvedValue({ result: true }) + + await expect(submitForm(registrationArgs, 7, 'doctorConsultForm')).resolves.toEqual({ + result: true, + data: { + gender: 'Female', + initials: ' AL ', + age: '42', + preferredLanguage: ' Mandarin ', + }, + qNum: 7, + }) + + expect(createPatient).not.toHaveBeenCalled() + expect(submitPatientForm).toHaveBeenCalledWith(7, 'doctorConsult', registrationArgs) + }) + + it('returns a failure when patient creation fails', async () => { + createPatient.mockResolvedValue({ result: false }) + + await expect(submitForm(registrationArgs, -1, 'registrationForm')).resolves.toEqual({ + result: false, + error: 'Failed to create patient', + }) + + expect(submitPatientForm).not.toHaveBeenCalled() + }) + + it('returns a failure when form saving fails', async () => { + createPatient.mockResolvedValue({ + result: true, + data: { queueNo: 17 }, + }) + submitPatientForm.mockResolvedValue({ result: false }) + + await expect(submitForm(registrationArgs, -1, 'registrationForm')).resolves.toEqual({ + result: false, + error: 'Failed to save form', + }) + }) + + it('returns a failure message when an API call throws', async () => { + submitPatientForm.mockRejectedValue(new Error('Backend unavailable')) + + await expect(submitForm(registrationArgs, 7, 'registrationForm')).resolves.toEqual({ + result: false, + error: 'Backend unavailable', + }) + }) + + it('deduplicates concurrent submissions with the same patient and form key', async () => { + let resolveSave + const savePromise = new Promise((resolve) => { + resolveSave = resolve + }) + submitPatientForm.mockReturnValue(savePromise) + + const firstSubmission = submitForm(registrationArgs, 7, 'registrationForm') + const duplicateSubmission = submitForm(registrationArgs, 7, 'registrationForm') + + expect(submitPatientForm).toHaveBeenCalledTimes(1) + + resolveSave({ result: true }) + + await expect(firstSubmission).resolves.toEqual({ + result: true, + data: { + gender: 'Female', + initials: ' AL ', + age: '42', + preferredLanguage: ' Mandarin ', + }, + qNum: 7, + }) + await expect(duplicateSubmission).resolves.toEqual({ + result: true, + data: { + gender: 'Female', + initials: ' AL ', + age: '42', + preferredLanguage: ' Mandarin ', + }, + qNum: 7, + }) + + submitPatientForm.mockResolvedValue({ result: true }) + await submitForm(registrationArgs, 7, 'registrationForm') + + expect(submitPatientForm).toHaveBeenCalledTimes(2) + }) +})