// The adapters, on JSON shaped like the backend's serializers (src/clinician/** in marmar-cds: // profile.ts, workspace/patients.ts, sharing/shared-data.ts, workspace/review.ts, check-ins/*). import { afterEach, describe, expect, it, vi } from "vitest"; import * as A from "../src/api/adapt"; import { checkInFromWire, eventFromWire, planFromWire } from "../src/api/adapt-checkins"; import { clinicianApi } from "../src/api/client"; import type * as W from "../src/api/wire"; import { applyEvent } from "../src/logic/checkin-events"; const clinician: W.Clinician = { id: "c1", email: "emeka.okafor@example.com", fullName: "Dr Emeka Okafor", title: "Dr", displayName: "Doctor Okafor", pronoun: "he", profession: "doctor", licenceBody: "MDCN", licenceNumber: "MDCN/R/104233", countryCode: "NG", mobilePhone: null, status: "verified", verifiedAt: "2026-10-30T08:00:00.000Z", verificationExpiresAt: "2027-10-30T08:00:00.000Z", idleLockMinutes: 30, termsVersion: null, termsAcceptedAt: null, dpaVersion: null, dpaAcceptedAt: null, }; const me = (session: Partial, totp = true): W.Me => ({ clinician: { ...clinician, lapsed: false }, session: { stepUpDone: false, locked: false, idleLockMinutes: 30, startedAt: "2026-10-30T08:00:00.000Z", endsAt: "2026-10-30T20:00:00.000Z", ...session }, secondFactor: { totp }, features: { realPatients: true, invites: true, checkIns: true }, }); describe("session and profile (GET /me)", () => { it("reads the step-up as enrol, required or done, and the terms", () => { expect(A.sessionFromMe(me({}, false))).toMatchObject({ stepUp: "enrol", methods: [], verification: "verified", termsAccepted: false, idleLockMinutes: 30, expiresAt: "2026-10-30T20:00:00.000Z" }); expect(A.sessionFromMe(me({ stepUpDone: true })).stepUp).toBe("done"); expect(A.sessionFromMe(me({ stepUpDone: true, locked: true })).stepUp).toBe("required"); expect(A.sessionFromMe({ ...me({}), clinician: { ...clinician, lapsed: true } }).verification).toBe("lapsed"); expect(A.sessionFromMe({ ...me({}), clinician: { ...clinician, status: "suspended", lapsed: false } }).verification).toBe("suspended"); }); it("splits the register name and keeps the spoken name and a pronoun not yet chosen", () => { const p = A.profileFromWire({ ...clinician, pronoun: null }, { browser: true }); expect(p).toMatchObject({ title: "Dr", firstName: "Emeka", lastName: "Okafor", fullName: "Dr Emeka Okafor", spokenName: "Doctor Okafor", pronoun: null, licensingBody: "MDCN", notifications: { browser: true, email: true } }); expect(A.splitName("Pharm Tunde Bakare", "Pharm")).toEqual({ firstName: "Tunde", lastName: "Bakare" }); }); it("sends only what PATCH /me takes; notifications stay in the browser", () => { expect(A.meChange({ spokenName: " Doctor Okafor ", pronoun: "he", idleLockMinutes: 15, notifications: { browser: true, email: true } })).toEqual({ displayName: "Doctor Okafor", pronoun: "he", idleLockMinutes: 15 }); expect(A.meChange({ notifications: { browser: false, email: true } })).toBeNull(); }); }); describe("the clinic (GET and PATCH /clinic)", () => { const clinic: W.Clinic = { id: "k1", name: "Surulere Family Clinic", addressLine: "12 Example Close", city: "Lagos", region: null, countryCode: "NG", timezone: "Africa/Lagos", phone: "+234 1 555 0142", openingHours: [{ day: 1, open: "08:00", close: "18:00" }, { day: 7, open: "10:00", close: "12:00" }], alertWatchHours: null, backupAlertName: null, backupAlertPhone: null, backupAlertEmail: null, version: 3, updatedAt: "2026-10-30T08:00:00.000Z", role: "owner", emergencyNumbers: [{ number: "112", label: "Nigeria", source: "registry", testDialledAt: null, registryRevision: 4 }], }; it("turns days 1 to 7 (Monday first) into 0 to 6 (Sunday first), and back", () => { const view = A.clinicFromWire(clinic); expect(view.openingHours).toEqual([{ day: 1, open: "08:00", close: "18:00" }, { day: 0, open: "10:00", close: "12:00" }]); expect(view).toMatchObject({ address: "12 Example Close", alertWatchHours: [], backupContact: null, role: "owner", version: 3 }); expect(A.wireHours(view.openingHours)).toEqual(clinic.openingHours); }); it("never sends the registry's numbers back as the clinic's own, and sends the version read", () => { const view = A.clinicFromWire(clinic); const change = A.clinicChange({ name: view.name, phone: view.phone, emergencyNumbers: view.emergencyNumbers, backupContact: { name: "Bisi", phone: "+234 803 555 0199", email: "" } }, view); expect(change).toEqual({ expectedVersion: 3, name: "Surulere Family Clinic", phone: "+234 1 555 0142", backupAlertName: "Bisi", backupAlertPhone: "+234 803 555 0199", backupAlertEmail: null }); }); it("carries the backup contact's email both ways, and the time zone and hours only when given", () => { const view = A.clinicFromWire({ ...clinic, backupAlertEmail: "bisi@clinic.example.test" }); expect(view.backupContact).toEqual({ name: "", phone: "", email: "bisi@clinic.example.test" }); expect(A.clinicChange({ backupContact: { name: "Bisi", phone: "", email: " Bisi@Clinic.Example.Test " } }, view)) .toEqual({ expectedVersion: 3, backupAlertName: "Bisi", backupAlertPhone: null, backupAlertEmail: "bisi@clinic.example.test" }); expect(A.clinicChange({ backupContact: null }, view)).toEqual({ expectedVersion: 3, backupAlertName: null, backupAlertPhone: null, backupAlertEmail: null }); expect(A.clinicChange({ timeZone: "Africa/Kigali", alertWatchHours: [{ day: 0, open: "09:00", close: "13:00" }] }, view)) .toEqual({ expectedVersion: 3, timezone: "Africa/Kigali", alertWatchHours: [{ day: 7, open: "09:00", close: "13:00" }] }); }); }); describe("access requests and invitations", () => { const input = { fullName: "Dr Ada Obi", profession: "doctor" as const, licensingBody: "MDCN" as const, licenceNumber: "MDCN/R/123456", country: "NG", clinicName: "Yaba Clinic", clinicPhone: "+234 1 555 0100", workEmail: "ada@example.com", mobile: "+234 803 555 0100", heardFrom: "patient_invite" as const, heardFromOther: null, acceptedTermsVersion: "clinician-terms-2026-10", inviteToken: "c_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", }; it("names the fields as POST /access-requests does (strict)", () => { expect(A.accessBody(input)).toEqual({ 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.com", mobilePhone: "+234 803 555 0100", heardFrom: "patient_invite", acceptTerms: true, termsVersion: "clinician-terms-2026-10", inviteCode: input.inviteToken, }); expect(A.accessBody({ ...input, country: "OTHER" }).countryCode).toBe("ZZ"); expect(A.receiptFromWire({ status: "received", message: "Thank you." }, input)).toEqual({ status: "received", message: "Thank you.", priority: true, countryOpen: true }); }); it("shows they/them for an invitation, which carries no pronoun", () => { const w: W.Invitation = { id: "i1", patientName: "Mariam Adeyemi", patientAge: 67, withPerson: { name: "Funke", relation: "daughter" }, scopes: ["medicines"], allowsCheckInCalls: true, withheldSome: false, sentAt: "2026-10-30T08:00:00.000Z", expiresAt: "2026-11-13T08:00:00.000Z", possibleMatch: null }; expect(A.invitationFromWire(w).patientPronoun).toBeNull(); }); }); describe("patients (GET /patients, GET /patients/:id and the shared tile)", () => { const item: W.PatientItem = { id: "p1", kind: "connected", clinicId: "k1", displayName: "Mariam Adeyemi", dateOfBirth: "1959-03-04T00:00:00.000Z", birthYear: 1959, ageYears: null, ageRecordedOn: null, sex: "female", shareId: "s1", lastActivityAt: "2026-10-30T08:00:00.000Z", createdAt: "2026-10-30T08:00:00.000Z", name: "Mariam Adeyemi", age: 67, attention: { urgent: false, unreadNotes: 0, newSinceLastCheck: true }, lastCheckId: null, lastCheckAt: null, newInformation: { since: "2026-10-30T08:00:00.000Z", items: ["3 records not checked yet"] }, sharing: { status: "connected", scopes: ["medicines", "doses", "check_ins", "records"], allowsCheckInCalls: true, changedAt: "2026-10-30T08:00:00.000Z" }, medicines: null, doses: null, lastCheckIn: null, }; const detail: W.PatientDetail = { ...item, firstName: "Mariam", dateOfBirthIso: "1959-03-04", identityOnFile: "date_of_birth", timeZone: "Africa/Lagos", hasOwnAccount: false, phone: null, pronoun: null, preferredTitle: null, activeCheckInId: null }; const tile: W.SharedSummary = { asOf: "2026-10-30T09:00:00.000Z", sharing: { status: "connected", sharedBy: ["Mariam", "Funke"], scopes: ["medicines", "doses"], allowsCheckInCalls: true, withheldSome: true, changedAt: "2026-10-30T08:00:00.000Z" }, medicines: { count: 3 }, doses: { taken: 12, expected: 14, onTime: 11 }, lastCheckIn: null, phone: "+234 803 555 0147" }; const plan = { eligibility: { ok: true, code: null, message: null }, limits: { usedToday: 0, usedThisWeek: 1, maxPerDay: 1, maxPerWeek: 3, nextPossibleAt: null }, window: { patientTimeZone: "Africa/Lagos", clinicTimeZone: "Africa/Lagos", quietHours: null, blockedTimes: [{ day: 7, start: "10:00", end: "12:00" }], defaultExpiryHours: 24, maxExpiryHours: 72 }, } as unknown as W.PlanTemplate; it("reads the list's sharing without names, and the item's counts as not shared on the list", () => { const s = A.summaryFromWire(item); expect(s).toMatchObject({ name: "Mariam Adeyemi", age: 67, pronoun: null, medicines: null, sharing: { sharedBy: [], withheldSome: false, allowsCheckInCalls: true } }); }); it("fills a connected patient's page from the tile, the plan and this clinician's check-ins", () => { const d = A.detailFromWire(detail, { tile, plan, checkIns: { checkIns: [{ id: "ci1", status: "later", statusAt: "", requestedAt: "", urgent: false, noteCount: 0, unreadNotes: 0, summaryReady: false }] } }); expect(d).toMatchObject({ dateOfBirth: "1959-03-04", sex: "female", medicines: { count: 3 }, doses: { taken: 12, expected: 14, onTime: 11 }, phone: "+234 803 555 0147", hasOwnAccount: true, quietHours: { start: "00:00", end: "00:00" }, blockedTimes: [{ day: 0, start: "10:00", end: "12:00", label: "" }], checkInLimits: { usedThisWeek: 1 }, activeCheckInId: "ci1", sharing: { sharedBy: ["Mariam", "Funke"], withheldSome: true }, }); expect(A.detailFromWire(detail, { tile, plan: { ...plan, eligibility: { ok: false, code: "no_own_account", message: "x" } } }).hasOwnAccount).toBe(false); expect(A.detailFromWire({ ...detail, kind: "clinic_added", sharing: null }).hasOwnAccount).toBe(false); }); it("adds a clinic patient with displayName and the PAT-4 attestation, a date of birth or an age", () => { expect(A.newPatientBody({ name: " Tunde Bakare ", dateOfBirth: null, age: 54, sex: null, attestation: { careRelationship: true, patientAgreed: true } })).toEqual({ displayName: "Tunde Bakare", ageYears: 54, attestation: { careRelationship: true, patientAgreement: true, version: "2026-10-30" }, }); }); }); describe("reading and review", () => { const wire = { id: "e1", patientId: "p1", revision: 2, createdAt: "2026-10-30T08:00:00.000Z", facts: [], unparsed: [], fingerprint: "f".repeat(64), mode: "local", model: null, durationMs: 1, records: [{ recordId: "r1", status: "held_back", error: null, inReading: true }], decisions: { a: "include" }, coverageReview: { m1: { fingerprint: "f".repeat(64), choice: "not_current", reason: "", confirmedBy: "Dr Emeka Okafor" } }, acknowledgedOriginals: true, confirmedHardToRead: ["a"], stale: false, recordsNotReady: [{ recordId: "r1", title: "Ikeja discharge", status: "held_back" }], } as unknown as W.Extraction; it("titles each record from the records list, the profile included", () => { const ex = A.extractionFromWire(wire, A.titlesOf("p1", [{ id: "r1", title: "Ikeja discharge" }])); expect(ex.records).toEqual([{ recordId: "r1", title: "Ikeja discharge", status: "held_back", error: null }]); expect(ex.titles["profile-p1"]).toBe("Patient profile"); expect(ex.coverageReview.m1.choice).toBe("not_current"); expect(ex.recordsNotReady).toHaveLength(1); }); it("sends the choice, drops empty reasons and confirms only included Hard to read facts", () => { const body = A.decisionsBody({ revision: 2, decisions: { a: "include", b: "exclude" }, acknowledgedOriginals: true, confirmedHardToRead: ["a", "b"], coverageReview: { m1: { fingerprint: "f", choice: "not_current", reason: " ", confirmedBy: "Dr X" }, m2: { fingerprint: "f", reason: "Old prescription, stopped in 2024", confirmedBy: "Dr X" } }, }); expect(body).toEqual({ revision: 2, decisions: { a: "include", b: "exclude" }, acknowledgedOriginals: true, confirmedHardToRead: ["a"], coverageReview: { m1: { choice: "not_current", fingerprint: "f" }, m2: { reason: "Old prescription, stopped in 2024", fingerprint: "f" } } }); }); }); describe("check-ins", () => { it("reads the plan's window in Context's days and fills a clinic-added patient's", () => { const wire = { eligibility: { ok: false, code: "clinic_added", message: "Invite Tunde to MARMAR Health to use check-ins." }, limits: null, window: { patientTimeZone: null, clinicTimeZone: "Africa/Lagos", quietHours: { start: "22:00", end: "07:00" }, defaultExpiryHours: 24, maxExpiryHours: 72 } } as unknown as W.PlanTemplate; const plan = planFromWire(wire); expect(plan.window).toMatchObject({ patientTimeZone: "Africa/Lagos", clinicHours: [], known: false, dayStart: "08:00", dayEnd: "20:00" }); expect(plan.limits.maxPerWeek).toBe(3); }); it("reads a check-in without linked tiers or the script, and folds the alert event", () => { const wire: W.CheckIn = { id: "ci1", patientId: "p1", patientName: "Mariam Adeyemi", requestedBy: "Dr Emeka Okafor", requestedAt: "2026-10-30T10:00:00.000Z", plan: { questions: [], closingIds: ["sent"], window: { kind: "now" }, expiresAt: "2026-10-31T10:00:00.000Z", linkedFindingIds: [], readToday: false }, script: { opening: "Good afternoon.", identity: [], consent: "", feeling: "", alwaysLast: "", closing: [], protocolVersion: "yci-protocol-2026-10-30-draft" }, status: "sent", statusAt: "2026-10-30T10:00:00.000Z", history: [], window: { start: "2026-10-30T10:00:00.000Z", end: "2026-10-31T10:00:00.000Z" }, later: null, urgent: false, urgentAcknowledgedAt: null, summary: null, transcript: [], lastEventId: 4, notes: [{ id: "n1", kind: "symptom", text: "Tired", quote: "I'm tired", questionId: null, transcriptLineIds: [], at: "2026-10-30T10:02:00.000Z", urgent: false, acknowledgedAt: null, acknowledgedBy: null, disputed: false, addedToCheckAt: null, validation: "pending", endedEarly: false }], }; const view = checkInFromWire(wire); expect(view.plan.linkedTiers).toEqual([]); expect(view).not.toHaveProperty("script"); expect(view.notes[0].validation).toBe("pending"); const next = applyEvent(view, eventFromWire({ id: 5, type: "alert", at: "2026-10-30T10:20:00.000Z", kind: "escalated", message: "Not acknowledged for 15 minutes" })); expect(next.alerts).toEqual([{ id: 5, at: "2026-10-30T10:20:00.000Z", kind: "escalated", message: "Not acknowledged for 15 minutes" }]); expect(applyEvent(next, { id: 5, type: "alert", at: "", kind: null, message: "again" }).alerts).toHaveLength(1); }); }); describe("the client calls the backend's routes", () => { afterEach(() => vi.unstubAllGlobals()); const reply = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); it("adds a patient with POST /patients, then reads its detail; searches by POST; locks and unlocks", async () => { const calls: string[] = []; vi.stubGlobal("fetch", async (url: string, init: RequestInit) => { calls.push(`${init.method} ${url}${init.body ? ` ${init.body}` : ""}`); if (url.endsWith("/patients") && init.method === "POST") return reply(201, { patient: { id: "p9" } }); if (url.endsWith("/patients/p9")) return reply(200, { patient: { id: "p9", kind: "clinic_added", name: "Tunde Bakare", sharing: null, attention: { urgent: false, unreadNotes: 0, newSinceLastCheck: false } } }); if (url.endsWith("/patients/search")) return reply(200, { items: [], asOf: "2026-10-30T10:00:00.000Z" }); if (url.endsWith("/me")) return reply(200, me({ stepUpDone: true })); return reply(200, { locked: true, stepUpDone: true, method: "totp" }); }); await clinicianApi.createPatient({ name: "Tunde Bakare", dateOfBirth: "1972-01-02", age: null, sex: null, attestation: { careRelationship: true, patientAgreed: true } }); await clinicianApi.searchPatients("Tunde"); expect((await clinicianApi.lock()).stepUp).toBe("done"); await clinicianApi.verifyTotp("123456"); expect(calls).toEqual([ 'POST /api/clinician/patients {"displayName":"Tunde Bakare","dateOfBirth":"1972-01-02","attestation":{"careRelationship":true,"patientAgreement":true,"version":"2026-10-30"}}', "GET /api/clinician/patients/p9", 'POST /api/clinician/patients/search {"query":"Tunde"}', "POST /api/clinician/session/lock {}", "GET /api/clinician/me", 'POST /api/clinician/step-up/verify {"code":"123456"}', "GET /api/clinician/me", ]); }); it("reads a switched-off area as empty and carries the error's extra fields", async () => { vi.stubGlobal("fetch", async (url: string) => url.endsWith("/invitations") ? reply(404, { code: "CLINICIAN_INVITES_NOT_ENABLED", message: "Inviting a clinician is not available yet" }) : reply(409, { code: "URGENT_CALL_FIRST", message: "Call Mariam first.", phone: "+234 803 555 0147" }), ); expect(await clinicianApi.invitations()).toEqual({ invitations: [] }); await expect(clinicianApi.createCheckIn("p1", {} as never)).rejects.toMatchObject({ status: 409, code: "URGENT_CALL_FIRST", details: { phone: "+234 803 555 0147" } }); }); });