diff --git a/packages/api/src/__tests__/notification-procedure-support.test.ts b/packages/api/src/__tests__/notification-procedure-support.test.ts index 3d33a98..51019bf 100644 --- a/packages/api/src/__tests__/notification-procedure-support.test.ts +++ b/packages/api/src/__tests__/notification-procedure-support.test.ts @@ -82,6 +82,24 @@ describe("notification procedure support", () => { } }); + it("rewrites broadcast source foreign-key errors to a not found TRPC error", () => { + const error = { + code: "P2003", + meta: { field_name: "Notification_sourceId_fkey" }, + }; + + try { + rethrowNotificationReferenceError(error); + throw new Error("expected notification reference error"); + } catch (caught) { + expect(caught).toBeInstanceOf(TRPCError); + expect(caught).toMatchObject>({ + code: "NOT_FOUND", + message: "Notification broadcast not found", + }); + } + }); + it("rethrows unrelated errors unchanged", () => { const error = new Error("boom"); diff --git a/packages/api/src/__tests__/notification-router.test.ts b/packages/api/src/__tests__/notification-router.test.ts index 1fea63a..49427f5 100644 --- a/packages/api/src/__tests__/notification-router.test.ts +++ b/packages/api/src/__tests__/notification-router.test.ts @@ -452,6 +452,66 @@ describe("notification.createBroadcast", () => { expect(outerCreateNotification).not.toHaveBeenCalled(); }); + it("maps broadcast source reference loss during recipient fan-out to not found errors", async () => { + resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); + + const txCreateBroadcast = vi.fn().mockResolvedValue({ + id: "broadcast_tx_source_missing", + title: "Ops update", + createdAt: new Date("2026-03-30T10:00:00Z"), + }); + const txUpdateBroadcast = vi.fn(); + const txCreateNotification = vi.fn().mockRejectedValue( + Object.assign(new Error("Foreign key constraint failed"), { + code: "P2003", + meta: { field_name: "Notification_sourceId_fkey" }, + }), + ); + const tx = { + notificationBroadcast: { + create: txCreateBroadcast, + update: txUpdateBroadcast, + }, + notification: { + create: txCreateNotification, + }, + }; + const outerCreateBroadcast = vi.fn(); + const outerUpdateBroadcast = vi.fn(); + const outerCreateNotification = vi.fn(); + const transaction = vi.fn(async (callback: (db: typeof tx) => Promise) => callback(tx)); + const db = { + $transaction: transaction, + notificationBroadcast: { + create: outerCreateBroadcast, + update: outerUpdateBroadcast, + }, + notification: { + create: outerCreateNotification, + }, + }; + + const caller = createManagerCaller(db); + + await expect(caller.createBroadcast({ + title: "Ops update", + targetType: "all", + })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Notification broadcast not found", + }); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(txCreateBroadcast).toHaveBeenCalledTimes(1); + expect(txCreateNotification).toHaveBeenCalledTimes(1); + expect(txUpdateBroadcast).not.toHaveBeenCalled(); + expect(outerCreateBroadcast).not.toHaveBeenCalled(); + expect(outerUpdateBroadcast).not.toHaveBeenCalled(); + expect(outerCreateNotification).not.toHaveBeenCalled(); + expect(emitNotificationCreated).not.toHaveBeenCalled(); + expect(emitTaskAssigned).not.toHaveBeenCalled(); + }); + it("emits recipient SSE only after an immediate broadcast commits", async () => { resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); @@ -688,6 +748,43 @@ describe("notification.createBroadcast", () => { }); }); + it("maps scheduled broadcast sender persistence failures to not found errors", async () => { + resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); + + const create = vi.fn().mockRejectedValue( + Object.assign(new Error("Foreign key constraint failed"), { + code: "P2003", + meta: { field_name: "NotificationBroadcast_senderId_fkey" }, + }), + ); + const db = { + notificationBroadcast: { + create, + update: vi.fn(), + }, + notification: { + create: vi.fn(), + }, + }; + + const caller = createManagerCaller(db); + + await expect(caller.createBroadcast({ + title: "Scheduled ops update", + targetType: "all", + scheduledAt: FUTURE_SCHEDULED_AT, + })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Sender user not found", + }); + + expect(create).toHaveBeenCalledTimes(1); + expect(db.notificationBroadcast.update).not.toHaveBeenCalled(); + expect(db.notification.create).not.toHaveBeenCalled(); + expect(emitNotificationCreated).not.toHaveBeenCalled(); + expect(emitTaskAssigned).not.toHaveBeenCalled(); + }); + it("rolls back an immediate broadcast when the final broadcast update fails", async () => { resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]);