diff --git a/src/__tests__/external-id-stability-guard.test.ts b/src/__tests__/external-id-stability-guard.test.ts index 4e59506c5..47f942f04 100644 --- a/src/__tests__/external-id-stability-guard.test.ts +++ b/src/__tests__/external-id-stability-guard.test.ts @@ -44,6 +44,19 @@ function read(rel: string): string { return readFileSync(join(SRC, rel), "utf8"); } +/** + * The file with its `import … from "…";` statements removed. + * + * A guard that greps the raw source cannot tell "this file APPLIES the + * check" from "this file merely imports it" — deleting the call while + * leaving the import would slip straight through. Every usage assertion + * below therefore runs against the body, and the module import is + * asserted separately where it matters. + */ +function body(rel: string): string { + return read(rel).replace(/import\s+[\s\S]*?from\s+"[^"]+";/g, ""); +} + /** * A REQUEST-side declaration of a client-supplied external identifier: * `externalId: z.…` / `externalSourceId: z.…` in a Zod object shape. @@ -106,11 +119,10 @@ describe("every client-supplied external id routes through the stability floor", return; } - const source = read(rel); const usesFieldCheck = - source.includes(VALIDATOR_MODULE) && - source.includes("assertStableExternalId"); - const marksPerEntry = MARKER.test(source); + read(rel).includes(VALIDATOR_MODULE) && + body(rel).includes("assertStableExternalId"); + const marksPerEntry = MARKER.test(read(rel)); expect( usesFieldCheck || marksPerEntry, `${rel} declares a client-supplied external id but neither applies ` + @@ -134,7 +146,7 @@ describe("every client-supplied external id routes through the stability floor", target.startsWith("src/"), `${rel}: marker target "${target}" must be a repo-root-relative src/ path`, ).toBe(true); - const checker = read(target.replace(/^src\//, "")); + const checker = body(target.replace(/^src\//, "")); expect( checker.includes("classifyExternalId"), `${rel} points its per-entry marker at ${target}, but that file never calls classifyExternalId.`, @@ -158,7 +170,7 @@ describe("the batch surfaces refuse per entry, never whole-batch", () => { ]; it.each(BATCH_ROUTES)("%s classifies each entry itself", (rel) => { - const source = read(rel); + const source = body(rel); expect(source).toContain("classifyExternalId"); expect(source).toContain("unstableExternalIdMeta"); }); @@ -168,7 +180,7 @@ describe("the batch surfaces refuse per entry, never whole-batch", () => { (rel) => { // The JSON importer has no per-entry status envelope — it reports a // skipped COUNT — so it is the one batch surface without the code. - expect(read(rel)).toContain("UNSTABLE_EXTERNAL_ID_REASON"); + expect(body(rel)).toContain("UNSTABLE_EXTERNAL_ID_REASON"); }, ); }); 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 bfbe3f9b6..081284866 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -461,6 +461,92 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { }); }); +describe("POST /api/mood-entries/bulk — unstable external ids", () => { + /** + * `(userId, source, externalId)` dedups only while the id is stable + * across client launches. A per-process value — an object description + * carrying a memory address — never matches its own earlier row, so + * every re-post mints another entry. The refusal is PER ENTRY: a client + * draining an offline queue must not lose its good rows to one bad one. + */ + beforeEach(() => { + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([]); + vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ + id: "entry-1", + } as never); + }); + + it("skips the offending entry and still writes the rest of the batch", async () => { + const res = await POST( + postReq({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30", + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + externalId: "", + }, + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T10:00:00.000Z", + externalId: "0xdeadbeef", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + processed: number; + inserted: number; + skipped: Array<{ index: number; reason: string }>; + entries: Array<{ + index: number; + status: string; + reason?: string; + externalId?: string; + }>; + }; + }; + expect(body.data.processed).toBe(3); + expect(body.data.entries[0].status).toBe("inserted"); + expect(body.data.entries[1]).toMatchObject({ + index: 1, + status: "skipped", + reason: "unstable_external_id", + externalId: "", + }); + expect(body.data.entries[2]).toMatchObject({ + index: 2, + status: "skipped", + reason: "unstable_external_id", + }); + expect(body.data.skipped).toEqual([ + { index: 1, reason: "unstable_external_id" }, + { index: 2, reason: "unstable_external_id" }, + ]); + // Only the good entry reached the writer. + expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1); + }); + + it("leaves an entry without an externalId untouched", async () => { + const res = await POST( + postReq({ + entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { entries: Array<{ status: string }> }; + }; + expect(body.data.entries[0].status).toBe("inserted"); + }); +}); + describe("POST /api/mood-entries/bulk — tombstone suppression", () => { it("keeps a tombstoned match deleted: true no-op reported as duplicate", async () => { // A soft-deleted row under the batched pre-read: the sync feed already