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)
+ })
+})