800a4c5fff
Phase 3b Tier 1: covers approve/reject/cancel vacation (single + batch), set/bulk-set entitlement, sync entitlement with carryover and cycle detection, and entitlement balance calculation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
approveVacation,
|
|
batchApproveVacations,
|
|
rejectVacation,
|
|
batchRejectVacations,
|
|
cancelVacation,
|
|
} from "../index.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const baseVacation = {
|
|
id: "vacation_1",
|
|
resourceId: "resource_1",
|
|
type: "ANNUAL" as const,
|
|
startDate: new Date("2026-06-01"),
|
|
endDate: new Date("2026-06-05"),
|
|
isHalfDay: false,
|
|
status: "PENDING" as const,
|
|
requestedById: "user_1",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// approveVacation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("approveVacation", () => {
|
|
function makeDb(vacation = baseVacation) {
|
|
return {
|
|
vacation: {
|
|
findUnique: vi.fn().mockResolvedValue(vacation),
|
|
update: vi.fn().mockResolvedValue({ ...vacation, status: "APPROVED" }),
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeDeps() {
|
|
return {
|
|
assertVacationApprovable: vi.fn(),
|
|
assertVacationStillChargeable: vi.fn().mockResolvedValue(undefined),
|
|
buildVacationApprovalWriteData: vi.fn().mockResolvedValue({ deductionSnapshot: { days: 5 } }),
|
|
checkVacationConflicts: vi.fn().mockResolvedValue({ warnings: [] }),
|
|
buildApprovedVacationUpdateData: vi.fn().mockReturnValue({ status: "APPROVED" }),
|
|
};
|
|
}
|
|
|
|
it("approves a PENDING vacation and returns the updated record", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
|
|
const result = await approveVacation(
|
|
db as never,
|
|
{ id: "vacation_1", actorUserId: "manager_1" },
|
|
deps,
|
|
);
|
|
|
|
expect(result.existingStatus).toBe("PENDING");
|
|
expect(result.vacation.status).toBe("APPROVED");
|
|
expect(result.warnings).toEqual([]);
|
|
|
|
expect(deps.assertVacationApprovable).toHaveBeenCalledWith("PENDING");
|
|
expect(deps.assertVacationStillChargeable).toHaveBeenCalledOnce();
|
|
expect(deps.buildVacationApprovalWriteData).toHaveBeenCalledOnce();
|
|
expect(deps.checkVacationConflicts).toHaveBeenCalledWith(db, "vacation_1", "manager_1");
|
|
expect(db.vacation.update).toHaveBeenCalledWith({
|
|
where: { id: "vacation_1" },
|
|
data: { status: "APPROVED" },
|
|
});
|
|
});
|
|
|
|
it("returns warnings from conflict check", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
deps.checkVacationConflicts.mockResolvedValue({
|
|
warnings: ["Overlaps with another approval"],
|
|
});
|
|
|
|
const result = await approveVacation(db as never, { id: "vacation_1" }, deps);
|
|
|
|
expect(result.warnings).toEqual(["Overlaps with another approval"]);
|
|
});
|
|
|
|
it("throws NOT_FOUND when vacation does not exist", async () => {
|
|
const db = {
|
|
vacation: { findUnique: vi.fn().mockResolvedValue(null) },
|
|
};
|
|
const deps = makeDeps();
|
|
|
|
await expect(approveVacation(db as never, { id: "nonexistent" }, deps)).rejects.toMatchObject({
|
|
code: "NOT_FOUND",
|
|
});
|
|
|
|
expect(deps.assertVacationApprovable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("propagates error when assertVacationApprovable throws", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
deps.assertVacationApprovable.mockImplementation(() => {
|
|
throw new Error("Status transition not allowed");
|
|
});
|
|
|
|
await expect(approveVacation(db as never, { id: "vacation_1" }, deps)).rejects.toThrow(
|
|
"Status transition not allowed",
|
|
);
|
|
|
|
expect(db.vacation.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("propagates error when assertVacationStillChargeable throws", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
deps.assertVacationStillChargeable.mockRejectedValue(new Error("Insufficient balance"));
|
|
|
|
await expect(approveVacation(db as never, { id: "vacation_1" }, deps)).rejects.toThrow(
|
|
"Insufficient balance",
|
|
);
|
|
|
|
expect(db.vacation.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// batchApproveVacations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("batchApproveVacations", () => {
|
|
const pendingVacations = [
|
|
{ ...baseVacation, id: "vacation_1" },
|
|
{ ...baseVacation, id: "vacation_2", resourceId: "resource_2" },
|
|
];
|
|
|
|
function makeDb(vacations = pendingVacations) {
|
|
return {
|
|
vacation: {
|
|
findMany: vi.fn().mockResolvedValue(vacations),
|
|
update: vi.fn().mockImplementation(({ where }) => {
|
|
const found = vacations.find((v) => v.id === where.id)!;
|
|
return Promise.resolve({ ...found, status: "APPROVED" });
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeDeps() {
|
|
return {
|
|
assertVacationStillChargeable: vi.fn().mockResolvedValue(undefined),
|
|
buildVacationApprovalWriteData: vi.fn().mockResolvedValue({ deductionSnapshot: {} }),
|
|
checkBatchVacationConflicts: vi.fn().mockResolvedValue(new Map()),
|
|
buildApprovedVacationUpdateData: vi.fn().mockReturnValue({ status: "APPROVED" }),
|
|
};
|
|
}
|
|
|
|
it("approves all PENDING vacations in the batch", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
|
|
const result = await batchApproveVacations(
|
|
db as never,
|
|
{ ids: ["vacation_1", "vacation_2"], actorUserId: "manager_1" },
|
|
deps,
|
|
);
|
|
|
|
expect(result.approved).toBe(2);
|
|
expect(result.updatedVacations).toHaveLength(2);
|
|
expect(result.warnings).toEqual([]);
|
|
expect(deps.assertVacationStillChargeable).toHaveBeenCalledTimes(2);
|
|
expect(db.vacation.update).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("collects warnings from batch conflict map", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
deps.checkBatchVacationConflicts.mockResolvedValue(
|
|
new Map([
|
|
["vacation_1", ["Conflicts with leave policy"]],
|
|
["vacation_2", ["Overlapping vacation"]],
|
|
]),
|
|
);
|
|
|
|
const result = await batchApproveVacations(
|
|
db as never,
|
|
{ ids: ["vacation_1", "vacation_2"] },
|
|
deps,
|
|
);
|
|
|
|
expect(result.warnings).toEqual(["Conflicts with leave policy", "Overlapping vacation"]);
|
|
});
|
|
|
|
it("returns zero approved when no PENDING vacations found", async () => {
|
|
const db = makeDb([]);
|
|
const deps = makeDeps();
|
|
|
|
const result = await batchApproveVacations(db as never, { ids: ["vacation_missing"] }, deps);
|
|
|
|
expect(result.approved).toBe(0);
|
|
expect(result.updatedVacations).toHaveLength(0);
|
|
expect(deps.assertVacationStillChargeable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("propagates error when assertVacationStillChargeable throws for any item", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
deps.assertVacationStillChargeable
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockRejectedValueOnce(new Error("Insufficient balance for resource_2"));
|
|
|
|
await expect(
|
|
batchApproveVacations(db as never, { ids: ["vacation_1", "vacation_2"] }, deps),
|
|
).rejects.toThrow("Insufficient balance for resource_2");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// rejectVacation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("rejectVacation", () => {
|
|
function makeDb(vacation = baseVacation) {
|
|
return {
|
|
vacation: {
|
|
findUnique: vi.fn().mockResolvedValue(vacation),
|
|
update: vi.fn().mockResolvedValue({ ...vacation, status: "REJECTED" }),
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeDeps() {
|
|
return {
|
|
assertVacationRejectable: vi.fn(),
|
|
buildRejectedVacationUpdateData: vi.fn().mockReturnValue({ status: "REJECTED" }),
|
|
};
|
|
}
|
|
|
|
it("rejects a PENDING vacation and returns the updated record", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
|
|
const result = await rejectVacation(
|
|
db as never,
|
|
{ id: "vacation_1", rejectionReason: "No budget" },
|
|
deps,
|
|
);
|
|
|
|
expect(result.vacation.status).toBe("REJECTED");
|
|
expect(deps.assertVacationRejectable).toHaveBeenCalledWith("PENDING");
|
|
expect(deps.buildRejectedVacationUpdateData).toHaveBeenCalledWith({
|
|
rejectionReason: "No budget",
|
|
});
|
|
expect(db.vacation.update).toHaveBeenCalledWith({
|
|
where: { id: "vacation_1" },
|
|
data: { status: "REJECTED" },
|
|
});
|
|
});
|
|
|
|
it("rejects without a reason when none provided", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
|
|
await rejectVacation(db as never, { id: "vacation_1" }, deps);
|
|
|
|
expect(deps.buildRejectedVacationUpdateData).toHaveBeenCalledWith({
|
|
rejectionReason: undefined,
|
|
});
|
|
});
|
|
|
|
it("throws NOT_FOUND when vacation does not exist", async () => {
|
|
const db = {
|
|
vacation: { findUnique: vi.fn().mockResolvedValue(null) },
|
|
};
|
|
const deps = makeDeps();
|
|
|
|
await expect(rejectVacation(db as never, { id: "nonexistent" }, deps)).rejects.toMatchObject({
|
|
code: "NOT_FOUND",
|
|
});
|
|
|
|
expect(deps.assertVacationRejectable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("propagates error when assertVacationRejectable throws", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
deps.assertVacationRejectable.mockImplementation(() => {
|
|
throw new Error("Cannot reject already-approved vacation");
|
|
});
|
|
|
|
await expect(rejectVacation(db as never, { id: "vacation_1" }, deps)).rejects.toThrow(
|
|
"Cannot reject already-approved vacation",
|
|
);
|
|
|
|
expect(db.vacation.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// batchRejectVacations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("batchRejectVacations", () => {
|
|
const pendingVacations = [
|
|
{ id: "vacation_1", resourceId: "resource_1" },
|
|
{ id: "vacation_2", resourceId: "resource_2" },
|
|
];
|
|
|
|
function makeDb(vacations = pendingVacations) {
|
|
return {
|
|
vacation: {
|
|
findMany: vi.fn().mockResolvedValue(vacations),
|
|
updateMany: vi.fn().mockResolvedValue({ count: vacations.length }),
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeDeps() {
|
|
return {
|
|
buildRejectedVacationUpdateData: vi.fn().mockReturnValue({ status: "REJECTED" }),
|
|
};
|
|
}
|
|
|
|
it("rejects all PENDING vacations in the batch", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps();
|
|
|
|
const result = await batchRejectVacations(
|
|
db as never,
|
|
{ ids: ["vacation_1", "vacation_2"], rejectionReason: "Budget freeze" },
|
|
deps,
|
|
);
|
|
|
|
expect(result.rejected).toBe(2);
|
|
expect(result.vacations).toEqual(pendingVacations);
|
|
expect(db.vacation.updateMany).toHaveBeenCalledWith({
|
|
where: { id: { in: ["vacation_1", "vacation_2"] } },
|
|
data: { status: "REJECTED" },
|
|
});
|
|
expect(deps.buildRejectedVacationUpdateData).toHaveBeenCalledWith({
|
|
rejectionReason: "Budget freeze",
|
|
});
|
|
});
|
|
|
|
it("returns zero rejected when no PENDING vacations found", async () => {
|
|
const db = makeDb([]);
|
|
const deps = makeDeps();
|
|
|
|
const result = await batchRejectVacations(db as never, { ids: ["vacation_missing"] }, deps);
|
|
|
|
expect(result.rejected).toBe(0);
|
|
expect(result.vacations).toHaveLength(0);
|
|
expect(db.vacation.updateMany).toHaveBeenCalledWith({
|
|
where: { id: { in: [] } },
|
|
data: { status: "REJECTED" },
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// cancelVacation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("cancelVacation", () => {
|
|
function makeDb(
|
|
vacation = baseVacation,
|
|
resource: { userId: string } | null = { userId: "user_1" },
|
|
) {
|
|
return {
|
|
vacation: {
|
|
findUnique: vi.fn().mockResolvedValue(vacation),
|
|
update: vi.fn().mockResolvedValue({ ...vacation, status: "CANCELLED" }),
|
|
},
|
|
resource: {
|
|
findUnique: vi.fn().mockResolvedValue(resource),
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeDeps({
|
|
cancelable = true,
|
|
isManager = false,
|
|
canCancel = true,
|
|
}: {
|
|
cancelable?: boolean;
|
|
isManager?: boolean;
|
|
canCancel?: boolean;
|
|
} = {}) {
|
|
return {
|
|
assertVacationCancelable: vi.fn().mockImplementation(() => {
|
|
if (!cancelable) throw new Error("Cannot cancel in current status");
|
|
}),
|
|
isVacationManagerRole: vi.fn().mockReturnValue(isManager),
|
|
canActorCancelVacation: vi.fn().mockReturnValue(canCancel),
|
|
};
|
|
}
|
|
|
|
it("allows the requester to cancel their own vacation", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps({ isManager: false, canCancel: true });
|
|
|
|
const result = await cancelVacation(
|
|
db as never,
|
|
{ id: "vacation_1", actorId: "user_1", actorRole: "EMPLOYEE" },
|
|
deps,
|
|
);
|
|
|
|
expect(result.existingStatus).toBe("PENDING");
|
|
expect(result.vacation.status).toBe("CANCELLED");
|
|
expect(deps.assertVacationCancelable).toHaveBeenCalledWith("PENDING");
|
|
expect(db.vacation.update).toHaveBeenCalledWith({
|
|
where: { id: "vacation_1" },
|
|
data: { status: "CANCELLED" },
|
|
});
|
|
});
|
|
|
|
it("allows a manager to cancel any vacation without resource lookup", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps({ isManager: true, canCancel: true });
|
|
|
|
// Manager is NOT the requester — resource lookup should be skipped.
|
|
await cancelVacation(
|
|
db as never,
|
|
{ id: "vacation_1", actorId: "manager_99", actorRole: "MANAGER" },
|
|
deps,
|
|
);
|
|
|
|
// Resource lookup is skipped for manager role
|
|
expect(db.resource.findUnique).not.toHaveBeenCalled();
|
|
expect(db.vacation.update).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("fetches resource and allows cancel when actor is resource owner", async () => {
|
|
const vacation = { ...baseVacation, requestedById: "other_user" };
|
|
const db = makeDb(vacation, { userId: "actor_user" });
|
|
const deps = makeDeps({ isManager: false, canCancel: true });
|
|
|
|
await cancelVacation(
|
|
db as never,
|
|
{ id: "vacation_1", actorId: "actor_user", actorRole: "EMPLOYEE" },
|
|
deps,
|
|
);
|
|
|
|
expect(db.resource.findUnique).toHaveBeenCalledWith({
|
|
where: { id: vacation.resourceId },
|
|
select: { userId: true },
|
|
});
|
|
expect(deps.canActorCancelVacation).toHaveBeenCalledWith({
|
|
actorId: "actor_user",
|
|
actorRole: "EMPLOYEE",
|
|
requestedById: "other_user",
|
|
resourceUserId: "actor_user",
|
|
});
|
|
expect(db.vacation.update).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("throws NOT_FOUND when vacation does not exist", async () => {
|
|
const db = {
|
|
vacation: { findUnique: vi.fn().mockResolvedValue(null) },
|
|
resource: { findUnique: vi.fn() },
|
|
};
|
|
const deps = makeDeps();
|
|
|
|
await expect(
|
|
cancelVacation(
|
|
db as never,
|
|
{ id: "nonexistent", actorId: "user_1", actorRole: "EMPLOYEE" },
|
|
deps,
|
|
),
|
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
|
|
expect(deps.assertVacationCancelable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("propagates error when assertVacationCancelable throws", async () => {
|
|
const db = makeDb();
|
|
const deps = makeDeps({ cancelable: false });
|
|
|
|
await expect(
|
|
cancelVacation(
|
|
db as never,
|
|
{ id: "vacation_1", actorId: "user_1", actorRole: "EMPLOYEE" },
|
|
deps,
|
|
),
|
|
).rejects.toThrow("Cannot cancel in current status");
|
|
|
|
expect(db.vacation.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws FORBIDDEN when actor has no permission to cancel", async () => {
|
|
// Actor is not manager, not the requester
|
|
const vacation = { ...baseVacation, requestedById: "other_user" };
|
|
const db = makeDb(vacation, { userId: "yet_another_user" });
|
|
const deps = makeDeps({ isManager: false, canCancel: false });
|
|
|
|
await expect(
|
|
cancelVacation(
|
|
db as never,
|
|
{ id: "vacation_1", actorId: "intruder", actorRole: "EMPLOYEE" },
|
|
deps,
|
|
),
|
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
|
|
expect(db.vacation.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips resource lookup when actor is the requester (non-manager)", async () => {
|
|
// requestedById matches actorId — no resource fetch needed
|
|
const db = makeDb({ ...baseVacation, requestedById: "user_1" });
|
|
const deps = makeDeps({ isManager: false, canCancel: true });
|
|
|
|
await cancelVacation(
|
|
db as never,
|
|
{ id: "vacation_1", actorId: "user_1", actorRole: "EMPLOYEE" },
|
|
deps,
|
|
);
|
|
|
|
expect(db.resource.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
});
|