test(medications): prove the reported export lands, row for row

The fixture is the export a self-hoster attached, de-identified as it
arrived: 3,395 rows, sixteen medications, three and a half years, two UTC
offsets, three statuses, two units, blank cells in four columns. A
hand-built fixture would have carried one status and one offset and proved
nothing about the file that prompted the work.

The integration pass runs it through the real derivation into a real
Postgres, because the claim is about rows: 3,387 land, 8 are refused because
the file records no decision about them, and imported plus refused equals
every row read. 2,799 of the doses were taken after they were due, which is
the fact that survives only because the scheduled slot and the take are two
columns.

Structural cover so the verdicts cannot rot: every column of both shapes has
exactly one ruling, and every parse-time refusal is spelled the same as a job
skip reason — a reason that exists in only one vocabulary renders as "reason
not recorded", which is a refusal nobody can act on.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 16:37:10 +02:00
1 parent 11e6402b5a
commit 34a220421c
20 files changed
+5322 -25

No files matched your search

@@ -212,6 +212,10 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/medications/compliance/route.ts",
"src/app/api/medications/intake/route.ts",
"src/app/api/medications/intake/bulk/route.ts",
// The account-wide dose-history import is the same class as the
// per-medication importer below: it writes intake rows and reads its own job.
"src/app/api/medications/intake/dose-history-import/route.ts",
"src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts",
// NB: `medications/extract` is NOT exempt — it gates on
// `requireAssistantSurface("coach")` (the NL-extraction is an assistant
// surface), so the inventory already counts it as a delegated gate.
@@ -239,14 +239,20 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
// A legacy `zaehler` on the first row rides along and decides nothing:
// both keys come from the instant, so two different times can never
// collapse onto one key again.
// This body carries one time per row and nothing to say which slot it
// belonged to, so `scheduledFor` mirrors the take: every dose it
// describes is ad-hoc, anchored on itself. The export-reading route is
// the one with two facts to write.
payload: {
entries: [
{
idempotencyKey: `import-m1-${Date.parse("2026-01-01T07:00:00")}`,
scheduledFor: expect.any(String),
takenAt: expect.any(String),
},
{
idempotencyKey: `import-m1-${Date.parse("2026-01-01T19:00:00")}`,
scheduledFor: expect.any(String),
takenAt: expect.any(String),
},
],
@@ -55,18 +55,64 @@ function useSkipReasonLabel(): (reason: string) => string {
const labels: Record<string, string> = {
duplicate_in_file: t("medications.import.reasonDuplicateInFile"),
already_recorded: t("medications.import.reasonAlreadyRecorded"),
medication_not_found: t("medications.import.reasonMedicationNotFound"),
medication_ambiguous: t("medications.import.reasonMedicationAmbiguous"),
medication_is_mirrored: t("medications.import.reasonMedicationIsMirrored"),
status_not_recorded: t("medications.import.reasonStatusNotRecorded"),
status_unknown: t("medications.import.reasonStatusUnknown"),
missing_timestamp: t("medications.import.reasonMissingTimestamp"),
missing_timezone_offset: t("medications.import.reasonMissingOffset"),
unreadable_timestamp: t("medications.import.reasonUnreadableTimestamp"),
implausible_timestamp: t("medications.import.reasonImplausibleTimestamp"),
missing_medication: t("medications.import.reasonMissingMedication"),
unreadable_dosage: t("medications.import.reasonUnreadableDosage"),
unreadable_row: t("medications.import.reasonUnreadableRow"),
};
const fallback = t("medications.import.reasonUnknown");
return (reason: string) => labels[reason] ?? fallback;
}
/**
* The grouped skip lines on their own, without the outcome sentence above them.
*
* Split out because a dry-run preview needs exactly these lines and must NOT
* borrow the outcome sentence: a preview has written nothing, so classifying it
* by the counts it WOULD produce would put a success tick on a run that never
* happened. The reason vocabulary is shared; the verdict is not.
*/
export function IntakeImportSkipGroups({
groups,
testId = "intake-import-skip-groups",
}: {
groups: readonly IntakeImportSkipGroup[];
testId?: string;
}) {
const { tCount } = useTranslations();
const labelFor = useSkipReasonLabel();
if (groups.length === 0) return null;
return (
<ul
data-testid={testId}
className="text-muted-foreground space-y-0.5 text-xs"
>
{groups.map((group) => (
<li key={group.reason}>
{tCount("medications.import.skipGroup", group.count, {
count: group.count,
reason: labelFor(group.reason),
})}
</li>
))}
</ul>
);
}
export function IntakeImportResultView({
result,
}: {
result: IntakeImportResultState;
}) {
const { t, tCount } = useTranslations();
const labelFor = useSkipReasonLabel();
const { t } = useTranslations();
if (result.kind === "notice") {
return (
@@ -106,21 +152,7 @@ export function IntakeImportResultView({
message={message}
testId="intake-import-outcome"
/>
{result.skipReasons.length > 0 && (
<ul
data-testid="intake-import-skip-groups"
className="text-muted-foreground space-y-0.5 text-xs"
>
{result.skipReasons.map((group) => (
<li key={group.reason}>
{tCount("medications.import.skipGroup", group.count, {
count: group.count,
reason: labelFor(group.reason),
})}
</li>
))}
</ul>
)}
<IntakeImportSkipGroups groups={result.skipReasons} />
</div>
);
}
@@ -48,6 +48,10 @@ const IMPORT_SURFACES: ReadonlyArray<{ path: string; through: RegExp }> = [
path: "src/components/medications/intake-import-dialog.tsx",
through: /IntakeImportResultView/,
},
{
path: "src/components/settings/import-panel/dose-history-import-card.tsx",
through: /IntakeImportResultView/,
},
];
/** The success affordance: the tick component and the success colour token. */
@@ -0,0 +1,94 @@
"use client";
/**
* What the dose-history importer does with every field of the export.
*
* Visible before a file is chosen, not discovered afterwards. A column read and
* silently dropped is indistinguishable, from the outside, from a column read and
* used — which is how a person ends up believing an importer honoured something
* it never looked at.
*
* The rulings come from `AUTO_EXPORT_COLUMN_RULINGS`, so the parser and this list
* cannot drift: a structural test asserts every column the format defines has
* exactly one ruling, and the sentence for each ruling is a literal `t()` call
* below so the i18n call-site guard sees all of them.
*/
import { AUTO_EXPORT_COLUMN_RULINGS } from "@/lib/medications/import/auto-export-format";
import { useTranslations } from "@/lib/i18n/context";
/** One literal `t()` per note key. A computed key would be invisible to the guard. */
function useColumnNote(): (noteKey: string) => string {
const { t } = useTranslations();
const notes: Record<string, string> = {
date: t("settings.sections.export.import.doseHistory.column.date"),
scheduledDate: t(
"settings.sections.export.import.doseHistory.column.scheduledDate",
),
medication: t(
"settings.sections.export.import.doseHistory.column.medication",
),
nickname: t("settings.sections.export.import.doseHistory.column.nickname"),
dosage: t("settings.sections.export.import.doseHistory.column.dosage"),
scheduledDosage: t(
"settings.sections.export.import.doseHistory.column.scheduledDosage",
),
unit: t("settings.sections.export.import.doseHistory.column.unit"),
status: t("settings.sections.export.import.doseHistory.column.status"),
archived: t("settings.sections.export.import.doseHistory.column.archived"),
codings: t("settings.sections.export.import.doseHistory.column.codings"),
form: t("settings.sections.export.import.doseHistory.column.form"),
start: t("settings.sections.export.import.doseHistory.column.start"),
end: t("settings.sections.export.import.doseHistory.column.end"),
};
return (noteKey: string) => notes[noteKey] ?? "";
}
function useVerdictLabel(): (verdict: string) => string {
const { t } = useTranslations();
const labels: Record<string, string> = {
honoured: t("settings.sections.export.import.doseHistory.verdict.honoured"),
reported: t("settings.sections.export.import.doseHistory.verdict.reported"),
ignored: t("settings.sections.export.import.doseHistory.verdict.ignored"),
};
return (verdict: string) => labels[verdict] ?? verdict;
}
export function DoseHistoryColumnRulings() {
const { t } = useTranslations();
const noteFor = useColumnNote();
const verdictFor = useVerdictLabel();
return (
<details
className="group border-border rounded-md border px-3 py-2"
data-testid="dose-history-columns"
>
<summary className="cursor-pointer text-xs font-medium">
{t("settings.sections.export.import.doseHistory.columnsHeading")}
</summary>
<dl className="mt-2 space-y-2">
{AUTO_EXPORT_COLUMN_RULINGS.map((ruling) => (
<div key={ruling.column} data-testid="dose-history-column-ruling">
<dt className="text-foreground flex flex-wrap items-baseline gap-x-2 text-xs">
<code className="font-mono">{ruling.column}</code>
<span
className="text-muted-foreground text-[0.6875rem] uppercase"
data-verdict={ruling.verdict}
>
{verdictFor(ruling.verdict)}
</span>
{ruling.jsonOnly && (
<span className="text-muted-foreground text-[0.6875rem]">
{t("settings.sections.export.import.doseHistory.jsonOnly")}
</span>
)}
</dt>
<dd className="text-muted-foreground text-xs">
{noteFor(ruling.noteKey)}
</dd>
</div>
))}
</dl>
</details>
);
}
@@ -0,0 +1,398 @@
"use client";
/**
* Import a dose history exported from another medication tracker.
*
* Account-wide, because such a file covers a whole regimen — the per-medication
* import dialog on a medication's own page stays what it is, a way to add dates
* and times for that one medication.
*
* Preview first. The dry run returns the entire verdict — how many rows land, how
* many are refused and under which reason, which medication names match nothing
* on the record — before a single row is written. Importing years of history
* unseen is not something to ask of anybody.
*
* The outcome renders through `IntakeImportResultView`, the same component the
* per-medication dialog uses, so a run that wrote nothing cannot carry a success
* tick here either and the skip reasons are grouped with a count rather than
* repeated per row.
*/
import { useId, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Loader2, PillBottle, Upload } from "lucide-react";
import {
IntakeImportResultView,
IntakeImportSkipGroups,
type IntakeImportResultState,
} from "@/components/medications/intake-import-result";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
MedicationIntakeImportError,
waitForMedicationIntakeImport,
} from "@/lib/medications/intake-import-poll";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { DoseHistoryColumnRulings } from "./dose-history-columns";
import { ImportCardShell } from "./import-card-shell";
import { MAX_PASTE_CHARS } from "./constants";
/** The file-level verdict, exactly as the route returns it. */
interface DoseHistoryFileVerdict {
rowsRead: number;
queued: number;
refused: number;
refusedByReason: Array<{ reason: string; count: number }>;
unmatchedMedications: string[];
ambiguousMedications: string[];
mirroredMedications: string[];
unknownColumns: string[];
codingsNotRead: number;
fromArchivedMedications: number;
}
interface DoseHistoryKickoff {
dryRun: boolean;
jobId: string | null;
statusUrl: string | null;
file: DoseHistoryFileVerdict;
}
/**
* Plain language per machine-readable file-level refusal. Literal `t()` calls so
* the i18n call-site guard sees every one.
*/
function useFatalMessage(): (code: string | undefined) => string {
const { t } = useTranslations();
const messages: Record<string, string> = {
empty_file: t(
"settings.sections.export.import.doseHistory.fatal.emptyFile",
),
missing_required_columns: t(
"settings.sections.export.import.doseHistory.fatal.missingColumns",
),
unreadable_json: t(
"settings.sections.export.import.doseHistory.fatal.unreadableJson",
),
json_not_an_array: t(
"settings.sections.export.import.doseHistory.fatal.jsonNotAnArray",
),
json_carries_no_intake_time: t(
"settings.sections.export.import.doseHistory.fatal.jsonNoIntakeTime",
),
too_many_rows: t(
"settings.sections.export.import.doseHistory.fatal.tooManyRows",
),
};
const fallback = t("settings.sections.export.import.doseHistory.failed");
return (code: string | undefined) =>
(code === undefined ? undefined : messages[code]) ?? fallback;
}
/** `true` when the pasted or loaded text looks like the JSON shape. */
function looksLikeJson(text: string): boolean {
const head = text.trimStart().charAt(0);
return head === "[" || head === "{";
}
export function DoseHistoryImportCard() {
const { t } = useTranslations();
const fatalMessageFor = useFatalMessage();
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaId = useId();
const queryClient = useQueryClient();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [verdict, setVerdict] = useState<DoseHistoryFileVerdict | null>(null);
const [previewOnly, setPreviewOnly] = useState(false);
const [outcome, setOutcome] = useState<IntakeImportResultState | null>(null);
async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setError(null);
setVerdict(null);
setOutcome(null);
try {
setText(await file.text());
} catch {
setError(t("settings.sections.export.import.doseHistory.readFailed"));
}
}
async function send(dryRun: boolean) {
setError(null);
setVerdict(null);
setOutcome(null);
if (text.trim().length === 0) {
setError(t("settings.sections.export.import.doseHistory.empty"));
return;
}
setBusy(true);
try {
const res = await apiFetchRaw(
`/api/medications/intake/dose-history-import${dryRun ? "?dryRun=1" : ""}`,
{
method: "POST",
headers: {
"Content-Type": looksLikeJson(text)
? "application/json"
: "text/csv",
},
credentials: "include",
body: text,
},
);
if (res.status === 429) {
setError(t("settings.sections.export.import.doseHistory.rateLimited"));
return;
}
if (res.status === 413) {
setError(t("settings.sections.export.import.doseHistory.tooLarge"));
return;
}
if (res.status === 409) {
setError(t("settings.sections.export.import.doseHistory.inProgress"));
return;
}
if (res.status === 503) {
setError(
t("settings.sections.export.import.doseHistory.workerUnavailable"),
);
return;
}
if (res.status === 422 || res.status === 415) {
const body = await res.json().catch(() => null);
const code =
typeof body?.meta?.errorCode === "string"
? body.meta.errorCode
: undefined;
setError(fatalMessageFor(code));
return;
}
if (!res.ok) {
setError(t("settings.sections.export.import.doseHistory.failed"));
return;
}
const data = (await res.json()).data as DoseHistoryKickoff;
setVerdict(data.file);
setPreviewOnly(data.dryRun);
if (data.dryRun || !data.statusUrl) return;
const result = await waitForMedicationIntakeImport(data.statusUrl);
setOutcome({
kind: "outcome",
imported: result.imported,
skipped: result.skipped,
skipReasons: result.skipReasons,
});
void invalidateMedicationReads(queryClient);
} catch (err) {
// A failed job carries a sanitised reason; anything else is a transport
// failure with nothing to add. Either way the run did not write, and the
// card says so rather than leaving the last verdict on screen.
setError(
err instanceof MedicationIntakeImportError && err.message.length > 0
? err.message
: t("settings.sections.export.import.doseHistory.failed"),
);
setVerdict(null);
} finally {
setBusy(false);
}
}
return (
<ImportCardShell
testId="import-card-dose-history"
icon={PillBottle}
title={t("settings.sections.export.import.doseHistory.title")}
description={t("settings.sections.export.import.doseHistory.description")}
>
<div className="space-y-1.5">
<Label htmlFor={textareaId} className="text-xs">
{t("settings.sections.export.import.doseHistory.pasteLabel")}
</Label>
<Textarea
id={textareaId}
data-testid="import-dose-history-textarea"
value={text}
onChange={(event) => setText(event.target.value)}
rows={5}
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder="Date,Scheduled Date,Medication,…"
className="font-mono text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
used: text.length,
max: MAX_PASTE_CHARS,
})}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json,text/csv,application/json"
className="sr-only"
aria-label={t(
"settings.sections.export.import.doseHistory.fileInputLabel",
)}
onChange={onFileChange}
/>
<DoseHistoryColumnRulings />
<div aria-live="polite" className="space-y-2">
{verdict && (
<div
data-testid="dose-history-verdict"
className="space-y-1 text-xs"
data-preview={previewOnly ? "true" : "false"}
>
<p className="text-foreground">
{previewOnly
? t("settings.sections.export.import.doseHistory.previewLine", {
rows: verdict.rowsRead,
queued: verdict.queued,
refused: verdict.refused,
})
: t(
"settings.sections.export.import.doseHistory.submittedLine",
{
rows: verdict.rowsRead,
queued: verdict.queued,
},
)}
</p>
{/* The reason lines, never the outcome sentence: a preview has
written nothing, and classifying it by the counts it would
produce would put a success tick on a run that never ran. */}
<IntakeImportSkipGroups
groups={verdict.refusedByReason}
testId="dose-history-refusal-groups"
/>
{verdict.unmatchedMedications.length > 0 && (
<p
className="text-muted-foreground"
data-testid="dose-history-unmatched"
>
{t("settings.sections.export.import.doseHistory.unmatched", {
names: verdict.unmatchedMedications.join(", "),
})}
</p>
)}
{verdict.ambiguousMedications.length > 0 && (
<p className="text-muted-foreground">
{t("settings.sections.export.import.doseHistory.ambiguous", {
names: verdict.ambiguousMedications.join(", "),
})}
</p>
)}
{verdict.mirroredMedications.length > 0 && (
<p className="text-muted-foreground">
{t("settings.sections.export.import.doseHistory.mirrored", {
names: verdict.mirroredMedications.join(", "),
})}
</p>
)}
{verdict.unknownColumns.length > 0 && (
<p className="text-muted-foreground">
{t(
"settings.sections.export.import.doseHistory.unknownColumns",
{ names: verdict.unknownColumns.join(", ") },
)}
</p>
)}
{verdict.codingsNotRead > 0 && (
<p className="text-muted-foreground">
{t(
"settings.sections.export.import.doseHistory.codingsNotRead",
{
count: verdict.codingsNotRead,
},
)}
</p>
)}
{verdict.fromArchivedMedications > 0 && (
<p
className="text-muted-foreground"
data-testid="dose-history-archived"
>
{t("settings.sections.export.import.doseHistory.archivedRows", {
count: verdict.fromArchivedMedications,
})}
</p>
)}
</div>
)}
{outcome && <IntakeImportResultView result={outcome} />}
{error && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-sm"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>{error}</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => fileInputRef.current?.click()}
data-testid="import-dose-history-choose-file"
>
<Upload className="h-3.5 w-3.5" />
{t("settings.sections.export.import.doseHistory.uploadFile")}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
onClick={() => void send(true)}
data-testid="import-dose-history-preview"
>
{busy && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sections.export.import.doseHistory.preview")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
onClick={() => void send(false)}
data-testid="import-action-dose-history"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PillBottle className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.doseHistory.import")}
</Button>
</div>
</ImportCardShell>
);
}
@@ -21,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { AppleHealthImportCard } from "./apple-health-import-card";
import { JsonImportCard } from "./json-import-card";
import { CsvImportCard } from "./csv-import-card";
import { DoseHistoryImportCard } from "./dose-history-import-card";
export {
EXAMPLE_IMPORT,
@@ -50,6 +51,7 @@ export function ImportPanel() {
<AppleHealthImportCard />
<JsonImportCard />
<CsvImportCard />
<DoseHistoryImportCard />
</div>
</section>
);
+2 -2
View File
@@ -365,9 +365,9 @@ interface ProcessChunkOutcome {
* what to recompute — two medications touched on the same day are two rollup
* rows. The medication is folded in ahead of a single space, which occurs in
* neither a cuid nor a `YYYY-MM-DD` key. Not a NUL byte: this value is persisted
* in the progress blob and Postgres rejects `` inside a jsonb string.
* in the progress blob and Postgres rejects a NUL byte inside a jsonb string.
*/
const TOUCHED_DAY_SEPARATOR = "";
const TOUCHED_DAY_SEPARATOR = " ";
function encodeTouchedDay(medicationId: string, dayKey: string): string {
return `${medicationId}${TOUCHED_DAY_SEPARATOR}${dayKey}`;
@@ -0,0 +1,89 @@
/**
* Structural: no field of the export can exist without a verdict.
*
* "Every column gets an explicit verdict" is only true while it stays true. A
* column added to the parser and not to the ruling table would be read, or not
* read, without anyone being told either way — which is the exact defect this
* import path was built to remove, and the kind that survives a review because
* every other check passes.
*
* Also pins that a parse-time refusal is spelled the same as a job skip reason.
* The two vocabularies feed one label set in the UI, so a reason that exists in
* only one of them renders as "reason not recorded" — a refusal the person cannot
* act on.
*/
import { describe, expect, it } from "vitest";
import { MEDICATION_IMPORT_SKIP_REASONS } from "@/lib/jobs/medication-intake-import";
import {
AUTO_EXPORT_COLUMN_RULINGS,
AUTO_EXPORT_CSV_COLUMNS,
AUTO_EXPORT_JSON_FIELDS,
AUTO_EXPORT_STATUSES,
AUTO_EXPORT_ACTIONABLE_STATUSES,
} from "../auto-export-format";
import { AUTO_EXPORT_REFUSAL_REASONS } from "../auto-export-parse";
describe("export column rulings", () => {
it("rules on every CSV column exactly once", () => {
const ruled = AUTO_EXPORT_COLUMN_RULINGS.filter(
(ruling) => !ruling.jsonOnly,
).map((ruling) => ruling.column);
expect(ruled).toEqual([...AUTO_EXPORT_CSV_COLUMNS]);
});
it("rules on every documented JSON field, under its CSV name where it shares one", () => {
const ruled = new Set(
AUTO_EXPORT_COLUMN_RULINGS.map((ruling) => ruling.column.toLowerCase()),
);
// The JSON spellings that differ from the CSV column they are the same fact
// as. Anything not on this map has to appear in the rulings by its own name.
const aliases: Record<string, string> = {
displaytext: "medication",
isarchived: "archived",
scheduleddate: "scheduled date",
};
for (const field of AUTO_EXPORT_JSON_FIELDS) {
const key = aliases[field.toLowerCase()] ?? field.toLowerCase();
expect(ruled, `no ruling for JSON field ${field}`).toContain(key);
}
});
it("carries no ruling for a field neither shape has", () => {
const known = new Set<string>([
...AUTO_EXPORT_CSV_COLUMNS.map((column) => column.toLowerCase()),
...AUTO_EXPORT_JSON_FIELDS.map((field) => field.toLowerCase()),
]);
for (const ruling of AUTO_EXPORT_COLUMN_RULINGS) {
expect(known, `ruling for unknown field ${ruling.column}`).toContain(
ruling.column.toLowerCase(),
);
}
});
it("gives every ruling a distinct note key, so no two share a sentence", () => {
const keys = AUTO_EXPORT_COLUMN_RULINGS.map((ruling) => ruling.noteKey);
expect(new Set(keys).size).toBe(keys.length);
});
});
describe("status vocabulary", () => {
it("treats only a recorded decision as actionable", () => {
for (const status of AUTO_EXPORT_ACTIONABLE_STATUSES) {
expect(AUTO_EXPORT_STATUSES).toContain(status);
}
// The other five documented values name the absence of a decision. Widening
// this set means deciding what row to write for "nobody acted", which is a
// compliance judgement the export never made.
expect(AUTO_EXPORT_ACTIONABLE_STATUSES).toEqual(["Taken", "Skipped"]);
});
});
describe("refusal vocabulary", () => {
it("spells every parse-time refusal the same as a job skip reason", () => {
for (const reason of AUTO_EXPORT_REFUSAL_REASONS) {
expect(MEDICATION_IMPORT_SKIP_REASONS, reason).toContain(reason);
}
});
});
@@ -0,0 +1,98 @@
/**
* The offset in an exported timestamp survives, or the row is refused.
*
* `2023-02-16 08:38:00 +1030` is not ISO 8601 — a space instead of `T`, a
* compact offset with no colon — so handing it to the platform's date parser is
* relying on leniency, not on a contract. Getting it wrong moves every dose in a
* file by hours, silently, and no later edit recovers the original.
*
* These assert the epoch arithmetic directly rather than round-tripping through
* a formatter, because a formatter that shares the same mistake would agree.
*/
import { describe, expect, it } from "vitest";
import { parseAutoExportInstant } from "../auto-export-instant";
describe("parseAutoExportInstant", () => {
it("reads a compact positive offset as the offset the file states", () => {
const result = parseAutoExportInstant("2023-02-16 08:38:00 +1030");
expect(result.ok).toBe(true);
if (!result.ok) return;
// 08:38 at +10:30 is 22:08 the previous day in UTC. Written out rather than
// computed so a shared error cannot make both sides agree.
expect(result.instant.toISOString()).toBe("2023-02-15T22:08:00.000Z");
expect(result.offsetMinutes).toBe(630);
});
it("reads a negative offset in the other direction", () => {
const result = parseAutoExportInstant("2024-01-01 08:00:00 -0800");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.instant.toISOString()).toBe("2024-01-01T16:00:00.000Z");
expect(result.offsetMinutes).toBe(-480);
});
it("keeps two adjacent rows of one file an hour apart across a DST change", () => {
// The reported export carries +1030 and +0930 on rows weeks apart, because
// the source zone observes summer time. Both rows say 08:30 locally; they
// are one hour apart as instants, and collapsing them onto one zone would
// erase that.
const summer = parseAutoExportInstant("2025-10-08 08:30:00 +1030");
const winter = parseAutoExportInstant("2025-06-29 08:30:00 +0930");
expect(summer.ok && winter.ok).toBe(true);
if (!summer.ok || !winter.ok) return;
expect(summer.instant.toISOString()).toBe("2025-10-07T22:00:00.000Z");
expect(winter.instant.toISOString()).toBe("2025-06-28T23:00:00.000Z");
expect(summer.offsetMinutes - winter.offsetMinutes).toBe(60);
});
it("accepts the colon-separated and Z spellings of the same instant", () => {
const compact = parseAutoExportInstant("2025-03-01 12:00:00 +0100");
const colon = parseAutoExportInstant("2025-03-01T12:00:00+01:00");
const zulu = parseAutoExportInstant("2025-03-01 11:00:00 Z");
expect(compact.ok && colon.ok && zulu.ok).toBe(true);
if (!compact.ok || !colon.ok || !zulu.ok) return;
expect(colon.instant.getTime()).toBe(compact.instant.getTime());
expect(zulu.instant.getTime()).toBe(compact.instant.getTime());
});
it("refuses a timestamp with no offset instead of assuming a zone", () => {
// The server's zone is not evidence about where a dose was taken. Reading
// this as local time would shift the whole file by the server's offset.
expect(parseAutoExportInstant("2023-02-16 08:38:00")).toEqual({
ok: false,
failure: "missing_offset",
});
});
it("tells an absent cell apart from an unreadable one", () => {
expect(parseAutoExportInstant("")).toEqual({
ok: false,
failure: "absent",
});
expect(parseAutoExportInstant(" ")).toEqual({
ok: false,
failure: "absent",
});
expect(parseAutoExportInstant("16/02/2023 8.38am")).toEqual({
ok: false,
failure: "unreadable",
});
});
it("refuses a day the calendar does not have", () => {
// `Date.UTC` rolls February 30 over to March 2. Accepting it would move the
// dose two days and report success.
expect(parseAutoExportInstant("2023-02-30 08:00:00 +0000")).toEqual({
ok: false,
failure: "out_of_range",
});
});
it("refuses an out-of-range clock or offset", () => {
expect(parseAutoExportInstant("2023-02-16 25:00:00 +0000").ok).toBe(false);
expect(parseAutoExportInstant("2023-02-16 08:61:00 +0000").ok).toBe(false);
expect(parseAutoExportInstant("2023-02-16 08:00:00 +1599").ok).toBe(false);
expect(parseAutoExportInstant("2023-02-16 08:00:00 +0075").ok).toBe(false);
});
});
@@ -0,0 +1,286 @@
/**
* Reading the export, against the file a self-hoster actually attached.
*
* The fixture is that export, de-identified as it arrived: 3,395 rows over three
* and a half years, sixteen medications, two UTC offsets, three statuses, two
* units, empty cells in four different columns. A hand-built fixture would have
* had one status and one offset and would have proved nothing about the file that
* prompted the work.
*
* `now` is pinned so the plausibility bound is decided against a fixed clock
* rather than against the day the suite happens to run.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
parseAutoExportCsv,
parseAutoExportJson,
type AutoExportParseOutcome,
} from "../auto-export-parse";
const FIXTURE = readFileSync(
join(__dirname, "fixtures", "dose-history-export.csv"),
"utf8",
);
/** After the newest row in the fixture, so nothing is refused as future-dated. */
const NOW = Date.parse("2026-07-01T00:00:00.000Z");
function parseFixture(): AutoExportParseOutcome {
return parseAutoExportCsv(FIXTURE, { now: NOW });
}
function reasonCounts(outcome: AutoExportParseOutcome) {
const counts: Record<string, number> = {};
for (const refusal of outcome.refusals) {
counts[refusal.reason] = (counts[refusal.reason] ?? 0) + 1;
}
return counts;
}
describe("parseAutoExportCsv — the reported export", () => {
it("reads every row and accounts for each one as a dose or a refusal", () => {
const outcome = parseFixture();
expect(outcome.fatal).toBeUndefined();
expect(outcome.rowsRead).toBe(3395);
// The property that matters: nothing falls between the two outcomes. A row
// that is neither a dose nor a named refusal is a silent drop.
expect(outcome.doses.length + outcome.refusals.length).toBe(
outcome.rowsRead,
);
expect(outcome.doses).toHaveLength(3387);
});
it("refuses only the rows whose status records no decision, and names why", () => {
// The file holds `Taken`, `Skipped` and `Not Interacted`. The first two are
// decisions the person made; the eight `Not Interacted` rows are the absence
// of one, and there is no honest dose to write for them.
expect(reasonCounts(parseFixture())).toEqual({ status_not_recorded: 8 });
});
it("keeps the scheduled slot and the time taken apart", () => {
const outcome = parseFixture();
const first = outcome.doses[0];
// Row 2 of the file: taken 08:38, due 08:00, both at +10:30.
expect(first.takenAt?.toISOString()).toBe("2023-02-15T22:08:00.000Z");
expect(first.scheduledFor.toISOString()).toBe("2023-02-15T21:30:00.000Z");
expect(first.adHoc).toBe(false);
// 2,799 of the 3,366 taken doses landed after they were due. That is the
// fact a flattened import destroys: anchoring each dose on its own take time
// would report every one of them as exactly on time, for ever.
const late = outcome.doses.filter(
(dose) =>
dose.takenAt !== null &&
dose.takenAt.getTime() > dose.scheduledFor.getTime(),
);
expect(late).toHaveLength(2799);
});
it("anchors a dose with no scheduled time on itself rather than inventing a slot", () => {
const outcome = parseFixture();
const adHoc = outcome.doses.filter((dose) => dose.adHoc);
expect(adHoc).toHaveLength(2);
for (const dose of adHoc) {
expect(dose.scheduledFor.getTime()).toBe(dose.takenAt?.getTime());
}
});
it("records a deliberate skip with no time taken", () => {
const outcome = parseFixture();
const skips = outcome.doses.filter((dose) => dose.skipped);
expect(skips).toHaveLength(21);
for (const skip of skips) {
// A skip is a dose that was not taken. The file's `Date` on such a row is
// when the person pressed skip, and writing that as a take would turn 21
// refusals into 21 doses.
expect(skip.takenAt).toBeNull();
expect(skip.doseTaken).toBeNull();
}
});
it("carries a per-dose amount only where it differs from the scheduled amount", () => {
const outcome = parseFixture();
const overridden = outcome.doses.filter((dose) => dose.doseTaken !== null);
// 25 rows of the file record an actual amount unlike the amount due; the
// other 3,362 restate the schedule and leave the override absent, which is
// what `doseTaken` being NULL already means.
expect(overridden).toHaveLength(25);
// `1.0 count` renders as `1`: the unit word carries nothing about substance
// amount, and the trailing zero is the export's spelling, not the dose.
expect(new Set(overridden.map((dose) => dose.doseTaken))).toEqual(
new Set(["0.25", "0.5", "1", "1.25", "2"]),
);
});
it("counts the archived rows without letting the flag drop a dose", () => {
const outcome = parseFixture();
expect(outcome.fromArchivedMedications).toBe(1462);
// Nearly half the file. Dropping those rows on the strength of a flag that
// describes the medication's state today would discard most of the history.
const archivedDoses = outcome.doses.filter(
(dose) => dose.fromArchivedMedication,
);
expect(archivedDoses.length).toBe(1460);
});
it("reads the whole header and finds nothing it has no rule for", () => {
const outcome = parseFixture();
expect(outcome.unknownColumns).toEqual([]);
// Empty on every row of this file. A populated cell would be counted here
// rather than read, because the CSV documents no encoding for it.
expect(outcome.csvCodingsIgnored).toBe(0);
});
it("keeps both names so either can match a medication on the record", () => {
const outcome = parseFixture();
expect(new Set(outcome.doses.map((dose) => dose.medicationName)).size).toBe(
16,
);
// 1,212 of the importable rows leave `Nickname` empty; where it is set it
// repeats `Medication` verbatim, so in this file it adds nothing — but the
// format documents it as a name the person chose, and it is the one that
// might match what the record calls the medication.
expect(outcome.doses.filter((dose) => dose.nickname === null)).toHaveLength(
1212,
);
});
});
describe("parseAutoExportCsv — refusals", () => {
const header =
"Date,Scheduled Date,Medication,Nickname,Dosage,Scheduled Dosage,Unit,Status,Archived,Codings\n";
function parseRows(...rows: string[]): AutoExportParseOutcome {
return parseAutoExportCsv(header + rows.join("\n") + "\n", { now: NOW });
}
it("refuses a timestamp with no offset rather than reading it in the server's zone", () => {
const outcome = parseRows(
"2025-01-01 08:00:00,2025-01-01 08:00:00 +0000,Med,Med,1.0,1.0,count,Taken,No,",
);
expect(outcome.doses).toHaveLength(0);
expect(outcome.refusals).toEqual([
{ line: 2, reason: "missing_timezone_offset" },
]);
});
it("refuses a future-dated dose", () => {
const outcome = parseRows(
"2027-01-01 08:00:00 +0000,,Med,Med,1.0,1.0,count,Taken,No,",
);
expect(outcome.refusals).toEqual([
{ line: 2, reason: "implausible_timestamp" },
]);
});
it("refuses a status the format does not define instead of guessing at it", () => {
const outcome = parseRows(
"2025-01-01 08:00:00 +0000,,Med,Med,1.0,1.0,count,Halbwegs,No,",
);
expect(outcome.refusals).toEqual([{ line: 2, reason: "status_unknown" }]);
});
it("refuses a non-numeric dosage instead of dropping the amount", () => {
const outcome = parseRows(
"2025-01-01 08:00:00 +0000,,Med,Med,one,1.0,count,Taken,No,",
);
expect(outcome.refusals).toEqual([
{ line: 2, reason: "unreadable_dosage" },
]);
});
it("refuses a row that lost cells rather than reading the survivors out of position", () => {
const outcome = parseRows("2025-01-01 08:00:00 +0000,,Med,Med,1.0");
expect(outcome.refusals).toEqual([{ line: 2, reason: "unreadable_row" }]);
});
it("refuses a row naming no medication at all", () => {
const outcome = parseRows(
"2025-01-01 08:00:00 +0000,,,,1.0,1.0,count,Taken,No,",
);
expect(outcome.refusals).toEqual([
{ line: 2, reason: "missing_medication" },
]);
});
it("counts a populated Codings cell without reading it", () => {
const outcome = parseRows(
"2025-01-01 08:00:00 +0000,,Med,Med,1.0,1.0,count,Taken,No,1191",
);
expect(outcome.doses).toHaveLength(1);
expect(outcome.csvCodingsIgnored).toBe(1);
});
it("names a header column it has no rule for", () => {
const outcome = parseAutoExportCsv(
"Date,Medication,Status,Mood\n2025-01-01 08:00:00 +0000,Med,Taken,fine\n",
{ now: NOW },
);
expect(outcome.unknownColumns).toEqual(["Mood"]);
expect(outcome.doses).toHaveLength(1);
});
it("refuses the whole file when a column it cannot do without is absent", () => {
const outcome = parseAutoExportCsv("Date,Nickname\n2025-01-01,x\n", {
now: NOW,
});
expect(outcome.fatal).toEqual({
reason: "missing_required_columns",
detail: "Medication, Status",
});
expect(outcome.doses).toHaveLength(0);
});
it("keeps the unit verbatim and drops it for a bare count", () => {
const outcome = parseRows(
"2025-01-01 08:00:00 +0000,,Med,Med,0.4,1.0,mL,Taken,No,",
"2025-01-02 08:00:00 +0000,,Med,Med,0.25,1.0,count,Taken,No,",
);
expect(outcome.doses.map((dose) => dose.doseTaken)).toEqual([
"0.4 mL",
"0.25",
]);
});
});
describe("parseAutoExportJson", () => {
it("refuses the JSON shape because it records no time a dose was taken", () => {
// The documented JSON carries `scheduledDate` and `status` but nothing for
// the moment of the take. Writing the scheduled time into `takenAt` would
// manufacture a spotless on-time history the file never claimed.
const outcome = parseAutoExportJson(
JSON.stringify([
{
displayText: "Aspirin",
start: "2024-01-01 08:00:00 -0800",
scheduledDate: "2024-02-06 08:00:00 -0800",
status: "Taken",
isArchived: false,
dosage: 81,
codings: [
{
code: "1191",
system: "http://www.nlm.nih.gov/research/umls/rxnorm",
},
],
},
]),
);
expect(outcome.fatal).toEqual({ reason: "json_carries_no_intake_time" });
expect(outcome.rowsRead).toBe(1);
expect(outcome.doses).toHaveLength(0);
});
it("tells unreadable text apart from a readable file it will not import", () => {
expect(parseAutoExportJson("not json").fatal).toEqual({
reason: "unreadable_json",
});
expect(parseAutoExportJson('"a string"').fatal).toEqual({
reason: "json_not_an_array",
});
expect(parseAutoExportJson("[]").fatal).toEqual({ reason: "empty_file" });
});
});
@@ -0,0 +1,218 @@
/**
* Matching a file's medications to the record, and what is refused.
*
* The whole-file numbers are asserted against the reported export because the
* arithmetic that matters is `imported + refused = rows read`. A count that does
* not add up is how a run that lost rows reads as a run that finished.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { totalMedicationImportSkips } from "@/lib/jobs/medication-intake-import";
import { parseAutoExportCsv } from "../auto-export-parse";
import {
planAutoExportImport,
type MedicationMatchCandidate,
} from "../auto-export-plan";
const FIXTURE = readFileSync(
join(__dirname, "fixtures", "dose-history-export.csv"),
"utf8",
);
const NOW = Date.parse("2026-07-01T00:00:00.000Z");
const FIXTURE_MEDICATION_NAMES = Array.from(
{ length: 16 },
(_, index) => `Med_${index + 1}`,
);
function medication(
name: string,
overrides: Partial<MedicationMatchCandidate> = {},
): MedicationMatchCandidate {
return {
id: `id-${name}`,
name,
externalSource: null,
...overrides,
};
}
function planFixture(medications: readonly MedicationMatchCandidate[]) {
return planAutoExportImport(
parseAutoExportCsv(FIXTURE, { now: NOW }),
medications,
);
}
const header =
"Date,Scheduled Date,Medication,Nickname,Dosage,Scheduled Dosage,Unit,Status,Archived,Codings\n";
function planRows(
rows: readonly string[],
medications: readonly MedicationMatchCandidate[],
) {
return planAutoExportImport(
parseAutoExportCsv(header + rows.join("\n") + "\n", { now: NOW }),
medications,
);
}
describe("planAutoExportImport — the reported export", () => {
it("queues every importable row when the record holds all sixteen medications", () => {
const plan = planFixture(
FIXTURE_MEDICATION_NAMES.map((n) => medication(n)),
);
expect(plan.entries).toHaveLength(3387);
expect(plan.unmatchedMedications).toEqual([]);
// The arithmetic the person reads at the end of the run.
expect(
plan.entries.length + totalMedicationImportSkips(plan.skippedByReason),
).toBe(plan.rowsRead);
expect(plan.skippedByReason).toEqual({ status_not_recorded: 8 });
});
it("names the medications it could not match instead of reporting a number", () => {
const plan = planFixture([medication("Med_13"), medication("Med_9")]);
// Two medications on the record, so only their rows land. The rest are
// refused under a reason, and the names come back so they can be added.
expect(plan.entries.length).toBeGreaterThan(0);
expect(plan.skippedByReason.medication_not_found).toBe(
3387 - plan.entries.length,
);
expect(plan.unmatchedMedications).toHaveLength(14);
expect(plan.unmatchedMedications).not.toContain("Med_13");
expect(
plan.entries.length + totalMedicationImportSkips(plan.skippedByReason),
).toBe(plan.rowsRead);
});
it("never creates a medication from the file", () => {
// With nothing on the record, an importer that invented medications would
// land 3,387 rows. Every one is refused instead, by name.
const plan = planFixture([]);
expect(plan.entries).toEqual([]);
expect(plan.skippedByReason.medication_not_found).toBe(3387);
expect(plan.unmatchedMedications).toHaveLength(16);
});
it("identifies a dose by its medication and its slot, so a re-run replays", () => {
const plan = planFixture(
FIXTURE_MEDICATION_NAMES.map((n) => medication(n)),
);
const keys = new Set(plan.entries.map((entry) => entry.idempotencyKey));
// One key per dose. The key used to come from an optional counter field,
// which is how 28 distinct instants collapsed onto one.
expect(keys.size).toBe(plan.entries.length);
const again = planFixture(
FIXTURE_MEDICATION_NAMES.map((n) => medication(n)),
);
expect(again.entries.map((entry) => entry.idempotencyKey)).toEqual(
plan.entries.map((entry) => entry.idempotencyKey),
);
});
it("carries the scheduled slot, the take and the medication onto every entry", () => {
const plan = planFixture(
FIXTURE_MEDICATION_NAMES.map((n) => medication(n)),
);
expect(plan.entries[0]).toEqual({
medicationId: "id-Med_13",
scheduledFor: "2023-02-15T21:30:00.000Z",
takenAt: "2023-02-15T22:08:00.000Z",
idempotencyKey: `import-id-Med_13-${Date.parse("2023-02-15T21:30:00.000Z")}`,
});
});
});
describe("planAutoExportImport — matching", () => {
it("matches a name case- and whitespace-blind", () => {
const plan = planRows(
["2025-01-01 08:00:00 +0000,, ramipril ,,1.0,1.0,count,Taken,No,"],
[medication("Ramipril")],
);
expect(plan.entries).toHaveLength(1);
expect(plan.entries[0].medicationId).toBe("id-Ramipril");
});
it("falls back to the nickname when the display name matches nothing", () => {
const plan = planRows(
[
"2025-01-01 08:00:00 +0000,,Long brand name,Evening pill,1.0,1.0,count,Taken,No,",
],
[medication("Evening pill")],
);
expect(plan.entries).toHaveLength(1);
expect(plan.entries[0].medicationId).toBe("id-Evening pill");
});
it("refuses an ambiguous name rather than picking one of the two", () => {
const plan = planRows(
["2025-01-01 08:00:00 +0000,,Ramipril,,1.0,1.0,count,Taken,No,"],
[
{ id: "a", name: "Ramipril", externalSource: null },
{ id: "b", name: "ramipril", externalSource: null },
],
);
expect(plan.entries).toEqual([]);
expect(plan.skippedByReason).toEqual({ medication_ambiguous: 1 });
expect(plan.ambiguousMedications).toEqual(["Ramipril"]);
});
it("refuses a medication mirrored from another app", () => {
// A mirrored row is a read-only copy and only that source's dose events
// attach to it. An imported dose inside the mirror would be a row the mirror
// never asserted.
const plan = planRows(
["2025-01-01 08:00:00 +0000,,Ramipril,,1.0,1.0,count,Taken,No,"],
[medication("Ramipril", { externalSource: "APPLE_HEALTH" })],
);
expect(plan.entries).toEqual([]);
expect(plan.skippedByReason).toEqual({ medication_is_mirrored: 1 });
expect(plan.mirroredMedications).toEqual(["Ramipril"]);
});
it("counts two rows of one file landing on the same slot apart from a replay", () => {
const plan = planRows(
[
"2025-01-01 08:05:00 +0000,2025-01-01 08:00:00 +0000,Ramipril,,1.0,1.0,count,Taken,No,",
"2025-01-01 08:40:00 +0000,2025-01-01 08:00:00 +0000,Ramipril,,1.0,1.0,count,Taken,No,",
],
[medication("Ramipril")],
);
expect(plan.entries).toHaveLength(1);
expect(plan.skippedByReason).toEqual({ duplicate_in_file: 1 });
});
it("keeps two medications sharing one slot apart", () => {
const plan = planRows(
[
"2025-01-01 08:00:00 +0000,2025-01-01 08:00:00 +0000,Ramipril,,1.0,1.0,count,Taken,No,",
"2025-01-01 08:00:00 +0000,2025-01-01 08:00:00 +0000,Metformin,,1.0,1.0,count,Taken,No,",
],
[medication("Ramipril"), medication("Metformin")],
);
expect(plan.entries).toHaveLength(2);
expect(plan.skippedByReason).toEqual({});
});
it("hands a deliberate skip through with no take time", () => {
const plan = planRows(
[
"2025-01-01 09:00:00 +0000,2025-01-01 08:00:00 +0000,Ramipril,,1.0,1.0,count,Skipped,No,",
],
[medication("Ramipril")],
);
expect(plan.entries).toEqual([
{
medicationId: "id-Ramipril",
scheduledFor: "2025-01-01T08:00:00.000Z",
takenAt: null,
idempotencyKey: `import-id-Ramipril-${Date.parse("2025-01-01T08:00:00.000Z")}`,
},
]);
});
});
File diff suppressed because it is too large. Load diff