feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish
Dashboard: expanded chargeability widget, resource/project table widgets with sorting and filters, stat cards with formatMoney integration. Chargeability: new report client with filtering, chargeability-bookings use case, updated dashboard overview logic. Dispo import: TBD project handling, parse-dispo-matrix improvements, stage-dispo-projects resource value scores, new tests. Estimates: CommercialTermsEditor component, commercial-terms engine module, expanded estimate schemas and types. UI: AppShell navigation updates, timeline filter/toolbar enhancements, role management improvements, signin page redesign, Tailwind/globals polish, SystemSettings SMTP section, anonymization support. Tests: new router tests (anonymization, chargeability, effort-rule, entitlement, estimate, experience-multiplier, notification, resource, staffing, vacation). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,854 @@
|
||||
import { SystemRole } from "@planarchy/shared";
|
||||
import { VacationStatus, VacationType } from "@planarchy/db";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
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(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/email.js", () => ({
|
||||
sendEmail: 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,
|
||||
};
|
||||
|
||||
describe("vacation router", () => {
|
||||
describe("list", () => {
|
||||
it("returns vacations with default filters", async () => {
|
||||
const db = {
|
||||
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 = {
|
||||
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("applies status and type filters", async () => {
|
||||
const db = {
|
||||
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("getById", () => {
|
||||
it("returns vacation by id", async () => {
|
||||
const db = {
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
},
|
||||
};
|
||||
|
||||
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("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 = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
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 = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
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 = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
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 = {
|
||||
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 = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("approve", () => {
|
||||
it("approves a PENDING vacation", async () => {
|
||||
const updatedVacation = {
|
||||
...sampleVacation,
|
||||
status: VacationStatus.APPROVED,
|
||||
approvedById: "mgr_1",
|
||||
};
|
||||
|
||||
const db = {
|
||||
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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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: {
|
||||
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: {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("batchCreatePublicHolidays", () => {
|
||||
it("creates public holidays for all active resources (admin only)", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "res_1" },
|
||||
{ id: "res_2" },
|
||||
]),
|
||||
},
|
||||
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" }]),
|
||||
},
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user