mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(import): import lab results and medications from CSV
Two streamed endpoints read a spreadsheet record by record with a column mapping from the query: separator, decimal mark, date order and zone are stated, never guessed per row. Lab rows resolve to markers the way scanned reports do, convert units where the conversion is known and keep what the file said; medication rows map name, dose, daily times, as-needed and a dated window, checked against the create schema, with reminders off unless asked for. A dry run writes nothing, a re-upload only finds duplicates, and every refused row comes back with its line and reason.
This commit is contained in:
1 parent
c94766efc3
commit
a84fb4b467
21 files changed
+3593
-3
No files matched your search
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The request side of `/api/import/labs-csv` and `/api/import/medications-csv`
|
||||
* (#1215): module gate first, a separate bucket for dry runs, the content
|
||||
* type, the byte cap while streaming, the mapping refused before anything is
|
||||
* read, and an audit row only for a real import.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
requireModuleEnabled: vi.fn(),
|
||||
importLabsCsv: vi.fn(),
|
||||
importMedicationsCsv: vi.fn(),
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: mocks.requireModuleEnabled,
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
vi.mock("@/lib/import/labs-csv", () => ({
|
||||
importLabsCsv: mocks.importLabsCsv,
|
||||
}));
|
||||
vi.mock("@/lib/import/medications-csv", () => ({
|
||||
importMedicationsCsv: mocks.importMedicationsCsv,
|
||||
}));
|
||||
|
||||
import { CsvTooLargeError } from "@/lib/import/csv-stream";
|
||||
import { POST as labsPost } from "../labs-csv/route";
|
||||
import { POST as medsPost } from "../medications-csv/route";
|
||||
|
||||
type Handler = (req: NextRequest) => Promise<Response>;
|
||||
const RESULT = {
|
||||
kind: "labs",
|
||||
dryRun: false,
|
||||
rows: 2,
|
||||
valid: 2,
|
||||
inserted: 2,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
errorsTruncated: false,
|
||||
preview: [],
|
||||
};
|
||||
|
||||
function post(
|
||||
handler: unknown,
|
||||
query: string,
|
||||
body = "a,b\n1,2\n",
|
||||
type = "text/csv",
|
||||
) {
|
||||
return (handler as Handler)(
|
||||
new NextRequest(`http://localhost/api/import/x?${query}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": type },
|
||||
body,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
mocks.importLabsCsv.mockResolvedValue(RESULT);
|
||||
mocks.importMedicationsCsv.mockResolvedValue({
|
||||
...RESULT,
|
||||
kind: "medications",
|
||||
});
|
||||
});
|
||||
|
||||
const LABS = "analyte=0&value=1&unit=2&date=3";
|
||||
const MEDS = "name=0&dose=1×=2";
|
||||
|
||||
describe("record CSV import routes", () => {
|
||||
it("answers the module gate before reading the file", async () => {
|
||||
mocks.requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
});
|
||||
expect((await post(labsPost, LABS)).status).toBe(403);
|
||||
expect(mocks.requireModuleEnabled).toHaveBeenCalledWith("user-1", "labs");
|
||||
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a mapping without the required columns, or with a shared column", async () => {
|
||||
expect((await post(labsPost, "analyte=0&value=1")).status).toBe(422);
|
||||
expect((await post(labsPost, "analyte=0&value=0&date=1")).status).toBe(422);
|
||||
expect((await post(medsPost, "name=0")).status).toBe(422);
|
||||
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("spends the preview bucket on a dry run and the import bucket otherwise", async () => {
|
||||
await post(labsPost, `${LABS}&dryRun=1`);
|
||||
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
|
||||
"import-preview:user-1",
|
||||
30,
|
||||
3_600_000,
|
||||
);
|
||||
expect(mocks.auditLog).not.toHaveBeenCalled();
|
||||
await post(medsPost, MEDS);
|
||||
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
|
||||
"import:user-1",
|
||||
5,
|
||||
3_600_000,
|
||||
);
|
||||
expect(mocks.auditLog).toHaveBeenCalledWith(
|
||||
"import.medications.csv",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("answers 429 when the bucket is spent", async () => {
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: false });
|
||||
expect((await post(labsPost, LABS)).status).toBe(429);
|
||||
});
|
||||
|
||||
it("refuses a body that is not CSV", async () => {
|
||||
expect((await post(labsPost, LABS, "{}", "application/json")).status).toBe(
|
||||
415,
|
||||
);
|
||||
});
|
||||
|
||||
it("turns the streaming cap into a 413", async () => {
|
||||
mocks.importLabsCsv.mockRejectedValue(new CsvTooLargeError(1));
|
||||
expect((await post(labsPost, LABS)).status).toBe(413);
|
||||
});
|
||||
|
||||
it("calls a file without data rows empty", async () => {
|
||||
mocks.importLabsCsv.mockResolvedValue({ ...RESULT, rows: 0 });
|
||||
expect((await post(labsPost, LABS)).status).toBe(422);
|
||||
});
|
||||
|
||||
it("hands the parsed mapping and the account zone to the importer", async () => {
|
||||
const res = await post(
|
||||
labsPost,
|
||||
`${LABS}&decimal=comma&dateOrder=dmy&delimiter=semicolon`,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const args = mocks.importLabsCsv.mock.calls[0][0];
|
||||
expect(args.tz).toBe("Europe/Berlin");
|
||||
expect(args.query).toMatchObject({
|
||||
analyte: 0,
|
||||
value: 1,
|
||||
unit: 2,
|
||||
date: 3,
|
||||
decimal: "comma",
|
||||
dateOrder: "dmy",
|
||||
delimiter: "semicolon",
|
||||
dryRun: false,
|
||||
header: true,
|
||||
});
|
||||
expect(args.stream).toBeInstanceOf(ReadableStream);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* `POST /api/import/labs-csv` — lab results from a spreadsheet (#1215).
|
||||
*
|
||||
* Body: the CSV file, streamed. Query: the column mapping and the file's
|
||||
* conventions (`labsCsvQuerySchema`). `dryRun=1` checks every row and reports
|
||||
* what would happen without writing; the real import writes in chunks, skips
|
||||
* readings the record already holds and reports every refused row by line.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { importLabsCsv } from "@/lib/import/labs-csv";
|
||||
import { handleRecordImport } from "@/lib/import/record-import-route";
|
||||
import { labsCsvQuerySchema } from "@/lib/validations/record-import";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) =>
|
||||
handleRecordImport(request, {
|
||||
kind: "labs",
|
||||
module: "labs",
|
||||
schema: labsCsvQuerySchema,
|
||||
run: (args) => importLabsCsv(args),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* `POST /api/import/medications-csv` — medications from a spreadsheet (#1215).
|
||||
*
|
||||
* Body: the CSV file, streamed. Query: the column mapping and the file's
|
||||
* conventions (`medicationsCsvQuerySchema`). Maps only name, dose, daily
|
||||
* times, as-needed and a start and end date; reminders stay off unless the
|
||||
* query turns them on. `dryRun=1` checks without writing.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { importMedicationsCsv } from "@/lib/import/medications-csv";
|
||||
import { handleRecordImport } from "@/lib/import/record-import-route";
|
||||
import { medicationsCsvQuerySchema } from "@/lib/validations/record-import";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) =>
|
||||
handleRecordImport(request, {
|
||||
kind: "medications",
|
||||
module: "medications",
|
||||
schema: medicationsCsvQuerySchema,
|
||||
run: (args) => importMedicationsCsv(args),
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user