feat(api): explain holiday-aware vacation deductions

This commit is contained in:
2026-03-31 22:42:00 +02:00
parent 8acfbf8c3e
commit cb363ca5b3
7 changed files with 857 additions and 99 deletions
@@ -110,6 +110,13 @@ const sampleVacation = {
note: "Summer vacation",
isHalfDay: false,
halfDayPart: null,
deductedDays: null,
holidayCountryCode: null,
holidayCountryName: null,
holidayFederalState: null,
holidayMetroCityName: null,
holidayCalendarDates: null,
holidayLegacyPublicHolidayDates: null,
requestedById: "user_1",
approvedById: null,
approvedAt: null,
@@ -636,6 +643,70 @@ describe("vacation router", () => {
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("keeps mixed vacation ranges chargeable when only some days are holidays", async () => {
const createdVacation = {
...sampleVacation,
startDate: new Date("2026-11-14T00:00:00.000Z"),
endDate: new Date("2026-11-16T00:00:00.000Z"),
};
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
countryId: "country_de",
metroCityId: "city_muc",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_muc",
name: "Muenchen lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-11-15T00:00:00.000Z"),
name: "Lokaler Stadtfeiertag",
isRecurringAnnual: true,
},
],
},
]),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdVacation),
},
});
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-11-14T00:00:00.000Z"),
endDate: new Date("2026-11-16T00:00:00.000Z"),
});
expect(result.effectiveDays).toBe(2);
expect(db.vacation.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
deductedDays: 2,
holidayCountryCode: "DE",
holidayCountryName: "Germany",
holidayFederalState: "BY",
holidayMetroCityName: "Muenchen",
holidayCalendarDates: ["2026-11-15"],
holidayLegacyPublicHolidayDates: [],
}),
}));
});
});
describe("previewRequest", () => {
@@ -833,6 +904,9 @@ describe("vacation router", () => {
data: expect.objectContaining({
status: VacationStatus.APPROVED,
rejectionReason: null,
deductedDays: 5,
holidayCalendarDates: [],
holidayLegacyPublicHolidayDates: [],
}),
}),
);
@@ -865,6 +939,53 @@ describe("vacation router", () => {
);
});
it("rejects approval when the current holiday context reduces the request to zero days", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
countryId: "country_de",
metroCityId: "city_muc",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_muc",
name: "Muenchen lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-11-15T00:00:00.000Z"),
name: "Lokaler Stadtfeiertag",
isRecurringAnnual: true,
},
],
},
]),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
startDate: new Date("2026-11-15T00:00:00.000Z"),
endDate: new Date("2026-11-15T00:00:00.000Z"),
}),
update: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
"Vacation no longer deducts any vacation days for the current holiday calendar and cannot be approved",
);
expect(db.vacation.update).not.toHaveBeenCalled();
});
it("forbids regular users from approving", async () => {
const db = {};
const caller = createProtectedCaller(db);
@@ -974,11 +1095,33 @@ describe("vacation router", () => {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
{ id: "vac_2", resourceId: "res_2" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { status?: string; type?: string } } = {}) => {
if (where?.type === VacationType.PUBLIC_HOLIDAY) {
return [];
}
if (where?.status === VacationStatus.PENDING) {
return [
{
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-05T00:00:00.000Z"),
isHalfDay: false,
},
{
id: "vac_2",
resourceId: "res_2",
type: VacationType.OTHER,
startDate: new Date("2026-06-08T00:00:00.000Z"),
endDate: new Date("2026-06-08T00:00:00.000Z"),
isHalfDay: false,
},
];
}
return [];
}),
update: vi.fn().mockResolvedValue(sampleVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
@@ -989,11 +1132,12 @@ describe("vacation router", () => {
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
expect(result.approved).toBe(2);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ["vac_1", "vac_2"] } },
where: { id: "vac_1" },
data: expect.objectContaining({
status: VacationStatus.APPROVED,
deductedDays: 5,
}),
}),
);
@@ -1005,10 +1149,25 @@ describe("vacation router", () => {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { status?: string; type?: string } } = {}) => {
if (where?.type === VacationType.PUBLIC_HOLIDAY) {
return [];
}
if (where?.status === VacationStatus.PENDING) {
return [
{
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-01T00:00:00.000Z"),
isHalfDay: false,
},
];
}
return [];
}),
update: vi.fn().mockResolvedValue(sampleVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),