Files
CapaKraken/packages/api/src/__tests__/entitlement-router.test.ts
T

739 lines
23 KiB
TypeScript

import { SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { entitlementRouter } from "../router/entitlement.js";
import { createCallerFactory } from "../trpc.js";
// Mock @capakraken/db to provide the enums used in the router
vi.mock("@capakraken/db", () => ({
VacationType: { ANNUAL: "ANNUAL", SICK: "SICK", OTHER: "OTHER", PUBLIC_HOLIDAY: "PUBLIC_HOLIDAY" },
VacationStatus: { APPROVED: "APPROVED", PENDING: "PENDING", REJECTED: "REJECTED" },
}));
const createCaller = createCallerFactory(entitlementRouter);
// ── Caller factories ─────────────────────────────────────────────────────────
/** Injects a default resource ownership mock so the ownership check in getBalance passes */
function createProtectedCaller(db: Record<string, unknown>) {
const withResourceOwnership = {
resource: {
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.userId ? { userId: "user_1" } : {}),
...(select.federalState ? { federalState: "BY" } : {}),
...(select.country ? { country: { code: "DE" } } : {}),
...(select.metroCity ? { metroCity: null } : {}),
};
}),
},
...db,
};
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: withResourceOwnership as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
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,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleEntitlement(overrides: Record<string, unknown> = {}) {
return {
id: "ent_1",
resourceId: "res_1",
year: 2026,
entitledDays: 30,
carryoverDays: 2,
usedDays: 5,
pendingDays: 3,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockEntitlementFindUniqueByYear(
entitlementsByYear: Record<number, ReturnType<typeof sampleEntitlement> | null>,
) {
return vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
entitlementsByYear[where.resourceId_year.year] ?? null
));
}
// ─── getBalance ──────────────────────────────────────────────────────────────
describe("entitlement.getBalance", () => {
it("returns vacation balance for a resource and year", async () => {
const entitlement = sampleEntitlement();
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.year).toBe(2026);
expect(result.resourceId).toBe("res_1");
expect(result.entitledDays).toBe(30);
expect(result.remainingDays).toBe(22); // 30 - 5 - 3
expect(result).toHaveProperty("sickDays");
});
it("creates entitlement with carryover when none exists", async () => {
const prevEntitlement = sampleEntitlement({
id: "ent_prev",
year: 2025,
entitledDays: 28,
usedDays: 20,
pendingDays: 0,
});
const createdEntitlement = sampleEntitlement({
year: 2026,
entitledDays: 36, // 28 default + 8 carryover
carryoverDays: 8,
usedDays: 0,
pendingDays: 0,
});
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({
2025: prevEntitlement,
}),
create: vi.fn().mockResolvedValue(createdEntitlement),
update: vi.fn().mockResolvedValue(createdEntitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.entitledDays).toBe(36);
expect(result.carryoverDays).toBe(8);
expect(db.vacationEntitlement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "res_1",
year: 2026,
carryoverDays: 8,
}),
}),
);
});
it("uses default of 28 days when no system settings exist", async () => {
const entitlement = sampleEntitlement({ entitledDays: 28, carryoverDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.entitledDays).toBe(28);
});
it("counts sick days separately", async () => {
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi
.fn()
// Public holiday vacations for holiday context
.mockResolvedValueOnce([])
// First call: balance-type vacations (for syncEntitlement)
.mockResolvedValueOnce([])
// Second call: sick days
.mockResolvedValueOnce([
{
startDate: new Date("2026-03-10"),
endDate: new Date("2026-03-12"),
isHalfDay: false,
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.sickDays).toBe(3);
});
it("does not deduct city-specific public holidays from leave balance", async () => {
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0, entitledDays: 30, carryoverDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
federalState: "BY",
country: { code: "DE" },
metroCity: { name: "Augsburg" },
}),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2028: entitlement }),
update: vi.fn().mockImplementation(async ({ data }) => ({
...entitlement,
...data,
})),
},
vacation: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
startDate: new Date("2028-08-08T00:00:00.000Z"),
endDate: new Date("2028-08-08T00:00:00.000Z"),
status: "APPROVED",
isHalfDay: false,
},
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2028 });
expect(result.usedDays).toBe(0);
expect(result.remainingDays).toBe(30);
});
it("recomputes carryover from the previous year when the next year already exists", async () => {
const entitlements = new Map([
[2025, sampleEntitlement({
id: "ent_2025",
year: 2025,
entitledDays: 28,
carryoverDays: 0,
usedDays: 8,
pendingDays: 0,
})],
[2026, sampleEntitlement({
id: "ent_2026",
year: 2026,
entitledDays: 28,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
})],
]);
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
federalState: "BY",
countryId: "country_de",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
vacationEntitlement: {
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
entitlements.get(where.resourceId_year.year) ?? null
)),
create: vi.fn(),
update: vi.fn().mockImplementation(async ({ where, data }: {
where: { id: string };
data: Record<string, number>;
}) => {
const current = [...entitlements.values()].find((entry) => entry.id === where.id);
if (!current) {
throw new Error(`Unknown entitlement ${where.id}`);
}
const updated = { ...current, ...data };
entitlements.set(updated.year, updated);
return updated;
}),
},
vacation: {
findMany: vi
.fn()
// 2025 holiday context
.mockResolvedValueOnce([])
// 2025 balance vacations
.mockResolvedValueOnce([
{
startDate: new Date("2025-06-10T00:00:00.000Z"),
endDate: new Date("2025-06-17T00:00:00.000Z"),
status: "APPROVED",
isHalfDay: false,
},
])
// 2026 holiday context
.mockResolvedValueOnce([])
// 2026 balance vacations
.mockResolvedValueOnce([])
// 2026 sick days
.mockResolvedValueOnce([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.carryoverDays).toBe(20);
expect(result.entitledDays).toBe(48);
expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ent_2026" },
data: expect.objectContaining({
carryoverDays: 20,
entitledDays: 48,
}),
}),
);
});
});
describe("entitlement.getBalanceDetail", () => {
it("returns assistant-friendly balance detail from the canonical balance workflow", async () => {
const entitlement = sampleEntitlement({ carryoverDays: 0, usedDays: 1, pendingDays: 0.5 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findUnique: vi.fn().mockImplementation(async ({ select }: { select?: Record<string, unknown> } = {}) => ({
...(select?.userId ? { userId: "user_1" } : {}),
...(select?.federalState ? { federalState: "BY" } : {}),
...(select?.country ? { country: { code: "DE" } } : {}),
...(select?.metroCity ? { metroCity: null } : {}),
...(select?.displayName ? { displayName: "Alice Example" } : {}),
...(select?.eid ? { eid: "EMP-001" } : {}),
})),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { type?: string } } = {}) => {
if (where?.type === "SICK") {
return [{ startDate: new Date("2026-02-01"), endDate: new Date("2026-02-01"), isHalfDay: false }];
}
return [];
}),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalanceDetail({ resourceId: "res_1", year: 2026 });
expect(result).toEqual({
resource: "Alice Example",
eid: "EMP-001",
year: 2026,
entitlement: 30,
carryOver: 0,
taken: 1,
pending: 0.5,
remaining: 28.5,
sickDays: 1,
});
});
});
// ─── get ─────────────────────────────────────────────────────────────────────
describe("entitlement.get", () => {
it("returns existing entitlement (manager role)", async () => {
const entitlement = sampleEntitlement({
entitledDays: 30,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
});
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 30 }),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockImplementation(async ({ data }: { data: Record<string, number> }) => ({
...entitlement,
...data,
})),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
const result = await caller.get({ resourceId: "res_1", year: 2026 });
expect(result.id).toBe("ent_1");
expect(result.entitledDays).toBe(30);
});
it("rejects access by a regular user (FORBIDDEN)", async () => {
const db = {
systemSettings: {
findUnique: vi.fn(),
},
vacationEntitlement: {
findUnique: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(caller.get({ resourceId: "res_1", year: 2026 })).rejects.toThrow();
});
});
// ─── set ─────────────────────────────────────────────────────────────────────
describe("entitlement.set", () => {
it("updates existing entitlement", async () => {
const existing = sampleEntitlement();
const updated = { ...existing, entitledDays: 35 };
const db = {
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(existing),
update: vi.fn().mockResolvedValue(updated),
create: vi.fn(),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.set({
resourceId: "res_1",
year: 2026,
entitledDays: 35,
});
expect(result.entitledDays).toBe(35);
expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ent_1" },
data: { entitledDays: 35 },
}),
);
expect(db.vacationEntitlement.create).not.toHaveBeenCalled();
});
it("creates new entitlement when none exists", async () => {
const created = sampleEntitlement({ entitledDays: 30, carryoverDays: 0 });
const db = {
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn(),
create: vi.fn().mockResolvedValue(created),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.set({
resourceId: "res_1",
year: 2026,
entitledDays: 30,
});
expect(result.entitledDays).toBe(30);
expect(db.vacationEntitlement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "res_1",
year: 2026,
entitledDays: 30,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
}),
}),
);
expect(db.vacationEntitlement.update).not.toHaveBeenCalled();
});
});
// ─── bulkSet ─────────────────────────────────────────────────────────────────
describe("entitlement.bulkSet", () => {
it("upserts entitlements for all active resources (admin role)", async () => {
const resources = [{ id: "res_1" }, { id: "res_2" }, { id: "res_3" }];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createAdminCaller(db);
const result = await caller.bulkSet({
year: 2026,
entitledDays: 30,
});
expect(result.updated).toBe(3);
expect(db.vacationEntitlement.upsert).toHaveBeenCalledTimes(3);
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ isActive: true }),
}),
);
});
it("filters by resourceIds when provided", async () => {
const resources = [{ id: "res_1" }];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createAdminCaller(db);
await caller.bulkSet({
year: 2026,
entitledDays: 30,
resourceIds: ["res_1"],
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
id: { in: ["res_1"] },
}),
}),
);
});
it("rejects bulk set by a manager (admin only)", async () => {
const db = {
resource: { findMany: vi.fn() },
vacationEntitlement: { upsert: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.bulkSet({ year: 2026, entitledDays: 30 }),
).rejects.toThrow();
});
});
// ─── getYearSummary ──────────────────────────────────────────────────────────
describe("entitlement.getYearSummary", () => {
it("returns summary for all active resources (manager role)", async () => {
const resources = [
{ id: "res_1", displayName: "Alice", eid: "alice", chapter: "VFX" },
{ id: "res_2", displayName: "Bob", eid: "bob", chapter: "Animation" },
];
const entitlement = sampleEntitlement({ usedDays: 5, pendingDays: 2 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
federalState: "BY",
country: { code: "DE" },
metroCity: null,
}),
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getYearSummary({ year: 2026 });
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("displayName");
expect(result[0]).toHaveProperty("remainingDays");
});
it("filters by chapter when provided", async () => {
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
vacationEntitlement: {
findUnique: vi.fn(),
update: vi.fn(),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createManagerCaller(db);
await caller.getYearSummary({ year: 2026, chapter: "VFX" });
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
chapter: "VFX",
}),
}),
);
});
});
describe("entitlement.getYearSummaryDetail", () => {
it("returns assistant-friendly year summary detail from the canonical summary workflow", async () => {
const resources = [
{ id: "res_1", displayName: "Alice Example", eid: "EMP-001", chapter: "Delivery" },
{ id: "res_2", displayName: "Bob Example", eid: "EMP-002", chapter: "CGI" },
];
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findMany: vi.fn().mockResolvedValue(resources),
findUnique: vi.fn().mockResolvedValue({
federalState: "BY",
country: { code: "DE" },
metroCity: null,
}),
},
vacationEntitlement: {
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { resourceId: string; year: number } } }) => {
if (where.resourceId_year.year !== 2026) {
return null;
}
return sampleEntitlement({
id: `ent_${where.resourceId_year.resourceId}`,
resourceId: where.resourceId_year.resourceId,
year: 2026,
entitledDays: 28,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
});
}),
create: vi.fn(),
update: vi.fn().mockImplementation(async (args?: { data?: Record<string, unknown>; where?: { id?: string } }) => ({
...sampleEntitlement({ entitledDays: 28, carryoverDays: 0, usedDays: 0, pendingDays: 0 }),
id: args?.where?.id ?? "ent_updated",
...(args?.data ?? {}),
})),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getYearSummaryDetail({ year: 2026, resourceName: "alice" });
expect(result).toEqual([
{
resource: "Alice Example",
eid: "EMP-001",
chapter: "Delivery",
year: 2026,
entitled: 28,
carryover: 0,
used: 0,
pending: 0,
remaining: 28,
},
]);
});
});