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 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 16:26:12 +02:00
parent 486a2239be
commit efe3b96676
3 changed files with 1130 additions and 421 deletions
@@ -1,25 +1,17 @@
import { BlueprintTarget, FieldType, SystemRole } from "@capakraken/shared"; 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 { blueprintRouter } from "../router/blueprint.js";
import { createCallerFactory } from "../trpc.js"; import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(blueprintRouter); // ─── Mocks ────────────────────────────────────────────────────────────────────
function createPlanningCaller(db: Record<string, unknown>) { vi.mock("../lib/audit.js", () => ({
return createCaller({ createAuditEntry: vi.fn().mockResolvedValue(undefined),
session: { }));
user: { email: "planning@example.com", name: "Planning", image: null },
expires: "2099-01-01T00:00:00.000Z", // ─── Caller factory ───────────────────────────────────────────────────────────
},
db: db as never, const createCaller = createCallerFactory(blueprintRouter);
dbUser: {
id: "user_planning",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
permissions: new Set(["view:planning"]),
});
}
function createAdminCaller(db: Record<string, unknown>) { function createAdminCaller(db: Record<string, unknown>) {
return createCaller({ return createCaller({
@@ -33,6 +25,23 @@ function createAdminCaller(db: Record<string, unknown>) {
systemRole: SystemRole.ADMIN, systemRole: SystemRole.ADMIN,
permissionOverrides: null, permissionOverrides: null,
}, },
roleDefaults: null,
});
}
function createManagerCaller(db: Record<string, unknown>) {
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<string, unknown> = {}) {
rolePresets: [], rolePresets: [],
isActive: true, isActive: true,
isGlobal: false, isGlobal: false,
createdAt: new Date(), createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date(), updatedAt: new Date("2026-01-01T00:00:00.000Z"),
deletedAt: null,
...overrides, ...overrides,
}; };
} }
describe("blueprint router", () => { // ─── Tests ────────────────────────────────────────────────────────────────────
it("lists active blueprints with the expected filters", async () => {
const findMany = vi.fn().mockResolvedValue([sampleBlueprint()]); 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 }); const result = await caller.list({ target: BlueprintTarget.PROJECT });
expect(findMany).toHaveBeenCalledWith({ expect(findMany).toHaveBeenCalledWith({
where: { where: { target: BlueprintTarget.PROJECT, isActive: true },
target: BlueprintTarget.PROJECT,
isActive: true,
},
orderBy: { name: "asc" }, 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 () => { it("returns only inactive blueprints when isActive=false is requested", async () => {
const findUnique = vi.fn().mockResolvedValue(null); const findMany = vi
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
.fn() .fn()
.mockResolvedValueOnce(sampleBlueprint({ id: "bp_1" })) .mockResolvedValue([sampleBlueprint({ id: "bp_old", name: "Legacy", isActive: false })]);
.mockResolvedValueOnce(sampleBlueprint({ id: "bp_2", name: "Animation Blueprint" }));
const auditCreate = vi.fn().mockResolvedValue({});
const transaction = vi
.fn()
.mockImplementation(async (operations: Promise<unknown>[]) => Promise.all(operations));
const caller = createAdminCaller({ const caller = createManagerCaller({ blueprint: { findMany } });
blueprint: { update },
auditLog: { create: auditCreate },
$transaction: transaction,
});
const result = await caller.batchDelete({ ids: ["bp_1", "bp_2"] });
expect(transaction).toHaveBeenCalledTimes(1); const result = await caller.list({ isActive: false });
expect(update).toHaveBeenNthCalledWith(1,
expect.objectContaining({ expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { isActive: false } }));
where: { id: "bp_1" }, expect(result[0]!.name).toBe("Legacy");
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 });
}); });
it("expands global field definitions with blueprint metadata", async () => { it("omits the target filter when no target is provided", async () => {
const findMany = vi.fn().mockResolvedValue([ const findMany = vi.fn().mockResolvedValue([]);
{ const caller = createManagerCaller({ blueprint: { findMany } });
id: "bp_global",
name: "Global Project Blueprint",
fieldDefs: [
{
id: "field_market",
key: "market",
label: "Market",
order: 0,
type: FieldType.TEXT,
required: false,
},
],
},
]);
const caller = createPlanningCaller({ await caller.list({});
blueprint: { findMany },
});
const result = await caller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT });
expect(findMany).toHaveBeenCalledWith({ expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { isActive: true } }));
where: { target: BlueprintTarget.PROJECT, isGlobal: true, isActive: true }, // target must not appear in the where clause
select: { id: true, name: true, fieldDefs: true }, const whereArg = (findMany.mock.calls[0]![0] as { where: Record<string, unknown> }).where;
}); expect(whereArg).not.toHaveProperty("target");
expect(result).toEqual([ });
expect.objectContaining({
blueprintId: "bp_global", it("returns an empty list when no blueprints match the filter", async () => {
blueprintName: "Global Project Blueprint", const findMany = vi.fn().mockResolvedValue([]);
key: "market", 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<string, unknown> })
.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");
}); });
}); });
+301 -194
View File
@@ -60,13 +60,31 @@ function createAdminCaller(db: Record<string, unknown>) {
}); });
} }
describe("client router", () => { // ─── client.list ──────────────────────────────────────────────────────────────
it("lists clients with filters and count includes", async () => {
const findMany = vi.fn().mockResolvedValue([{ id: "client_1", name: "Acme" }]);
const caller = createPlanningCaller({ describe("client.list", () => {
client: { findMany }, 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" }); const result = await caller.list({ parentId: "parent_1", isActive: true, search: "Acme" });
expect(findMany).toHaveBeenCalledWith({ expect(findMany).toHaveBeenCalledWith({
@@ -84,199 +102,288 @@ describe("client router", () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
}); });
it("returns a nested tree from ordered flat records", async () => { it("filters by parentId: null to return only root clients", async () => {
const findMany = vi.fn().mockResolvedValue([ const findMany = vi.fn().mockResolvedValue([]);
{ const caller = createPlanningCaller({ client: { findMany } });
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"),
},
]);
const caller = createPlanningCaller({ await caller.list({ parentId: null });
client: { findMany },
});
const result = await caller.getTree({ isActive: true });
expect(findMany).toHaveBeenCalledWith({ expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { parentId: null } }));
where: { isActive: true }, });
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
}); it("applies case-insensitive name/code search without other filters", async () => {
expect(result).toEqual([ const findMany = vi.fn().mockResolvedValue([]);
const caller = createPlanningCaller({ client: { findMany } });
await caller.list({ search: "acme" });
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: "client_root", where: {
children: [expect.objectContaining({ id: "client_child" })], 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.getById ───────────────────────────────────────────────────────────
client: { update },
}); describe("client.getById", () => {
const result = await caller.batchUpdateSortOrder([ it("returns the client with parent, children, and counts when found", async () => {
{ id: "client_1", sortOrder: 10 }, const client = {
{ id: "client_2", sortOrder: 20 }, id: "client_1",
]); name: "Acme",
code: "ACME",
expect($transaction).toHaveBeenCalledTimes(1); parentId: null,
expect(update).toHaveBeenNthCalledWith(1, { parent: null,
where: { id: "client_1" }, isActive: true,
data: { sortOrder: 10 }, sortOrder: 0,
}); tags: [],
expect(update).toHaveBeenNthCalledWith(2, { createdAt: new Date("2026-01-01T00:00:00.000Z"),
where: { id: "client_2" }, updatedAt: new Date("2026-01-01T00:00:00.000Z"),
data: { sortOrder: 20 }, children: [],
}); _count: { projects: 2, children: 0 },
expect(result).toEqual({ ok: true }); };
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 },
});
}); });
}); });
@@ -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<string, unknown>) {
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<string, unknown>) {
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<unknown>) =>
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<unknown>) =>
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<unknown>) =>
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<unknown>) =>
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" });
});
});