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