Files
HealthLog/tests/integration/csv-reimport-idempotent.test.ts
T
Marc-André Bombeck 24050d84c7 fix(import): make re-importing the same CSV idempotent and fast
Re-uploading a large export with an externalId column ran one
updateMany per row inside a single interactive transaction; on a
9 000-row file that outlived Prisma's 5 s timeout and the request ended
in a 500.

A re-imported externalId row whose stored value, unit, instant, glucose
context and note already match the line is now a duplicate: not written,
reported as skipped / duplicate. Changed rows still update, a tombstoned
one is still resurrected. The existence probes run per measurement type
in chunks of 1 000 keys, and the writes commit in chunks of 200 (creates
via createManyAndReturn, updates as one batch transaction per chunk)
instead of one transaction around the whole file. A failure part-way
leaves earlier chunks written, and re-uploading the same file completes
the rest.

The OpenAPI description now says what a re-upload does, including the
natural-key dedup for rows without an externalId, which it used to call
a duplicate.
2026-09-26 19:58:50 +02:00

149 lines
4.8 KiB
TypeScript

/**
* v1.39.3 — importing the same large CSV twice.
*
* A re-upload of a 9 000-row export with an externalId column ended in a 500:
* every row already existed, so the route ran one update per row, serially,
* inside a single interactive transaction, and that outlived Prisma's 5 s
* timeout. Only a real database shows it. This imports the same file twice and
* then a lightly edited copy, and reads the table back after each pass.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "user-csv-reimport";
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";
const ROWS = 9000;
/** 8 000 externalId rows and 1 000 without one, a minute apart. */
function fileLines(changed: ReadonlySet<number> = new Set()): string[] {
return Array.from({ length: ROWS }, (_, i) => {
const at = new Date(Date.UTC(2025, 0, 1) + i * 60_000).toISOString();
const value = (70 + (i % 50) / 10 + (changed.has(i) ? 1 : 0)).toFixed(1);
const note = i % 100 === 0 ? "after the long ride" : "";
const externalId = i < 8000 ? `export-${i}` : "";
return `WEIGHT,${value},kg,${at},,${note},${externalId}`;
});
}
function csvRequest(lines: string[]): NextRequest {
return new NextRequest("http://localhost/api/import/csv", {
method: "POST",
headers: { "content-type": "text/csv" },
body: [HEADER, ...lines].join("\n"),
});
}
interface CsvEnvelope {
data: {
inserted: number;
updated: number;
skipped: number;
total: number;
} | null;
}
async function signIn() {
await getPrismaClient().user.create({
data: {
id: USER,
username: "csv-reimport",
email: "csv-reimport@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);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
});
describe("POST /api/import/csv — re-importing a 9 000-row file (real Postgres)", () => {
it("imports once, writes nothing the second time, and updates only what changed", async () => {
await signIn();
const { POST } = await import("@/app/api/import/csv/route");
const prisma = getPrismaClient();
const first = await POST(csvRequest(fileLines()));
expect(first.status).toBe(200);
expect(((await first.json()) as CsvEnvelope).data).toMatchObject({
inserted: ROWS,
updated: 0,
skipped: 0,
});
expect(await prisma.measurement.count({ where: { userId: USER } })).toBe(
ROWS,
);
const before = await prisma.measurement.findMany({
where: { userId: USER },
select: { id: true, value: true, notesEncrypted: true },
orderBy: { measuredAt: "asc" },
});
const second = await POST(csvRequest(fileLines()));
expect(second.status).toBe(200);
expect(((await second.json()) as CsvEnvelope).data).toMatchObject({
inserted: 0,
updated: 0,
skipped: ROWS,
});
const after = await prisma.measurement.findMany({
where: { userId: USER },
select: { id: true, value: true, notesEncrypted: true },
orderBy: { measuredAt: "asc" },
});
// Same rows, untouched: not even the note ciphertext was re-sealed.
expect(after).toEqual(before);
const changed = new Set([0, 1, 4999, 7999]);
const third = await POST(csvRequest(fileLines(changed)));
expect(third.status).toBe(200);
expect(((await third.json()) as CsvEnvelope).data).toMatchObject({
inserted: 0,
updated: changed.size,
skipped: ROWS - changed.size,
});
expect(await prisma.measurement.count({ where: { userId: USER } })).toBe(
ROWS,
);
const edited = await prisma.measurement.findFirst({
where: { userId: USER, externalId: "export-4999" },
select: { value: true },
});
expect(edited?.value).toBeCloseTo(70 + (4999 % 50) / 10 + 1, 5);
}, 120_000);
});