From f648c1748a705b127d2a0535f617fd934b4b06a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:12:30 +0200 Subject: [PATCH] fix(import): classify operational failures --- src/app/api/import/__tests__/route.test.ts | 277 ++++++++++++++++++++- src/app/api/import/route.ts | 91 ++++--- 2 files changed, 333 insertions(+), 35 deletions(-) diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index c31297c3b..5e0647b8f 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -33,6 +33,12 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); +const { encryptNoteMock } = vi.hoisted(() => ({ encryptNoteMock: vi.fn() })); + +vi.mock("@/lib/crypto/note-cipher", () => ({ + encryptNote: encryptNoteMock, +})); + vi.mock("@/lib/arrivals/measurement-emit", () => ({ emitInsertedMeasurementArrivals: vi.fn(), })); @@ -65,12 +71,17 @@ import { prisma } from "@/lib/db"; import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; +import { Prisma } from "@/generated/prisma/client"; +import { auditLog } from "@/lib/auth/audit"; beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null); vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined); vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined); + encryptNoteMock.mockImplementation((note: string | null) => + note === null ? null : `encrypted:${note}`, + ); }); interface ApiErrorEnvelope { @@ -234,7 +245,10 @@ describe("POST /api/import — measurement rollup hook (v1.4.39.1)", () => { resetAt: new Date(Date.now() + 1000), } as never); vi.mocked(prisma.measurement.create).mockRejectedValue( - new Error("P2002 duplicate"), + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + }), ); const response = await POST( @@ -390,10 +404,10 @@ describe("POST /api/import — arrival wiring", () => { }); it("does not emit or refresh an existing external-id update", async () => { - vi.mocked(prisma.measurement.findUnique).mockResolvedValue({ - id: "existing", - } as never); - vi.mocked(prisma.measurement.upsert).mockResolvedValue({ + vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.measurement.update).mockResolvedValue({ id: "existing", type: "SLEEP_DURATION", measuredAt: new Date("2026-05-14T06:00:00.000Z"), @@ -573,3 +587,256 @@ describe("POST /api/import — measurement externalId dedup (v1.17.1)", () => { expect(vi.mocked(prisma.measurement.upsert)).not.toHaveBeenCalled(); }); }); + +describe("POST /api/import — write failure classification", () => { + beforeEach(() => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: new Date(Date.now() + 1000), + } as never); + }); + + function request(body: Record): NextRequest { + return new NextRequest("http://localhost/api/import", { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); + } + + function measurement(value = 80): Record { + return { + type: "WEIGHT", + value, + unit: "kg", + measuredAt: "2026-05-01T08:00:00.000Z", + }; + } + + function moodEntry(): Record { + return { + date: "2026-05-01", + mood: "GUT", + score: 4, + loggedAt: "2026-05-01T12:00:00.000Z", + }; + } + + function expectRetryableFailure( + body: unknown, + stats: { measurements: number; moodEntries: number; skipped: number }, + ): void { + expect(body).toEqual({ + data: null, + error: "Import write failed", + meta: { + errorCode: "import.write_failed", + retryable: true, + stats, + }, + }); + } + + it("counts an authentic P2002 measurement error as a duplicate", async () => { + vi.mocked(prisma.measurement.create).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + meta: { + target: ["userId", "type", "measuredAt", "source", "sleepStage"], + }, + }), + ); + + const response = await POST(request({ measurements: [measurement()] })); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: { measurements: 0, moodEntries: 0, skipped: 1 }, + error: null, + }); + }); + + it("counts an authentic P2002 mood error as a duplicate", async () => { + vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + meta: { + target: ["userId", "date", "moodLoggedAt"], + }, + }), + ); + + const response = await POST(request({ moodEntries: [moodEntry()] })); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: { measurements: 0, moodEntries: 0, skipped: 1 }, + error: null, + }); + }); + + it("skips an external-id insert race lost on timestamp", async () => { + vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValueOnce( + [] as never, + ); + vi.mocked(prisma.measurement.update).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Record to update not found", { + code: "P2025", + clientVersion: "test", + }), + ); + + const response = await POST( + request({ + measurements: [{ ...measurement(), externalId: "new-source-id" }], + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: { measurements: 0, moodEntries: 0, skipped: 1 }, + error: null, + }); + }); + + it("returns retryable counts on database timeout", async () => { + vi.mocked(prisma.measurement.create) + .mockResolvedValueOnce({ + id: "measurement-1", + type: "WEIGHT", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + } as never) + .mockRejectedValueOnce( + new Prisma.PrismaClientInitializationError( + "Operations timed out", + "test", + "P1008", + ), + ); + + const response = await POST( + request({ measurements: [measurement(80), measurement(81)] }), + ); + + expect(response.status).toBe(503); + expectRetryableFailure(await response.json(), { + measurements: 1, + moodEntries: 0, + skipped: 0, + }); + }); + + it("returns retryable counts on connection loss", async () => { + vi.mocked(prisma.measurement.create).mockResolvedValueOnce({ + id: "measurement-1", + type: "WEIGHT", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + } as never); + vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce( + new Prisma.PrismaClientInitializationError( + "Can't reach database server", + "test", + "P1001", + ), + ); + + const response = await POST( + request({ + measurements: [measurement()], + moodEntries: [moodEntry()], + }), + ); + + expect(response.status).toBe(503); + expectRetryableFailure(await response.json(), { + measurements: 1, + moodEntries: 0, + skipped: 0, + }); + }); + + it("returns a retryable failure when note encryption fails", async () => { + encryptNoteMock.mockImplementationOnce(() => { + throw new Error("Encryption key unavailable"); + }); + + const response = await POST( + request({ + measurements: [{ ...measurement(), notes: "private note" }], + }), + ); + + expect(response.status).toBe(503); + expectRetryableFailure(await response.json(), { + measurements: 0, + moodEntries: 0, + skipped: 0, + }); + }); + + it("returns a retryable failure for a generic mood write error", async () => { + vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce( + new Error("write failed"), + ); + + const response = await POST(request({ moodEntries: [moodEntry()] })); + + expect(response.status).toBe(503); + expectRetryableFailure(await response.json(), { + measurements: 0, + moodEntries: 0, + skipped: 0, + }); + }); + + it("audits rows committed before a later write failure", async () => { + vi.mocked(prisma.measurement.create) + .mockResolvedValueOnce({ + id: "measurement-1", + type: "WEIGHT", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + } as never) + .mockRejectedValueOnce(new Error("write failed")); + + const response = await POST( + request({ measurements: [measurement(80), measurement(81)] }), + ); + + expect(response.status).toBe(503); + expect(auditLog).toHaveBeenCalledWith("import.upload", { + userId: "u-1", + ipAddress: null, + details: { measurements: 1, moodEntries: 0, skipped: 0 }, + }); + }); + + it("keeps the classified failure when its audit also fails", async () => { + vi.mocked(prisma.measurement.create).mockRejectedValueOnce( + new Prisma.PrismaClientInitializationError( + "Can't reach database server", + "test", + "P1001", + ), + ); + vi.mocked(auditLog).mockRejectedValueOnce( + new Prisma.PrismaClientInitializationError( + "Can't reach database server", + "test", + "P1001", + ), + ); + + const response = await POST(request({ measurements: [measurement()] })); + + expect(response.status).toBe(503); + expectRetryableFailure(await response.json(), { + measurements: 0, + moodEntries: 0, + skipped: 0, + }); + expect(auditLog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index bb8a0e863..6e633e756 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -27,6 +27,7 @@ import { import type { MeasurementType } from "@/generated/prisma/client"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; +import { isP2002, isP2025 } from "@/lib/prisma-errors"; // Derived from canonical enum so round-trip export → import covers every // type. Previous hardcoded subset silently dropped 4 of 11 types @@ -113,6 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const userId = user.id; const data = parsed.data; const stats = { measurements: 0, moodEntries: 0, skipped: 0 }; + let writeFailed = false; // Import measurements // v1.4.39.1 — track each (type, measuredAt) we wrote so we can re- @@ -164,24 +166,32 @@ export const POST = apiHandler(async (request: NextRequest) => { // A pre-existing row or a raced insert owns the key. Preserve // idempotent re-import semantics without classifying this update // as an arrival. - await prisma.measurement.update({ - where: { - userId_type_source_externalId: { - userId, - type: m.type, - source: "IMPORT", - externalId: m.externalId, + try { + await prisma.measurement.update({ + where: { + userId_type_source_externalId: { + userId, + type: m.type, + source: "IMPORT", + externalId: m.externalId, + }, }, - }, - data: { - value: m.value, - unit: m.unit, - measuredAt, - notes: null, - notesEncrypted, - glucoseContext: m.glucoseContext ?? null, - }, - }); + data: { + value: m.value, + unit: m.unit, + measuredAt, + notes: null, + notesEncrypted, + glucoseContext: m.glucoseContext ?? null, + }, + }); + } catch (err) { + if (isP2025(err)) { + stats.skipped++; + continue; + } + throw err; + } } } else { const inserted = await prisma.measurement.create({ @@ -205,9 +215,13 @@ export const POST = apiHandler(async (request: NextRequest) => { measuredAt, }); stats.measurements++; - } catch { - // Unique constraint violation — skip duplicate - stats.skipped++; + } catch (err) { + if (isP2002(err)) { + stats.skipped++; + continue; + } + writeFailed = true; + break; } } } @@ -225,7 +239,7 @@ export const POST = apiHandler(async (request: NextRequest) => { ).catch(() => {}); // Import mood entries - if (data.moodEntries?.length) { + if (!writeFailed && data.moodEntries?.length) { for (const e of data.moodEntries) { try { const loggedAt = e.loggedAt ?? new Date(); @@ -274,9 +288,13 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } stats.moodEntries++; - } catch { - // Unique constraint violation — skip duplicate - stats.skipped++; + } catch (err) { + if (isP2002(err)) { + stats.skipped++; + continue; + } + writeFailed = true; + break; } } } @@ -322,13 +340,26 @@ export const POST = apiHandler(async (request: NextRequest) => { } } - await auditLog("import.upload", { - userId, - ipAddress: getClientIp(request), - details: stats, - }); - annotate({ meta: { import_stats: stats } }); + try { + await auditLog("import.upload", { + userId, + ipAddress: getClientIp(request), + details: stats, + }); + } catch (err) { + if (!writeFailed) throw err; + annotate({ meta: { import_audit_failed: true } }); + } + + if (writeFailed) { + return apiError("Import write failed", 503, { + errorCode: "import.write_failed", + retryable: true, + stats, + }); + } + return apiSuccess(stats); });