diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 56d842b15..e4a162f53 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -190,15 +190,15 @@ describe("GET /api/insights/cards", () => { pulseRows.push({ value: i < 3 ? 300 : 68, measuredAt: at }); } - vi.mocked(prisma.measurement.findMany).mockImplementation( - (async (args: { where?: { type?: unknown } }) => { - // `readDayMeanSeries`'s live-fallback probes a single `type` - // equality filter; the manual WEIGHT/BP query below probes an - // `{ in: [...] }` list — the two calls are distinguishable by shape. - if (args?.where?.type === "PULSE") return pulseRows; - return []; - }) as never, - ); + vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: { + where?: { type?: unknown }; + }) => { + // `readDayMeanSeries`'s live-fallback probes a single `type` + // equality filter; the manual WEIGHT/BP query below probes an + // `{ in: [...] }` list — the two calls are distinguishable by shape. + if (args?.where?.type === "PULSE") return pulseRows; + return []; + }) as never); const res = await callGet(makeReq()); expect(res.status).toBe(200); @@ -216,13 +216,12 @@ describe("GET /api/insights/cards", () => { .mocked(prisma.measurement.findMany) .mock.calls.find( (call) => - typeof (call[0] as { where?: { type?: { in?: unknown[] } } }) - ?.where?.type === "object", + typeof (call[0] as { where?: { type?: { in?: unknown[] } } })?.where + ?.type === "object", ); expect(manualCall).toBeDefined(); - const typeIn = ( - manualCall?.[0] as { where: { type: { in: string[] } } } - ).where.type.in; + const typeIn = (manualCall?.[0] as { where: { type: { in: string[] } } }) + .where.type.in; expect(typeIn).not.toContain("PULSE"); }); diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index e5e3a00a2..3154ea718 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -3,6 +3,10 @@ * /api/medications/[id]/intake/import. Preserves the * `medication.intake.import.invalid_format` errorCode meta so the CSV * import client UI can branch on the prefix semantics. + * + * v1.29 perf fix — the per-row `findUnique` duplicate probe was replaced by + * one indexed `findMany` existence read over every row's idempotencyKey + * before the write loop. `create` + `consumeForIntake` stay per-row. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -13,7 +17,7 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), }, medicationIntakeEvent: { - findUnique: vi.fn(), + findMany: vi.fn(), create: vi.fn(), }, auditLog: { create: vi.fn() }, @@ -75,6 +79,12 @@ function postReq(body: unknown): NextRequest { const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; +/** Mirrors the route's own dedup-key derivation for a zaehler-less row. */ +function keyFor(medId: string, datum: string, uhrzeit: string): string { + const takenAt = new Date(`${datum}T${uhrzeit}`); + return `import-${medId}-${takenAt.getTime()}`; +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); @@ -172,10 +182,9 @@ describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 describe("POST /api/medications/[id]/intake/import — inventory consumption", () => { beforeEach(() => { - // Fresh imports: no pre-existing dedup row, and create echoes an id. - vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue( - null as never, - ); + // Fresh imports: the batched existence read finds no prior rows, and + // create echoes an id. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]); let n = 0; vi.mocked(prisma.medicationIntakeEvent.create).mockImplementation( (async () => ({ id: `evt-${++n}`, takenAt: new Date() })) as never, @@ -200,16 +209,24 @@ describe("POST /api/medications/[id]/intake/import — inventory consumption", ( expect(call?.userId).toBe("user-1"); expect(call?.eventId).toBe("evt-1"); + // The three-row import reads existence in ONE indexed query, not one + // findUnique probe per row. + expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalledTimes(1); + const findManyArg = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0]?.[0] as { where: { idempotencyKey: { in: string[] } } }; + expect(findManyArg.where.idempotencyKey.in).toHaveLength(3); + // (#22) — the whole import queues exactly ONE cross-device sync // fan-out, not one per imported row. expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); }); it("does not consume for duplicate (already-imported) rows", async () => { - // First entry is a duplicate (existing dedup row), second is fresh. - vi.mocked(prisma.medicationIntakeEvent.findUnique) - .mockResolvedValueOnce({ id: "existing" } as never) - .mockResolvedValueOnce(null as never); + // First entry's key is already present — the batched existence read + // reports it, the second entry's key is absent (fresh). + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { idempotencyKey: keyFor("m1", "2026-01-01", "07:00:00") }, + ] as never); const res = await POST( postReq([ { datum: "2026-01-01", uhrzeit: "07:00:00" }, diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 75fa97360..6059f98ff 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -99,6 +99,20 @@ export const POST = apiHandler( // so the rollup pass fires once per day rather than once per row. const touchedDays = new Set(); + // Perf — a CSV import can carry up to 1000 rows; the pre-fix loop ran a + // `findUnique` duplicate probe before every single `create`, up to 2000 + // sequential round trips. Resolve `takenAt` + the dedup key for every + // row first, then read every key's existence in ONE indexed query + // (`idempotencyKey` carries its own unique index) before the write + // loop. `create` + `consumeForIntake` (inventory decrement) stay + // per-row — each has a real side effect keyed off the freshly-created + // event's id, so they aren't safely batchable without a deeper change + // to the inventory-consumption path. + interface PreparedIntake { + takenAt: Date; + idempotencyKey: string; + } + const prepared: PreparedIntake[] = []; for (const entry of entries) { // Parse as local datetime first; if invalid, fall back to CET offset. let takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`); @@ -115,10 +129,26 @@ export const POST = apiHandler( ? `import-${id}-${String(entry.zaehler)}` : `import-${id}-${takenAt.getTime()}`; - const existing = await prisma.medicationIntakeEvent.findUnique({ - where: { idempotencyKey }, - }); - if (existing) { + prepared.push({ takenAt, idempotencyKey }); + } + + const existingRows = + prepared.length > 0 + ? await prisma.medicationIntakeEvent.findMany({ + where: { + idempotencyKey: { in: prepared.map((p) => p.idempotencyKey) }, + }, + select: { idempotencyKey: true }, + }) + : []; + const existingKeys = new Set( + existingRows + .map((r) => r.idempotencyKey) + .filter((k): k is string => k !== null), + ); + + for (const { takenAt, idempotencyKey } of prepared) { + if (existingKeys.has(idempotencyKey)) { skippedDuplicates++; continue; } diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 7bf24ea65..00a815a76 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -321,7 +321,8 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { }; expect(json.data.inserted).toBe(1); expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1); - const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as { + const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock + .calls[0]?.[0] as { where: { userId: string; OR: Array> }; }; expect(findManyArg.where.OR).toContainEqual({ @@ -349,7 +350,8 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { const json = (await res.json()) as { data: { entries: Array<{ externalId?: string }> }; }; - const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as { + const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock + .calls[0]?.[0] as { where: { OR: Array> }; }; expect(findManyArg.where.OR).toContainEqual({ diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index c74dc1d9a..95fde2461 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -209,7 +209,10 @@ async function postBulk(request: NextRequest): Promise { > = entries.map((entry) => entry.externalId ? { source: entry.source, externalId: entry.externalId } - : { date: moodDateKey(entry.moodLoggedAt, tz), moodLoggedAt: entry.moodLoggedAt }, + : { + date: moodDateKey(entry.moodLoggedAt, tz), + moodLoggedAt: entry.moodLoggedAt, + }, ); const existingRows = orClauses.length > 0 @@ -231,7 +234,10 @@ async function postBulk(request: NextRequest): Promise { if (row.externalId !== null) { existingByExternalKey.set(`${row.source}::${row.externalId}`, row); } - existingByDateKey.set(`${row.date}::${row.moodLoggedAt.toISOString()}`, row); + existingByDateKey.set( + `${row.date}::${row.moodLoggedAt.toISOString()}`, + row, + ); } for (let i = 0; i < entries.length; i++) { @@ -271,8 +277,9 @@ async function postBulk(request: NextRequest): Promise { const existing = (entry.externalId ? existingByExternalKey.get(`${resolvedSource}::${entry.externalId}`) - : existingByDateKey.get(`${date}::${entry.moodLoggedAt.toISOString()}`)) ?? - null; + : existingByDateKey.get( + `${date}::${entry.moodLoggedAt.toISOString()}`, + )) ?? null; // Tombstone suppression: a soft-deleted match stays deleted. The // user-facing DELETE route flips `deletedAt` so the `/api/sync/changes` diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts index 1d53a6597..1faac5028 100644 --- a/src/app/api/nutrients/batch/__tests__/route.test.ts +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -113,9 +113,8 @@ beforeEach(() => { vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({} as never); // Real Prisma resolves every promise in the `$transaction([...])` array; // the mock does the same so the update path actually runs in these tests. - vi.mocked(prisma.$transaction).mockImplementation( - (async (ops: unknown[]) => Promise.all(ops)) as never, - ); + vi.mocked(prisma.$transaction).mockImplementation((async (ops: unknown[]) => + Promise.all(ops)) as never); }); describe("POST /api/nutrients/batch — module gate", () => { diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 8a6dae2b9..5e775572c 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -35,9 +35,7 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ })); vi.mock("@/lib/insights/derived/baseline", () => ({ - readDayMeanSeries: vi - .fn() - .mockResolvedValue({ points: [], source: "none" }), + readDayMeanSeries: vi.fn().mockResolvedValue({ points: [], source: "none" }), })); vi.mock("@/lib/analytics/intraday-pulse-io", () => ({