diff --git a/packages/application/src/__tests__/entitlement-operations.test.ts b/packages/application/src/__tests__/entitlement-operations.test.ts new file mode 100644 index 0000000..3d5da8b --- /dev/null +++ b/packages/application/src/__tests__/entitlement-operations.test.ts @@ -0,0 +1,707 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { setEntitlement, bulkSetEntitlements } from "../use-cases/entitlement/set-entitlement.js"; +import { syncEntitlement } from "../use-cases/entitlement/sync-entitlement.js"; +import { getEntitlementBalance } from "../use-cases/entitlement/read-entitlement-balance.js"; +import type { SyncEntitlementDeps } from "../use-cases/entitlement/sync-entitlement.js"; +import type { ReadEntitlementBalanceDeps } from "../use-cases/entitlement/read-entitlement-balance.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEntitlement(overrides: Record = {}) { + return { + id: "ent_1", + resourceId: "resource_1", + year: 2026, + entitledDays: 28, + carryoverDays: 0, + usedDays: 0, + pendingDays: 0, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + ...overrides, + }; +} + +function makeSyncDeps(overrides: Partial = {}): SyncEntitlementDeps { + return { + loadResourceHolidayContext: vi.fn().mockResolvedValue({ + countryCode: "DE", + countryName: "Germany", + federalState: null, + metroCityName: null, + calendarHolidayStrings: [], + publicHolidayStrings: [], + }), + countCalendarDaysInPeriod: vi.fn().mockReturnValue(0), + countVacationChargeableDays: vi.fn().mockReturnValue(0), + countVacationChargeableDaysFromSnapshot: vi.fn().mockReturnValue(null), + ...overrides, + }; +} + +function makeReadDeps( + overrides: Partial = {}, +): ReadEntitlementBalanceDeps { + return { + ...makeSyncDeps(), + buildVacationPreview: vi.fn().mockReturnValue({ + requestedDays: 0, + deductedDays: 0, + holidayDetails: [], + holidayContext: { + countryCode: null, + countryName: null, + federalState: null, + metroCityName: null, + sources: { hasCalendarHolidays: false, hasLegacyPublicHolidayEntries: false }, + }, + }), + parseVacationSnapshotDateList: vi.fn().mockReturnValue([]), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// setEntitlement +// --------------------------------------------------------------------------- + +describe("setEntitlement", () => { + it("creates a new entitlement when none exists for resource+year", async () => { + const created = makeEntitlement(); + const db = { + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue(created), + update: vi.fn(), + upsert: vi.fn(), + }, + vacation: { findMany: vi.fn() }, + systemSettings: { findUnique: vi.fn() }, + resource: { findMany: vi.fn() }, + }; + + const result = await setEntitlement(db as never, { + resourceId: "resource_1", + year: 2026, + entitledDays: 28, + }); + + expect(result.existing).toBeNull(); + expect(result.result.id).toBe("ent_1"); + expect(db.vacationEntitlement.create).toHaveBeenCalledOnce(); + expect(db.vacationEntitlement.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + resourceId: "resource_1", + year: 2026, + entitledDays: 28, + carryoverDays: 0, + usedDays: 0, + pendingDays: 0, + }), + }), + ); + expect(db.vacationEntitlement.update).not.toHaveBeenCalled(); + }); + + it("updates an existing entitlement and returns the previous snapshot as existing", async () => { + const existing = makeEntitlement({ entitledDays: 20 }); + const updated = makeEntitlement({ entitledDays: 30 }); + const db = { + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValue(existing), + update: vi.fn().mockResolvedValue(updated), + create: vi.fn(), + upsert: vi.fn(), + }, + vacation: { findMany: vi.fn() }, + systemSettings: { findUnique: vi.fn() }, + resource: { findMany: vi.fn() }, + }; + + const result = await setEntitlement(db as never, { + resourceId: "resource_1", + year: 2026, + entitledDays: 30, + }); + + expect(result.existing).toEqual(existing); + expect((result.result as typeof updated).entitledDays).toBe(30); + expect(db.vacationEntitlement.update).toHaveBeenCalledOnce(); + expect(db.vacationEntitlement.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "ent_1" }, + data: { entitledDays: 30 }, + }), + ); + expect(db.vacationEntitlement.create).not.toHaveBeenCalled(); + }); + + it("looks up entitlement using composite key resourceId_year", async () => { + const db = { + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue(makeEntitlement()), + update: vi.fn(), + upsert: vi.fn(), + }, + vacation: { findMany: vi.fn() }, + systemSettings: { findUnique: vi.fn() }, + resource: { findMany: vi.fn() }, + }; + + await setEntitlement(db as never, { resourceId: "resource_42", year: 2025, entitledDays: 25 }); + + expect(db.vacationEntitlement.findUnique).toHaveBeenCalledWith({ + where: { resourceId_year: { resourceId: "resource_42", year: 2025 } }, + }); + }); +}); + +// --------------------------------------------------------------------------- +// bulkSetEntitlements +// --------------------------------------------------------------------------- + +describe("bulkSetEntitlements", () => { + it("upserts entitlement for every active resource and returns correct count", async () => { + const resources = [{ id: "r1" }, { id: "r2" }, { id: "r3" }]; + const db = { + resource: { findMany: vi.fn().mockResolvedValue(resources) }, + vacationEntitlement: { upsert: vi.fn().mockResolvedValue(makeEntitlement()) }, + vacation: { findMany: vi.fn() }, + systemSettings: { findUnique: vi.fn() }, + }; + + const result = await bulkSetEntitlements(db as never, { year: 2026, entitledDays: 28 }); + + expect(result.updated).toBe(3); + expect(db.vacationEntitlement.upsert).toHaveBeenCalledTimes(3); + }); + + it("upserts only for the specified resourceIds when provided", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([{ id: "r1" }, { id: "r2" }]), + }, + vacationEntitlement: { upsert: vi.fn().mockResolvedValue(makeEntitlement()) }, + vacation: { findMany: vi.fn() }, + systemSettings: { findUnique: vi.fn() }, + }; + + const result = await bulkSetEntitlements(db as never, { + year: 2026, + entitledDays: 20, + resourceIds: ["r1", "r2"], + }); + + expect(result.updated).toBe(2); + // resource.findMany should have received the id filter + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: { in: ["r1", "r2"] } }), + }), + ); + }); + + it("returns 0 when no active resources exist", async () => { + const db = { + resource: { findMany: vi.fn().mockResolvedValue([]) }, + vacationEntitlement: { upsert: vi.fn() }, + vacation: { findMany: vi.fn() }, + systemSettings: { findUnique: vi.fn() }, + }; + + const result = await bulkSetEntitlements(db as never, { year: 2026, entitledDays: 28 }); + + expect(result.updated).toBe(0); + expect(db.vacationEntitlement.upsert).not.toHaveBeenCalled(); + }); + + it("passes correct create and update payloads to upsert", async () => { + const db = { + resource: { findMany: vi.fn().mockResolvedValue([{ id: "r99" }]) }, + vacationEntitlement: { upsert: vi.fn().mockResolvedValue(makeEntitlement()) }, + vacation: { findMany: vi.fn() }, + systemSettings: { findUnique: vi.fn() }, + }; + + await bulkSetEntitlements(db as never, { year: 2025, entitledDays: 15 }); + + expect(db.vacationEntitlement.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { resourceId_year: { resourceId: "r99", year: 2025 } }, + create: expect.objectContaining({ + resourceId: "r99", + year: 2025, + entitledDays: 15, + carryoverDays: 0, + usedDays: 0, + pendingDays: 0, + }), + update: { entitledDays: 15 }, + }), + ); + }); + + it("queries only active resources (isActive: true)", async () => { + const db = { + resource: { findMany: vi.fn().mockResolvedValue([]) }, + vacationEntitlement: { upsert: vi.fn() }, + vacation: { findMany: vi.fn() }, + systemSettings: { findUnique: vi.fn() }, + }; + + await bulkSetEntitlements(db as never, { year: 2026, entitledDays: 28 }); + + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ isActive: true }), + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// syncEntitlement +// --------------------------------------------------------------------------- + +describe("syncEntitlement", () => { + it("creates a new entitlement when none exists for the year (no previous year)", async () => { + const createdEnt = makeEntitlement({ usedDays: 0, pendingDays: 0 }); + const updatedEnt = makeEntitlement({ usedDays: 0, pendingDays: 0 }); + + const db = { + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValue(null), // no current year, no previous year + create: vi.fn().mockResolvedValue(createdEnt), + update: vi.fn().mockResolvedValue(updatedEnt), + }, + vacation: { findMany: vi.fn().mockResolvedValue([]) }, + systemSettings: { findUnique: vi.fn() }, + resource: { findMany: vi.fn() }, + }; + + const deps = makeSyncDeps(); + + const result = await syncEntitlement(db as never, "resource_1", 2026, 28, deps); + + expect(db.vacationEntitlement.create).toHaveBeenCalledOnce(); + expect(db.vacationEntitlement.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + resourceId: "resource_1", + year: 2026, + entitledDays: 28, + carryoverDays: 0, + }), + }), + ); + expect(result.usedDays).toBe(0); + expect(result.pendingDays).toBe(0); + }); + + it("carries over remaining days from previous year when creating new entitlement", async () => { + // Previous year has 28 entitled, 20 used, 0 pending => 8 carryover + const prevYearEnt = makeEntitlement({ + year: 2025, + entitledDays: 28, + usedDays: 20, + pendingDays: 0, + carryoverDays: 0, + }); + const newlyCreated = makeEntitlement({ + year: 2026, + entitledDays: 36, + carryoverDays: 8, + usedDays: 0, + pendingDays: 0, + }); + const finalUpdated = makeEntitlement({ + year: 2026, + entitledDays: 36, + carryoverDays: 8, + usedDays: 0, + pendingDays: 0, + }); + + const db = { + vacationEntitlement: { + // First call: lookup current year (2026) => null + // Second call: lookup prev year (2025) for getOrCreateEntitlement => prevYearEnt + // Third call: lookup prev year (2025) for the top-level previousYearEntitlement check => prevYearEnt + // (syncEntitlement also recurses into prev year, which will call findUnique again) + findUnique: vi + .fn() + // top-level: check year-1 (2025) for recursive sync + .mockResolvedValueOnce(prevYearEnt) + // recursive sync for 2025: check year-1 (2024) => null (no further recursion) + .mockResolvedValueOnce(null) + // getOrCreateEntitlement for 2025 current year => found + .mockResolvedValueOnce(prevYearEnt) + // getOrCreateEntitlement for 2025 prev year (2024) - not reached since already found + // getOrCreateEntitlement for 2026 current year => null (need to create) + .mockResolvedValueOnce(null) + // getOrCreateEntitlement for 2026 prev year (2025) => prevYearEnt + .mockResolvedValueOnce(prevYearEnt), + create: vi.fn().mockResolvedValue(newlyCreated), + update: vi.fn().mockResolvedValue(finalUpdated), + }, + vacation: { findMany: vi.fn().mockResolvedValue([]) }, + systemSettings: { findUnique: vi.fn() }, + resource: { findMany: vi.fn() }, + }; + + const deps = makeSyncDeps(); + + const result = await syncEntitlement(db as never, "resource_1", 2026, 28, deps); + + // Should have created for 2026 with carryover = 28 - 20 - 0 = 8 + const createCall = (db.vacationEntitlement.create as ReturnType).mock.calls.find( + (call) => call[0]?.data?.year === 2026, + ); + expect(createCall).toBeDefined(); + expect(createCall![0].data.carryoverDays).toBe(8); + expect(createCall![0].data.entitledDays).toBe(36); // 28 + 8 + }); + + it("throws INTERNAL_SERVER_ERROR when cycle detection triggers (visitedYears already contains year)", async () => { + const db = { + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn(), + update: vi.fn(), + }, + vacation: { findMany: vi.fn() }, + systemSettings: { findUnique: vi.fn() }, + resource: { findMany: vi.fn() }, + }; + + const deps = makeSyncDeps(); + const visitedWithCycle = new Set([2026]); + + await expect( + syncEntitlement(db as never, "resource_1", 2026, 28, deps, visitedWithCycle), + ).rejects.toThrow(TRPCError); + + await expect( + syncEntitlement(db as never, "resource_1", 2026, 28, deps, new Set([2026])), + ).rejects.toMatchObject({ + code: "INTERNAL_SERVER_ERROR", + message: expect.stringContaining("2026"), + }); + }); + + it("does not call create when entitlement already exists for the year", async () => { + const existingEnt = makeEntitlement({ entitledDays: 28, carryoverDays: 0 }); + const updatedEnt = makeEntitlement({ + entitledDays: 28, + carryoverDays: 0, + usedDays: 0, + pendingDays: 0, + }); + + const db = { + vacationEntitlement: { + // top-level: no prev year + findUnique: vi + .fn() + .mockResolvedValueOnce(null) // year-1 check (2025) for recursive sync — no prev year + // getOrCreateEntitlement for 2026 => found + .mockResolvedValueOnce(existingEnt), + create: vi.fn(), + update: vi.fn().mockResolvedValue(updatedEnt), + }, + vacation: { findMany: vi.fn().mockResolvedValue([]) }, + systemSettings: { findUnique: vi.fn() }, + resource: { findMany: vi.fn() }, + }; + + const deps = makeSyncDeps(); + + await syncEntitlement(db as never, "resource_1", 2026, 28, deps); + + expect(db.vacationEntitlement.create).not.toHaveBeenCalled(); + }); + + it("accumulates usedDays from APPROVED vacations and pendingDays from PENDING vacations", async () => { + const { VacationStatus } = await import("@capakraken/db"); + const existingEnt = makeEntitlement({ entitledDays: 28, carryoverDays: 0 }); + const updatedEnt = makeEntitlement({ + entitledDays: 28, + carryoverDays: 0, + usedDays: 5, + pendingDays: 3, + }); + + const approvedVacation = { + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-05"), + status: VacationStatus.APPROVED, + isHalfDay: false, + deductedDays: 5, + holidayCountryCode: null, + holidayFederalState: null, + holidayMetroCityName: null, + holidayCalendarDates: null, + holidayLegacyPublicHolidayDates: null, + }; + const pendingVacation = { + startDate: new Date("2026-07-01"), + endDate: new Date("2026-07-03"), + status: VacationStatus.PENDING, + isHalfDay: false, + deductedDays: 3, + holidayCountryCode: null, + holidayFederalState: null, + holidayMetroCityName: null, + holidayCalendarDates: null, + holidayLegacyPublicHolidayDates: null, + }; + + const db = { + vacationEntitlement: { + findUnique: vi + .fn() + .mockResolvedValueOnce(null) // prev year check + .mockResolvedValueOnce(existingEnt), // getOrCreateEntitlement + create: vi.fn(), + update: vi.fn().mockResolvedValue(updatedEnt), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([approvedVacation, pendingVacation]), + }, + systemSettings: { findUnique: vi.fn() }, + resource: { findMany: vi.fn() }, + }; + + const deps = makeSyncDeps({ + // snapshot returns null => falls back to countVacationChargeableDays + countVacationChargeableDaysFromSnapshot: vi.fn().mockReturnValue(null), + countVacationChargeableDays: vi + .fn() + .mockReturnValueOnce(5) // approved vacation + .mockReturnValueOnce(3), // pending vacation + }); + + await syncEntitlement(db as never, "resource_1", 2026, 28, deps); + + const finalUpdate = (db.vacationEntitlement.update as ReturnType).mock.calls.at( + -1, + ); + expect(finalUpdate![0].data.usedDays).toBe(5); + expect(finalUpdate![0].data.pendingDays).toBe(3); + }); + + it("uses persisted snapshot days when countVacationChargeableDaysFromSnapshot returns a non-null value", async () => { + const { VacationStatus } = await import("@capakraken/db"); + const existingEnt = makeEntitlement({ entitledDays: 28, carryoverDays: 0 }); + const updatedEnt = makeEntitlement({ entitledDays: 28, usedDays: 7, pendingDays: 0 }); + + const approvedVacation = { + startDate: new Date("2026-08-01"), + endDate: new Date("2026-08-07"), + status: VacationStatus.APPROVED, + isHalfDay: false, + deductedDays: 7, + holidayCountryCode: "DE", + holidayFederalState: null, + holidayMetroCityName: null, + holidayCalendarDates: null, + holidayLegacyPublicHolidayDates: null, + }; + + const db = { + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(existingEnt), + create: vi.fn(), + update: vi.fn().mockResolvedValue(updatedEnt), + }, + vacation: { findMany: vi.fn().mockResolvedValue([approvedVacation]) }, + systemSettings: { findUnique: vi.fn() }, + resource: { findMany: vi.fn() }, + }; + + const chargeableDaysFromSnapshot = vi.fn().mockReturnValue(7); + const chargeableDays = vi.fn(); + + const deps = makeSyncDeps({ + countVacationChargeableDaysFromSnapshot: chargeableDaysFromSnapshot, + countVacationChargeableDays: chargeableDays, + }); + + await syncEntitlement(db as never, "resource_1", 2026, 28, deps); + + expect(chargeableDaysFromSnapshot).toHaveBeenCalled(); + // Because snapshot returned a value, the live calculator should NOT be called + expect(chargeableDays).not.toHaveBeenCalled(); + + const finalUpdate = (db.vacationEntitlement.update as ReturnType).mock.calls.at( + -1, + ); + expect(finalUpdate![0].data.usedDays).toBe(7); + }); +}); + +// --------------------------------------------------------------------------- +// getEntitlementBalance +// --------------------------------------------------------------------------- + +describe("getEntitlementBalance", () => { + it("returns correct remainingDays = entitled + carryover - used - pending", async () => { + // entitledDays=30 (28 base + 2 carryover), usedDays=10, pendingDays=5 => remaining=15 + const syncedEnt = makeEntitlement({ + entitledDays: 30, + carryoverDays: 2, + usedDays: 10, + pendingDays: 5, + }); + + const db = { + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }), + }, + vacationEntitlement: { + findUnique: vi + .fn() + .mockResolvedValueOnce(null) // prev year check in syncEntitlement + .mockResolvedValueOnce(syncedEnt), // getOrCreateEntitlement finds existing + create: vi.fn(), + update: vi.fn().mockResolvedValue(syncedEnt), + }, + vacation: { findMany: vi.fn().mockResolvedValue([]) }, + resource: { findMany: vi.fn() }, + }; + + const deps = makeReadDeps(); + + const result = await getEntitlementBalance( + db as never, + { resourceId: "resource_1", year: 2026 }, + deps, + ); + + expect(result.entitledDays).toBe(30); + expect(result.carryoverDays).toBe(2); + expect(result.usedDays).toBe(10); + expect(result.pendingDays).toBe(5); + expect(result.remainingDays).toBe(15); + expect(result.year).toBe(2026); + expect(result.resourceId).toBe("resource_1"); + }); + + it("clamps remainingDays to 0 when used+pending exceed entitled", async () => { + const syncedEnt = makeEntitlement({ + entitledDays: 10, + carryoverDays: 0, + usedDays: 8, + pendingDays: 5, // 8+5=13 > 10 + }); + + const db = { + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }), + }, + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(syncedEnt), + create: vi.fn(), + update: vi.fn().mockResolvedValue(syncedEnt), + }, + vacation: { findMany: vi.fn().mockResolvedValue([]) }, + resource: { findMany: vi.fn() }, + }; + + const deps = makeReadDeps(); + + const result = await getEntitlementBalance( + db as never, + { resourceId: "resource_1", year: 2026 }, + deps, + ); + + expect(result.remainingDays).toBe(0); + }); + + it("falls back to 28 default vacation days when systemSettings is null", async () => { + const syncedEnt = makeEntitlement({ + entitledDays: 28, + carryoverDays: 0, + usedDays: 0, + pendingDays: 0, + }); + + const db = { + systemSettings: { + findUnique: vi.fn().mockResolvedValue(null), // no settings record + }, + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null), // force creation path + create: vi.fn().mockResolvedValue(syncedEnt), + update: vi.fn().mockResolvedValue(syncedEnt), + }, + vacation: { findMany: vi.fn().mockResolvedValue([]) }, + resource: { findMany: vi.fn() }, + }; + + const deps = makeReadDeps(); + + // Should not throw — falls back to 28 default days + const result = await getEntitlementBalance( + db as never, + { resourceId: "resource_1", year: 2026 }, + deps, + ); + + expect(result.entitledDays).toBe(28); + }); + + it("counts sick days separately from annual leave balance", async () => { + const { VacationStatus } = await import("@capakraken/db"); + const syncedEnt = makeEntitlement({ + entitledDays: 28, + carryoverDays: 0, + usedDays: 0, + pendingDays: 0, + }); + const sickVacation = { + startDate: new Date("2026-03-01"), + endDate: new Date("2026-03-03"), + isHalfDay: false, + }; + + const db = { + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }), + }, + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(syncedEnt), + create: vi.fn(), + update: vi.fn().mockResolvedValue(syncedEnt), + }, + vacation: { + // First call: annual leave vacations for syncEntitlement => empty + // Second call: sick vacations for readBalanceSnapshot + findMany: vi + .fn() + .mockResolvedValueOnce([]) // annual balance vacations + .mockResolvedValueOnce([sickVacation]), // sick vacations + }, + resource: { findMany: vi.fn() }, + }; + + const countCalendarDaysInPeriod = vi.fn().mockReturnValue(3); + const deps = makeReadDeps({ countCalendarDaysInPeriod }); + + const result = await getEntitlementBalance( + db as never, + { resourceId: "resource_1", year: 2026 }, + deps, + ); + + expect(result.sickDays).toBe(3); + // Sick days should not reduce remaining annual leave + expect(result.remainingDays).toBe(28); + }); +}); diff --git a/packages/application/src/__tests__/vacation-operations.test.ts b/packages/application/src/__tests__/vacation-operations.test.ts new file mode 100644 index 0000000..6d5ec19 --- /dev/null +++ b/packages/application/src/__tests__/vacation-operations.test.ts @@ -0,0 +1,518 @@ +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(); + }); +});