1416 lines
42 KiB
TypeScript
1416 lines
42 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,
|
|
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),
|
|
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({}),
|
|
},
|
|
};
|
|
|
|
return {
|
|
...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) },
|
|
};
|
|
}
|
|
|
|
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 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({
|
|
...sampleVacation,
|
|
status: VacationStatus.APPROVED,
|
|
}),
|
|
},
|
|
});
|
|
|
|
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 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({
|
|
...sampleVacation,
|
|
status: VacationStatus.APPROVED,
|
|
}),
|
|
},
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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: { id: "vac_1" },
|
|
data: expect.objectContaining({
|
|
status: VacationStatus.APPROVED,
|
|
rejectionReason: null,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
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("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({
|
|
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().mockResolvedValue([
|
|
{ id: "vac_1", resourceId: "res_1" },
|
|
{ id: "vac_2", resourceId: "res_2" },
|
|
]),
|
|
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
|
|
},
|
|
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.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: { in: ["vac_1", "vac_2"] } },
|
|
data: expect.objectContaining({
|
|
status: VacationStatus.APPROVED,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
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().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.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 }),
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
create: vi.fn().mockResolvedValue({}),
|
|
},
|
|
};
|
|
|
|
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.create).toHaveBeenCalled();
|
|
});
|
|
|
|
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: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "existing" }),
|
|
create: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const caller = createAdminCaller(db);
|
|
const result = await caller.batchCreatePublicHolidays({
|
|
year: 2026,
|
|
federalState: "BY",
|
|
});
|
|
|
|
expect(result.created).toBe(0);
|
|
expect(db.vacation.create).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");
|
|
});
|
|
});
|
|
});
|