From efe3b966760735cfd8b4ec020dd29ebdd0c75690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 16:26:12 +0200 Subject: [PATCH] test(api): add 48 router tests for client, role, and blueprint CRUD Phase 3c: covers list/getById/create/update for all three routers including authorization guards, conflict detection, NOT_FOUND errors, and audit logging verification. Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/blueprint-router.test.ts | 542 ++++++++++-------- .../api/src/__tests__/client-router.test.ts | 495 +++++++++------- .../api/src/__tests__/role-router.test.ts | 514 +++++++++++++++++ 3 files changed, 1130 insertions(+), 421 deletions(-) create mode 100644 packages/api/src/__tests__/role-router.test.ts diff --git a/packages/api/src/__tests__/blueprint-router.test.ts b/packages/api/src/__tests__/blueprint-router.test.ts index b2e9924..7619d65 100644 --- a/packages/api/src/__tests__/blueprint-router.test.ts +++ b/packages/api/src/__tests__/blueprint-router.test.ts @@ -1,25 +1,17 @@ import { BlueprintTarget, FieldType, SystemRole } from "@capakraken/shared"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { blueprintRouter } from "../router/blueprint.js"; import { createCallerFactory } from "../trpc.js"; -const createCaller = createCallerFactory(blueprintRouter); +// ─── Mocks ──────────────────────────────────────────────────────────────────── -function createPlanningCaller(db: Record) { - return createCaller({ - session: { - user: { email: "planning@example.com", name: "Planning", image: null }, - expires: "2099-01-01T00:00:00.000Z", - }, - db: db as never, - dbUser: { - id: "user_planning", - systemRole: SystemRole.MANAGER, - permissionOverrides: null, - }, - permissions: new Set(["view:planning"]), - }); -} +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +// ─── Caller factory ─────────────────────────────────────────────────────────── + +const createCaller = createCallerFactory(blueprintRouter); function createAdminCaller(db: Record) { return createCaller({ @@ -33,6 +25,23 @@ function createAdminCaller(db: Record) { systemRole: SystemRole.ADMIN, permissionOverrides: null, }, + roleDefaults: null, + }); +} + +function createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "manager@example.com", name: "Manager", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_manager", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + roleDefaults: null, }); } @@ -48,234 +57,313 @@ function sampleBlueprint(overrides: Record = {}) { rolePresets: [], isActive: true, isGlobal: false, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + deletedAt: null, ...overrides, }; } -describe("blueprint router", () => { - it("lists active blueprints with the expected filters", async () => { - const findMany = vi.fn().mockResolvedValue([sampleBlueprint()]); +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("blueprint.list", () => { + it("returns active blueprints ordered by name for the given target", async () => { + const findMany = vi + .fn() + .mockResolvedValue([ + sampleBlueprint({ id: "bp_1", name: "Feature Film" }), + sampleBlueprint({ id: "bp_2", name: "Short Film" }), + ]); + + const caller = createManagerCaller({ blueprint: { findMany } }); - const caller = createPlanningCaller({ - blueprint: { findMany }, - }); const result = await caller.list({ target: BlueprintTarget.PROJECT }); expect(findMany).toHaveBeenCalledWith({ - where: { - target: BlueprintTarget.PROJECT, - isActive: true, - }, + where: { target: BlueprintTarget.PROJECT, isActive: true }, orderBy: { name: "asc" }, }); - expect(result).toHaveLength(1); + expect(result).toHaveLength(2); + expect(result[0]!.name).toBe("Feature Film"); }); - it("resolves a blueprint by identifier through the protected router query", async () => { - const findUnique = vi.fn().mockResolvedValue(null); - const findFirst = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ - id: "bp_1", - name: "Consulting Blueprint", - target: BlueprintTarget.PROJECT, - isActive: true, - }); - - const caller = createPlanningCaller({ - blueprint: { findUnique, findFirst }, - }); - const result = await caller.resolveByIdentifier({ identifier: " consulting " }); - - expect(findUnique).toHaveBeenCalledWith({ - where: { id: "consulting" }, - select: { - id: true, - name: true, - target: true, - isActive: true, - }, - }); - expect(findFirst).toHaveBeenNthCalledWith(2, { - where: { name: { contains: "consulting", mode: "insensitive" } }, - select: { - id: true, - name: true, - target: true, - isActive: true, - }, - }); - expect(result.name).toBe("Consulting Blueprint"); - }); - - it("creates a blueprint and writes an audit entry", async () => { - const create = vi.fn().mockResolvedValue(sampleBlueprint()); - const auditCreate = vi.fn().mockResolvedValue({}); - - const caller = createAdminCaller({ - blueprint: { create }, - auditLog: { create: auditCreate }, - }); - const result = await caller.create({ - name: "Consulting Blueprint", - target: BlueprintTarget.PROJECT, - description: "Default setup", - fieldDefs: [], - defaults: { market: "EU" }, - validationRules: [], - }); - - expect(create).toHaveBeenCalledWith({ - data: { - name: "Consulting Blueprint", - target: BlueprintTarget.PROJECT, - description: "Default setup", - fieldDefs: [], - defaults: { market: "EU" }, - validationRules: [], - }, - }); - expect(auditCreate).toHaveBeenCalledTimes(1); - expect(result.id).toBe("bp_1"); - }); - - it("updates a blueprint through the router and preserves the before snapshot", async () => { - const update = vi.fn().mockResolvedValue( - sampleBlueprint({ - name: "Updated Blueprint", - description: "Updated setup", - }), - ); - const auditCreate = vi.fn().mockResolvedValue({}); - - const caller = createAdminCaller({ - blueprint: { - findUnique: vi.fn().mockResolvedValue(sampleBlueprint()), - update, - }, - auditLog: { create: auditCreate }, - }); - const result = await caller.update({ - id: "bp_1", - data: { - name: "Updated Blueprint", - description: "Updated setup", - }, - }); - - expect(update).toHaveBeenCalledWith({ - where: { id: "bp_1" }, - data: { - name: "Updated Blueprint", - description: "Updated setup", - }, - }); - expect(auditCreate).toHaveBeenCalledTimes(1); - expect(result.name).toBe("Updated Blueprint"); - }); - - it("updates role presets with the dedicated mutation payload", async () => { - const update = vi.fn().mockResolvedValue( - sampleBlueprint({ - rolePresets: [{ roleId: "role_1" }], - }), - ); - const auditCreate = vi.fn().mockResolvedValue({}); - - const caller = createAdminCaller({ - blueprint: { - findUnique: vi.fn().mockResolvedValue(sampleBlueprint({ rolePresets: [] })), - update, - }, - auditLog: { create: auditCreate }, - }); - const rolePresets = [{ roleId: "role_1", allocation: 0.5 }]; - const result = await caller.updateRolePresets({ - id: "bp_1", - rolePresets, - }); - - expect(update).toHaveBeenCalledWith({ - where: { id: "bp_1" }, - data: { rolePresets }, - }); - expect(auditCreate).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - summary: "Updated role presets", - }), - }), - ); - expect(result.rolePresets).toEqual([{ roleId: "role_1" }]); - }); - - it("soft deletes blueprints in batch and audits each deleted blueprint", async () => { - const update = vi + it("returns only inactive blueprints when isActive=false is requested", async () => { + const findMany = vi .fn() - .mockResolvedValueOnce(sampleBlueprint({ id: "bp_1" })) - .mockResolvedValueOnce(sampleBlueprint({ id: "bp_2", name: "Animation Blueprint" })); - const auditCreate = vi.fn().mockResolvedValue({}); - const transaction = vi - .fn() - .mockImplementation(async (operations: Promise[]) => Promise.all(operations)); + .mockResolvedValue([sampleBlueprint({ id: "bp_old", name: "Legacy", isActive: false })]); - const caller = createAdminCaller({ - blueprint: { update }, - auditLog: { create: auditCreate }, - $transaction: transaction, - }); - const result = await caller.batchDelete({ ids: ["bp_1", "bp_2"] }); + const caller = createManagerCaller({ blueprint: { findMany } }); - expect(transaction).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenNthCalledWith(1, - expect.objectContaining({ - where: { id: "bp_1" }, - data: expect.objectContaining({ isActive: false }), - }), - ); - expect(update).toHaveBeenNthCalledWith(2, - expect.objectContaining({ - where: { id: "bp_2" }, - data: expect.objectContaining({ isActive: false }), - }), - ); - expect(auditCreate).toHaveBeenCalledTimes(2); - expect(result).toEqual({ count: 2 }); + const result = await caller.list({ isActive: false }); + + expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { isActive: false } })); + expect(result[0]!.name).toBe("Legacy"); }); - it("expands global field definitions with blueprint metadata", async () => { - const findMany = vi.fn().mockResolvedValue([ - { - id: "bp_global", - name: "Global Project Blueprint", - fieldDefs: [ - { - id: "field_market", - key: "market", - label: "Market", - order: 0, - type: FieldType.TEXT, - required: false, - }, - ], - }, - ]); + it("omits the target filter when no target is provided", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const caller = createManagerCaller({ blueprint: { findMany } }); - const caller = createPlanningCaller({ - blueprint: { findMany }, - }); - const result = await caller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT }); + await caller.list({}); - expect(findMany).toHaveBeenCalledWith({ - where: { target: BlueprintTarget.PROJECT, isGlobal: true, isActive: true }, - select: { id: true, name: true, fieldDefs: true }, - }); - expect(result).toEqual([ - expect.objectContaining({ - blueprintId: "bp_global", - blueprintName: "Global Project Blueprint", - key: "market", - }), - ]); + expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { isActive: true } })); + // target must not appear in the where clause + const whereArg = (findMany.mock.calls[0]![0] as { where: Record }).where; + expect(whereArg).not.toHaveProperty("target"); + }); + + it("returns an empty list when no blueprints match the filter", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const caller = createManagerCaller({ blueprint: { findMany } }); + + const result = await caller.list({ target: BlueprintTarget.PROJECT, isActive: true }); + + expect(result).toEqual([]); + }); +}); + +describe("blueprint.getById", () => { + it("returns the blueprint when it exists", async () => { + const findUnique = vi.fn().mockResolvedValue(sampleBlueprint()); + + const caller = createManagerCaller({ blueprint: { findUnique } }); + + const result = await caller.getById({ id: "bp_1" }); + + expect(findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "bp_1" } })); + expect(result).toMatchObject({ id: "bp_1", name: "Consulting Blueprint" }); + }); + + it("throws NOT_FOUND when the blueprint does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + + const caller = createManagerCaller({ blueprint: { findUnique } }); + + await expect(caller.getById({ id: "missing_bp" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + it("returns blueprint including fieldDefs and defaults", async () => { + const fieldDefs = [ + { + id: "f_1", + label: "Episode Count", + key: "episode_count", + type: FieldType.NUMBER, + required: true, + order: 0, + }, + ]; + const defaults = { episode_count: 10 }; + + const findUnique = vi.fn().mockResolvedValue(sampleBlueprint({ fieldDefs, defaults })); + const caller = createManagerCaller({ blueprint: { findUnique } }); + + const result = await caller.getById({ id: "bp_1" }); + + expect(result.fieldDefs).toEqual(fieldDefs); + expect(result.defaults).toEqual(defaults); + }); + + it("rejects when the caller lacks VIEW_PLANNING permission (VIEWER role)", async () => { + const viewerCaller = createCaller({ + session: { + user: { email: "viewer@example.com", name: "Viewer", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: {} as never, + dbUser: { + id: "user_viewer", + systemRole: SystemRole.VIEWER, + permissionOverrides: null, + }, + roleDefaults: null, + }); + + await expect(viewerCaller.getById({ id: "bp_1" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); +}); + +describe("blueprint.create", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a blueprint with name and target and returns it", async () => { + const created = sampleBlueprint({ id: "bp_new_1", name: "Episodic", description: null }); + const blueprintCreate = vi.fn().mockResolvedValue(created); + const auditCreate = vi.fn().mockResolvedValue({}); + + const caller = createAdminCaller({ + blueprint: { create: blueprintCreate }, + auditLog: { create: auditCreate }, + }); + + const result = await caller.create({ + name: "Episodic", + target: BlueprintTarget.PROJECT, + }); + + expect(blueprintCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: "Episodic", + target: BlueprintTarget.PROJECT, + }), + }); + expect(result).toMatchObject({ id: "bp_new_1", name: "Episodic" }); + }); + + it("creates a blueprint with optional description", async () => { + const created = sampleBlueprint({ id: "bp_new_2", description: "Short commercial production" }); + const blueprintCreate = vi.fn().mockResolvedValue(created); + const auditCreate = vi.fn().mockResolvedValue({}); + + const caller = createAdminCaller({ + blueprint: { create: blueprintCreate }, + auditLog: { create: auditCreate }, + }); + + const result = await caller.create({ + name: "Commercial", + target: BlueprintTarget.PROJECT, + description: "Short commercial production", + }); + + expect(result.description).toBe("Short commercial production"); + }); + + it("creates a blueprint with fieldDefs and stores them verbatim", async () => { + const fieldDefs = [ + { + id: "f_budget", + label: "Budget", + key: "budget", + type: FieldType.NUMBER, + required: false, + order: 0, + }, + ]; + const created = sampleBlueprint({ id: "bp_new_3", fieldDefs }); + const blueprintCreate = vi.fn().mockResolvedValue(created); + const auditCreate = vi.fn().mockResolvedValue({}); + + const caller = createAdminCaller({ + blueprint: { create: blueprintCreate }, + auditLog: { create: auditCreate }, + }); + + const result = await caller.create({ + name: "Budget Template", + target: BlueprintTarget.PROJECT, + fieldDefs, + }); + + expect(blueprintCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ fieldDefs }), + }); + expect(result.fieldDefs).toEqual(fieldDefs); + }); + + it("rejects creation when the caller is not an admin", async () => { + await expect( + createManagerCaller({}).create({ + name: "Blocked Blueprint", + target: BlueprintTarget.PROJECT, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +describe("blueprint.update", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("updates the blueprint name and description", async () => { + const existing = sampleBlueprint(); + const updated = sampleBlueprint({ name: "Feature Film v2", description: "Updated" }); + + const findUnique = vi.fn().mockResolvedValue(existing); + const blueprintUpdate = vi.fn().mockResolvedValue(updated); + const auditCreate = vi.fn().mockResolvedValue({}); + + const caller = createAdminCaller({ + blueprint: { findUnique, update: blueprintUpdate }, + auditLog: { create: auditCreate }, + }); + + const result = await caller.update({ + id: "bp_1", + data: { name: "Feature Film v2", description: "Updated" }, + }); + + expect(blueprintUpdate).toHaveBeenCalledWith({ + where: { id: "bp_1" }, + data: { name: "Feature Film v2", description: "Updated" }, + }); + expect(result).toMatchObject({ name: "Feature Film v2", description: "Updated" }); + }); + + it("updates the blueprint fieldDefs with new definitions", async () => { + const newFieldDefs = [ + { + id: "f_genre", + label: "Genre", + key: "genre", + type: FieldType.TEXT, + required: true, + order: 0, + }, + ]; + const existing = sampleBlueprint(); + const updated = sampleBlueprint({ fieldDefs: newFieldDefs }); + + const findUnique = vi.fn().mockResolvedValue(existing); + const blueprintUpdate = vi.fn().mockResolvedValue(updated); + const auditCreate = vi.fn().mockResolvedValue({}); + + const caller = createAdminCaller({ + blueprint: { findUnique, update: blueprintUpdate }, + auditLog: { create: auditCreate }, + }); + + const result = await caller.update({ id: "bp_1", data: { fieldDefs: newFieldDefs } }); + + expect(result.fieldDefs).toEqual(newFieldDefs); + }); + + it("throws NOT_FOUND when the blueprint to update does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + + const caller = createAdminCaller({ blueprint: { findUnique } }); + + await expect( + caller.update({ id: "missing_bp", data: { name: "Ghost" } }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("sends only changed fields in the update payload (partial update)", async () => { + const existing = sampleBlueprint({ description: "Original description" }); + const updated = sampleBlueprint({ name: "Renamed Film" }); + + const findUnique = vi.fn().mockResolvedValue(existing); + const blueprintUpdate = vi.fn().mockResolvedValue(updated); + const auditCreate = vi.fn().mockResolvedValue({}); + + const caller = createAdminCaller({ + blueprint: { findUnique, update: blueprintUpdate }, + auditLog: { create: auditCreate }, + }); + + await caller.update({ id: "bp_1", data: { name: "Renamed Film" } }); + + const updateData = (blueprintUpdate.mock.calls[0]![0] as { data: Record }) + .data; + expect(updateData).toHaveProperty("name", "Renamed Film"); + // description was not included in the patch so it must not appear in the data payload + expect(updateData).not.toHaveProperty("description"); }); }); diff --git a/packages/api/src/__tests__/client-router.test.ts b/packages/api/src/__tests__/client-router.test.ts index a70d90f..84677a4 100644 --- a/packages/api/src/__tests__/client-router.test.ts +++ b/packages/api/src/__tests__/client-router.test.ts @@ -60,13 +60,31 @@ function createAdminCaller(db: Record) { }); } -describe("client router", () => { - it("lists clients with filters and count includes", async () => { - const findMany = vi.fn().mockResolvedValue([{ id: "client_1", name: "Acme" }]); +// ─── client.list ────────────────────────────────────────────────────────────── - const caller = createPlanningCaller({ - client: { findMany }, +describe("client.list", () => { + it("returns all clients with count includes when no filter is provided", async () => { + const clients = [ + { id: "client_1", name: "Acme" }, + { id: "client_2", name: "Beta" }, + ]; + const findMany = vi.fn().mockResolvedValue(clients); + const caller = createPlanningCaller({ client: { findMany } }); + + const result = await caller.list(undefined); + + expect(result).toEqual(clients); + expect(findMany).toHaveBeenCalledWith({ + where: {}, + include: { _count: { select: { children: true, projects: true } } }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], }); + }); + + it("filters by isActive and parentId when both are provided", async () => { + const findMany = vi.fn().mockResolvedValue([{ id: "client_1", name: "Acme" }]); + const caller = createPlanningCaller({ client: { findMany } }); + const result = await caller.list({ parentId: "parent_1", isActive: true, search: "Acme" }); expect(findMany).toHaveBeenCalledWith({ @@ -84,199 +102,288 @@ describe("client router", () => { expect(result).toHaveLength(1); }); - it("returns a nested tree from ordered flat records", async () => { - const findMany = vi.fn().mockResolvedValue([ - { - id: "client_root", - name: "Root", - code: "ROOT", - parentId: null, - isActive: true, - sortOrder: 10, - tags: [], - createdAt: new Date("2026-03-01T00:00:00.000Z"), - updatedAt: new Date("2026-03-01T00:00:00.000Z"), - }, - { - id: "client_child", - name: "Child", - code: "CHILD", - parentId: "client_root", - isActive: true, - sortOrder: 20, - tags: [], - createdAt: new Date("2026-03-01T00:00:00.000Z"), - updatedAt: new Date("2026-03-01T00:00:00.000Z"), - }, - ]); + it("filters by parentId: null to return only root clients", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const caller = createPlanningCaller({ client: { findMany } }); - const caller = createPlanningCaller({ - client: { findMany }, - }); - const result = await caller.getTree({ isActive: true }); + await caller.list({ parentId: null }); - expect(findMany).toHaveBeenCalledWith({ - where: { isActive: true }, - orderBy: [{ sortOrder: "asc" }, { name: "asc" }], - }); - expect(result).toEqual([ + expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { parentId: null } })); + }); + + it("applies case-insensitive name/code search without other filters", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const caller = createPlanningCaller({ client: { findMany } }); + + await caller.list({ search: "acme" }); + + expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ - id: "client_root", - children: [expect.objectContaining({ id: "client_child" })], + where: { + OR: [ + { name: { contains: "acme", mode: "insensitive" } }, + { code: { contains: "acme", mode: "insensitive" } }, + ], + }, }), - ]); - }); - - it("resolves a client by identifier via the protected query", async () => { - const findUnique = vi - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ - id: "client_1", - code: "ACME", - name: "Acme", - parentId: null, - isActive: true, - }); - - const caller = createPlanningCaller({ - client: { - findUnique, - findFirst: vi.fn(), - }, - }); - const result = await caller.resolveByIdentifier({ identifier: " ACME " }); - - expect(findUnique).toHaveBeenNthCalledWith(2, { - where: { code: "ACME" }, - select: { - id: true, - name: true, - code: true, - parentId: true, - isActive: true, - }, - }); - expect(result).toEqual({ - id: "client_1", - code: "ACME", - name: "Acme", - parentId: null, - isActive: true, - }); - }); - - it("creates and updates a client through the router", async () => { - const findUnique = vi - .fn() - .mockResolvedValueOnce({ id: "parent_1", name: "Parent" }) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ - id: "client_1", - name: "Acme", - code: "ACME", - isActive: true, - }); - const create = vi.fn().mockResolvedValue({ - id: "client_1", - name: "Acme", - code: "ACME", - parentId: "parent_1", - sortOrder: 10, - tags: ["enterprise"], - isActive: true, - }); - const update = vi.fn().mockResolvedValue({ - id: "client_1", - name: "Acme Updated", - code: "ACME", - isActive: true, - }); - - const caller = createManagerCaller({ - client: { findUnique, create, update }, - }); - - const created = await caller.create({ - name: "Acme", - code: "ACME", - parentId: "parent_1", - sortOrder: 10, - tags: ["enterprise"], - }); - const updated = await caller.update({ - id: "client_1", - data: { name: "Acme Updated", code: "ACME" }, - }); - - expect(create).toHaveBeenCalledWith({ - data: { - name: "Acme", - code: "ACME", - parentId: "parent_1", - sortOrder: 10, - tags: ["enterprise"], - }, - }); - expect(update).toHaveBeenCalledWith({ - where: { id: "client_1" }, - data: { name: "Acme Updated", code: "ACME" }, - }); - expect(created.id).toBe("client_1"); - expect(updated.name).toBe("Acme Updated"); - expect(createAuditEntry).toHaveBeenCalledTimes(2); - }); - - it("deletes a deletable client through the admin router", async () => { - const remove = vi.fn().mockResolvedValue({ id: "client_1" }); - - const caller = createAdminCaller({ - client: { - findUnique: vi.fn().mockResolvedValue({ - id: "client_1", - name: "Acme", - _count: { projects: 0, children: 0 }, - }), - delete: remove, - }, - }); - const result = await caller.delete({ id: "client_1" }); - - expect(remove).toHaveBeenCalledWith({ - where: { id: "client_1" }, - }); - expect(result).toEqual({ - id: "client_1", - name: "Acme", - _count: { projects: 0, children: 0 }, - }); - }); - - it("batch-updates sort order through the manager router", async () => { - const update = vi.fn( - ({ where, data }: { where: { id: string }; data: { sortOrder: number } }) => - Promise.resolve({ id: where.id, sortOrder: data.sortOrder }), ); - const $transaction = vi.fn().mockResolvedValue([]); - - const caller = createManagerCaller({ - $transaction, - client: { update }, - }); - const result = await caller.batchUpdateSortOrder([ - { id: "client_1", sortOrder: 10 }, - { id: "client_2", sortOrder: 20 }, - ]); - - expect($transaction).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenNthCalledWith(1, { - where: { id: "client_1" }, - data: { sortOrder: 10 }, - }); - expect(update).toHaveBeenNthCalledWith(2, { - where: { id: "client_2" }, - data: { sortOrder: 20 }, - }); - expect(result).toEqual({ ok: true }); + }); +}); + +// ─── client.getById ─────────────────────────────────────────────────────────── + +describe("client.getById", () => { + it("returns the client with parent, children, and counts when found", async () => { + const client = { + id: "client_1", + name: "Acme", + code: "ACME", + parentId: null, + parent: null, + isActive: true, + sortOrder: 0, + tags: [], + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + children: [], + _count: { projects: 2, children: 0 }, + }; + const findUnique = vi.fn().mockResolvedValue(client); + const caller = createAdminCaller({ client: { findUnique } }); + + const result = await caller.getById({ id: "client_1" }); + + expect(result).toEqual(client); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "client_1" }, + include: { + parent: true, + children: { orderBy: { sortOrder: "asc" } }, + _count: { select: { projects: true, children: true } }, + }, + }); + }); + + it("throws NOT_FOUND when no client matches the given id", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ client: { findUnique } }); + + await expect(caller.getById({ id: "missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Client not found", + }); + }); + + it("passes the id through to the DB query unchanged", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ client: { findUnique } }); + + await expect(caller.getById({ id: "client_xyz_123" })).rejects.toBeDefined(); + expect(findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "client_xyz_123" } }), + ); + }); + + it("calls the DB exactly once for a successful lookup", async () => { + const client = { + id: "client_2", + name: "Beta", + parent: null, + children: [], + _count: { projects: 0, children: 0 }, + }; + const findUnique = vi.fn().mockResolvedValue(client); + const caller = createAdminCaller({ client: { findUnique } }); + + await caller.getById({ id: "client_2" }); + + expect(findUnique).toHaveBeenCalledTimes(1); + }); +}); + +// ─── client.create ──────────────────────────────────────────────────────────── + +describe("client.create", () => { + it("creates a client without a code and returns the new record", async () => { + const created = { + id: "c_new", + name: "New Client", + code: null, + parentId: null, + isActive: true, + sortOrder: 0, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + const create = vi.fn().mockResolvedValue(created); + const caller = createManagerCaller({ client: { create } }); + + const result = await caller.create({ name: "New Client", sortOrder: 0 }); + + expect(result).toEqual(created); + expect(create).toHaveBeenCalledWith({ data: { name: "New Client", sortOrder: 0 } }); + expect(createAuditEntry).toHaveBeenCalled(); + }); + + it("checks code availability and creates the client when code is unique", async () => { + const created = { + id: "c_coded", + name: "Coded Client", + code: "CLI01", + parentId: null, + isActive: true, + sortOrder: 5, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + // findUnique for assertClientCodeAvailable returns null = code available + const findUnique = vi.fn().mockResolvedValue(null); + const create = vi.fn().mockResolvedValue(created); + const caller = createManagerCaller({ client: { findUnique, create } }); + + const result = await caller.create({ name: "Coded Client", code: "CLI01", sortOrder: 5 }); + + expect(result).toEqual(created); + expect(findUnique).toHaveBeenCalledWith({ where: { code: "CLI01" } }); + expect(create).toHaveBeenCalledWith({ + data: { name: "Coded Client", code: "CLI01", sortOrder: 5 }, + }); + }); + + it("rejects creation with CONFLICT when the code is already taken", async () => { + const existingWithSameCode = { id: "c_other", code: "DUP" }; + const findUnique = vi.fn().mockResolvedValue(existingWithSameCode); + const create = vi.fn(); + const caller = createManagerCaller({ client: { findUnique, create } }); + + await expect( + caller.create({ name: "Duplicate Code Client", code: "DUP", sortOrder: 0 }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: 'Client code "DUP" already exists', + }); + + expect(create).not.toHaveBeenCalled(); + }); + + it("rejects creation with NOT_FOUND when parentId references a missing parent", async () => { + // findUnique for parent lookup returns null + const findUnique = vi.fn().mockResolvedValue(null); + const create = vi.fn(); + const caller = createManagerCaller({ client: { findUnique, create } }); + + await expect( + caller.create({ name: "Orphan", parentId: "missing_parent", sortOrder: 0 }), + ).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Parent client not found", + }); + + expect(create).not.toHaveBeenCalled(); + }); +}); + +// ─── client.update ──────────────────────────────────────────────────────────── + +describe("client.update", () => { + it("updates the name of an existing client and returns the updated record", async () => { + const existing = { + id: "client_1", + name: "Old Name", + code: null, + isActive: true, + sortOrder: 0, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + const updated = { ...existing, name: "New Name" }; + const findUnique = vi.fn().mockResolvedValue(existing); + const update = vi.fn().mockResolvedValue(updated); + const caller = createManagerCaller({ client: { findUnique, update } }); + + const result = await caller.update({ id: "client_1", data: { name: "New Name" } }); + + expect(result).toEqual(updated); + expect(update).toHaveBeenCalledWith({ + where: { id: "client_1" }, + data: { name: "New Name" }, + }); + expect(createAuditEntry).toHaveBeenCalled(); + }); + + it("throws NOT_FOUND when the target client does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const update = vi.fn(); + const caller = createManagerCaller({ client: { findUnique, update } }); + + await expect(caller.update({ id: "missing", data: { name: "Ghost" } })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Client not found", + }); + + expect(update).not.toHaveBeenCalled(); + }); + + it("rejects update with CONFLICT when the new code is already taken by another client", async () => { + const existing = { + id: "client_1", + name: "Acme", + code: "OLD_CODE", + isActive: true, + sortOrder: 0, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + const conflicting = { id: "client_2", code: "NEW_CODE" }; + const findUnique = vi + .fn() + .mockResolvedValueOnce(existing) // findUniqueOrThrow for existing client + .mockResolvedValueOnce(conflicting); // assertClientCodeAvailable + const update = vi.fn(); + const caller = createManagerCaller({ client: { findUnique, update } }); + + await expect( + caller.update({ id: "client_1", data: { code: "NEW_CODE" } }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: 'Client code "NEW_CODE" already exists', + }); + + expect(update).not.toHaveBeenCalled(); + }); + + it("skips code availability check when the code is unchanged", async () => { + const existing = { + id: "client_1", + name: "Acme", + code: "SAME_CODE", + isActive: true, + sortOrder: 0, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + const updated = { ...existing, sortOrder: 10 }; + // Only one findUnique call expected — no assertClientCodeAvailable call + const findUnique = vi.fn().mockResolvedValue(existing); + const update = vi.fn().mockResolvedValue(updated); + const caller = createManagerCaller({ client: { findUnique, update } }); + + const result = await caller.update({ + id: "client_1", + data: { code: "SAME_CODE", sortOrder: 10 }, + }); + + expect(result).toEqual(updated); + // findUnique called exactly once: for the existence check, not for code conflict + expect(findUnique).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith({ + where: { id: "client_1" }, + data: { code: "SAME_CODE", sortOrder: 10 }, + }); }); }); diff --git a/packages/api/src/__tests__/role-router.test.ts b/packages/api/src/__tests__/role-router.test.ts new file mode 100644 index 0000000..a6a84c4 --- /dev/null +++ b/packages/api/src/__tests__/role-router.test.ts @@ -0,0 +1,514 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { roleRouter } from "../router/role.js"; +import { createCallerFactory } from "../trpc.js"; + +// ─── Hoisted mocks ──────────────────────────────────────────────────────────── + +const { countPlanningEntries } = vi.hoisted(() => ({ + countPlanningEntries: vi.fn(), +})); + +const { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } = vi.hoisted(() => ({ + emitRoleCreated: vi.fn(), + emitRoleDeleted: vi.fn(), + emitRoleUpdated: vi.fn(), +})); + +vi.mock("@capakraken/application", () => ({ + countPlanningEntries, +})); + +vi.mock("../sse/event-bus.js", () => ({ + emitRoleCreated, + emitRoleDeleted, + emitRoleUpdated, +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +// ─── Caller factory ─────────────────────────────────────────────────────────── + +const createCaller = createCallerFactory(roleRouter); + +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, + }); +} + +function createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "manager@example.com", name: "Manager", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_manager", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function mockPlanningCounts(roleId: string, count = 0) { + countPlanningEntries.mockResolvedValue({ + countsByRoleId: new Map([[roleId, count]]), + }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("role.list", () => { + beforeEach(() => { + countPlanningEntries.mockReset(); + }); + + it("returns all roles ordered by name with allocation counts", async () => { + countPlanningEntries.mockResolvedValue({ + countsByRoleId: new Map([ + ["role_fx", 3], + ["role_td", 1], + ]), + }); + + const findMany = vi.fn().mockResolvedValue([ + { id: "role_fx", name: "FX Artist", _count: { resourceRoles: 2 } }, + { id: "role_td", name: "Technical Director", _count: { resourceRoles: 5 } }, + ]); + + const caller = createAdminCaller({ + role: { findMany }, + demandRequirement: {}, + assignment: {}, + }); + + const result = await caller.list({}); + + expect(findMany).toHaveBeenCalledWith({ + where: {}, + include: { _count: { select: { resourceRoles: true } } }, + orderBy: { name: "asc" }, + }); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + id: "role_fx", + _count: { resourceRoles: 2, allocations: 3 }, + }); + expect(result[1]).toMatchObject({ + id: "role_td", + _count: { resourceRoles: 5, allocations: 1 }, + }); + }); + + it("filters by isActive when supplied", async () => { + countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map() }); + + const findMany = vi.fn().mockResolvedValue([]); + const caller = createAdminCaller({ + role: { findMany }, + demandRequirement: {}, + assignment: {}, + }); + + await caller.list({ isActive: true }); + + expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { isActive: true } })); + }); + + it("filters by search string using case-insensitive contains", async () => { + countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map() }); + + const findMany = vi.fn().mockResolvedValue([]); + const caller = createAdminCaller({ + role: { findMany }, + demandRequirement: {}, + assignment: {}, + }); + + await caller.list({ search: "model" }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { name: { contains: "model", mode: "insensitive" } }, + }), + ); + }); + + it("assigns zero allocation count for roles not present in the planning counts map", async () => { + countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map() }); + + const findMany = vi + .fn() + .mockResolvedValue([{ id: "role_new", name: "New Role", _count: { resourceRoles: 0 } }]); + const caller = createAdminCaller({ + role: { findMany }, + demandRequirement: {}, + assignment: {}, + }); + + const result = await caller.list({}); + + expect(result[0]!._count.allocations).toBe(0); + }); +}); + +describe("role.getById", () => { + beforeEach(() => { + countPlanningEntries.mockReset(); + }); + + it("returns the role with resources and allocation count", async () => { + mockPlanningCounts("role_1", 4); + + const findUnique = vi.fn().mockResolvedValue({ + id: "role_1", + name: "Compositor", + description: "Comp work", + color: "#ff0000", + isActive: true, + _count: { resourceRoles: 2 }, + resourceRoles: [], + }); + + const caller = createAdminCaller({ + role: { findUnique }, + demandRequirement: {}, + assignment: {}, + }); + + const result = await caller.getById({ id: "role_1" }); + + expect(findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "role_1" } })); + expect(result).toMatchObject({ + id: "role_1", + name: "Compositor", + _count: { resourceRoles: 2, allocations: 4 }, + }); + }); + + it("throws NOT_FOUND when the role does not exist", async () => { + countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map() }); + + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ + role: { findUnique }, + demandRequirement: {}, + assignment: {}, + }); + + await expect(caller.getById({ id: "missing_role" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + it("returns role with zero allocation count when no planning entries exist", async () => { + countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map() }); + + const findUnique = vi.fn().mockResolvedValue({ + id: "role_2", + name: "Rigger", + description: null, + color: null, + isActive: true, + _count: { resourceRoles: 0 }, + resourceRoles: [], + }); + + const caller = createAdminCaller({ + role: { findUnique }, + demandRequirement: {}, + assignment: {}, + }); + + const result = await caller.getById({ id: "role_2" }); + + expect(result._count.allocations).toBe(0); + }); + + it("includes linked resources in the response", async () => { + mockPlanningCounts("role_3", 1); + + const resource = { id: "res_1", displayName: "Alice", eid: "A001", lcrCents: 0, chapter: null }; + const findUnique = vi.fn().mockResolvedValue({ + id: "role_3", + name: "Animator", + description: null, + color: "#00ff00", + isActive: true, + _count: { resourceRoles: 1 }, + resourceRoles: [{ resource }], + }); + + const caller = createAdminCaller({ + role: { findUnique }, + demandRequirement: {}, + assignment: {}, + }); + + const result = await caller.getById({ id: "role_3" }); + + expect(result.resourceRoles).toHaveLength(1); + expect(result.resourceRoles[0]!.resource).toMatchObject({ id: "res_1", displayName: "Alice" }); + }); +}); + +describe("role.create", () => { + beforeEach(() => { + countPlanningEntries.mockReset(); + emitRoleCreated.mockReset(); + }); + + it("creates a role with required name only and returns it with zero counts", async () => { + const created = { + id: "role_new_1", + name: "Layout Artist", + description: null, + color: null, + isActive: true, + _count: { resourceRoles: 0 }, + }; + + const roleFindUnique = vi.fn().mockResolvedValue(null); // name availability check + const transaction = vi.fn().mockImplementation(async (fn: (tx: unknown) => Promise) => + fn({ + role: { + create: vi.fn().mockResolvedValue(created), + }, + auditLog: { create: vi.fn().mockResolvedValue(undefined) }, + }), + ); + + const caller = createManagerCaller({ + role: { findUnique: roleFindUnique }, + $transaction: transaction, + demandRequirement: {}, + assignment: {}, + }); + + const result = await caller.create({ name: "Layout Artist" }); + + expect(result).toMatchObject({ + id: "role_new_1", + name: "Layout Artist", + _count: { resourceRoles: 0, allocations: 0 }, + }); + expect(emitRoleCreated).toHaveBeenCalledWith({ id: "role_new_1", name: "Layout Artist" }); + }); + + it("creates a role with optional color and description", async () => { + const created = { + id: "role_new_2", + name: "VFX Supervisor", + description: "Oversees VFX pipeline", + color: "#aabbcc", + isActive: true, + _count: { resourceRoles: 0 }, + }; + + const roleFindUnique = vi.fn().mockResolvedValue(null); + const transaction = vi.fn().mockImplementation(async (fn: (tx: unknown) => Promise) => + fn({ + role: { + create: vi.fn().mockResolvedValue(created), + }, + auditLog: { create: vi.fn().mockResolvedValue(undefined) }, + }), + ); + + const caller = createManagerCaller({ + role: { findUnique: roleFindUnique }, + $transaction: transaction, + demandRequirement: {}, + assignment: {}, + }); + + const result = await caller.create({ + name: "VFX Supervisor", + description: "Oversees VFX pipeline", + color: "#aabbcc", + }); + + expect(result).toMatchObject({ + name: "VFX Supervisor", + description: "Oversees VFX pipeline", + color: "#aabbcc", + }); + }); + + it("throws CONFLICT when a role with the same name already exists", async () => { + const roleFindUnique = vi.fn().mockResolvedValue({ id: "role_existing", name: "FX Artist" }); + + const caller = createManagerCaller({ + role: { findUnique: roleFindUnique }, + demandRequirement: {}, + assignment: {}, + }); + + await expect(caller.create({ name: "FX Artist" })).rejects.toMatchObject({ + code: "CONFLICT", + message: 'Role "FX Artist" already exists', + }); + }); + + it("rejects creation when the caller does not have MANAGER or ADMIN role", async () => { + const viewerCaller = createCaller({ + session: { + user: { email: "viewer@example.com", name: "Viewer", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: {} as never, + dbUser: { + id: "user_viewer", + systemRole: SystemRole.VIEWER, + permissionOverrides: null, + }, + roleDefaults: null, + }); + + await expect(viewerCaller.create({ name: "New Role" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); +}); + +describe("role.update", () => { + beforeEach(() => { + countPlanningEntries.mockReset(); + emitRoleUpdated.mockReset(); + }); + + it("updates the role name when a new unique name is provided", async () => { + mockPlanningCounts("role_1", 0); + + const existing = { + id: "role_1", + name: "Animator", + description: null, + color: null, + isActive: true, + }; + const updated = { ...existing, name: "Senior Animator", _count: { resourceRoles: 1 } }; + + const roleFindUnique = vi + .fn() + .mockResolvedValueOnce(existing) // fetch existing for update guard + .mockResolvedValueOnce(null); // name availability check + + const transaction = vi.fn().mockImplementation(async (fn: (tx: unknown) => Promise) => + fn({ + role: { update: vi.fn().mockResolvedValue(updated) }, + auditLog: { create: vi.fn().mockResolvedValue(undefined) }, + }), + ); + + const caller = createManagerCaller({ + role: { findUnique: roleFindUnique }, + $transaction: transaction, + demandRequirement: {}, + assignment: {}, + }); + + const result = await caller.update({ id: "role_1", data: { name: "Senior Animator" } }); + + expect(result).toMatchObject({ id: "role_1", name: "Senior Animator" }); + expect(emitRoleUpdated).toHaveBeenCalledWith({ id: "role_1", name: "Senior Animator" }); + }); + + it("updates color and description without renaming the role", async () => { + mockPlanningCounts("role_2", 2); + + const existing = { + id: "role_2", + name: "Rigger", + description: null, + color: null, + isActive: true, + }; + const updated = { + ...existing, + description: "Character rigging", + color: "#123456", + _count: { resourceRoles: 0 }, + }; + + const roleFindUnique = vi.fn().mockResolvedValueOnce(existing); + + const transaction = vi.fn().mockImplementation(async (fn: (tx: unknown) => Promise) => + fn({ + role: { update: vi.fn().mockResolvedValue(updated) }, + auditLog: { create: vi.fn().mockResolvedValue(undefined) }, + }), + ); + + const caller = createManagerCaller({ + role: { findUnique: roleFindUnique }, + $transaction: transaction, + demandRequirement: {}, + assignment: {}, + }); + + const result = await caller.update({ + id: "role_2", + data: { description: "Character rigging", color: "#123456" }, + }); + + expect(result).toMatchObject({ description: "Character rigging", color: "#123456" }); + }); + + it("throws NOT_FOUND when the target role does not exist", async () => { + const roleFindUnique = vi.fn().mockResolvedValue(null); + + const caller = createManagerCaller({ + role: { findUnique: roleFindUnique }, + demandRequirement: {}, + assignment: {}, + }); + + await expect( + caller.update({ id: "missing_role", data: { name: "Something" } }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("throws CONFLICT when renaming to an existing role name", async () => { + const existing = { + id: "role_1", + name: "Animator", + description: null, + color: null, + isActive: true, + }; + const conflicting = { id: "role_99", name: "Senior Animator" }; + + const roleFindUnique = vi + .fn() + .mockResolvedValueOnce(existing) // existing check + .mockResolvedValueOnce(conflicting); // name availability check + + const caller = createManagerCaller({ + role: { findUnique: roleFindUnique }, + demandRequirement: {}, + assignment: {}, + }); + + await expect( + caller.update({ id: "role_1", data: { name: "Senior Animator" } }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); +});