// 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<void>((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<R>(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 = <R>(method: string, path: string, body?: unknown) => call<R>(method, `/api/clinician${path}`, body);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const titles = async (patientId: string) => titlesOf(patientId, (await api<W.Records>("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<W.InvitePreview>("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<W.ErrorBody>("GET", "/invite-preview/nope");
expect(bad).toMatchObject({ status: 404, data: { code: "INVITE_NOT_FOUND" } });
const request = await api<W.AccessRequestReceived>("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<W.ErrorBody>("POST", "/access-requests", { fullName: "x" })).data.code).toBe("VALIDATION_ERROR");
const patients = await api<W.ErrorBody>("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<W.Me>("GET", "/me")).data).stepUp).toBe("enrol");
expect(await api<W.ErrorBody>("GET", "/patients")).toMatchObject({ status: 403, data: { code: "STEP_UP_REQUIRED" } });
const enrol = await api<W.TotpStart>("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(/^<svg [^>]*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<W.TotpConfirmed>("POST", "/step-up/totp/confirm", { code: FIXTURE_TOTP_CODE });
expect(confirmed.data.recoveryCodes).toHaveLength(10);
expect(sessionFromMe((await api<W.Me>("GET", "/me")).data)).toMatchObject({ stepUp: "done", termsAccepted: false });
expect(await api<W.ErrorBody>("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<W.ErrorBody>("GET", "/patients")).toMatchObject({ status: 403, data: { code: "SESSION_LOCKED" } });
expect((await api<W.StepUpVerified>("POST", "/step-up/verify", { code: FIXTURE_TOTP_CODE })).data.method).toBe("totp");
expect((await api<W.PatientList>("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<W.InvitationList>("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<W.Accepted>("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<W.SharedSummary>("GET", `/patients/${patientId}/shared`)).data;
expect(tile).toMatchObject({ medicines: { count: 3 }, doses: { taken: 12, expected: 14 } });
const plan = (await api<W.PlanTemplate>("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<W.SharedMedicines>("GET", `/patients/${patientId}/shared/medicines`)).data;
expect(first.medicines).toHaveLength(3);
expect(first.lastLookedAt).toBeNull();
expect((await api<W.SharedMedicines>("GET", `/patients/${patientId}/shared/medicines`)).data.lastLookedAt).not.toBeNull();
expect((await api<W.ErrorBody>("GET", "/patients/p-chidi/shared/check-ins")).data.code).toBe("SCOPE_NOT_SHARED");
expect((await api<W.ErrorBody>("GET", "/patients/p-halima/shared/medicines")).data.code).toBe("SHARING_STOPPED");
expect((await api<W.Viewed>("POST", `/patients/${patientId}/viewed`, { section: "overview" })).data.lastLookedAt).toBeNull();
expect((await api<W.Viewed>("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<W.Extraction>("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<W.ErrorBody>("PUT", `/extractions/${ex.id}/decisions`, { ...body, revision: 0 })).toMatchObject({ status: 409, data: { code: "STALE_REVISION" } });
ex = (await api<W.Extraction>("GET", `/extractions/${ex.id}`)).data;
const run = await api<W.CheckRun>("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<W.CheckRunList>("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("<!doctype html>");
});
it("requires a reason for Not relevant", async () => {
const f = check.assessment.contextual.findings[0];
expect((await api<W.ErrorBody>("POST", `/checks/${check.id}/findings/${f.id}/mark`, { kind: "not_relevant", reason: null, note: null })).data.code).toBe("REASON_REQUIRED");
const marked = await api<W.FindingMark>("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<W.PlanTemplate>("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<W.QuestionCheck>("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<W.PlanTemplate>("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<W.CheckIn>("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<W.CheckInUpdates>("GET", `/check-ins/${snapshot.id}/updates?after=${after}`)).data;
after = updates.lastEventId;
snapshot = (await api<W.CheckIn>("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<W.AddedNotesToCheck>("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<W.Records>("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<W.Extraction>("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<W.ErrorBody>("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<W.Extraction>("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<W.Extraction>("GET", `/extractions/${ex.id}`)).data;
const run = (await api<W.CheckRun>("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<W.ErrorBody>("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<W.CheckIn>("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<W.CheckIn>("GET", `/check-ins/${snapshot.id}`)).data;
}
expect(snapshot.urgent).toBe(true);
const events = (await api<W.CheckInUpdates>("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<W.AlertList>("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<W.AlertList>("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<W.ErrorBody>("PATCH", "/clinic", { openingHours: [{ day: 1, open: "17:00", close: "08:00" }] });
expect(backwards).toMatchObject({ status: 400, data: { code: "VALIDATION_ERROR" } });
expect((await api<W.ErrorBody>("PATCH", "/clinic", { timezone: "Mars/Olympus" })).status).toBe(400);
expect((await api<W.ErrorBody>("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<W.ErrorBody>("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<W.ErrorBody>("PATCH", "/me", { mobilePhone: "08035550101" })).status).toBe(400);
expect((await api<{ clinician: W.Clinician }>("PATCH", "/me", { mobilePhone: null })).data.clinician.mobilePhone).toBeNull();
});
});