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"); + }); +});