import { SystemRole } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { notificationRouter } from "../router/notification.js"; import { emitBroadcastSent, emitNotificationCreated, emitTaskAssigned, } from "../sse/event-bus.js"; import { createCallerFactory } from "../trpc.js"; const { resolveRecipientsMock } = vi.hoisted(() => ({ resolveRecipientsMock: vi.fn(), })); // Mock the SSE event bus — we don't test real event emission here vi.mock("../sse/event-bus.js", () => ({ emitNotificationCreated: vi.fn(), emitTaskAssigned: vi.fn(), emitTaskCompleted: vi.fn(), emitTaskStatusChanged: vi.fn(), emitBroadcastSent: vi.fn(), })); vi.mock("../lib/notification-targeting.js", () => ({ resolveRecipients: resolveRecipientsMock, })); const createCaller = createCallerFactory(notificationRouter); beforeEach(() => { vi.clearAllMocks(); resolveRecipientsMock.mockReset(); }); // ── Caller factories ───────────────────────────────────────────────────────── function createProtectedCaller(db: Record) { return createCaller({ session: { user: { email: "user@example.com", name: "User", image: null }, expires: "2099-01-01T00:00:00.000Z", }, db: db as never, dbUser: { id: "user_1", systemRole: SystemRole.USER, permissionOverrides: null, }, }); } function createManagerCaller(db: Record) { return createCaller({ session: { user: { email: "mgr@example.com", name: "Manager", image: null }, expires: "2099-01-01T00:00:00.000Z", }, db: db as never, dbUser: { id: "user_mgr", systemRole: SystemRole.MANAGER, permissionOverrides: null, }, }); } // ── Sample data ────────────────────────────────────────────────────────────── function sampleNotification(overrides: Record = {}) { return { id: "notif_1", userId: "user_1", type: "VACATION_APPROVED", title: "Vacation approved", body: null, entityId: null, entityType: null, readAt: null, createdAt: new Date("2026-01-15T10:00:00Z"), ...overrides, }; } /** DB mock that resolves the session user for resolveUserId */ function withUserLookup(db: Record, userId = "user_1") { return { user: { findUnique: vi.fn().mockResolvedValue({ id: userId }), }, ...db, }; } // ─── list ──────────────────────────────────────────────────────────────────── describe("notification.list", () => { it("returns notifications for the current user", async () => { const notifications = [sampleNotification(), sampleNotification({ id: "notif_2" })]; const db = withUserLookup({ notification: { findMany: vi.fn().mockResolvedValue(notifications), }, }); const caller = createProtectedCaller(db); const result = await caller.list({ limit: 50 }); expect(result).toHaveLength(2); expect(db.notification.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ userId: "user_1" }), orderBy: { createdAt: "desc" }, take: 50, }), ); }); it("filters to unread only when unreadOnly is true", async () => { const db = withUserLookup({ notification: { findMany: vi.fn().mockResolvedValue([]), }, }); const caller = createProtectedCaller(db); await caller.list({ unreadOnly: true, limit: 10 }); expect(db.notification.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ userId: "user_1", readAt: null }), take: 10, }), ); }); }); // ─── unreadCount ───────────────────────────────────────────────────────────── describe("notification.unreadCount", () => { it("returns count of unread notifications", async () => { const db = withUserLookup({ notification: { count: vi.fn().mockResolvedValue(5), }, }); const caller = createProtectedCaller(db); const result = await caller.unreadCount(); expect(result).toBe(5); expect(db.notification.count).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: "user_1", readAt: null }, }), ); }); }); // ─── markRead ──────────────────────────────────────────────────────────────── describe("notification.markRead", () => { it("marks a single notification as read when id is provided", async () => { const db = withUserLookup({ notification: { update: vi.fn().mockResolvedValue(sampleNotification({ readAt: new Date() })), updateMany: vi.fn(), }, }); const caller = createProtectedCaller(db); await caller.markRead({ id: "notif_1" }); expect(db.notification.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "notif_1", userId: "user_1" }, data: expect.objectContaining({ readAt: expect.any(Date) }), }), ); expect(db.notification.updateMany).not.toHaveBeenCalled(); }); it("marks all unread notifications as read when no id is provided", async () => { const db = withUserLookup({ notification: { update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 3 }), }, }); const caller = createProtectedCaller(db); await caller.markRead({}); expect(db.notification.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: "user_1", readAt: null }, data: expect.objectContaining({ readAt: expect.any(Date) }), }), ); expect(db.notification.update).not.toHaveBeenCalled(); }); }); // ─── create ────────────────────────────────────────────────────────────────── describe("notification.create", () => { it("creates a notification (manager role)", async () => { const created = sampleNotification({ userId: "target_user" }); const db = withUserLookup( { notification: { create: vi.fn().mockResolvedValue(created), findUnique: vi.fn().mockResolvedValue(created), }, }, "user_mgr", ); const caller = createManagerCaller(db); const result = await caller.create({ userId: "target_user", type: "INFO", title: "Test notification", }); expect(result.id).toBe("notif_1"); expect(db.notification.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: "target_user", type: "INFO", title: "Test notification", }), }), ); expect(db.notification.findUnique).toHaveBeenCalledWith({ where: { id: "notif_1" } }); }); it("creates a notification with optional fields", async () => { const created = sampleNotification({ userId: "target_user", body: "Details here", entityId: "proj_1", entityType: "PROJECT", }); const db = withUserLookup( { notification: { create: vi.fn().mockResolvedValue(created), findUnique: vi.fn().mockResolvedValue(created), }, }, "user_mgr", ); const caller = createManagerCaller(db); await caller.create({ userId: "target_user", type: "INFO", title: "Test", body: "Details here", entityId: "proj_1", entityType: "PROJECT", }); expect(db.notification.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ body: "Details here", entityId: "proj_1", entityType: "PROJECT", }), }), ); }); it("rejects creation by a regular user (FORBIDDEN)", async () => { const db = withUserLookup({ notification: { create: vi.fn(), }, }); const caller = createProtectedCaller(db); await expect( caller.create({ userId: "target", type: "INFO", title: "Nope" }), ).rejects.toThrow(); }); }); // ─── createBroadcast ──────────────────────────────────────────────────────── describe("notification.createBroadcast", () => { it("rejects broadcasts when no recipients match the target", async () => { resolveRecipientsMock.mockResolvedValue([]); const create = vi.fn().mockResolvedValue({ id: "broadcast_1", title: "Ops update", createdAt: new Date("2026-03-30T10:00:00Z"), }); const update = vi.fn(); const db = { notificationBroadcast: { create, update, }, }; const caller = createManagerCaller(db); await expect(caller.createBroadcast({ title: "Ops update", targetType: "all", })).rejects.toMatchObject({ code: "BAD_REQUEST", message: "No recipients matched the broadcast target.", }); expect(create).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); expect(resolveRecipientsMock).toHaveBeenCalledWith( "all", undefined, db, "user_mgr", ); }); it("does not partially persist an immediate broadcast when recipient fan-out fails", async () => { resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); const txCreateBroadcast = vi.fn().mockResolvedValue({ id: "broadcast_tx_1", title: "Ops update", createdAt: new Date("2026-03-30T10:00:00Z"), }); const txUpdateBroadcast = vi.fn(); const txCreateNotification = vi.fn() .mockResolvedValueOnce({ id: "notif_a", userId: "user_a" }) .mockRejectedValueOnce(new Error("fan-out failed")); 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.toThrow("fan-out failed"); expect(transaction).toHaveBeenCalledTimes(1); expect(txCreateBroadcast).toHaveBeenCalledTimes(1); expect(txCreateNotification).toHaveBeenCalledTimes(2); expect(txUpdateBroadcast).not.toHaveBeenCalled(); expect(outerCreateBroadcast).not.toHaveBeenCalled(); expect(outerUpdateBroadcast).not.toHaveBeenCalled(); expect(outerCreateNotification).not.toHaveBeenCalled(); }); it("rolls back an immediate broadcast when the final broadcast update fails", async () => { resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); const txCreateBroadcast = vi.fn().mockResolvedValue({ id: "broadcast_tx_2", title: "Ops update", createdAt: new Date("2026-03-30T10:00:00Z"), }); const txCreateNotification = vi.fn() .mockResolvedValueOnce({ id: "notif_a", userId: "user_a" }) .mockResolvedValueOnce({ id: "notif_b", userId: "user_b" }); const txUpdateBroadcast = vi.fn().mockRejectedValue( Object.assign(new Error("Record to update not found"), { code: "P2025", meta: { modelName: "NotificationBroadcast" }, }), ); 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(2); expect(txUpdateBroadcast).toHaveBeenCalledTimes(1); expect(outerCreateBroadcast).not.toHaveBeenCalled(); expect(outerUpdateBroadcast).not.toHaveBeenCalled(); expect(outerCreateNotification).not.toHaveBeenCalled(); expect(emitNotificationCreated).not.toHaveBeenCalled(); expect(emitTaskAssigned).not.toHaveBeenCalled(); expect(emitBroadcastSent).not.toHaveBeenCalled(); }); });