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:
@@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
return createCaller({
|
||||
@@ -33,6 +25,23 @@ function createAdminCaller(db: Record<string, unknown>) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,234 +57,313 @@ function sampleBlueprint(overrides: Record<string, unknown> = {}) {
|
||||
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<unknown>[]) => 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<string, unknown> }).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<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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,13 +60,31 @@ function createAdminCaller(db: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user