From ba7bf49faed5b43597c7803a1c37de6ae5411648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 04:53:50 +0200 Subject: [PATCH] test: cover atomic notification event claims - race record event claims through PostgreSQL\n- require safety producers to honor the claim --- .../illness/__tests__/red-flag-notify.test.ts | 31 +++++--- .../__tests__/safety-floor-notify.test.ts | 31 +++++--- .../__tests__/reminder-dedup.test.ts | 48 +++++++----- .../notification-attribution.test.ts | 74 +++++++++++++++++++ 4 files changed, 141 insertions(+), 43 deletions(-) diff --git a/src/lib/illness/__tests__/red-flag-notify.test.ts b/src/lib/illness/__tests__/red-flag-notify.test.ts index bd3bee473..9cdaf2399 100644 --- a/src/lib/illness/__tests__/red-flag-notify.test.ts +++ b/src/lib/illness/__tests__/red-flag-notify.test.ts @@ -11,6 +11,7 @@ const findFirstMock = vi.fn(); const createMock = vi.fn(); const dispatchMock = vi.fn(); const userFindUniqueMock = vi.fn(); +const claimMock = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { @@ -32,6 +33,10 @@ vi.mock("@/lib/notifications/dispatch-localised", () => ({ dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a), })); +vi.mock("@/lib/notifications/reminder-dedup", () => ({ + claimNotificationEvent: (...a: unknown[]) => claimMock(...a), +})); + import { notifyIllnessRedFlag } from "../red-flag-notify"; import type { IllnessRedFlag } from "../correlation"; @@ -53,6 +58,7 @@ beforeEach(() => { findFirstMock.mockReset().mockResolvedValue(null); createMock.mockReset().mockResolvedValue({}); dispatchMock.mockReset().mockResolvedValue(undefined); + claimMock.mockReset().mockResolvedValue(true); }); afterEach(() => vi.clearAllMocks()); @@ -90,29 +96,31 @@ describe("notifyIllnessRedFlag", () => { ); }); - it("de-dupes when a recent ledger row exists for the episode", async () => { - findFirstMock.mockResolvedValueOnce({ id: "prior" }); + it("does not dispatch when a concurrent claimant already owns the episode", async () => { + claimMock.mockResolvedValueOnce(false); await notifyIllnessRedFlag({ userId: "u1", episodeId: "e1", redFlags: [feverFlag], }); expect(dispatchMock).not.toHaveBeenCalled(); - expect(createMock).not.toHaveBeenCalled(); + expect(claimMock).toHaveBeenCalledTimes(1); }); - it("stamps the record event anchor before dispatching", async () => { + it("claims the record event anchor before dispatching", async () => { await notifyIllnessRedFlag({ userId: "u1", episodeId: "e9", redFlags: [feverFlag], }); - expect(createMock).toHaveBeenCalledTimes(1); - expect(createMock.mock.calls[0][0].data).toMatchObject({ - recordUserId: "u1", - eventType: "SYSTEM_ALERT", - dedupKey: expect.stringContaining("e9"), - }); + expect(claimMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + recordUserId: "u1", + eventType: "SYSTEM_ALERT", + dedupKey: expect.stringContaining("e9"), + }), + ); }); it("does not dispatch or write an anchor for a managed profile", async () => { @@ -126,8 +134,7 @@ describe("notifyIllnessRedFlag", () => { redFlags: [feverFlag], }); - expect(findFirstMock).not.toHaveBeenCalled(); - expect(createMock).not.toHaveBeenCalled(); + expect(claimMock).not.toHaveBeenCalled(); expect(dispatchMock).not.toHaveBeenCalled(); }); diff --git a/src/lib/illness/__tests__/safety-floor-notify.test.ts b/src/lib/illness/__tests__/safety-floor-notify.test.ts index 87babef6d..0eef5e6a3 100644 --- a/src/lib/illness/__tests__/safety-floor-notify.test.ts +++ b/src/lib/illness/__tests__/safety-floor-notify.test.ts @@ -12,6 +12,7 @@ const findFirstMock = vi.fn(); const createMock = vi.fn(); const dispatchMock = vi.fn(); const userFindUniqueMock = vi.fn(); +const claimMock = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { @@ -33,6 +34,10 @@ vi.mock("@/lib/notifications/dispatch-localised", () => ({ dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a), })); +vi.mock("@/lib/notifications/reminder-dedup", () => ({ + claimNotificationEvent: (...a: unknown[]) => claimMock(...a), +})); + import { notifySafetyFloor } from "../safety-floor-notify"; import type { SafetyFloorDecision } from "../safety-floors"; @@ -58,6 +63,7 @@ beforeEach(() => { findFirstMock.mockReset().mockResolvedValue(null); createMock.mockReset().mockResolvedValue({}); dispatchMock.mockReset().mockResolvedValue(undefined); + claimMock.mockReset().mockResolvedValue(true); }); afterEach(() => vi.clearAllMocks()); @@ -99,21 +105,23 @@ describe("notifySafetyFloor", () => { expect(opts.params).toEqual({ value: 2.7, unit: "mmol/L", threshold: 3 }); }); - it("de-dupes by reason when a recent ledger row exists", async () => { - findFirstMock.mockResolvedValueOnce({ id: "prior" }); + it("does not dispatch when a concurrent claimant owns the safety event", async () => { + claimMock.mockResolvedValueOnce(false); await notifySafetyFloor({ userId: "u1", decision: bpHigh }); expect(dispatchMock).not.toHaveBeenCalled(); - expect(createMock).not.toHaveBeenCalled(); + expect(claimMock).toHaveBeenCalledTimes(1); }); - it("stamps the record event anchor (keyed by reason) before dispatching", async () => { + it("claims the record event anchor (keyed by reason) before dispatching", async () => { await notifySafetyFloor({ userId: "u9", decision: bpHigh }); - expect(createMock).toHaveBeenCalledTimes(1); - expect(createMock.mock.calls[0][0].data).toMatchObject({ - recordUserId: "u9", - eventType: "SYSTEM_ALERT", - dedupKey: expect.stringContaining("bp_hypertensive"), - }); + expect(claimMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + recordUserId: "u9", + eventType: "SYSTEM_ALERT", + dedupKey: expect.stringContaining("bp_hypertensive"), + }), + ); }); it("does not dispatch or write an anchor for a managed profile", async () => { @@ -123,8 +131,7 @@ describe("notifySafetyFloor", () => { await notifySafetyFloor({ userId: "managed-record", decision: bpHigh }); - expect(findFirstMock).not.toHaveBeenCalled(); - expect(createMock).not.toHaveBeenCalled(); + expect(claimMock).not.toHaveBeenCalled(); expect(dispatchMock).not.toHaveBeenCalled(); }); diff --git a/src/lib/notifications/__tests__/reminder-dedup.test.ts b/src/lib/notifications/__tests__/reminder-dedup.test.ts index 415f0e5b1..8c84a4305 100644 --- a/src/lib/notifications/__tests__/reminder-dedup.test.ts +++ b/src/lib/notifications/__tests__/reminder-dedup.test.ts @@ -4,9 +4,13 @@ import { prisma } from "@/lib/db"; const findFirstMock = vi.fn(); const createMock = vi.fn(); +const queryRawMock = vi.fn(); +const transactionMock = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { + $queryRaw: (...args: unknown[]) => queryRawMock(...args), + $transaction: (...args: unknown[]) => transactionMock(...args), notificationEvent: { findFirst: (...args: unknown[]) => findFirstMock(...args), create: (...args: unknown[]) => createMock(...args), @@ -14,29 +18,29 @@ vi.mock("@/lib/db", () => ({ }, })); -import { - hasReminderDedupAnchor, - writeReminderDedupAnchor, -} from "../reminder-dedup"; +import { claimNotificationEvent } from "../reminder-dedup"; beforeEach(() => { findFirstMock.mockReset().mockResolvedValue(null); createMock.mockReset().mockResolvedValue({ id: "event" }); + queryRawMock.mockReset().mockResolvedValue([{ locked: 1 }]); + transactionMock.mockReset().mockImplementation(async (run) => run(prisma)); }); describe("reminder dedup anchors", () => { - it("looks up a record-scoped event rather than a delivery attempt", async () => { + it("claims one record-scoped event under a short transaction lock", async () => { const now = new Date("2026-08-06T08:00:00.000Z"); await expect( - hasReminderDedupAnchor(prisma as never, { - userId: "managed-record", + claimNotificationEvent(prisma as never, { + recordUserId: "managed-record", eventType: "MEDICATION_REMINDER", - reason: "med:slot", - now, + dedupKey: "med:slot", + since: now, }), - ).resolves.toBe(false); + ).resolves.toBe(true); + expect(queryRawMock).toHaveBeenCalledTimes(1); expect(findFirstMock).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ @@ -46,15 +50,6 @@ describe("reminder dedup anchors", () => { }), }), ); - }); - - it("writes an append-only record event without inventing a recipient", async () => { - await writeReminderDedupAnchor(prisma as never, { - userId: "managed-record", - eventType: "MEDICATION_REMINDER", - reason: "med:slot", - }); - expect(createMock).toHaveBeenCalledWith({ data: { recordUserId: "managed-record", @@ -63,4 +58,19 @@ describe("reminder dedup anchors", () => { }, }); }); + + it("does not claim when the locked window already contains the event", async () => { + findFirstMock.mockResolvedValueOnce({ id: "prior" }); + + await expect( + claimNotificationEvent(prisma as never, { + recordUserId: "managed-record", + eventType: "MEDICATION_REMINDER", + dedupKey: "med:slot", + since: new Date("2026-08-06T08:00:00.000Z"), + }), + ).resolves.toBe(false); + + expect(createMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/integration/notification-attribution.test.ts b/tests/integration/notification-attribution.test.ts index 25af08248..a36131ce6 100644 --- a/tests/integration/notification-attribution.test.ts +++ b/tests/integration/notification-attribution.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity"; import { + claimNotificationEvent, hasReminderDedupAnchor, writeReminderDedupAnchor, } from "@/lib/notifications/reminder-dedup"; @@ -140,6 +141,79 @@ describe("notification attribution (real Postgres)", () => { ).toBeNull(); }); + it("allows exactly one concurrent claim for a notification event", async () => { + const recordUser = await createUser("claim-record", true); + const client = getPrismaClient(); + const since = new Date(Date.now() - 60_000); + const input = { + recordUserId: recordUser.id, + eventType: "MEDICATION_REMINDER", + dedupKey: "med:race", + since, + }; + + const results = await Promise.all([ + claimNotificationEvent(client, input), + claimNotificationEvent(client, input), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + expect( + await client.notificationEvent.count({ + where: { + recordUserId: recordUser.id, + eventType: "MEDICATION_REMINDER", + dedupKey: "med:race", + }, + }), + ).toBe(1); + }); + + it("uses the rolling window and allows a retry after a failed claim", async () => { + const client = getPrismaClient(); + const missingRecordId = `missing-claim-${sequence++}`; + const missingInput = { + recordUserId: missingRecordId, + eventType: "SYSTEM_ALERT", + dedupKey: "safety:retry", + since: new Date(Date.now() - 24 * 60 * 60 * 1000), + }; + + await expect(claimNotificationEvent(client, missingInput)).resolves.toBe( + false, + ); + await client.user.create({ + data: { + id: missingRecordId, + username: `claim-retry-${sequence++}`, + email: `claim-retry-${sequence++}@example.test`, + }, + }); + await expect(claimNotificationEvent(client, missingInput)).resolves.toBe( + true, + ); + + const recordUser = await createUser("expired-claim", true); + const now = new Date(); + await client.notificationEvent.create({ + data: { + recordUserId: recordUser.id, + eventType: "SYSTEM_ALERT", + dedupKey: "safety:window", + createdAt: new Date(now.getTime() - 25 * 60 * 60 * 1000), + }, + }); + + await expect( + claimNotificationEvent(client, { + recordUserId: recordUser.id, + eventType: "SYSTEM_ALERT", + dedupKey: "safety:window", + since: new Date(now.getTime() - 24 * 60 * 60 * 1000), + }), + ).resolves.toBe(true); + }); + it("keeps Telegram reminder tracking independent for each recipient slot", async () => { const recordUser = await createUser("telegram-record", true); const firstRecipient = await createUser("telegram-recipient-a");