feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
@@ -9,12 +9,30 @@ vi.mock("../sse/event-bus.js", () => ({
emitVacationUpdated: vi.fn(),
emitVacationDeleted: vi.fn(),
emitNotificationCreated: vi.fn(),
emitTaskAssigned: vi.fn(),
}));
vi.mock("../lib/email.js", () => ({
sendEmail: vi.fn(),
}));
vi.mock("../lib/create-notification.js", () => ({
createNotification: vi.fn().mockResolvedValue("notif_1"),
}));
vi.mock("../lib/vacation-conflicts.js", () => ({
checkVacationConflicts: vi.fn().mockResolvedValue({ warnings: [] }),
checkBatchVacationConflicts: vi.fn().mockResolvedValue(new Map()),
}));
vi.mock("../lib/webhook-dispatcher.js", () => ({
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/audit.js", () => ({
createAuditEntry: vi.fn().mockResolvedValue(undefined),
}));
const createCaller = createCallerFactory(vacationRouter);
function createProtectedCaller(db: Record<string, unknown>) {
@@ -91,6 +109,56 @@ const sampleVacation = {
approvedBy: null,
};
function createVacationDb(overrides: Record<string, unknown> = {}) {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
},
resource: {
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.userId ? { userId: "user_1" } : {}),
...(select.displayName ? { displayName: "Alice" } : {}),
...(select.user ? { user: null } : {}),
...(select.federalState ? { federalState: "BY" } : {}),
...(select.country ? { country: { code: "DE", name: "Germany" } } : {}),
...(select.metroCity ? { metroCity: null } : {}),
};
}),
count: vi.fn().mockResolvedValue(0),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(sampleVacation),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(sampleVacation),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
notification: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
};
return {
...db,
...overrides,
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
resource: { ...db.resource, ...(overrides.resource as Record<string, unknown> | undefined) },
vacation: { ...db.vacation, ...(overrides.vacation as Record<string, unknown> | undefined) },
notification: {
...db.notification,
...(overrides.notification as Record<string, unknown> | undefined),
},
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
};
}
describe("vacation router", () => {
describe("list", () => {
it("returns vacations with default filters", async () => {
@@ -199,18 +267,11 @@ describe("vacation router", () => {
status: VacationStatus.PENDING,
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
},
const db = createVacationDb({
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
});
const caller = createProtectedCaller(db);
const result = await caller.create({
@@ -239,15 +300,14 @@ describe("vacation router", () => {
approvedById: "mgr_1",
};
const db = {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.create({
@@ -269,17 +329,11 @@ describe("vacation router", () => {
});
it("rejects overlapping vacation", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
},
const db = createVacationDb({
vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
},
};
});
const caller = createProtectedCaller(db);
await expect(
@@ -293,10 +347,10 @@ describe("vacation router", () => {
});
it("rejects when end date is before start date", async () => {
const db = {
const db = createVacationDb({
user: { findUnique: vi.fn() },
vacation: { findFirst: vi.fn() },
};
});
const caller = createProtectedCaller(db);
await expect(
@@ -316,18 +370,11 @@ describe("vacation router", () => {
halfDayPart: "MORNING",
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
},
const db = createVacationDb({
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
});
const caller = createProtectedCaller(db);
const result = await caller.create({
@@ -349,6 +396,235 @@ describe("vacation router", () => {
}),
);
});
it("rejects multi-day half-day vacations", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-02"),
isHalfDay: true,
halfDayPart: "MORNING",
})).rejects.toThrow();
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("rejects half-day vacations without a half-day part", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-01"),
isHalfDay: true,
})).rejects.toThrow();
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("rejects half-day parts on full-day vacations", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-01"),
halfDayPart: "AFTERNOON",
})).rejects.toThrow();
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("rejects leave requests that only hit public holidays", async () => {
const db = createVacationDb({
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
})).rejects.toThrow("does not deduct any vacation days");
expect(db.vacation.create).not.toHaveBeenCalled();
});
});
describe("previewRequest", () => {
it("shows public holidays as non-deductible leave days", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Augsburg" },
}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
const result = await caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2028-08-08T00:00:00.000Z"),
endDate: new Date("2028-08-08T00:00:00.000Z"),
});
expect(result.requestedDays).toBe(1);
expect(result.effectiveDays).toBe(0);
expect(result.deductedDays).toBe(0);
expect(result.publicHolidayDates).toContain("2028-08-08");
expect(result.holidayContext).toEqual({
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Augsburg",
sources: {
hasCalendarHolidays: true,
hasLegacyPublicHolidayEntries: false,
},
});
expect(result.holidayDetails).toContainEqual({
date: "2028-08-08",
source: "CALENDAR",
});
});
it("uses custom city holiday calendars for non-deductible leave days", async () => {
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: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
const result = await caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-11-15T00:00:00.000Z"),
endDate: new Date("2026-11-15T00:00:00.000Z"),
});
expect(result.requestedDays).toBe(1);
expect(result.effectiveDays).toBe(0);
expect(result.publicHolidayDates).toContain("2026-11-15");
expect(result.holidayContext.countryName).toBe("Germany");
expect(result.holidayContext.metroCityName).toBe("Muenchen");
expect(db.holidayCalendar.findMany).toHaveBeenCalled();
});
it("marks legacy public holiday entries as a separate preview source", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
federalState: "HH",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Hamburg" },
}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-01T00:00:00.000Z"),
},
]),
},
});
const caller = createProtectedCaller(db);
const result = await caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-01T00:00:00.000Z"),
});
expect(result.publicHolidayDates).toContain("2026-05-01");
expect(result.holidayContext.sources).toEqual({
hasCalendarHolidays: true,
hasLegacyPublicHolidayEntries: true,
});
expect(result.holidayDetails).toContainEqual({
date: "2026-05-01",
source: "CALENDAR_AND_LEGACY",
});
});
it("rejects multi-day half-day previews", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-02"),
isHalfDay: true,
})).rejects.toThrow();
});
});
describe("create manual public holiday handling", () => {
it("rejects manual public holiday creation requests", async () => {
const db = createVacationDb();
const caller = createManagerCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.PUBLIC_HOLIDAY,
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-01T00:00:00.000Z"),
})).rejects.toThrow("Public holidays must be managed via Holiday Calendars or the legacy holiday import");
expect(db.vacation.create).not.toHaveBeenCalled();
});
});
describe("approve", () => {
@@ -359,7 +635,7 @@ describe("vacation router", () => {
approvedById: "mgr_1",
};
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
@@ -370,7 +646,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
@@ -388,25 +664,25 @@ describe("vacation router", () => {
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("rejects approving an already APPROVED vacation", async () => {
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
};
});
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
@@ -429,7 +705,7 @@ describe("vacation router", () => {
rejectionReason: "Team conflict",
};
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
@@ -437,7 +713,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
@@ -454,14 +730,14 @@ describe("vacation router", () => {
});
it("throws when rejecting non-PENDING vacation", async () => {
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
};
});
const caller = createManagerCaller(db);
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
@@ -477,15 +753,12 @@ describe("vacation router", () => {
status: VacationStatus.CANCELLED,
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
};
});
const caller = createProtectedCaller(db);
const result = await caller.cancel({ id: "vac_1" });
@@ -494,25 +767,25 @@ describe("vacation router", () => {
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("throws when already cancelled", async () => {
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.CANCELLED,
}),
},
};
});
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
@@ -521,7 +794,7 @@ describe("vacation router", () => {
describe("batchApprove", () => {
it("approves multiple pending vacations", async () => {
const db = {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
@@ -535,7 +808,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
@@ -552,7 +825,7 @@ describe("vacation router", () => {
});
it("only approves PENDING vacations from the requested set", async () => {
const db = {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
@@ -565,7 +838,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
@@ -581,7 +854,10 @@ describe("vacation router", () => {
describe("batchReject", () => {
it("rejects multiple pending vacations with optional reason", async () => {
const db = {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
@@ -591,7 +867,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.batchReject({
@@ -731,8 +1007,8 @@ describe("vacation router", () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{ id: "res_1" },
{ id: "res_2" },
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
{ id: "res_2", federalState: "BY", country: { code: "DE" }, metroCity: null },
]),
},
user: {
@@ -759,7 +1035,9 @@ describe("vacation router", () => {
it("skips already existing holidays", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]),
findMany: vi.fn().mockResolvedValue([
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),