// The fixture server answers in the backend's own shapes (src/api/wire.ts), end to end: the film's // flow, with the real local reader and engine, and a check-in played fast. The adapters then turn // each answer into what the screens read (api-adapt.test.ts covers them on the backend's JSON). import type { AddressInfo } from "node:net"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { checkInFromWire, planFromWire } from "../src/api/adapt-checkins"; import { detailFromWire, extractionFromWire, sessionFromMe, titlesOf } from "../src/api/adapt"; import type * as W from "../src/api/wire"; import { FIXTURE_EMAIL, FIXTURE_EMAIL_CODE, FIXTURE_TOTP_CODE } from "./fixture/data"; import { createFixtureServer } from "./fixture/server"; let base = ""; let cookie = ""; const { server } = createFixtureServer({ alwaysOpen: true, speed: 40, noSse: false }, "dist"); beforeAll(async () => { await new Promise((done) => server.listen(0, "127.0.0.1", done)); base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(() => { server.closeAllConnections(); server.close(); }); async function call(method: string, path: string, body?: unknown): Promise<{ status: number; data: R }> { const res = await fetch(`${base}${path}`, { method, headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) }, body: body === undefined ? undefined : JSON.stringify(body), }); const set = res.headers.get("set-cookie"); if (set) cookie = set.split(";")[0]; return { status: res.status, data: (await res.json().catch(() => null)) as R }; } const api = (method: string, path: string, body?: unknown) => call(method, `/api/clinician${path}`, body); const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); const titles = async (patientId: string) => titlesOf(patientId, (await api("GET", `/patients/${patientId}/records`)).data.records); describe("fixture: signed out", () => { it("accepts an access request and previews an invite without a session", async () => { const preview = await api("GET", "/invite-preview/mariam-invite-demo"); expect(preview.data).toMatchObject({ patientName: "Mariam Adeyemi", withPerson: { name: "Funke" } }); expect(preview.data).not.toHaveProperty("patientPronoun"); const bad = await api("GET", "/invite-preview/nope"); expect(bad).toMatchObject({ status: 404, data: { code: "INVITE_NOT_FOUND" } }); const request = await api("POST", "/access-requests", { fullName: "Dr Ada Obi", profession: "doctor", licenceBody: "MDCN", licenceNumber: "MDCN/R/123456", countryCode: "NG", facilityName: "Yaba Clinic", facilityPhone: "+234 1 555 0100", workEmail: "ada@example.test", mobilePhone: "+234 803 555 0100", heardFrom: "film", acceptTerms: true, termsVersion: "clinician-terms-2026-10", } satisfies W.AccessRequestBody); expect(request).toMatchObject({ status: 202, data: { status: "received" } }); expect((await api("POST", "/access-requests", { fullName: "x" })).data.code).toBe("VALIDATION_ERROR"); const patients = await api("GET", "/patients"); expect(patients).toMatchObject({ status: 401, data: { code: "SIGN_IN_REQUIRED" } }); }); }); describe("fixture: the film's flow", () => { it("signs in with an email code, needs the second factor, then the terms", async () => { expect((await call("POST", "/api/auth/clinician/email-otp/send-verification-otp", { email: FIXTURE_EMAIL, type: "sign-in" })).status).toBe(200); expect((await call("POST", "/api/auth/clinician/sign-in/email-otp", { email: FIXTURE_EMAIL, otp: "000000" })).status).toBe(400); expect((await call("POST", "/api/auth/clinician/sign-in/email-otp", { email: FIXTURE_EMAIL, otp: FIXTURE_EMAIL_CODE })).status).toBe(200); expect(sessionFromMe((await api("GET", "/me")).data).stepUp).toBe("enrol"); expect(await api("GET", "/patients")).toMatchObject({ status: 403, data: { code: "STEP_UP_REQUIRED" } }); const enrol = await api("POST", "/step-up/totp"); // The QR beside the key is drawn by the backend's own code, never a third-party service. expect(enrol.data.qrSvg).toMatch(/^]*viewBox="0 0 \d+ \d+"/); expect(enrol.data.qrSvg).not.toContain(enrol.data.secret); expect(enrol.status).toBe(201); expect(enrol.data.otpauthUrl).toMatch(/^otpauth:\/\/totp\//); const confirmed = await api("POST", "/step-up/totp/confirm", { code: FIXTURE_TOTP_CODE }); expect(confirmed.data.recoveryCodes).toHaveLength(10); expect(sessionFromMe((await api("GET", "/me")).data)).toMatchObject({ stepUp: "done", termsAccepted: false }); expect(await api("GET", "/patients")).toMatchObject({ status: 403, data: { code: "TERMS_REQUIRED" } }); expect((await api("POST", "/me/terms", { termsVersion: "clinician-terms-2026-10", dpaVersion: "context-dpa-2026-10" })).status).toBe(200); // Lock, then unlock with the authenticator code (ACC-7). expect((await api("POST", "/session/lock")).data).toEqual({ locked: true }); expect(await api("GET", "/patients")).toMatchObject({ status: 403, data: { code: "SESSION_LOCKED" } }); expect((await api("POST", "/step-up/verify", { code: FIXTURE_TOTP_CODE })).data.method).toBe("totp"); expect((await api("GET", "/patients")).data.items.length).toBeGreaterThan(0); }); let patientId = ""; it("lists Mariam's invitation with Funke and links it to the clinic record", async () => { const list = await api("GET", "/invitations"); const inv = list.data.invitations.find((i) => i.patientName === "Mariam Adeyemi")!; expect(inv).toMatchObject({ withPerson: { name: "Funke", relation: "daughter" }, allowsCheckInCalls: true, withheldSome: false, possibleMatch: { patientId: "p-mariam" } }); expect((await api("POST", `/invitations/${inv.id}/accept`, {})).status).toBe(400); const accepted = await api("POST", `/invitations/${inv.id}/accept`, { confirmedMyPatient: true, linkToPatientId: "p-mariam" }); patientId = accepted.data.patientId; expect(accepted.data.shareId).toBe(inv.id); const wire = (await api<{ patient: W.PatientDetail }>("GET", `/patients/${patientId}`)).data.patient; // The patient answer carries the tile like the list does (SHR-1), as the backend now answers. expect(wire).toMatchObject({ kind: "connected", displayName: "Mariam Adeyemi", age: 67, medicines: { count: 3 } }); const tile = (await api("GET", `/patients/${patientId}/shared`)).data; expect(tile).toMatchObject({ medicines: { count: 3 }, doses: { taken: 12, expected: 14 } }); const plan = (await api("GET", `/patients/${patientId}/check-ins/plan`)).data; const detail = detailFromWire(wire, { tile, plan, checkIns: { checkIns: [] } }); expect(detail).toMatchObject({ medicines: { count: 3 }, hasOwnAccount: true, phone: "+234 803 555 0147" }); expect(detail.sharing?.sharedBy).toEqual(["Mariam", "Funke"]); }); it("guards shared data by scope and says when the clinician last looked", async () => { const first = (await api("GET", `/patients/${patientId}/shared/medicines`)).data; expect(first.medicines).toHaveLength(3); expect(first.lastLookedAt).toBeNull(); expect((await api("GET", `/patients/${patientId}/shared/medicines`)).data.lastLookedAt).not.toBeNull(); expect((await api("GET", "/patients/p-chidi/shared/check-ins")).data.code).toBe("SCOPE_NOT_SHARED"); expect((await api("GET", "/patients/p-halima/shared/medicines")).data.code).toBe("SHARING_STOPPED"); expect((await api("POST", `/patients/${patientId}/viewed`, { section: "overview" })).data.lastLookedAt).toBeNull(); expect((await api("POST", `/patients/${patientId}/viewed`, { section: "overview" })).data.lastLookedAt).not.toBeNull(); }); let check: W.CheckRun; it("reads, reviews and checks: 0 for the list alone, 2 personalized", async () => { let ex = (await api("POST", `/patients/${patientId}/extractions`)).data; expect(ex.records.every((r) => r.status === "read")).toBe(true); expect(extractionFromWire(ex, await titles(patientId)).records.map((r) => r.title)).toContain("From MARMAR Health"); const decisions = Object.fromEntries(ex.facts.filter((f) => !f.problem).map((f) => [f.id, "include" as const])); const body: W.DecisionsBody = { revision: ex.revision, decisions, coverageReview: {}, acknowledgedOriginals: true, confirmedHardToRead: [] }; expect((await api<{ revision: number }>("PUT", `/extractions/${ex.id}/decisions`, body)).data.revision).toBe(1); expect(await api("PUT", `/extractions/${ex.id}/decisions`, { ...body, revision: 0 })).toMatchObject({ status: 409, data: { code: "STALE_REVISION" } }); ex = (await api("GET", `/extractions/${ex.id}`)).data; const run = await api("POST", `/patients/${patientId}/checks`, { extractionId: ex.id, revision: ex.revision }); expect(run.status).toBe(201); check = run.data; expect(check.assessment.baseline.findings).toHaveLength(0); expect(check.assessment.contextual.findings.map((f) => f.ruleId).sort()).toEqual(["allergy-penicillin", "lab-metformin-egfr-45"]); expect(check).toMatchObject({ latest: true, summary: { before: 0, after: 2, added: 2 }, versions: { signedOffOnly: false } }); const metformin = check.assessment.contextual.findings.find((f) => f.ruleId === "lab-metformin-egfr-45")!; expect(check.details[metformin.id].clinicianTitle).toBe("Metformin and the latest eGFR result"); expect(Object.values(check.details).map((d) => d.wording)).toEqual(["engine", "engine"]); expect(check.notChecked.checkInAnswers).toBe(true); expect(check.missingFactors.map((m) => m.id)).toContain("weight"); expect((await api("GET", `/patients/${patientId}/checks`)).data.checks[0]).toMatchObject({ id: check.id, findings: 2, added: 2 }); expect(check.assessment.contextual.get_help_now.show).toBe(false); const printable = await fetch(`${base}/api/clinician/checks/${check.id}/export?format=pdf`, { headers: { Cookie: cookie } }); expect(printable.headers.get("x-export-format")).toBe("html"); expect(await printable.text()).toContain(""); }); it("requires a reason for Not relevant", async () => { const f = check.assessment.contextual.findings[0]; expect((await api("POST", `/checks/${check.id}/findings/${f.id}/mark`, { kind: "not_relevant", reason: null, note: null })).data.code).toBe("REASON_REQUIRED"); const marked = await api("POST", `/checks/${check.id}/findings/${f.id}/mark`, { kind: "reviewed", reason: null, note: null }); expect(marked).toMatchObject({ status: 201, data: { kind: "reviewed" } }); }); it("plans a check-in from the findings and rejects an instruction", async () => { const wire = (await api("GET", `/patients/${patientId}/check-ins/plan`)).data; expect(wire.window.clinicHours?.every((h) => h.day >= 1 && h.day <= 7)).toBe(true); const plan = planFromWire(wire); expect(plan.eligibility).toEqual({ ok: true, code: null, message: null }); expect(plan.opening[0]).toContain("I'm Yasmar, MARMAR's computer assistant. I'm not a person."); expect(plan.suggestions.find((s) => s.bankId === "allergy-history")?.reason).toBe("From the finding: Amoxicillin and a recorded Penicillin allergy"); const rejected = await api("POST", "/check-ins/questions/validate", { patientId, text: "Stop the amoxicillin today" }); expect(rejected.data).toMatchObject({ ok: false, reason: "Yasmar can ask questions but cannot give instructions. Call Mariam to tell them this." }); }); it("streams the film's six notes and a summary, then adds notes to the check", async () => { const plan = planFromWire((await api("GET", `/patients/${patientId}/check-ins/plan`)).data); const questions = plan.suggestions.filter((s) => s.suggested).map((s) => ({ id: s.id, text: s.text, origin: "bank", bankId: s.bankId, reason: s.reason, findingId: s.source.kind === "finding" ? s.source.findingId : null })); const created = await api("POST", `/patients/${patientId}/check-ins`, { questions, closingIds: ["sent", "read_today", "see_in_clinic"], window: { kind: "now" }, expiresInHours: 24, linkedFindingIds: [], checkId: check.id, urgentCallConfirmedAt: null, readToday: true, }); expect(created.status).toBe(201); expect(created.data.status).toBe("sent"); expect(created.data.plan).not.toHaveProperty("linkedTiers"); let after = 0; let snapshot = created.data; for (let i = 0; i < 40 && !snapshot.summary; i++) { await wait(150); const updates = (await api("GET", `/check-ins/${snapshot.id}/updates?after=${after}`)).data; after = updates.lastEventId; snapshot = (await api("GET", `/check-ins/${snapshot.id}`)).data; } const view = checkInFromWire(snapshot); expect(view.status).toBe("completed"); expect(view.notes.map((n) => n.text)).toEqual([ "Tired, with loose stools 3 to 4 times a day for about 1 week", "No vomiting reported", "Skips evening Metformin when unwell", "Amoxicillin not bought or started", "Past penicillin: itchy rash all over", "Takes bitter leaf water and agbo", ]); expect(view.notes.every((n) => n.validation === "matched")).toBe(true); expect(view.summary!.sentences.every((s) => s.noteIds.length > 0)).toBe(true); expect(view.notes.every((n) => n.transcriptLineIds.every((id) => view.transcript.some((l) => l.id === id)))).toBe(true); const agbo = view.notes.find((n) => n.kind === "other_medicines")!; const notStarted = view.notes.find((n) => n.kind === "not_started")!; // As the backend's batch route: the chosen notes make one record from the call. const added = (await api("POST", `/check-ins/${snapshot.id}/add-to-check`, { noteIds: [agbo.id, notStarted.id] })).data; expect(added.recordId).toBeTruthy(); expect(added.notes.map((n) => n.id)).toEqual([agbo.id, notStarted.id]); expect(added.notes.every((n) => n.addedToCheckAt)).toBe(true); const records = (await api("GET", `/patients/${patientId}/records`)).data.records; expect(records.filter((r) => r.origin === "check_in")).toHaveLength(1); const detail = (await api<{ patient: W.PatientDetail }>("GET", `/patients/${patientId}`)).data.patient; expect(detail.attention.newSinceLastCheck).toBe(true); const latest = (await api<{ extraction: W.Extraction }>("GET", `/patients/${patientId}/extractions/latest`)).data.extraction; expect(latest.stale).toBe(true); const again = (await api("POST", `/patients/${patientId}/extractions`)).data; expect(again.coverage?.filter((c) => !c.matchedFactIds.length).map((c) => c.text)).toEqual(["bitter leaf water", "agbo"]); // A second check-in the same day is over the limit (YCI-6). const limited = await api("POST", `/patients/${patientId}/check-ins`, { questions: [], closingIds: [], window: { kind: "now" }, expiresInHours: 24, linkedFindingIds: [], checkId: null, urgentCallConfirmedAt: null, readToday: false }); expect(limited).toMatchObject({ status: 429, data: { code: "LIMIT_REACHED", eligibility: "limit_reached" } }); }); it("holds Ask Yasmar for an Urgent review finding until the clinician will call", async () => { let ex = (await api("POST", "/patients/p-adaeze/extractions")).data; const decisions = Object.fromEntries(ex.facts.filter((f) => !f.problem).map((f) => [f.id, "include"])); await api("PUT", `/extractions/${ex.id}/decisions`, { revision: ex.revision, decisions, coverageReview: {}, acknowledgedOriginals: true, confirmedHardToRead: [] }); ex = (await api("GET", `/extractions/${ex.id}`)).data; const run = (await api("POST", "/patients/p-adaeze/checks", { extractionId: ex.id, revision: ex.revision })).data; expect(run.assessment.contextual.findings.some((f) => f.tier === "get_help_now")).toBe(true); expect(run.urgentReview.show).toBe(true); const body = { questions: [], closingIds: ["sent"], window: { kind: "now" }, expiresInHours: 24, linkedFindingIds: [], checkId: run.id, urgentCallConfirmedAt: null, readToday: false }; const blocked = await api("POST", "/patients/p-adaeze/check-ins", body); expect(blocked).toMatchObject({ status: 409, data: { code: "URGENT_CALL_FIRST" } }); expect(blocked.data.message).toContain("Call Adaeze first"); const ok = await api("POST", "/patients/p-adaeze/check-ins", { ...body, urgentCallConfirmedAt: new Date().toISOString() }); expect(ok.status).toBe(201); let snapshot = ok.data; for (let i = 0; i < 40 && !snapshot.summary; i++) { await wait(150); snapshot = (await api("GET", `/check-ins/${snapshot.id}`)).data; } expect(snapshot.urgent).toBe(true); const events = (await api("GET", `/check-ins/${snapshot.id}/updates?after=0`)).data.events; expect(events.some((e) => e.type === "alert" && e.kind === "urgent_sent")).toBe(true); const alerts = (await api("GET", "/alerts")).data.alerts; const urgentNote = alerts.find((a) => a.patientId === "p-adaeze")!; expect(urgentNote.text).toContain("Chest tightness"); await api("POST", `/check-ins/${snapshot.id}/notes/${urgentNote.noteId}/acknowledge`); expect((await api("GET", "/alerts")).data.alerts.some((a) => a.patientId === "p-adaeze")).toBe(false); }); }); describe("fixture: Settings for when Yasmar may ring (YCI-5) and the backup contact (YCI-29)", () => { it("saves the time zone, the opening and alert-watch hours, and refuses a span that ends before it starts", async () => { const clinic = (await api<{ clinic: W.Clinic }>("GET", "/clinic")).data.clinic; const hours = [{ day: 1, open: "08:00", close: "17:00" }, { day: 6, open: "09:00", close: "13:00" }]; const saved = await api<{ clinic: W.Clinic }>("PATCH", "/clinic", { expectedVersion: clinic.version, timezone: "Africa/Kigali", openingHours: hours, alertWatchHours: hours.slice(0, 1) }); expect(saved.status).toBe(200); expect(saved.data.clinic).toMatchObject({ timezone: "Africa/Kigali", openingHours: hours, alertWatchHours: hours.slice(0, 1), version: clinic.version + 1 }); const backwards = await api("PATCH", "/clinic", { openingHours: [{ day: 1, open: "17:00", close: "08:00" }] }); expect(backwards).toMatchObject({ status: 400, data: { code: "VALIDATION_ERROR" } }); expect((await api("PATCH", "/clinic", { timezone: "Mars/Olympus" })).status).toBe(400); expect((await api("PATCH", "/clinic", { expectedVersion: clinic.version, name: "Old tab" })).data.code).toBe("CLINIC_CHANGED"); }); it("stores the backup contact's name, email and phone, the phone in E.164", async () => { const saved = await api<{ clinic: W.Clinic }>("PATCH", "/clinic", { backupAlertName: "Nurse Bisi Ade", backupAlertPhone: "+234 (803) 555-0199", backupAlertEmail: "Bisi@Clinic.Example.Test" }); expect(saved.data.clinic).toMatchObject({ backupAlertName: "Nurse Bisi Ade", backupAlertPhone: "+2348035550199", backupAlertEmail: "bisi@clinic.example.test" }); const national = await api("PATCH", "/clinic", { backupAlertPhone: "0803 555 0199" }); expect(national).toMatchObject({ status: 400, data: { details: [{ path: "backupAlertPhone" }] } }); }); it("saves the clinician's own mobile in E.164 through PATCH /me, and clears it", async () => { expect((await api<{ clinician: W.Clinician }>("PATCH", "/me", { mobilePhone: "00234 803 555 0101" })).data.clinician.mobilePhone).toBe("+2348035550101"); expect((await api("PATCH", "/me", { mobilePhone: "08035550101" })).status).toBe(400); expect((await api<{ clinician: W.Clinician }>("PATCH", "/me", { mobilePhone: null })).data.clinician.mobilePhone).toBeNull(); }); });