1672 lines
48 KiB
TypeScript
1672 lines
48 KiB
TypeScript
import { PermissionKey, SystemRole } from "@capakraken/shared";
|
|
import { ResourceType } from "@capakraken/shared";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
|
return {
|
|
...actual,
|
|
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
|
|
isChargeabilityRelevantProject: actual.isChargeabilityRelevantProject,
|
|
listAssignmentBookings: vi.fn(),
|
|
recomputeResourceValueScores: vi.fn(),
|
|
};
|
|
});
|
|
|
|
import { listAssignmentBookings } from "@capakraken/application";
|
|
import { resourceRouter } from "../router/resource.js";
|
|
import { createCallerFactory } from "../trpc.js";
|
|
|
|
const createCaller = createCallerFactory(resourceRouter);
|
|
|
|
function createControllerCaller(db: Record<string, unknown>) {
|
|
return createCaller({
|
|
session: {
|
|
user: { email: "controller@example.com", name: "Controller", image: null, role: "CONTROLLER" },
|
|
expires: "2026-03-14T00:00:00.000Z",
|
|
},
|
|
db: db as never,
|
|
dbUser: {
|
|
id: "user_controller",
|
|
systemRole: SystemRole.CONTROLLER,
|
|
permissionOverrides: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
function createProtectedCaller(db: Record<string, unknown>) {
|
|
return createCaller({
|
|
session: {
|
|
user: { email: "user@example.com", name: "User", image: null, role: "USER" },
|
|
expires: "2026-03-14T00:00:00.000Z",
|
|
},
|
|
db: db as never,
|
|
dbUser: {
|
|
id: "user_1",
|
|
systemRole: SystemRole.USER,
|
|
permissionOverrides: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
function createProtectedCallerWithOverrides(
|
|
db: Record<string, unknown>,
|
|
overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null,
|
|
) {
|
|
return createCaller({
|
|
session: {
|
|
user: { email: "user@example.com", name: "User", image: null, role: "USER" },
|
|
expires: "2026-03-14T00:00:00.000Z",
|
|
},
|
|
db: db as never,
|
|
dbUser: {
|
|
id: "user_1",
|
|
systemRole: SystemRole.USER,
|
|
permissionOverrides: overrides,
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("resource router", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("filters proposed utilization rows unless explicitly requested", async () => {
|
|
const resource = {
|
|
id: "resource_1",
|
|
eid: "E-001",
|
|
displayName: "Alice",
|
|
email: "alice@example.com",
|
|
chapter: "CGI",
|
|
lcrCents: 5000,
|
|
ucrCents: 9000,
|
|
currency: "EUR",
|
|
chargeabilityTarget: 80,
|
|
availability: {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
},
|
|
skills: [],
|
|
dynamicFields: {},
|
|
blueprintId: null,
|
|
isActive: true,
|
|
createdAt: new Date("2026-03-01"),
|
|
updatedAt: new Date("2026-03-01"),
|
|
roleId: null,
|
|
portfolioUrl: null,
|
|
postalCode: null,
|
|
federalState: null,
|
|
valueScore: null,
|
|
valueScoreBreakdown: null,
|
|
valueScoreUpdatedAt: null,
|
|
userId: null,
|
|
countryId: "country_de",
|
|
metroCityId: null,
|
|
country: { code: "DE" },
|
|
metroCity: null,
|
|
};
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([resource]),
|
|
},
|
|
};
|
|
|
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
|
{
|
|
id: "assignment_confirmed",
|
|
projectId: "project_1",
|
|
resourceId: "resource_1",
|
|
startDate: new Date("2026-03-02T00:00:00.000Z"),
|
|
endDate: new Date("2026-03-06T00:00:00.000Z"),
|
|
hoursPerDay: 4,
|
|
dailyCostCents: 0,
|
|
status: "CONFIRMED",
|
|
project: {
|
|
id: "project_1",
|
|
name: "Project 1",
|
|
shortCode: "P1",
|
|
status: "ACTIVE",
|
|
orderType: "CLIENT",
|
|
dynamicFields: null,
|
|
},
|
|
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
|
|
},
|
|
{
|
|
id: "assignment_proposed",
|
|
projectId: "project_2",
|
|
resourceId: "resource_1",
|
|
startDate: new Date("2026-03-02T00:00:00.000Z"),
|
|
endDate: new Date("2026-03-06T00:00:00.000Z"),
|
|
hoursPerDay: 4,
|
|
dailyCostCents: 0,
|
|
status: "PROPOSED",
|
|
project: {
|
|
id: "project_2",
|
|
name: "Project 2",
|
|
shortCode: "P2",
|
|
status: "ACTIVE",
|
|
orderType: "CLIENT",
|
|
dynamicFields: null,
|
|
},
|
|
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
|
|
},
|
|
]);
|
|
|
|
const caller = createControllerCaller(db);
|
|
|
|
const strict = await caller.listWithUtilization({
|
|
startDate: "2026-03-02T00:00:00.000Z",
|
|
endDate: "2026-03-08T00:00:00.000Z",
|
|
});
|
|
const withProposed = await caller.listWithUtilization({
|
|
startDate: "2026-03-02T00:00:00.000Z",
|
|
endDate: "2026-03-08T00:00:00.000Z",
|
|
includeProposed: true,
|
|
});
|
|
|
|
expect(strict[0]).toMatchObject({
|
|
bookingCount: 1,
|
|
bookedHours: 20,
|
|
utilizationPercent: 50,
|
|
});
|
|
expect(withProposed[0]).toMatchObject({
|
|
bookingCount: 2,
|
|
bookedHours: 40,
|
|
utilizationPercent: 100,
|
|
});
|
|
});
|
|
|
|
it("calculates utilization with regional holidays removed from available hours", async () => {
|
|
const resource = {
|
|
id: "resource_1",
|
|
eid: "E-001",
|
|
displayName: "Alice",
|
|
email: "alice@example.com",
|
|
chapter: "CGI",
|
|
lcrCents: 5000,
|
|
ucrCents: 9000,
|
|
currency: "EUR",
|
|
chargeabilityTarget: 80,
|
|
availability: {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
saturday: 0,
|
|
sunday: 0,
|
|
},
|
|
skills: [],
|
|
dynamicFields: {},
|
|
blueprintId: null,
|
|
isActive: true,
|
|
createdAt: new Date("2026-03-01"),
|
|
updatedAt: new Date("2026-03-01"),
|
|
roleId: null,
|
|
portfolioUrl: null,
|
|
postalCode: null,
|
|
federalState: "BY",
|
|
countryId: "country_de",
|
|
metroCityId: null,
|
|
valueScore: null,
|
|
valueScoreBreakdown: null,
|
|
valueScoreUpdatedAt: null,
|
|
userId: null,
|
|
country: { code: "DE" },
|
|
metroCity: null,
|
|
};
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([resource]),
|
|
},
|
|
};
|
|
|
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
|
{
|
|
id: "assignment_confirmed",
|
|
projectId: "project_1",
|
|
resourceId: "resource_1",
|
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
|
hoursPerDay: 8,
|
|
dailyCostCents: 0,
|
|
status: "CONFIRMED",
|
|
project: {
|
|
id: "project_1",
|
|
name: "Project 1",
|
|
shortCode: "P1",
|
|
status: "ACTIVE",
|
|
orderType: "CLIENT",
|
|
dynamicFields: null,
|
|
},
|
|
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
|
|
},
|
|
]);
|
|
|
|
const caller = createControllerCaller(db);
|
|
const result = await caller.listWithUtilization({
|
|
startDate: "2026-01-05T00:00:00.000Z",
|
|
endDate: "2026-01-06T00:00:00.000Z",
|
|
});
|
|
|
|
expect(result[0]).toMatchObject({
|
|
bookingCount: 1,
|
|
bookedHours: 8,
|
|
availableHours: 8,
|
|
utilizationPercent: 100,
|
|
});
|
|
});
|
|
|
|
it("shifts marketplace availability when a local holiday blocks today", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-06T10:00:00.000Z"));
|
|
|
|
try {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "resource_by",
|
|
displayName: "Bavaria Artist",
|
|
eid: "E-BY",
|
|
chapter: "CGI",
|
|
skills: [{ skill: "Houdini", proficiency: 5 }],
|
|
availability: {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
saturday: 0,
|
|
sunday: 0,
|
|
},
|
|
chargeabilityTarget: 80,
|
|
countryId: "country_de",
|
|
federalState: "BY",
|
|
metroCityId: null,
|
|
country: { code: "DE" },
|
|
metroCity: null,
|
|
},
|
|
{
|
|
id: "resource_hh",
|
|
displayName: "Hamburg Artist",
|
|
eid: "E-HH",
|
|
chapter: "CGI",
|
|
skills: [{ skill: "Houdini", proficiency: 5 }],
|
|
availability: {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
saturday: 0,
|
|
sunday: 0,
|
|
},
|
|
chargeabilityTarget: 80,
|
|
countryId: "country_de",
|
|
federalState: "HH",
|
|
metroCityId: null,
|
|
country: { code: "DE" },
|
|
metroCity: null,
|
|
},
|
|
]),
|
|
},
|
|
assignment: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
demandRequirement: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
const result = await caller.getSkillMarketplace({
|
|
searchSkill: "houdini",
|
|
availableOnly: true,
|
|
});
|
|
|
|
const bavaria = result.searchResults.find((resource) => resource.id === "resource_by");
|
|
const hamburg = result.searchResults.find((resource) => resource.id === "resource_hh");
|
|
|
|
expect(bavaria?.availableFrom).toBe("2026-01-07T00:00:00.000Z");
|
|
expect(hamburg?.availableFrom).toBe("2026-01-06T00:00:00.000Z");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("uses a composite displayName/id cursor for stable pagination", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{ id: "2", displayName: "Alex", eid: "E-002", email: "alex2@example.com" },
|
|
{ id: "3", displayName: "Bea", eid: "E-003", email: "bea@example.com" },
|
|
]),
|
|
count: vi.fn().mockResolvedValue(3),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
const result = await caller.listStaff({
|
|
limit: 1,
|
|
cursor: JSON.stringify({ displayName: "Alex", id: "1" }),
|
|
});
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
AND: expect.arrayContaining([
|
|
{
|
|
OR: [
|
|
{ displayName: { gt: "Alex" } },
|
|
{ displayName: "Alex", id: { gt: "1" } },
|
|
],
|
|
},
|
|
]),
|
|
}),
|
|
orderBy: [{ displayName: "asc" }, { id: "asc" }],
|
|
take: 2,
|
|
}),
|
|
);
|
|
expect(result.nextCursor).toBe(JSON.stringify({ displayName: "Alex", id: "2" }));
|
|
});
|
|
|
|
it("resolves resource ownership server-side without exposing linked user email", async () => {
|
|
const db = {
|
|
resource: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "resource_1" }),
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: "resource_1",
|
|
eid: "E-001",
|
|
displayName: "Alice",
|
|
email: "alice@example.com",
|
|
chapter: "CGI",
|
|
lcrCents: 5000,
|
|
ucrCents: 9000,
|
|
currency: "EUR",
|
|
chargeabilityTarget: 80,
|
|
availability: {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
},
|
|
skills: [],
|
|
dynamicFields: {},
|
|
blueprint: null,
|
|
blueprintId: null,
|
|
isActive: true,
|
|
createdAt: new Date("2026-03-01"),
|
|
updatedAt: new Date("2026-03-01"),
|
|
resourceRoles: [],
|
|
areaRole: null,
|
|
portfolioUrl: null,
|
|
roleId: null,
|
|
aiSummary: null,
|
|
aiSummaryUpdatedAt: null,
|
|
skillMatrixUpdatedAt: null,
|
|
valueScore: null,
|
|
valueScoreBreakdown: null,
|
|
valueScoreUpdatedAt: null,
|
|
userId: "user_1",
|
|
}),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
systemSettings: {
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
const result = await caller.getById({ id: "resource_1" });
|
|
|
|
expect(result).toMatchObject({
|
|
id: "resource_1",
|
|
displayName: "Alice",
|
|
eid: "E-001",
|
|
email: "alice@example.com",
|
|
isOwnedByCurrentUser: true,
|
|
});
|
|
expect(result).not.toHaveProperty("user");
|
|
expect(db.resource.findUnique).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
include: expect.not.objectContaining({
|
|
user: expect.anything(),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("counts imported TBD draft projects in chargeability stats only when proposed work is enabled", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "resource_1",
|
|
eid: "E-001",
|
|
displayName: "Alice",
|
|
chapter: "CGI",
|
|
chargeabilityTarget: 80,
|
|
availability: {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
},
|
|
},
|
|
]),
|
|
},
|
|
};
|
|
|
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
|
{
|
|
id: "assignment_tbd",
|
|
projectId: "project_tbd",
|
|
resourceId: "resource_1",
|
|
startDate: new Date("2026-03-02T00:00:00.000Z"),
|
|
endDate: new Date("2026-03-06T00:00:00.000Z"),
|
|
hoursPerDay: 4,
|
|
dailyCostCents: 0,
|
|
status: "PROPOSED",
|
|
project: {
|
|
id: "project_tbd",
|
|
name: "TBD Project",
|
|
shortCode: "TBD-P1",
|
|
status: "DRAFT",
|
|
orderType: "CLIENT",
|
|
dynamicFields: { dispoImport: { isTbd: true } },
|
|
},
|
|
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
|
|
},
|
|
]);
|
|
|
|
const caller = createControllerCaller(db);
|
|
const strict = await caller.getChargeabilityStats({});
|
|
const withProposed = await caller.getChargeabilityStats({ includeProposed: true });
|
|
|
|
expect(strict[0]?.actualChargeability).toBe(0);
|
|
expect(strict[0]?.expectedChargeability).toBeGreaterThan(0);
|
|
expect(withProposed[0]?.actualChargeability).toBeGreaterThan(strict[0]?.actualChargeability ?? 0);
|
|
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability);
|
|
});
|
|
|
|
it("excludes regional public holidays from chargeability stats", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "resource_by",
|
|
eid: "E-BY",
|
|
displayName: "Bavaria",
|
|
chapter: "CGI",
|
|
chargeabilityTarget: 80,
|
|
countryId: "country_de",
|
|
federalState: "BY",
|
|
metroCityId: "city_munich",
|
|
country: { code: "DE" },
|
|
metroCity: { name: "Munich" },
|
|
availability: {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
},
|
|
},
|
|
]),
|
|
},
|
|
};
|
|
|
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
|
{
|
|
id: "assignment_holiday",
|
|
projectId: "project_1",
|
|
resourceId: "resource_by",
|
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
|
hoursPerDay: 8,
|
|
dailyCostCents: 0,
|
|
status: "CONFIRMED",
|
|
project: {
|
|
id: "project_1",
|
|
name: "Project 1",
|
|
shortCode: "P1",
|
|
status: "ACTIVE",
|
|
orderType: "CLIENT",
|
|
dynamicFields: null,
|
|
},
|
|
resource: { id: "resource_by", displayName: "Bavaria", chapter: "CGI" },
|
|
},
|
|
]);
|
|
|
|
const RealDate = Date;
|
|
class MockDate extends Date {
|
|
constructor(...args: ConstructorParameters<typeof Date>) {
|
|
if (args.length === 0) {
|
|
super("2026-01-15T00:00:00.000Z");
|
|
return;
|
|
}
|
|
super(...args);
|
|
}
|
|
static now() {
|
|
return new RealDate("2026-01-15T00:00:00.000Z").getTime();
|
|
}
|
|
}
|
|
vi.stubGlobal("Date", MockDate);
|
|
|
|
try {
|
|
const caller = createControllerCaller(db);
|
|
const result = await caller.getChargeabilityStats({});
|
|
|
|
expect(result[0]).toMatchObject({
|
|
actualChargeability: 0,
|
|
expectedChargeability: 0,
|
|
availableHours: 168,
|
|
});
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("returns a holiday-aware chargeability summary readmodel", async () => {
|
|
const resourceRecord = {
|
|
id: "res_1",
|
|
displayName: "Bruce Banner",
|
|
eid: "bruce.banner",
|
|
fte: 1,
|
|
lcrCents: 5000,
|
|
chargeabilityTarget: 80,
|
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
|
countryId: "country_de",
|
|
federalState: "BY",
|
|
metroCityId: null,
|
|
country: { id: "country_de", code: "DE", name: "Deutschland", dailyWorkingHours: 8, scheduleRules: null },
|
|
metroCity: null,
|
|
managementLevelGroup: null,
|
|
};
|
|
const db = {
|
|
resource: {
|
|
findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord),
|
|
},
|
|
assignment: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "assign_1",
|
|
hoursPerDay: 8,
|
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
|
dailyCostCents: 40000,
|
|
status: "CONFIRMED",
|
|
project: {
|
|
id: "project_gamma",
|
|
name: "Gamma",
|
|
shortCode: "GAM",
|
|
orderType: "CLIENT",
|
|
utilizationCategory: { code: "Chg" },
|
|
},
|
|
},
|
|
]),
|
|
},
|
|
vacation: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
holidayCalendar: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
calculationRule: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
const result = await caller.getChargeabilitySummary({ resourceId: "res_1", month: "2026-01" });
|
|
|
|
expect(result.bookedHours).toBe(8);
|
|
expect(result.allocations).toEqual([expect.objectContaining({ hours: 8, project: "Gamma", code: "GAM" })]);
|
|
expect(result.baseWorkingDays).toBe(22);
|
|
expect(result.baseAvailableHours).toBe(176);
|
|
expect(result.availableHours).toBe(160);
|
|
expect(result.workingDays).toBe(20);
|
|
expect(result.targetHours).toBe(128);
|
|
expect(result.unassignedHours).toBe(152);
|
|
expect(result.locationContext.federalState).toBe("BY");
|
|
expect(result.holidaySummary).toEqual(
|
|
expect.objectContaining({
|
|
count: 2,
|
|
workdayCount: 2,
|
|
hoursDeduction: 16,
|
|
}),
|
|
);
|
|
expect(result.capacityBreakdown).toEqual(
|
|
expect.objectContaining({
|
|
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
|
|
holidayHoursDeduction: 16,
|
|
absenceHoursDeduction: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("allows regular users to read their own chargeability summary", async () => {
|
|
const resourceRecord = {
|
|
id: "res_own",
|
|
displayName: "Bruce Banner",
|
|
eid: "bruce.banner",
|
|
fte: 1,
|
|
lcrCents: 5000,
|
|
chargeabilityTarget: 80,
|
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
|
countryId: "country_de",
|
|
federalState: "BY",
|
|
metroCityId: null,
|
|
country: { id: "country_de", code: "DE", name: "Deutschland", dailyWorkingHours: 8, scheduleRules: null },
|
|
metroCity: null,
|
|
managementLevelGroup: null,
|
|
};
|
|
const db = {
|
|
resource: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
|
findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord),
|
|
},
|
|
assignment: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
vacation: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
holidayCalendar: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
calculationRule: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
const result = await caller.getChargeabilitySummary({ resourceId: "res_own", month: "2026-01" });
|
|
|
|
expect(result.resource).toBe("Bruce Banner");
|
|
expect(db.resource.findFirst).toHaveBeenCalledWith({
|
|
where: { userId: "user_1" },
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("blocks controller-only resource analytics for plain users", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn(),
|
|
},
|
|
assignment: {
|
|
findMany: vi.fn(),
|
|
},
|
|
demandRequirement: {
|
|
findMany: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
|
|
await expect(caller.getSkillsAnalytics()).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Controller access required",
|
|
});
|
|
await expect(caller.searchBySkills({
|
|
rules: [{ skill: "Houdini", minProficiency: 4 }],
|
|
})).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Controller access required",
|
|
});
|
|
await expect(caller.listWithUtilization({})).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Controller access required",
|
|
});
|
|
await expect(caller.getChargeabilityStats({})).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Controller access required",
|
|
});
|
|
await expect(caller.getSkillMarketplace({
|
|
searchSkill: "Houdini",
|
|
availableOnly: true,
|
|
})).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Controller access required",
|
|
});
|
|
|
|
expect(db.resource.findMany).not.toHaveBeenCalled();
|
|
expect(db.assignment.findMany).not.toHaveBeenCalled();
|
|
expect(db.demandRequirement.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects chargeability summary access for foreign resources", async () => {
|
|
const db = {
|
|
resource: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
|
findUniqueOrThrow: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
|
|
await expect(
|
|
caller.getChargeabilitySummary({ resourceId: "res_other", month: "2026-01" }),
|
|
).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "You can only view chargeability details for your own resource unless you have staff access",
|
|
});
|
|
expect(db.resource.findUniqueOrThrow).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("requires explicit score permission for value scores", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
|
|
await expect(caller.getValueScores({})).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Permission required: viewScores",
|
|
});
|
|
expect(db.resource.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns value scores when the caller has explicit score permission", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "res_1",
|
|
eid: "E-001",
|
|
displayName: "Alice",
|
|
chapter: "CGI",
|
|
lcrCents: 5000,
|
|
valueScore: 93,
|
|
valueScoreBreakdown: { delivery: 50, scarcity: 43 },
|
|
valueScoreUpdatedAt: new Date("2026-03-01T00:00:00.000Z"),
|
|
},
|
|
]),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCallerWithOverrides(db, { granted: [PermissionKey.VIEW_SCORES] });
|
|
const result = await caller.getValueScores({ limit: 25 });
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toMatchObject({
|
|
id: "res_1",
|
|
displayName: "Alice",
|
|
valueScore: 93,
|
|
});
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { isActive: true },
|
|
take: 25,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects resource summary searches for regular users", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
|
|
await expect(
|
|
caller.listSummaries({ search: "Alice", limit: 10 }),
|
|
).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Resource overview access required",
|
|
});
|
|
expect(db.resource.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows resource summary searches with broad resource read permission", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "res_1",
|
|
eid: "E-001",
|
|
displayName: "Alice",
|
|
chapter: "CGI",
|
|
isActive: true,
|
|
areaRole: { name: "Developer" },
|
|
country: { code: "DE", name: "Germany" },
|
|
metroCity: { name: "Munich" },
|
|
orgUnit: { name: "Studio A" },
|
|
},
|
|
]),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCallerWithOverrides(db, { granted: [PermissionKey.VIEW_ALL_RESOURCES] });
|
|
const result = await caller.listSummaries({ search: "Ali", limit: 10 });
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
id: "res_1",
|
|
eid: "E-001",
|
|
name: "Alice",
|
|
chapter: "CGI",
|
|
role: "Developer",
|
|
country: "Germany",
|
|
countryCode: "DE",
|
|
metroCity: "Munich",
|
|
orgUnit: "Studio A",
|
|
active: true,
|
|
},
|
|
]);
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
OR: expect.arrayContaining([
|
|
{ displayName: { contains: "Ali", mode: "insensitive" } },
|
|
{ eid: { contains: "Ali", mode: "insensitive" } },
|
|
]),
|
|
}),
|
|
select: {
|
|
id: true,
|
|
eid: true,
|
|
displayName: true,
|
|
chapter: true,
|
|
isActive: true,
|
|
areaRole: { select: { name: true } },
|
|
country: { select: { code: true, name: true } },
|
|
metroCity: { select: { name: true } },
|
|
orgUnit: { select: { name: true } },
|
|
},
|
|
take: 10,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns assistant-facing resource summary details from the canonical router", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "res_1",
|
|
eid: "E-001",
|
|
displayName: "Alice",
|
|
chapter: "CGI",
|
|
fte: 1,
|
|
lcrCents: 5000,
|
|
chargeabilityTarget: 80,
|
|
isActive: true,
|
|
areaRole: { name: "Developer" },
|
|
country: { code: "DE", name: "Germany" },
|
|
metroCity: { name: "Munich" },
|
|
orgUnit: { name: "Studio A" },
|
|
},
|
|
]),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCallerWithOverrides(db, { granted: [PermissionKey.VIEW_ALL_RESOURCES] });
|
|
const result = await caller.listSummariesDetail({ search: "Ali", limit: 10 });
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
id: "res_1",
|
|
eid: "E-001",
|
|
name: "Alice",
|
|
chapter: "CGI",
|
|
role: "Developer",
|
|
country: "Germany",
|
|
countryCode: "DE",
|
|
metroCity: "Munich",
|
|
orgUnit: "Studio A",
|
|
fte: 1,
|
|
lcr: "50,00 EUR",
|
|
chargeabilityTarget: "80%",
|
|
active: true,
|
|
},
|
|
]);
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
select: expect.objectContaining({
|
|
fte: true,
|
|
lcrCents: true,
|
|
chargeabilityTarget: true,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns only safe directory fields for generic resource lookups", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "res_1",
|
|
eid: "E-001",
|
|
displayName: "Alice",
|
|
chapter: "CGI",
|
|
isActive: true,
|
|
},
|
|
]),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
const result = await caller.directory({ limit: 10 });
|
|
|
|
expect(result.resources).toHaveLength(1);
|
|
expect(result.resources[0]).toEqual({
|
|
id: "res_1",
|
|
eid: "E-001",
|
|
displayName: "Alice",
|
|
chapter: "CGI",
|
|
isActive: true,
|
|
});
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
select: {
|
|
id: true,
|
|
eid: true,
|
|
displayName: true,
|
|
chapter: true,
|
|
isActive: true,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not include email search in the safe resource directory", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
await caller.directory({ search: "alice@example.com", limit: 25 });
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
{ isActive: true },
|
|
{
|
|
OR: [
|
|
{ displayName: { contains: "alice@example.com", mode: "insensitive" } },
|
|
{ eid: { contains: "alice@example.com", mode: "insensitive" } },
|
|
],
|
|
},
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects staff resource lists for regular users", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn(),
|
|
count: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
|
|
await expect(
|
|
caller.listStaff({ limit: 10 }),
|
|
).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Resource overview access required",
|
|
});
|
|
expect(db.resource.findMany).not.toHaveBeenCalled();
|
|
expect(db.resource.count).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("includes email search in explicit staff resource lists", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
await caller.listStaff({ search: "alice@example.com", limit: 25 });
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
{ isActive: true },
|
|
{
|
|
OR: [
|
|
{ displayName: { contains: "alice@example.com", mode: "insensitive" } },
|
|
{ eid: { contains: "alice@example.com", mode: "insensitive" } },
|
|
{ email: { contains: "alice@example.com", mode: "insensitive" } },
|
|
],
|
|
},
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns sensitive fields and optional roles in explicit staff resource lists", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "res_other",
|
|
eid: "E-OTHER",
|
|
displayName: "Bob",
|
|
email: "bob@example.com",
|
|
chapter: "CGI",
|
|
lcrCents: 7000,
|
|
ucrCents: 12000,
|
|
currency: "EUR",
|
|
roleId: "role_artist",
|
|
federalState: "BY",
|
|
dynamicFields: { level: "senior" },
|
|
resourceRoles: [
|
|
{
|
|
roleId: "role_artist",
|
|
isPrimary: true,
|
|
role: { id: "role_artist", name: "Artist", color: "#123456" },
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
const result = await caller.listStaff({ limit: 10, includeRoles: true });
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
include: expect.objectContaining({
|
|
resourceRoles: expect.objectContaining({
|
|
include: expect.any(Object),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
expect(result.resources[0]).toMatchObject({
|
|
id: "res_other",
|
|
email: "bob@example.com",
|
|
lcrCents: 7000,
|
|
ucrCents: 12000,
|
|
currency: "EUR",
|
|
roleId: "role_artist",
|
|
federalState: "BY",
|
|
dynamicFields: { level: "senior" },
|
|
resourceRoles: [
|
|
expect.objectContaining({
|
|
roleId: "role_artist",
|
|
isPrimary: true,
|
|
role: expect.objectContaining({ id: "role_artist", name: "Artist" }),
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it("allows exact self lookup via getByIdentifier without broad search", async () => {
|
|
const db = {
|
|
resource: {
|
|
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
|
|
findFirst: vi.fn()
|
|
.mockResolvedValueOnce({
|
|
id: "res_own",
|
|
eid: "E-OWN",
|
|
displayName: "Alice Example",
|
|
chapter: "CGI",
|
|
isActive: true,
|
|
})
|
|
.mockResolvedValueOnce({ id: "res_own" }),
|
|
findMany: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
const result = await caller.getByIdentifier({ identifier: "Alice Example" });
|
|
|
|
expect(result).toEqual({
|
|
id: "res_own",
|
|
eid: "E-OWN",
|
|
displayName: "Alice Example",
|
|
chapter: "CGI",
|
|
isActive: true,
|
|
});
|
|
expect(db.resource.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns assistant-facing resource details from the canonical router", async () => {
|
|
const db = {
|
|
resource: {
|
|
findUnique: vi.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({
|
|
id: "res_own",
|
|
eid: "E-OWN",
|
|
displayName: "Alice Example",
|
|
chapter: "CGI",
|
|
isActive: true,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
id: "res_own",
|
|
eid: "E-OWN",
|
|
displayName: "Alice Example",
|
|
email: "alice@example.com",
|
|
chapter: "CGI",
|
|
fte: 1,
|
|
lcrCents: 5000,
|
|
ucrCents: 9000,
|
|
chargeabilityTarget: 80,
|
|
isActive: true,
|
|
availability: {},
|
|
skills: [{ name: "Houdini", level: 5 }],
|
|
postalCode: "80331",
|
|
federalState: "BY",
|
|
areaRole: { name: "Developer", color: "#123456" },
|
|
country: { code: "DE", name: "Germany", dailyWorkingHours: 8 },
|
|
metroCity: { name: "Munich" },
|
|
managementLevelGroup: { name: "Senior", targetPercentage: 75 },
|
|
orgUnit: { name: "Studio A", level: 5 },
|
|
_count: { assignments: 4, vacations: 2 },
|
|
}),
|
|
findFirst: vi.fn().mockResolvedValueOnce({ id: "res_own" }),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
const result = await caller.getByIdentifierDetail({ identifier: "E-OWN" });
|
|
|
|
expect(result).toEqual({
|
|
id: "res_own",
|
|
eid: "E-OWN",
|
|
name: "Alice Example",
|
|
email: "alice@example.com",
|
|
chapter: "CGI",
|
|
role: "Developer",
|
|
country: "Germany",
|
|
countryCode: "DE",
|
|
countryHours: 8,
|
|
metroCity: "Munich",
|
|
fte: 1,
|
|
lcr: "50,00 EUR",
|
|
ucr: "90,00 EUR",
|
|
chargeabilityTarget: "80%",
|
|
managementLevel: "Senior",
|
|
orgUnit: "Studio A",
|
|
postalCode: "80331",
|
|
federalState: "BY",
|
|
active: true,
|
|
totalAssignments: 4,
|
|
totalVacations: 2,
|
|
skillCount: 1,
|
|
topSkills: ["Houdini (5)"],
|
|
});
|
|
});
|
|
|
|
it("rejects foreign identifier lookups for regular users", async () => {
|
|
const db = {
|
|
resource: {
|
|
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
|
|
findFirst: vi.fn()
|
|
.mockResolvedValueOnce({
|
|
id: "res_other",
|
|
eid: "E-OTHER",
|
|
displayName: "Bob Other",
|
|
chapter: "CGI",
|
|
isActive: true,
|
|
})
|
|
.mockResolvedValueOnce({ id: "res_own" }),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
|
|
await expect(
|
|
caller.getByIdentifier({ identifier: "Bob Other" }),
|
|
).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "You can only view your own resource unless you have staff access",
|
|
});
|
|
});
|
|
|
|
it("does not return fuzzy identifier suggestions to regular users", async () => {
|
|
const db = {
|
|
resource: {
|
|
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
|
|
findFirst: vi.fn().mockResolvedValueOnce(null),
|
|
findMany: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
const result = await caller.getByIdentifier({ identifier: "Ali" });
|
|
|
|
expect(result).toEqual({ error: "Resource not found: Ali" });
|
|
expect(db.resource.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects foreign EID lookups for regular users", async () => {
|
|
const db = {
|
|
resource: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: "res_other",
|
|
eid: "E-OTHER",
|
|
displayName: "Bob Other",
|
|
}),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
|
|
await expect(
|
|
caller.getByEid({ eid: "E-OTHER" }),
|
|
).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "You can only view your own resource unless you have staff access",
|
|
});
|
|
});
|
|
|
|
it("rejects foreign resolveByIdentifier lookups for regular users", async () => {
|
|
const db = {
|
|
resource: {
|
|
findFirst: vi.fn()
|
|
.mockResolvedValueOnce({
|
|
id: "res_other",
|
|
eid: "E-OTHER",
|
|
displayName: "Bob Other",
|
|
chapter: "CGI",
|
|
isActive: true,
|
|
})
|
|
.mockResolvedValueOnce({ id: "res_own" }),
|
|
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
|
|
await expect(
|
|
caller.resolveByIdentifier({ identifier: "Bob Other" }),
|
|
).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "You can only resolve your own resource unless you have staff access",
|
|
});
|
|
});
|
|
|
|
it("returns only identity-safe fields from resolveByIdentifier", async () => {
|
|
const db = {
|
|
resource: {
|
|
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
|
|
id: "res_own",
|
|
eid: "E-OWN",
|
|
displayName: "Alice Example",
|
|
chapter: "CGI",
|
|
isActive: true,
|
|
}),
|
|
findFirst: vi.fn().mockResolvedValueOnce({ id: "res_own" }),
|
|
findMany: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
const result = await caller.resolveByIdentifier({ identifier: "E-OWN" });
|
|
|
|
expect(result).toEqual({
|
|
id: "res_own",
|
|
eid: "E-OWN",
|
|
displayName: "Alice Example",
|
|
chapter: "CGI",
|
|
isActive: true,
|
|
});
|
|
expect(db.resource.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves responsible person names through the canonical resource search boundary", async () => {
|
|
const db = {
|
|
resource: {
|
|
findFirst: vi.fn().mockResolvedValueOnce(null),
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{ displayName: "Peter Parker", eid: "EMP-001" },
|
|
]),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCallerWithOverrides(db, { granted: [PermissionKey.VIEW_ALL_RESOURCES] });
|
|
const result = await caller.resolveResponsiblePersonName({ name: "Peter" });
|
|
|
|
expect(result).toEqual({
|
|
status: "resolved",
|
|
displayName: "Peter Parker",
|
|
});
|
|
});
|
|
|
|
it("rejects responsible-person resolution for regular users without resource overview access", async () => {
|
|
const db = {
|
|
resource: {
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createProtectedCaller(db);
|
|
await expect(
|
|
caller.resolveResponsiblePersonName({ name: "Peter" }),
|
|
).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Resource overview access required",
|
|
});
|
|
expect(db.resource.findFirst).not.toHaveBeenCalled();
|
|
expect(db.resource.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies country filters on the staff list including explicit no-country toggle", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
await caller.listStaff({
|
|
countryIds: ["country_de", "country_us"],
|
|
includeWithoutCountry: false,
|
|
});
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
{ isActive: true },
|
|
{ countryId: { in: ["country_de", "country_us"] } },
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("excludes disabled countries on the staff list while leaving all others visible", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
await caller.listStaff({
|
|
excludedCountryIds: ["country_fr"],
|
|
includeWithoutCountry: true,
|
|
});
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
{ isActive: true },
|
|
{ NOT: { countryId: { in: ["country_fr"] } } },
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("applies resource type filters on the staff list while keeping unspecified rows when requested", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
await caller.listStaff({
|
|
resourceTypes: [ResourceType.EMPLOYEE, ResourceType.INTERN],
|
|
includeWithoutResourceType: true,
|
|
});
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
{ isActive: true },
|
|
{
|
|
OR: [
|
|
{ resourceType: { in: [ResourceType.EMPLOYEE, ResourceType.INTERN] } },
|
|
{ resourceType: null },
|
|
],
|
|
},
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("excludes disabled resource types on the staff list while leaving all others visible", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
await caller.listStaff({
|
|
excludedResourceTypes: [ResourceType.FREELANCER],
|
|
includeWithoutResourceType: true,
|
|
});
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
{ isActive: true },
|
|
{ NOT: { resourceType: { in: [ResourceType.FREELANCER] } } },
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("applies rolled-off and departed filters on the staff list", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
await caller.listStaff({
|
|
rolledOff: true,
|
|
departed: false,
|
|
});
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
{ isActive: true },
|
|
{ rolledOff: true },
|
|
{ departed: false },
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("applies multi-select chapter filters on the staff list", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
await caller.listStaff({
|
|
chapters: ["Art Direction", "Project Management"],
|
|
});
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
{ isActive: true },
|
|
{ chapter: { in: ["Art Direction", "Project Management"] } },
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("supports stable anonymized identities and alias-based filtering on the staff list", async () => {
|
|
const resource = {
|
|
id: "resource_anon_1",
|
|
eid: "h.noerenberg",
|
|
displayName: "Hartmut Noerenberg",
|
|
email: "h.noerenberg@accenture.com",
|
|
chapter: "Art Direction",
|
|
lcrCents: 15000,
|
|
isActive: true,
|
|
createdAt: new Date("2026-03-01"),
|
|
updatedAt: new Date("2026-03-01"),
|
|
};
|
|
|
|
const db = {
|
|
systemSettings: {
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
anonymizationEnabled: true,
|
|
anonymizationDomain: "superhartmut.de",
|
|
anonymizationSeed: null,
|
|
anonymizationMode: "global",
|
|
}),
|
|
},
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([resource]),
|
|
count: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
const first = await caller.listStaff({ limit: 10 });
|
|
const alias = first.resources[0];
|
|
|
|
expect(alias).toBeDefined();
|
|
expect(alias?.displayName).not.toBe(resource.displayName);
|
|
expect(alias?.eid).not.toBe(resource.eid);
|
|
expect(alias?.email).toBe(`${alias?.eid}@superhartmut.de`);
|
|
|
|
const byAlias = await caller.listStaff({ eids: [alias!.eid], limit: 10 });
|
|
const byAliasSearch = await caller.listStaff({ search: alias!.displayName.slice(0, 4), limit: 10 });
|
|
|
|
expect(byAlias.resources).toHaveLength(1);
|
|
expect(byAlias.resources[0]?.id).toBe(resource.id);
|
|
expect(byAliasSearch.resources).toHaveLength(1);
|
|
expect(byAliasSearch.resources[0]?.id).toBe(resource.id);
|
|
});
|
|
|
|
it("includes email search for staff resource list lookups", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
await caller.listStaff({ search: "alice@example.com", limit: 25 });
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
{ isActive: true },
|
|
{
|
|
OR: [
|
|
{ displayName: { contains: "alice@example.com", mode: "insensitive" } },
|
|
{ eid: { contains: "alice@example.com", mode: "insensitive" } },
|
|
{ email: { contains: "alice@example.com", mode: "insensitive" } },
|
|
],
|
|
},
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps contact and cost fields intact in staff resource lists", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "res_other",
|
|
eid: "E-OTHER",
|
|
displayName: "Bob",
|
|
email: "bob@example.com",
|
|
lcrCents: 7000,
|
|
ucrCents: 12000,
|
|
chargeabilityTarget: 85,
|
|
valueScore: 88,
|
|
valueScoreBreakdown: { scarcity: 40 },
|
|
valueScoreUpdatedAt: new Date("2026-03-02T00:00:00.000Z"),
|
|
},
|
|
]),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
const result = await caller.listStaff({ limit: 10 });
|
|
|
|
expect(result.resources[0]).toMatchObject({
|
|
id: "res_other",
|
|
email: "bob@example.com",
|
|
lcrCents: 7000,
|
|
ucrCents: 12000,
|
|
chargeabilityTarget: 85,
|
|
valueScore: 88,
|
|
valueScoreBreakdown: { scarcity: 40 },
|
|
});
|
|
expect(result.resources[0]?.valueScoreUpdatedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("keeps includeRoles available for the staff route", async () => {
|
|
const db = {
|
|
resource: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
},
|
|
};
|
|
|
|
const caller = createControllerCaller(db);
|
|
await caller.listStaff({ limit: 10, includeRoles: true });
|
|
|
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
include: expect.objectContaining({
|
|
resourceRoles: expect.objectContaining({
|
|
include: expect.any(Object),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|