Files
CapaKraken/packages/api/src/__tests__/vacation-router.test.ts
T
Hartmut 070be70848 refactor(application): extract vacation management into application use-cases
Moves approve, reject, cancel, and request vacation business logic
out of the tRPC procedure layer into packages/application, matching
the pattern used by allocation use-cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 17:11:37 +02:00

1622 lines
50 KiB
TypeScript

import { SystemRole } from "@capakraken/shared";
import { VacationStatus, VacationType } from "@capakraken/db";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { createNotification } from "../lib/create-notification.js";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { vacationRouter } from "../router/vacation.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitVacationCreated: vi.fn(),
emitVacationUpdated: vi.fn(),
emitVacationDeleted: vi.fn(),
emitNotificationCreated: vi.fn(),
emitTaskAssigned: vi.fn(),
}));
vi.mock("../lib/email.js", () => ({
sendEmail: vi.fn(),
}));
vi.mock("../lib/create-notification.js", () => ({
createNotification: vi.fn().mockResolvedValue("notif_1"),
}));
vi.mock("../lib/vacation-conflicts.js", () => ({
checkVacationConflicts: vi.fn().mockResolvedValue({ warnings: [] }),
checkBatchVacationConflicts: vi.fn().mockResolvedValue(new Map()),
}));
vi.mock("../lib/webhook-dispatcher.js", () => ({
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/audit.js", () => ({
createAuditEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
const createCaller = createCallerFactory(vacationRouter);
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "manager@example.com", name: "Manager", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "mgr_1",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "admin_1",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
function createUnauthenticatedCaller(db: Record<string, unknown>) {
return createCaller({
session: null,
db: db as never,
dbUser: null,
});
}
const sampleVacation = {
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
status: VacationStatus.PENDING,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
note: "Summer vacation",
isHalfDay: false,
halfDayPart: null,
deductedDays: null,
holidayCountryCode: null,
holidayCountryName: null,
holidayFederalState: null,
holidayMetroCityName: null,
holidayCalendarDates: null,
holidayLegacyPublicHolidayDates: null,
requestedById: "user_1",
approvedById: null,
approvedAt: null,
rejectionReason: null,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
resource: { id: "res_1", displayName: "Alice", eid: "E-001" },
requestedBy: { id: "user_1", name: "User", email: "user@example.com" },
approvedBy: null,
};
function createVacationDb(overrides: Record<string, unknown> = {}) {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
},
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.userId ? { userId: "user_1" } : {}),
...(select.displayName ? { displayName: "Alice" } : {}),
...(select.user ? { user: null } : {}),
...(select.federalState ? { federalState: "BY" } : {}),
...(select.country ? { country: { code: "DE", name: "Germany" } } : {}),
...(select.metroCity ? { metroCity: null } : {}),
};
}),
count: vi.fn().mockResolvedValue(0),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(sampleVacation),
findUniqueOrThrow: vi.fn().mockResolvedValue(sampleVacation),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(sampleVacation),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
notification: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
vacationEntitlement: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
};
const merged = {
...db,
...overrides,
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
resource: { ...db.resource, ...(overrides.resource as Record<string, unknown> | undefined) },
vacation: { ...db.vacation, ...(overrides.vacation as Record<string, unknown> | undefined) },
notification: {
...db.notification,
...(overrides.notification as Record<string, unknown> | undefined),
},
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
vacationEntitlement: {
...db.vacationEntitlement,
...(overrides.vacationEntitlement as Record<string, unknown> | undefined),
},
};
return {
...merged,
$transaction: vi.fn(async (callback: (tx: typeof merged) => Promise<unknown>) => callback(merged)),
};
}
describe("vacation router", () => {
describe("list", () => {
it("returns vacations with default filters", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([sampleVacation]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.list({});
expect(result).toHaveLength(1);
expect(result[0].id).toBe("vac_1");
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { startDate: "asc" },
take: 100,
}),
);
});
it("applies resourceId filter", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({ resourceId: "res_1" });
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ resourceId: "res_1" }),
}),
);
});
it("scopes regular users to their own resource when no filter is provided", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({});
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ resourceId: "res_own" }),
}),
);
});
it("forbids regular users from listing another resource's vacations", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(caller.list({ resourceId: "res_other" })).rejects.toThrow(
"You can only view vacation data for your own resource",
);
expect(db.vacation.findMany).not.toHaveBeenCalled();
});
it("applies status and type filters", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({
status: VacationStatus.APPROVED,
type: VacationType.SICK,
});
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: VacationStatus.APPROVED,
type: VacationType.SICK,
}),
}),
);
});
it("rejects unauthenticated users", async () => {
const db = { vacation: { findMany: vi.fn() } };
const caller = createUnauthenticatedCaller(db);
await expect(caller.list({})).rejects.toThrow("Authentication required");
});
});
describe("background side effects", () => {
it("logs and swallows async notification failures during approval", async () => {
vi.mocked(createNotification).mockRejectedValueOnce(new Error("notification down"));
const approvedVacation = { ...sampleVacation, status: VacationStatus.APPROVED };
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
resource: {
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.displayName ? { displayName: "Alice" } : {}),
...(select.user
? { user: { id: "user_1", email: "user@example.com", name: "User" } }
: {}),
};
}),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.PENDING,
}),
update: vi.fn().mockResolvedValue(approvedVacation),
},
});
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
await Promise.resolve();
await Promise.resolve();
expect(result.status).toBe(VacationStatus.APPROVED);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({
effectName: "notifyVacationStatus",
vacationId: "vac_1",
resourceId: "res_1",
newStatus: VacationStatus.APPROVED,
}),
"Vacation background side effect failed",
);
});
it("logs and swallows webhook failures during approval", async () => {
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook down"));
const approvedVacation = { ...sampleVacation, status: VacationStatus.APPROVED };
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
resource: {
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.displayName ? { displayName: "Alice" } : {}),
...(select.user
? { user: { id: "user_1", email: "user@example.com", name: "User" } }
: {}),
};
}),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.PENDING,
}),
update: vi.fn().mockResolvedValue(approvedVacation),
},
});
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
await Promise.resolve();
await Promise.resolve();
expect(result.status).toBe(VacationStatus.APPROVED);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "dispatchWebhooks", event: "vacation.approved" }),
"Vacation background side effect failed",
);
});
});
describe("getById", () => {
it("returns vacation by id", async () => {
const db = {
resource: {
findFirst: vi.fn(),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
resource: { ...sampleVacation.resource, userId: "user_1" },
}),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getById({ id: "vac_1" });
expect(result.id).toBe("vac_1");
expect(db.vacation.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "vac_1" },
}),
);
});
it("forbids regular users from reading another user's vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
requestedById: "someone_else",
resource: { ...sampleVacation.resource, userId: "someone_else" },
}),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "vac_1" })).rejects.toThrow(
"You can only view your own vacation data",
);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "missing" })).rejects.toThrow("Vacation not found");
});
});
describe("create", () => {
it("creates vacation as PENDING for regular user", async () => {
const createdVacation = {
...sampleVacation,
status: VacationStatus.PENDING,
};
const db = createVacationDb({
vacation: {
create: vi.fn().mockResolvedValue(createdVacation),
},
});
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result.status).toBe(VacationStatus.PENDING);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.PENDING,
resourceId: "res_1",
type: VacationType.ANNUAL,
}),
}),
);
});
it("creates vacation as APPROVED for manager", async () => {
const createdVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
};
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
vacation: {
create: vi.fn().mockResolvedValue(createdVacation),
},
});
const caller = createManagerCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result.status).toBe(VacationStatus.APPROVED);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
}),
}),
);
});
it("rejects overlapping vacation", async () => {
const db = createVacationDb({
vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
},
});
const caller = createProtectedCaller(db);
await expect(
caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
}),
).rejects.toThrow("Overlapping vacation already exists");
});
it("rejects when end date is before start date", async () => {
const db = createVacationDb({
user: { findUnique: vi.fn() },
vacation: { findFirst: vi.fn() },
});
const caller = createProtectedCaller(db);
await expect(
caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-05"),
endDate: new Date("2026-06-01"),
}),
).rejects.toThrow();
});
it("supports half-day vacations", async () => {
const createdVacation = {
...sampleVacation,
isHalfDay: true,
halfDayPart: "MORNING",
};
const db = createVacationDb({
vacation: {
create: vi.fn().mockResolvedValue(createdVacation),
},
});
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-01"),
isHalfDay: true,
halfDayPart: "MORNING",
});
expect(result.isHalfDay).toBe(true);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
isHalfDay: true,
halfDayPart: "MORNING",
}),
}),
);
});
it("rejects multi-day half-day vacations", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-02"),
isHalfDay: true,
halfDayPart: "MORNING",
})).rejects.toThrow();
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("rejects half-day vacations without a half-day part", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-01"),
isHalfDay: true,
})).rejects.toThrow();
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("rejects half-day parts on full-day vacations", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-01"),
halfDayPart: "AFTERNOON",
})).rejects.toThrow();
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("rejects leave requests that only hit public holidays", async () => {
const db = createVacationDb({
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
})).rejects.toThrow("does not deduct any vacation days");
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("keeps mixed vacation ranges chargeable when only some days are holidays", async () => {
const createdVacation = {
...sampleVacation,
startDate: new Date("2026-11-14T00:00:00.000Z"),
endDate: new Date("2026-11-16T00:00:00.000Z"),
};
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
countryId: "country_de",
metroCityId: "city_muc",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_muc",
name: "Muenchen lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-11-15T00:00:00.000Z"),
name: "Lokaler Stadtfeiertag",
isRecurringAnnual: true,
},
],
},
]),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdVacation),
},
});
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-11-14T00:00:00.000Z"),
endDate: new Date("2026-11-16T00:00:00.000Z"),
});
expect(result.effectiveDays).toBe(2);
expect(db.vacation.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
deductedDays: 2,
holidayCountryCode: "DE",
holidayCountryName: "Germany",
holidayFederalState: "BY",
holidayMetroCityName: "Muenchen",
holidayCalendarDates: ["2026-11-15"],
holidayLegacyPublicHolidayDates: [],
}),
}));
});
});
describe("previewRequest", () => {
it("shows public holidays as non-deductible leave days", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Augsburg" },
}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
const result = await caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2028-08-08T00:00:00.000Z"),
endDate: new Date("2028-08-08T00:00:00.000Z"),
});
expect(result.requestedDays).toBe(1);
expect(result.effectiveDays).toBe(0);
expect(result.deductedDays).toBe(0);
expect(result.publicHolidayDates).toContain("2028-08-08");
expect(result.holidayContext).toEqual({
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Augsburg",
sources: {
hasCalendarHolidays: true,
hasLegacyPublicHolidayEntries: false,
},
});
expect(result.holidayDetails).toContainEqual({
date: "2028-08-08",
source: "CALENDAR",
});
});
it("uses custom city holiday calendars for non-deductible leave days", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
countryId: "country_de",
metroCityId: "city_muc",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_muc",
name: "Muenchen lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-11-15T00:00:00.000Z"),
name: "Lokaler Stadtfeiertag",
isRecurringAnnual: true,
},
],
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
const result = await caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-11-15T00:00:00.000Z"),
endDate: new Date("2026-11-15T00:00:00.000Z"),
});
expect(result.requestedDays).toBe(1);
expect(result.effectiveDays).toBe(0);
expect(result.publicHolidayDates).toContain("2026-11-15");
expect(result.holidayContext.countryName).toBe("Germany");
expect(result.holidayContext.metroCityName).toBe("Muenchen");
expect(db.holidayCalendar.findMany).toHaveBeenCalled();
});
it("marks legacy public holiday entries as a separate preview source", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
federalState: "HH",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Hamburg" },
}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-01T00:00:00.000Z"),
},
]),
},
});
const caller = createProtectedCaller(db);
const result = await caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-01T00:00:00.000Z"),
});
expect(result.publicHolidayDates).toContain("2026-05-01");
expect(result.holidayContext.sources).toEqual({
hasCalendarHolidays: true,
hasLegacyPublicHolidayEntries: true,
});
expect(result.holidayDetails).toContainEqual({
date: "2026-05-01",
source: "CALENDAR_AND_LEGACY",
});
});
it("rejects multi-day half-day previews", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-02"),
isHalfDay: true,
})).rejects.toThrow();
});
});
describe("create manual public holiday handling", () => {
it("rejects manual public holiday creation requests", async () => {
const db = createVacationDb();
const caller = createManagerCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.PUBLIC_HOLIDAY,
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-01T00:00:00.000Z"),
})).rejects.toThrow("Public holidays must be managed via Holiday Calendars or the legacy holiday import");
expect(db.vacation.create).not.toHaveBeenCalled();
});
});
describe("approve", () => {
it("approves a PENDING vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
};
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
expect(result.status).toBe(VacationStatus.APPROVED);
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: "vac_1" }),
data: expect.objectContaining({
status: VacationStatus.APPROVED,
rejectionReason: null,
deductedDays: 5,
holidayCalendarDates: [],
holidayLegacyPublicHolidayDates: [],
}),
}),
);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("rejects approving an already APPROVED vacation", async () => {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
});
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
"Only PENDING, CANCELLED, or REJECTED vacations can be approved",
);
});
it("rejects approval when the current holiday context reduces the request to zero days", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
countryId: "country_de",
metroCityId: "city_muc",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_muc",
name: "Muenchen lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-11-15T00:00:00.000Z"),
name: "Lokaler Stadtfeiertag",
isRecurringAnnual: true,
},
],
},
]),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
startDate: new Date("2026-11-15T00:00:00.000Z"),
endDate: new Date("2026-11-15T00:00:00.000Z"),
}),
update: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
"Vacation no longer deducts any vacation days for the current holiday calendar and cannot be approved",
);
expect(db.vacation.update).not.toHaveBeenCalled();
});
it("forbids regular users from approving", async () => {
const db = {};
const caller = createProtectedCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow();
});
});
describe("reject", () => {
it("rejects a PENDING vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.REJECTED,
rejectionReason: "Team conflict",
};
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createManagerCaller(db);
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
expect(result.status).toBe(VacationStatus.REJECTED);
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: "vac_1" }),
data: expect.objectContaining({
status: VacationStatus.REJECTED,
rejectionReason: "Team conflict",
}),
}),
);
});
it("throws when rejecting non-PENDING vacation", async () => {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
});
const caller = createManagerCaller(db);
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
"Only PENDING vacations can be rejected",
);
});
});
describe("cancel", () => {
it("cancels an existing vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.CANCELLED,
};
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
});
const caller = createProtectedCaller(db);
const result = await caller.cancel({ id: "vac_1" });
expect(result.status).toBe(VacationStatus.CANCELLED);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("throws when already cancelled", async () => {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.CANCELLED,
}),
},
});
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
});
});
describe("batchApprove", () => {
it("approves multiple pending vacations", async () => {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { status?: string; type?: string } } = {}) => {
if (where?.type === VacationType.PUBLIC_HOLIDAY) {
return [];
}
if (where?.status === VacationStatus.PENDING) {
return [
{
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-05T00:00:00.000Z"),
isHalfDay: false,
},
{
id: "vac_2",
resourceId: "res_2",
type: VacationType.OTHER,
startDate: new Date("2026-06-08T00:00:00.000Z"),
endDate: new Date("2026-06-08T00:00:00.000Z"),
isHalfDay: false,
},
];
}
return [];
}),
update: vi.fn().mockResolvedValue(sampleVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
expect(result.approved).toBe(2);
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "vac_1" },
data: expect.objectContaining({
status: VacationStatus.APPROVED,
deductedDays: 5,
}),
}),
);
});
it("only approves PENDING vacations from the requested set", async () => {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { status?: string; type?: string } } = {}) => {
if (where?.type === VacationType.PUBLIC_HOLIDAY) {
return [];
}
if (where?.status === VacationStatus.PENDING) {
return [
{
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-01T00:00:00.000Z"),
isHalfDay: false,
},
];
}
return [];
}),
update: vi.fn().mockResolvedValue(sampleVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
expect(result.approved).toBe(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ["vac_1", "vac_already_approved"] }, status: VacationStatus.PENDING },
}),
);
});
});
describe("batchReject", () => {
it("rejects multiple pending vacations with optional reason", async () => {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createManagerCaller(db);
const result = await caller.batchReject({
ids: ["vac_1"],
rejectionReason: "Budget freeze",
});
expect(result.rejected).toBe(1);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.REJECTED,
rejectionReason: "Budget freeze",
}),
}),
);
});
});
describe("getForResource", () => {
it("returns approved vacations in date range", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
id: "vac_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
type: VacationType.ANNUAL,
status: VacationStatus.APPROVED,
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getForResource({
resourceId: "res_1",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-12-31"),
});
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resourceId: "res_1",
status: VacationStatus.APPROVED,
}),
}),
);
});
it("forbids regular users from reading another resource's approved vacations", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getForResource({
resourceId: "res_other",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-12-31"),
}),
).rejects.toThrow("You can only view vacation data for your own resource");
expect(db.vacation.findMany).not.toHaveBeenCalled();
});
});
describe("getPendingApprovals", () => {
it("returns all pending vacations for managers", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([sampleVacation]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getPendingApprovals();
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: VacationStatus.PENDING },
}),
);
});
it("forbids regular users from viewing pending approvals", async () => {
const db = {};
const caller = createProtectedCaller(db);
await expect(caller.getPendingApprovals()).rejects.toThrow();
});
});
describe("getTeamOverlap", () => {
it("returns overlapping vacations for the same chapter", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue({ chapter: "Animation" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
...sampleVacation,
id: "vac_other",
resourceId: "res_2",
resource: { id: "res_2", displayName: "Bob", eid: "E-002" },
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlap({
resourceId: "res_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resource: { chapter: "Animation" },
resourceId: { not: "res_1" },
}),
}),
);
});
it("returns empty array when resource has no chapter", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue({ chapter: null }),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlap({
resourceId: "res_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result).toEqual([]);
});
it("forbids regular users from reading another resource's team overlap", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
findUnique: vi.fn(),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getTeamOverlap({
resourceId: "res_other",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
}),
).rejects.toThrow("You can only view vacation data for your own resource");
expect(db.resource.findUnique).not.toHaveBeenCalled();
expect(db.vacation.findMany).not.toHaveBeenCalled();
});
});
describe("getTeamOverlapDetail", () => {
it("returns assistant-friendly overlap detail from the canonical overlap query", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi
.fn()
.mockResolvedValueOnce({ displayName: "Bruce Banner", chapter: "CGI" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
...sampleVacation,
id: "vac_other",
resourceId: "res_2",
status: VacationStatus.APPROVED,
resource: { id: "res_2", displayName: "Clark Kent", eid: "E-002" },
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlapDetail({
resourceId: "res_1",
startDate: new Date("2026-08-10T00:00:00.000Z"),
endDate: new Date("2026-08-12T00:00:00.000Z"),
});
expect(result).toEqual({
resource: "Bruce Banner",
chapter: "CGI",
period: "2026-08-10 to 2026-08-12",
overlapCount: 1,
overlappingVacations: [
{
resource: "Clark Kent",
type: VacationType.ANNUAL,
status: VacationStatus.APPROVED,
start: "2026-06-01",
end: "2026-06-05",
},
],
});
});
});
describe("batchCreatePublicHolidays", () => {
it("creates public holidays for all active resources (admin only)", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
{ id: "res_2", federalState: "BY", country: { code: "DE" }, metroCity: null },
]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
vacation: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
findMany: vi.fn().mockResolvedValue([]),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
};
const caller = createAdminCaller(db);
const result = await caller.batchCreatePublicHolidays({
year: 2026,
federalState: "BY",
});
expect(result.created).toBeGreaterThan(0);
expect(result.resources).toBe(2);
expect(db.vacation.createMany).toHaveBeenCalled();
});
it("resolves holidays once per unique country/state combination", async () => {
// 3 resources: res_1 and res_2 share the same combo, res_3 is different
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{ id: "res_1", federalState: "BY", countryId: "de", country: { code: "DE" }, metroCityId: null, metroCity: null },
{ id: "res_2", federalState: "BY", countryId: "de", country: { code: "DE" }, metroCityId: null, metroCity: null },
{ id: "res_3", federalState: "BE", countryId: "de", country: { code: "DE" }, metroCityId: null, metroCity: null },
]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createAdminCaller(db);
const result = await caller.batchCreatePublicHolidays({ year: 2026 });
// 3 resources, 2 unique combos → holiday calendars queried twice, not 3 times
expect(db.holidayCalendar.findMany).toHaveBeenCalledTimes(2);
expect(result.resources).toBe(3);
});
it("skips already existing holidays", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
vacation: {
// Return one entry per day of 2026 so every holiday date is "already existing"
findMany: vi.fn().mockResolvedValue(
Array.from({ length: 365 }, (_, i) => {
const d = new Date(Date.UTC(2026, 0, 1));
d.setUTCDate(d.getUTCDate() + i);
return { resourceId: "res_1", startDate: d };
}),
),
createMany: vi.fn(),
},
};
const caller = createAdminCaller(db);
const result = await caller.batchCreatePublicHolidays({
year: 2026,
federalState: "BY",
});
expect(result.created).toBe(0);
expect(db.vacation.createMany).not.toHaveBeenCalled();
});
it("forbids non-admin users", async () => {
const db = {};
const caller = createManagerCaller(db);
await expect(
caller.batchCreatePublicHolidays({ year: 2026 }),
).rejects.toThrow("Admin role required");
});
});
describe("updateStatus", () => {
it("allows manager to approve via updateStatus", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
};
const caller = createManagerCaller(db);
const result = await caller.updateStatus({ id: "vac_1", status: "APPROVED" });
expect(result.status).toBe(VacationStatus.APPROVED);
});
it("allows any user to cancel via updateStatus", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.CANCELLED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
};
const caller = createProtectedCaller(db);
const result = await caller.updateStatus({ id: "vac_1", status: "CANCELLED" });
expect(result.status).toBe(VacationStatus.CANCELLED);
});
it("forbids non-manager from approving via updateStatus", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.updateStatus({ id: "vac_1", status: "APPROVED" }),
).rejects.toThrow("Manager role required to approve/reject");
});
it("throws NOT_FOUND when vacation does not exist", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.updateStatus({ id: "missing", status: "CANCELLED" }),
).rejects.toThrow("Vacation not found");
});
});
});