diff --git a/packages/api/src/__tests__/notification-router.test.ts b/packages/api/src/__tests__/notification-router.test.ts index 3452153..f0f6ae1 100644 --- a/packages/api/src/__tests__/notification-router.test.ts +++ b/packages/api/src/__tests__/notification-router.test.ts @@ -1,15 +1,27 @@ import { SystemRole } from "@capakraken/shared"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { notificationRouter } from "../router/notification.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(), })); +vi.mock("../lib/notification-targeting.js", () => ({ + resolveRecipients: resolveRecipientsMock, +})); + const createCaller = createCallerFactory(notificationRouter); +beforeEach(() => { + resolveRecipientsMock.mockReset(); +}); + // ── Caller factories ───────────────────────────────────────────────────────── function createProtectedCaller(db: Record) { @@ -264,3 +276,49 @@ describe("notification.create", () => { ).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).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + senderId: "user_mgr", + title: "Ops update", + targetType: "all", + }), + })); + expect(update).not.toHaveBeenCalled(); + expect(resolveRecipientsMock).toHaveBeenCalledWith( + "all", + undefined, + db, + "user_mgr", + ); + }); +}); diff --git a/packages/api/src/__tests__/user-router.test.ts b/packages/api/src/__tests__/user-router.test.ts new file mode 100644 index 0000000..a222af8 --- /dev/null +++ b/packages/api/src/__tests__/user-router.test.ts @@ -0,0 +1,121 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { userRouter } from "../router/user.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(userRouter); + +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_admin", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +describe("user.linkResource", () => { + it("returns NOT_FOUND when the target user does not exist", async () => { + const userFindUnique = vi.fn().mockResolvedValue(null); + const resourceFindUnique = vi.fn(); + const updateMany = vi.fn(); + const update = vi.fn(); + const caller = createAdminCaller({ + user: { + findUnique: userFindUnique, + }, + resource: { + findUnique: resourceFindUnique, + updateMany, + update, + }, + }); + + await expect(caller.linkResource({ + userId: "missing_user", + resourceId: "resource_1", + })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "User not found", + }); + + expect(userFindUnique).toHaveBeenCalledWith({ + where: { id: "missing_user" }, + select: { id: true }, + }); + expect(resourceFindUnique).not.toHaveBeenCalled(); + expect(updateMany).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("returns NOT_FOUND when the target resource does not exist", async () => { + const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); + const resourceFindUnique = vi.fn().mockResolvedValue(null); + const updateMany = vi.fn(); + const update = vi.fn(); + const caller = createAdminCaller({ + user: { + findUnique: userFindUnique, + }, + resource: { + findUnique: resourceFindUnique, + updateMany, + update, + }, + }); + + await expect(caller.linkResource({ + userId: "user_1", + resourceId: "missing_resource", + })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Resource not found", + }); + + expect(resourceFindUnique).toHaveBeenCalledWith({ + where: { id: "missing_resource" }, + select: { id: true }, + }); + expect(updateMany).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("unlinks existing assignments before linking the requested resource", async () => { + const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); + const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1" }); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const update = vi.fn().mockResolvedValue({ id: "resource_1", userId: "user_1" }); + const caller = createAdminCaller({ + user: { + findUnique: userFindUnique, + }, + resource: { + findUnique: resourceFindUnique, + updateMany, + update, + }, + }); + + const result = await caller.linkResource({ + userId: "user_1", + resourceId: "resource_1", + }); + + expect(result).toEqual({ success: true }); + expect(updateMany).toHaveBeenCalledWith({ + where: { userId: "user_1" }, + data: { userId: null }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "resource_1" }, + data: { userId: "user_1" }, + }); + }); +});