From b64f48bce31b9d57ba9a02241cb605066f292019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 03:42:36 +0200 Subject: [PATCH] fix(mood): honour externalId for idempotent re-import on the native entry routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0122 added MoodEntry.externalId plus the NULL-distinct @@unique([userId, source, externalId]) and wired the moodLog webhook and sync importer onto it, but the iOS-facing native routes never used it: POST /api/mood-entries dropped externalId entirely (the schema did not declare it) and POST /api/mood-entries/bulk accepted it in its Zod schema yet parsed-and-ignored it, still upserting on the legacy (userId, date, moodLoggedAt) key. The dedup index existed but nothing on the native write path reached it, so the idempotent re-import the client needs was not functional. Declare externalId on createMoodEntrySchema (bounded to match the bulk schema), persist it, and — when present — upsert the single-entry write on (userId, source, externalId) so a re-post with the same id updates the existing row in place instead of 409-ing or minting a duplicate. When absent, keep the legacy first-write create and its 409-on-conflict behaviour untouched. On the bulk path, branch the per-entry probe and upsert onto the same compound key when an entry carries an externalId, refreshing date and moodLoggedAt in the update so a re-zoned re-import lands the corrected wall-clock on the same row; absent ids keep the legacy wall-clock key. Both paths resolve source once so the dedup key and the row write agree. Echo externalId back on the single response (via the row) and on each bulk per-entry result so the client can map server ids onto its local rows. Cover the present-vs-absent upsert key, cross-user isolation, and the echo with unit tests on both routes plus a real-Postgres integration test proving a re-posted externalId resolves to one row updated in place, a null externalId keeps wall-clock behaviour, and the same id under two users stays isolated. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 6a65de22bc436daeeb73dd2a1cb612336c74a928) --- .../api/mood-entries/__tests__/route.test.ts | 128 +++++++ .../mood-entries/bulk/__tests__/route.test.ts | 106 ++++++ src/app/api/mood-entries/bulk/route.ts | 77 +++-- src/app/api/mood-entries/route.ts | 80 ++++- src/lib/validations/moodlog.ts | 8 + .../mood-entries-external-id.test.ts | 320 ++++++++++++++++++ 6 files changed, 685 insertions(+), 34 deletions(-) create mode 100644 tests/integration/mood-entries-external-id.test.ts diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index b6831c601..b501ed812 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -11,6 +11,10 @@ import { NextRequest } from "next/server"; const txClient = { moodEntry: { create: vi.fn(), + // v1.12.1 — the create path upserts on `(userId, source, externalId)` + // when the body carries a source-stable id, so the tx client must + // expose `upsert` too. + upsert: vi.fn(), }, moodEntryTagLink: { findMany: vi.fn().mockResolvedValue([]), @@ -392,3 +396,127 @@ describe("POST /api/mood-entries — rated factors (v1.12.0)", () => { expect(res.status).toBe(422); }); }); + +describe("POST /api/mood-entries — externalId idempotent re-import (v1.12.1)", () => { + it("upserts on (userId, source, externalId) when externalId is present", async () => { + txClient.moodEntry.upsert.mockResolvedValue({ + id: "mood-ext", + tags: null, + moodLoggedAt: new Date("2026-06-01T08:00:00.000Z"), + mood: "GUT", + note: null, + source: "MANUAL", + externalId: "ios-uuid-1", + date: "2026-06-01", + }); + + const res = await POST( + postReq({ + mood: "GUT", + moodLoggedAt: "2026-06-01T08:00:00.000Z", + externalId: "ios-uuid-1", + }), + ); + expect(res.status).toBe(201); + // The legacy `create` path is NOT taken when externalId is present. + expect(txClient.moodEntry.create).not.toHaveBeenCalled(); + expect(txClient.moodEntry.upsert).toHaveBeenCalledTimes(1); + const call = txClient.moodEntry.upsert.mock.calls[0]?.[0] as { + where: { + userId_source_externalId: { + userId: string; + source: string; + externalId: string; + }; + }; + create: { externalId: string; source: string }; + }; + expect(call.where.userId_source_externalId).toEqual({ + userId: "user-1", + source: "MANUAL", + externalId: "ios-uuid-1", + }); + expect(call.create.externalId).toBe("ios-uuid-1"); + }); + + it("echoes externalId back in the create response", async () => { + txClient.moodEntry.upsert.mockResolvedValue({ + id: "mood-ext", + tags: null, + moodLoggedAt: new Date("2026-06-01T08:00:00.000Z"), + mood: "GUT", + note: null, + source: "MANUAL", + externalId: "ios-uuid-2", + date: "2026-06-01", + }); + const res = await POST( + postReq({ + mood: "GUT", + moodLoggedAt: "2026-06-01T08:00:00.000Z", + externalId: "ios-uuid-2", + }), + ); + expect(res.status).toBe(201); + const out = (await res.json()) as { data: { externalId: string } }; + expect(out.data.externalId).toBe("ios-uuid-2"); + }); + + it("threads the upserted source into the dedup key for a non-MANUAL source", async () => { + txClient.moodEntry.upsert.mockResolvedValue({ + id: "mood-ext", + tags: null, + moodLoggedAt: new Date("2026-06-01T08:00:00.000Z"), + mood: "GUT", + note: null, + source: "DAYLIO", + externalId: "daylio-7", + date: "2026-06-01", + }); + const res = await POST( + postReq({ + mood: "GUT", + moodLoggedAt: "2026-06-01T08:00:00.000Z", + source: "DAYLIO", + externalId: "daylio-7", + }), + ); + expect(res.status).toBe(201); + const call = txClient.moodEntry.upsert.mock.calls[0]?.[0] as { + where: { userId_source_externalId: { source: string } }; + }; + expect(call.where.userId_source_externalId.source).toBe("DAYLIO"); + }); + + it("falls back to the legacy create when externalId is absent", async () => { + txClient.moodEntry.create.mockResolvedValue({ + id: "mood-legacy", + tags: null, + moodLoggedAt: new Date("2026-06-01T08:00:00.000Z"), + mood: "GUT", + note: null, + source: "MANUAL", + externalId: null, + date: "2026-06-01", + }); + const res = await POST( + postReq({ mood: "GUT", moodLoggedAt: "2026-06-01T08:00:00.000Z" }), + ); + expect(res.status).toBe(201); + expect(txClient.moodEntry.create).toHaveBeenCalledTimes(1); + expect(txClient.moodEntry.upsert).not.toHaveBeenCalled(); + }); + + it("rejects an over-long externalId at the Zod layer (422)", async () => { + const res = await POST( + postReq({ + mood: "GUT", + moodLoggedAt: "2026-06-01T08:00:00.000Z", + externalId: "x".repeat(121), + }), + ); + expect(res.status).toBe(422); + expect(txClient.moodEntry.upsert).not.toHaveBeenCalled(); + expect(txClient.moodEntry.create).not.toHaveBeenCalled(); + }); +}); 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 6e40a22ec..c9ec4b489 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -265,6 +265,112 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { ); }); + it("dedups on (userId, source, externalId) when an entry carries externalId", async () => { + vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ + id: "entry-ext", + } as never); + const res = await POST( + postReq({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "ios-uuid-1", + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { inserted: number; entries: Array<{ externalId?: string }> }; + }; + expect(json.data.inserted).toBe(1); + // The probe + upsert both key on the externalId compound, not the + // legacy wall-clock tuple. + const probeWhere = vi.mocked(prisma.moodEntry.findUnique).mock.calls[0]?.[0] + ?.where as Record; + expect(probeWhere).toHaveProperty("userId_source_externalId"); + const upsertArg = vi.mocked(prisma.moodEntry.upsert).mock.calls[0]?.[0] as { + where: Record; + create: { externalId: string | null; source: string }; + }; + expect(upsertArg.where).toHaveProperty("userId_source_externalId"); + expect(upsertArg.create.externalId).toBe("ios-uuid-1"); + // The per-entry result echoes the externalId back for iOS hydration. + expect(json.data.entries[0].externalId).toBe("ios-uuid-1"); + }); + + it("uses the legacy wall-clock key and omits externalId when none is sent", async () => { + vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ + id: "entry-legacy", + } as never); + const res = await POST( + postReq({ + entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { entries: Array<{ externalId?: string }> }; + }; + const probeWhere = vi.mocked(prisma.moodEntry.findUnique).mock.calls[0]?.[0] + ?.where as Record; + expect(probeWhere).toHaveProperty("userId_date_moodLoggedAt"); + expect(json.data.entries[0].externalId).toBeUndefined(); + }); + + it("reports a re-posted externalId entry as a duplicate (idempotent)", async () => { + // Probe finds the row → the response classifies it duplicate, not + // inserted, and the upsert update branch refreshes it in place. + vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ + id: "entry-ext", + } as never); + vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ + id: "entry-ext", + } as never); + const res = await POST( + postReq({ + entries: [ + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "ios-uuid-1", + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { inserted: number; duplicates: number }; + }; + expect(json.data.inserted).toBe(0); + expect(json.data.duplicates).toBe(1); + const upsertArg = vi.mocked(prisma.moodEntry.upsert).mock.calls[0]?.[0] as { + update: Record; + }; + // The externalId update branch also refreshes date + moodLoggedAt. + expect(upsertArg.update).toHaveProperty("date"); + expect(upsertArg.update).toHaveProperty("moodLoggedAt"); + }); + + it("rejects an over-long externalId at the Zod boundary (422)", async () => { + const res = await POST( + postReq({ + entries: [ + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "x".repeat(121), + }, + ], + }), + ); + expect(res.status).toBe(422); + expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); + }); + it("marks the single entry skipped (not the batch) on an out-of-scale rating", async () => { const { RatedFactorOutOfRangeError } = await import("@/lib/mood/tag-links"); // First entry's factor write throws; the loop catch records it skipped. diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 2879727eb..8679e847d 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -106,6 +106,10 @@ interface EntryResult { status: EntryStatus; reason?: string; id?: string; + // v1.12.1 — echo the client-supplied source-stable id back on each + // result so iOS can map a server row id onto its local SwiftData row + // without re-deriving it. Omitted when the entry sent no externalId. + externalId?: string; } export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); @@ -185,30 +189,43 @@ async function postBulk(request: NextRequest): Promise { const score = getScoreForMood(entry.mood); try { + // v1.12.1 — when the entry carries a source-stable `externalId`, + // dedup on the NULL-distinct `(userId, source, externalId)` key so a + // re-post with the same id (an iOS retry after a network hiccup, or a + // second adopt-on-pair backfill) updates the existing row in place + // instead of minting a duplicate when `moodLoggedAt` re-rounds / + // re-zones. Absent → the legacy `(userId, date, moodLoggedAt)` key. + // `source` carries a schema default of "MANUAL"; resolve it once so + // the probe, upsert key, and create write all agree on the value. + const resolvedSource = entry.source; + const probeWhere = entry.externalId + ? { + userId_source_externalId: { + userId: user.id, + source: resolvedSource, + externalId: entry.externalId, + }, + } + : { + userId_date_moodLoggedAt: { + userId: user.id, + date, + moodLoggedAt: entry.moodLoggedAt, + }, + }; + // Probe-then-upsert so the response reliably distinguishes // "inserted" from "duplicate". Two round-trips per entry is // acceptable given the 500-entry cap; a more cache-friendly // shape (batched probe) is a v1.4.31 optimisation if the cap // grows. const existing = await prisma.moodEntry.findUnique({ - where: { - userId_date_moodLoggedAt: { - userId: user.id, - date, - moodLoggedAt: entry.moodLoggedAt, - }, - }, + where: probeWhere, select: { id: true }, }); const result = await prisma.moodEntry.upsert({ - where: { - userId_date_moodLoggedAt: { - userId: user.id, - date, - moodLoggedAt: entry.moodLoggedAt, - }, - }, + where: probeWhere, create: { userId: user.id, date, @@ -217,17 +234,24 @@ async function postBulk(request: NextRequest): Promise { score, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: entry.note ?? null, - source: entry.source, + source: resolvedSource, + externalId: entry.externalId ?? null, moodLoggedAt: entry.moodLoggedAt, }, update: { // Last-writer-wins on the mood + tags + note triple. The // iOS client only re-posts an existing entry when it has - // new data; the server trusts that decision. + // new data; the server trusts that decision. When the dedup + // key is `externalId`, also refresh `date` / `moodLoggedAt` + // so a re-zoned re-import lands the corrected wall-clock on + // the same row. mood: entry.mood, score, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: entry.note ?? null, + ...(entry.externalId + ? { date, moodLoggedAt: entry.moodLoggedAt } + : {}), }, }); @@ -258,16 +282,31 @@ async function postBulk(request: NextRequest): Promise { if (existing) { duplicates += 1; - results.push({ index: i, status: "duplicate", id: result.id }); + results.push({ + index: i, + status: "duplicate", + id: result.id, + ...(entry.externalId ? { externalId: entry.externalId } : {}), + }); } else { inserted += 1; - results.push({ index: i, status: "inserted", id: result.id }); + results.push({ + index: i, + status: "inserted", + id: result.id, + ...(entry.externalId ? { externalId: entry.externalId } : {}), + }); } } catch (err: unknown) { const reason = err instanceof Error ? err.message.slice(0, 120) : "upsert_failed"; skipped.push({ index: i, reason }); - results.push({ index: i, status: "skipped", reason }); + results.push({ + index: i, + status: "skipped", + reason, + ...(entry.externalId ? { externalId: entry.externalId } : {}), + }); } } diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index e0e0451df..4da453a97 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -173,8 +173,16 @@ async function postMoodEntry(request: NextRequest) { return returnAllZodIssues(parsed.error, 422); } - const { mood, tags, tagKeys, ratedFactors, note, moodLoggedAt, source } = - parsed.data; + const { + mood, + tags, + tagKeys, + ratedFactors, + note, + moodLoggedAt, + source, + externalId, + } = parsed.data; // v1.4.25 W7b (Decision A) — anchor the `date` string to the user's // current displayTimezone and store the resolved zone on the row. // Legacy rows with `tz IS NULL` continue to read as Europe/Berlin @@ -182,6 +190,10 @@ async function postMoodEntry(request: NextRequest) { const tz = user.timezone ?? DEFAULT_TIMEZONE; const date = moodDateKey(moodLoggedAt, tz); const score = getScoreForMood(mood); + // v1.12.1 — `source` carries a schema default of "MANUAL"; resolve it + // once so the externalId upsert key (`(userId, source, externalId)`) + // and the row write agree on the exact value. + const resolvedSource = source ?? "MANUAL"; try { // v1.8.5 — write the entry and its structured-tag links in one @@ -191,19 +203,57 @@ async function postMoodEntry(request: NextRequest) { // through the helper so both writes commit (or abort) together. const { entry, persistedTagKeys, persistedRatedFactors } = await prisma.$transaction(async (tx) => { - const created = await tx.moodEntry.create({ - data: { - userId: user.id, - date, - tz, - mood, - score, - tags: tags ? JSON.stringify(tags) : null, - note: note ?? null, - source: source ?? "MANUAL", - moodLoggedAt, - }, - }); + // v1.12.1 — when the client supplies a source-stable `externalId`, + // upsert on the NULL-distinct `(userId, source, externalId)` key so + // a re-post with the same id updates the existing row in place + // (idempotent re-import) instead of minting a duplicate or 409-ing. + // Without an `externalId`, fall back to the legacy first-write + // `create` exactly as before — a same-tuple re-post then trips the + // `(userId, date, moodLoggedAt)` unique and surfaces as a 409 below. + const created = externalId + ? await tx.moodEntry.upsert({ + where: { + userId_source_externalId: { + userId: user.id, + source: resolvedSource, + externalId, + }, + }, + create: { + userId: user.id, + date, + tz, + mood, + score, + tags: tags ? JSON.stringify(tags) : null, + note: note ?? null, + source: resolvedSource, + externalId, + moodLoggedAt, + }, + update: { + date, + tz, + mood, + score, + tags: tags ? JSON.stringify(tags) : null, + note: note ?? null, + moodLoggedAt, + }, + }) + : await tx.moodEntry.create({ + data: { + userId: user.id, + date, + tz, + mood, + score, + tags: tags ? JSON.stringify(tags) : null, + note: note ?? null, + source: resolvedSource, + moodLoggedAt, + }, + }); if ( (tagKeys && tagKeys.length > 0) || diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index 730a2eb0d..80dabb994 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -136,6 +136,14 @@ export const createMoodEntrySchema = z.object({ note: z.string().max(500).optional(), moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), source: moodSourceEnum.optional().default("MANUAL"), + // v1.12.1 — optional source-stable id (e.g. an iOS SwiftData row UUID). + // When present, the create upserts on `(userId, source, externalId)` + // so a re-post with the same id updates the existing row in place + // instead of either 409-ing or minting a duplicate — the idempotent + // re-import iOS drives over Bearer. NULL keeps the legacy + // `(userId, date, moodLoggedAt)` behaviour. Bound matches the bulk + // `externalId` so one path can't accept an id the other rejects. + externalId: z.string().min(1).max(120).optional(), }); export const updateMoodEntrySchema = z.object({ diff --git a/tests/integration/mood-entries-external-id.test.ts b/tests/integration/mood-entries-external-id.test.ts new file mode 100644 index 000000000..6945419ab --- /dev/null +++ b/tests/integration/mood-entries-external-id.test.ts @@ -0,0 +1,320 @@ +/** + * v1.12.1 — native mood-entry `externalId` idempotent re-import against + * real Postgres. + * + * Migration 0122 added `MoodEntry.externalId` plus the NULL-distinct + * `@@unique([userId, source, externalId])`. This test pins the iOS-facing + * native write paths onto that index: + * + * - single `POST /api/mood-entries` with an `externalId` re-posted → + * one row, updated in place (no duplicate, no 409) + * - bulk `POST /api/mood-entries/bulk` with an `externalId` re-posted → + * one row, reported `duplicate`, updated in place + * - a NULL `externalId` keeps the legacy `(userId, date, moodLoggedAt)` + * wall-clock behaviour exactly + * - the same `externalId` under two different users is isolated (the + * NULL-distinct unique is per-user, so no cross-tenant collision) + */ +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_A = "user-mood-extid-a"; +const USER_B = "user-mood-extid-b"; + +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), +})); + +async function createUserSession(userId: string, username: string) { + await getPrismaClient().user.create({ + data: { + id: userId, + username, + email: `${username}@example.test`, + timezone: "Europe/Berlin", + }, + }); + return getPrismaClient().session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +function singleRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/mood-entries", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function bulkRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/mood-entries/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/mood-entries — externalId idempotent re-import (real Postgres)", () => { + it("re-posting the same externalId updates one row in place (no duplicate)", async () => { + const session = await createUserSession(USER_A, "mood-extid-a"); + cookieJar.set("healthlog_session", session.id); + const { POST } = await import("@/app/api/mood-entries/route"); + + const first = await POST( + singleRequest({ + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "ios-uuid-1", + }), + ); + expect(first.status).toBe(201); + const firstJson = (await first.json()) as { + data: { id: string; externalId: string; mood: string }; + }; + expect(firstJson.data.externalId).toBe("ios-uuid-1"); + + // Re-post the SAME externalId with a different mood + a re-rounded + // wall-clock (10 minutes later). The row must update in place. + const second = await POST( + singleRequest({ + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T08:10:00.000Z", + externalId: "ios-uuid-1", + }), + ); + expect(second.status).toBe(201); + const secondJson = (await second.json()) as { + data: { id: string; mood: string }; + }; + // Same primary key — updated in place, not a new row. + expect(secondJson.data.id).toBe(firstJson.data.id); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER_A }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].mood).toBe("SUPER_GUT"); + expect(rows[0].score).toBe(5); + expect(rows[0].externalId).toBe("ios-uuid-1"); + expect(rows[0].moodLoggedAt.toISOString()).toBe("2026-05-16T08:10:00.000Z"); + }); + + it("keeps legacy first-write behaviour when externalId is absent", async () => { + const session = await createUserSession(USER_A, "mood-extid-a"); + cookieJar.set("healthlog_session", session.id); + const { POST } = await import("@/app/api/mood-entries/route"); + + const body = { mood: "GUT", moodLoggedAt: "2026-05-16T09:00:00.000Z" }; + const first = await POST(singleRequest(body)); + expect(first.status).toBe(201); + + // Same (userId, date, moodLoggedAt) tuple, no externalId → the legacy + // unique trips and the route surfaces a 409, exactly as before. + const second = await POST(singleRequest(body)); + expect(second.status).toBe(409); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER_A }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].externalId).toBeNull(); + }); + + it("isolates the same externalId across two users (no cross-tenant collision)", async () => { + const sessionA = await createUserSession(USER_A, "mood-extid-a"); + const sessionB = await createUserSession(USER_B, "mood-extid-b"); + const { POST } = await import("@/app/api/mood-entries/route"); + + cookieJar.set("healthlog_session", sessionA.id); + const resA = await POST( + singleRequest({ + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "shared-uuid", + }), + ); + expect(resA.status).toBe(201); + + cookieJar.clear(); + cookieJar.set("healthlog_session", sessionB.id); + const resB = await POST( + singleRequest({ + mood: "LAUSIG", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "shared-uuid", + }), + ); + expect(resB.status).toBe(201); + + // Two distinct rows — one per user — despite the shared externalId. + const rowsA = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER_A }, + }); + const rowsB = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER_B }, + }); + expect(rowsA).toHaveLength(1); + expect(rowsB).toHaveLength(1); + expect(rowsA[0].mood).toBe("OKAY"); + expect(rowsB[0].mood).toBe("LAUSIG"); + }); +}); + +describe("POST /api/mood-entries/bulk — externalId idempotent re-import (real Postgres)", () => { + it("re-posting the same externalId reports a duplicate and updates one row", async () => { + const session = await createUserSession(USER_A, "mood-extid-a"); + cookieJar.set("healthlog_session", session.id); + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + + const first = await POST( + bulkRequest({ + entries: [ + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "ios-bulk-1", + }, + ], + }), + ); + expect(first.status).toBe(200); + const firstJson = (await first.json()) as { + data: { inserted: number; entries: Array<{ externalId?: string }> }; + }; + expect(firstJson.data.inserted).toBe(1); + expect(firstJson.data.entries[0].externalId).toBe("ios-bulk-1"); + + // Re-post the same externalId with a different mood + re-rounded + // wall-clock → duplicate, row updated in place. + const second = await POST( + bulkRequest({ + entries: [ + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T08:12:00.000Z", + externalId: "ios-bulk-1", + }, + ], + }), + ); + expect(second.status).toBe(200); + const secondJson = (await second.json()) as { + data: { inserted: number; duplicates: number }; + }; + expect(secondJson.data.inserted).toBe(0); + expect(secondJson.data.duplicates).toBe(1); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER_A }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].mood).toBe("SUPER_GUT"); + expect(rows[0].externalId).toBe("ios-bulk-1"); + expect(rows[0].moodLoggedAt.toISOString()).toBe("2026-05-16T08:12:00.000Z"); + }); + + it("keeps the legacy wall-clock key when an entry omits externalId", async () => { + const session = await createUserSession(USER_A, "mood-extid-a"); + cookieJar.set("healthlog_session", session.id); + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + + const body = { + entries: [{ mood: "GUT", moodLoggedAt: "2026-05-16T08:00:00.000Z" }], + }; + await POST(bulkRequest(body)); + // Re-post the identical tuple → legacy dedup classifies it duplicate. + const second = await POST(bulkRequest(body)); + const json = (await second.json()) as { + data: { inserted: number; duplicates: number }; + }; + expect(json.data.inserted).toBe(0); + expect(json.data.duplicates).toBe(1); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER_A }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].externalId).toBeNull(); + }); + + it("isolates the same externalId across two users on the bulk path", async () => { + const sessionA = await createUserSession(USER_A, "mood-extid-a"); + const sessionB = await createUserSession(USER_B, "mood-extid-b"); + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + + cookieJar.set("healthlog_session", sessionA.id); + await POST( + bulkRequest({ + entries: [ + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "shared-bulk", + }, + ], + }), + ); + + cookieJar.clear(); + cookieJar.set("healthlog_session", sessionB.id); + const resB = await POST( + bulkRequest({ + entries: [ + { + mood: "LAUSIG", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "shared-bulk", + }, + ], + }), + ); + const jsonB = (await resB.json()) as { data: { inserted: number } }; + // User B's first write with the shared id is a fresh insert, not a + // duplicate of user A's row. + expect(jsonB.data.inserted).toBe(1); + + const rowsA = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER_A }, + }); + const rowsB = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER_B }, + }); + expect(rowsA).toHaveLength(1); + expect(rowsB).toHaveLength(1); + expect(rowsA[0].mood).toBe("OKAY"); + expect(rowsB[0].mood).toBe("LAUSIG"); + }); +});