mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(medications): show what the importer does with each column
The dose-history import gets its own card in Settings, because the file covers a whole regimen and the per-medication dialog on a medication's page covers one medication. Preview before writing. The dry run parses the file, matches every row against the record, and answers with the whole verdict: rows read, rows that would land, rows refused grouped by reason, and the medication names that match nothing — verbatim, so they can be added and the file re-run. Nothing is written until the second button. The column rulings sit on the card before a file is chosen, one line each, naming whether the column is used, reported, or not read and why. A column read and dropped in silence is indistinguishable from a column honoured, and that is how somebody comes to believe an importer looked at something it never did. Outcomes render through the same primitive the other three import surfaces use, so a run that wrote nothing cannot carry a success tick. The preview borrows the reason lines and deliberately not the outcome sentence: a preview has written nothing, and classifying it by the counts it would produce would put a tick on a run that never ran.
This commit is contained in:
1 parent
34a220421c
commit
8a36117a0c
3 files changed
+369
-2
No files matched your search
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* The dose-history import kickoff contract.
|
||||
*
|
||||
* The route parses, matches, and hands the write to the background job. What is
|
||||
* asserted here is the contract around that: the verdict it returns, that a dry
|
||||
* run writes nothing at all, that the job it enqueues is account-wide with the
|
||||
* pre-queue refusals already seeded into its progress, and that a file it cannot
|
||||
* read is refused with a code naming the actual problem.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const prismaMock = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "user-1" }]),
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeImportJob: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
...prismaMock,
|
||||
$transaction: vi.fn((callback: (tx: typeof prismaMock) => unknown) =>
|
||||
callback(prismaMock),
|
||||
),
|
||||
},
|
||||
toJson: (value: unknown) => value,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
user: { id: "user-1", username: "owner", role: "USER", email: null },
|
||||
};
|
||||
|
||||
const HEADER =
|
||||
"Date,Scheduled Date,Medication,Nickname,Dosage,Scheduled Dosage,Unit,Status,Archived,Codings";
|
||||
|
||||
/** Two takeable rows for one medication, plus a row recording no decision. */
|
||||
const CSV = [
|
||||
HEADER,
|
||||
"2025-01-01 08:20:00 +1030,2025-01-01 08:00:00 +1030,Ramipril,Ramipril,1.0,1.0,count,Taken,No,",
|
||||
"2025-01-02 08:05:00 +1030,2025-01-02 08:00:00 +1030,Ramipril,Ramipril,0.5,1.0,count,Taken,Yes,",
|
||||
"2025-01-03 08:00:00 +1030,2025-01-03 08:00:00 +1030,Ramipril,Ramipril,,1.0,count,Not Interacted,No,",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
function postReq(
|
||||
body: string,
|
||||
opts: { contentType?: string; query?: string } = {},
|
||||
) {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/medications/intake/dose-history-import${opts.query ?? ""}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": opts.contentType ?? "text/csv" },
|
||||
body,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(
|
||||
SESSION_OK as unknown as Awaited<ReturnType<typeof getSession>>,
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: new Date(),
|
||||
} as unknown as Awaited<ReturnType<typeof checkRateLimit>>);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1", name: "Ramipril", externalSource: null },
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeImportJob.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medicationIntakeImportJob.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeImportJob.create).mockResolvedValue({
|
||||
id: "job-1",
|
||||
} as never);
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({
|
||||
send: vi.fn().mockResolvedValue("boss-1"),
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/medications/intake/dose-history-import", () => {
|
||||
it("returns the whole verdict and a pollable handle", async () => {
|
||||
const res = await POST(postReq(CSV));
|
||||
expect(res.status).toBe(202);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({
|
||||
dryRun: false,
|
||||
jobId: "job-1",
|
||||
status: "queued",
|
||||
statusUrl: "/api/medications/intake/dose-history-import/job-1/status",
|
||||
file: {
|
||||
rowsRead: 3,
|
||||
queued: 2,
|
||||
refused: 1,
|
||||
refusedByReason: [{ reason: "status_not_recorded", count: 1 }],
|
||||
unmatchedMedications: [],
|
||||
ambiguousMedications: [],
|
||||
mirroredMedications: [],
|
||||
unknownColumns: [],
|
||||
codingsNotRead: 0,
|
||||
// Read and reported, never a reason to drop the dose.
|
||||
fromArchivedMedications: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enqueues an account-wide job carrying both instants and the seeded refusals", async () => {
|
||||
await POST(postReq(CSV));
|
||||
expect(prisma.medicationIntakeImportJob.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "user-1",
|
||||
// No medication owns a file covering a regimen; each entry names its own.
|
||||
medicationId: null,
|
||||
status: "queued",
|
||||
payload: {
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2024-12-31T21:30:00.000Z",
|
||||
takenAt: "2024-12-31T21:50:00.000Z",
|
||||
idempotencyKey: `import-med-1-${Date.parse("2024-12-31T21:30:00.000Z")}`,
|
||||
},
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2025-01-01T21:30:00.000Z",
|
||||
takenAt: "2025-01-01T21:35:00.000Z",
|
||||
idempotencyKey: `import-med-1-${Date.parse("2025-01-01T21:30:00.000Z")}`,
|
||||
// 0.5 against a scheduled 1.0 — a real deviation, so it is kept.
|
||||
doseTaken: "0.5",
|
||||
},
|
||||
],
|
||||
},
|
||||
progress: {
|
||||
processed: 0,
|
||||
total: 2,
|
||||
imported: 0,
|
||||
// Seeded, so the count at the end of the run covers the whole file.
|
||||
skippedByReason: { status_not_recorded: 1 },
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("writes nothing on a dry run", async () => {
|
||||
const res = await POST(postReq(CSV, { query: "?dryRun=1" }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.dryRun).toBe(true);
|
||||
expect(body.data.jobId).toBeNull();
|
||||
expect(body.data.file.queued).toBe(2);
|
||||
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
|
||||
expect(getGlobalBoss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the medications it could not match", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
const res = await POST(postReq(CSV, { query: "?dryRun=1" }));
|
||||
const body = await res.json();
|
||||
expect(body.data.file.unmatchedMedications).toEqual(["Ramipril"]);
|
||||
expect(body.data.file.queued).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses the JSON shape with the reason it cannot be imported", async () => {
|
||||
const res = await POST(
|
||||
postReq(
|
||||
JSON.stringify([
|
||||
{
|
||||
displayText: "Ramipril",
|
||||
scheduledDate: "2025-01-01 08:00:00 +1030",
|
||||
status: "Taken",
|
||||
isArchived: false,
|
||||
},
|
||||
]),
|
||||
{ contentType: "application/json" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe("json_carries_no_intake_time");
|
||||
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the missing column when the header cannot carry a dose", async () => {
|
||||
const res = await POST(postReq("Date,Nickname\n2025-01-01,x\n"));
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "missing_required_columns",
|
||||
detail: "Medication, Status",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a body it has no parser for rather than guessing", async () => {
|
||||
const res = await POST(postReq(CSV, { contentType: "application/x-yaml" }));
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
|
||||
it("refuses a second run while one is still going", async () => {
|
||||
vi.mocked(prisma.medicationIntakeImportJob.findFirst).mockResolvedValue({
|
||||
id: "job-0",
|
||||
} as never);
|
||||
const res = await POST(postReq(CSV));
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails the job and answers 503 when the worker cannot take it", async () => {
|
||||
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
|
||||
const res = await POST(postReq(CSV));
|
||||
expect(res.status).toBe(503);
|
||||
// The job row is not left sitting in `queued` with nothing to pick it up.
|
||||
expect(prisma.medicationIntakeImportJob.update).toHaveBeenCalledWith({
|
||||
where: { id: "job-1" },
|
||||
data: expect.objectContaining({
|
||||
status: "failed",
|
||||
failureReason: "Background worker enqueue failed",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses once the import quota for the hour is spent", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date(),
|
||||
} as unknown as Awaited<ReturnType<typeof checkRateLimit>>);
|
||||
const res = await POST(postReq(CSV));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.medication.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* What the dose-history import card says before anything is written.
|
||||
*
|
||||
* Project convention is SSR-only component tests (vitest runs `node`;
|
||||
* `@testing-library/react` is not installed), so what is pinned here is the
|
||||
* markup each state renders rather than a click-through.
|
||||
*
|
||||
* The two claims: every column of the export has a visible verdict on the card
|
||||
* before a file is chosen, and every machine-readable refusal reason has a
|
||||
* sentence. A reason without one renders as "reason not recorded", which is a
|
||||
* refusal the person can read but cannot act on — the same class of defect as
|
||||
* dropping the column silently.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/export",
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MEDICATION_IMPORT_SKIP_REASONS } from "@/lib/jobs/medication-intake-import";
|
||||
import { AUTO_EXPORT_COLUMN_RULINGS } from "@/lib/medications/import/auto-export-format";
|
||||
import { IntakeImportSkipGroups } from "@/components/medications/intake-import-result";
|
||||
|
||||
import { DoseHistoryColumnRulings } from "../dose-history-columns";
|
||||
import { DoseHistoryImportCard } from "../dose-history-import-card";
|
||||
|
||||
function render(node: React.ReactElement) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DoseHistoryColumnRulings>", () => {
|
||||
const html = render(<DoseHistoryColumnRulings />);
|
||||
|
||||
it("gives every column of the export a visible verdict", () => {
|
||||
for (const ruling of AUTO_EXPORT_COLUMN_RULINGS) {
|
||||
expect(html, `no row for ${ruling.column}`).toContain(
|
||||
`>${ruling.column}<`,
|
||||
);
|
||||
expect(html).toContain(`data-verdict="${ruling.verdict}"`);
|
||||
}
|
||||
const rows =
|
||||
html.split('data-testid="dose-history-column-ruling"').length - 1;
|
||||
expect(rows).toBe(AUTO_EXPORT_COLUMN_RULINGS.length);
|
||||
});
|
||||
|
||||
it("says what happens to a column it does not read, rather than omitting it", () => {
|
||||
// `Codings` is the one column with a code HealthLog stores a field for and
|
||||
// still does not read. Leaving it off the list would be the silent drop.
|
||||
expect(html).toContain(">Codings<");
|
||||
expect(html).toContain("no documented encoding");
|
||||
expect(html).toContain("not read");
|
||||
});
|
||||
|
||||
it("marks the fields only the JSON shape carries", () => {
|
||||
expect(html).toContain("JSON only");
|
||||
for (const ruling of AUTO_EXPORT_COLUMN_RULINGS.filter((r) => r.jsonOnly)) {
|
||||
expect(html).toContain(`>${ruling.column}<`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DoseHistoryImportCard>", () => {
|
||||
const html = render(<DoseHistoryImportCard />);
|
||||
|
||||
it("shows the column verdicts and both actions with no result claimed yet", () => {
|
||||
expect(html).toContain('data-testid="import-card-dose-history"');
|
||||
expect(html).toContain('data-testid="dose-history-columns"');
|
||||
expect(html).toContain('data-testid="import-dose-history-preview"');
|
||||
expect(html).toContain('data-testid="import-action-dose-history"');
|
||||
// Nothing has run, so no outcome and no success affordance.
|
||||
expect(html).not.toContain('data-testid="dose-history-verdict"');
|
||||
expect(html).not.toContain('data-outcome="success"');
|
||||
expect(html).not.toContain("text-success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusal reason sentences", () => {
|
||||
it("has plain language for every reason the importer can report", () => {
|
||||
const html = render(
|
||||
<IntakeImportSkipGroups
|
||||
groups={MEDICATION_IMPORT_SKIP_REASONS.map((reason) => ({
|
||||
reason,
|
||||
count: 1,
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
const lines = html.split("<li>").length - 1;
|
||||
expect(lines).toBe(MEDICATION_IMPORT_SKIP_REASONS.length);
|
||||
// The fallback exists for a reason a future server sends that this client
|
||||
// does not know. It must not be reachable for one this client ships with.
|
||||
expect(html).not.toContain("reason not recorded");
|
||||
});
|
||||
});
|
||||
@@ -31,8 +31,6 @@ export const AUTO_EXPORT_CSV_COLUMNS = [
|
||||
"Codings",
|
||||
] as const;
|
||||
|
||||
export type AutoExportCsvColumn = (typeof AUTO_EXPORT_CSV_COLUMNS)[number];
|
||||
|
||||
/**
|
||||
* What the importer does with a column.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user