Files
CapaKraken/packages/application/src/__tests__/entitlement-operations.test.ts
T
Hartmut 800a4c5fff test(application): add 40 use-case tests for vacation and entitlement operations
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>
2026-04-10 15:51:23 +02:00

708 lines
24 KiB
TypeScript

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<string, unknown> = {}) {
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> = {}): 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> = {},
): 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
});
});