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>
This commit is contained in:
@@ -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<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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user