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(); }); });