Files
HealthLog/tests/integration/csv-import-contextless-glucose.test.ts
T
Marc-André Bombeck 2dc3f8984e test: derive the glucose import assertions from the shared factor
Two integration assertions carried the import path's own copy of the
mmol/L conversion, so unifying the two conversions left them asserting
numbers the code no longer produces. They now multiply by the exported
factor, which is what the unit tests already do.

The reason these were missed before the merge is worth writing down:
`pnpm test` does not run tests/integration, and neither the change that
unified the factor nor the gate on the integrated branch ran the suite
that covers the import path. A literal here re-pins whichever copy of a
conversion the caller happened to use when the test was written, so the
assertion is derived rather than pinned.
2026-09-03 07:42:59 +02:00

225 lines
7.4 KiB
TypeScript

/**
* #640 — a contextless blood-glucose reading reaches Postgres.
*
* The reported import wrote nothing. Two layers had to agree that a reading
* without a fasting / post-meal classification is a reading: the CSV parser,
* and the CHECK constraint from migration 0021 that demanded a context on
* every `BLOOD_GLUCOSE` row. Only a real database proves the second half, so
* this runs the route end to end and reads the row back.
*
* Also pinned here: the constraint's surviving half (a context on any other
* type is still refused), the re-upload idempotency the importer promises,
* and the writers that were failing silently against the old constraint
* (the CGM sync shape and the single-value tool shape) now landing.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { MGDL_PER_MMOL } from "@/lib/glucose";
const USER = "user-csv-glucose";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const HEADER = "type,value,unit,measuredAt,glucoseContext,notes,externalId";
async function signIn() {
await getPrismaClient().user.create({
data: {
id: USER,
username: "csv-glucose",
email: "csv-glucose@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: USER,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
}
function csvRequest(rows: string[]): NextRequest {
return new NextRequest("http://localhost/api/import/csv", {
method: "POST",
headers: { "content-type": "text/csv" },
body: [HEADER, ...rows].join("\n"),
});
}
interface CsvEnvelope {
data: {
inserted: number;
updated: number;
skipped: number;
total: number;
rows: Array<{ line: number; status: string; reason?: string }>;
} | null;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
});
describe("POST /api/import/csv — contextless glucose (real Postgres)", () => {
it("persists a sensor reading with no context, in the canonical shape", async () => {
await signIn();
const { POST } = await import("@/app/api/import/csv/route");
const res = await POST(
csvRequest([
"BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1",
]),
);
expect(res.status).toBe(200);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(1);
expect(body.data?.skipped).toBe(0);
const rows = await getPrismaClient().measurement.findMany({
where: { userId: USER },
});
expect(rows).toHaveLength(1);
expect(rows[0].type).toBe("BLOOD_GLUCOSE");
expect(rows[0].glucoseContext).toBeNull();
expect(rows[0].source).toBe("IMPORT");
expect(rows[0].unit).toBe("mg/dL");
// Derived from the shared factor rather than pinned, for the same reason
// as below: a literal here re-pins whichever copy of the conversion the
// import path happened to use when it was written.
expect(rows[0].value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3);
expect(rows[0].externalId).toBe("sensor-1");
expect(rows[0].measuredAt.toISOString()).toBe("2024-04-03T02:15:00.000Z");
});
it("stays idempotent when the same external id is re-uploaded", async () => {
await signIn();
const { POST } = await import("@/app/api/import/csv/route");
const first = await POST(
csvRequest([
"BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1",
]),
);
expect(((await first.json()) as CsvEnvelope).data?.inserted).toBe(1);
const second = await POST(
csvRequest([
"BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1",
]),
);
const secondBody = (await second.json()) as CsvEnvelope;
expect(secondBody.data?.inserted).toBe(0);
expect(secondBody.data?.updated).toBe(1);
const rows = await getPrismaClient().measurement.findMany({
where: { userId: USER },
});
expect(rows).toHaveLength(1);
expect(rows[0].glucoseContext).toBeNull();
// Derived, not pinned: this carried the import path's own copy of the
// conversion factor, so unifying the two conversions left it asserting a
// number the code no longer produces.
expect(rows[0].value).toBeCloseTo(5.9 * MGDL_PER_MMOL, 3);
});
it("keeps a named context, and still refuses one that is not in the enum", async () => {
await signIn();
const { POST } = await import("@/app/api/import/csv/route");
const res = await POST(
csvRequest([
"BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,FASTING,,meter-1",
"BLOOD_GLUCOSE,95,mg/dL,2026-05-01T09:00:00Z,LUNCH,,meter-2",
]),
);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(1);
expect(body.data?.rows[1]).toMatchObject({
status: "skipped",
reason: "invalid_glucose_context",
});
const rows = await getPrismaClient().measurement.findMany({
where: { userId: USER },
});
expect(rows).toHaveLength(1);
expect(rows[0].glucoseContext).toBe("FASTING");
});
});
describe("measurements.glucose_context — surviving CHECK half", () => {
it("still refuses a context on a row that is not blood glucose", async () => {
await signIn();
await expect(
getPrismaClient().measurement.create({
data: {
userId: USER,
type: "WEIGHT",
value: 80,
unit: "kg",
source: "IMPORT",
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
glucoseContext: "FASTING",
},
}),
).rejects.toThrow();
});
it("accepts the contextless shape the device writers build", async () => {
await signIn();
// The Nightscout SGV upsert, the HealthKit batch mapping, and the MCP /
// Telegram single-value captures all build a BLOOD_GLUCOSE row with no
// context. Under the old constraint every one of those inserts was
// refused by the database.
const sources = ["NIGHTSCOUT", "APPLE_HEALTH", "MCP", "TELEGRAM"] as const;
for (const [index, source] of sources.entries()) {
await getPrismaClient().measurement.create({
data: {
userId: USER,
type: "BLOOD_GLUCOSE",
value: 95,
unit: "mg/dL",
source,
measuredAt: new Date(`2026-05-01T0${index}:00:00.000Z`),
externalId: `${source}-1`,
},
});
}
const rows = await getPrismaClient().measurement.findMany({
where: { userId: USER, type: "BLOOD_GLUCOSE" },
});
expect(rows).toHaveLength(sources.length);
expect(rows.every((row) => row.glucoseContext === null)).toBe(true);
});
});