From 8655cb5bfa8397992114187dc88cc1433c36ac0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 14:26:47 +0200 Subject: [PATCH] test(api): cover timeline fallback paths --- .../src/__tests__/timeline-allocation.test.ts | 111 +++++++++++++++ .../api/src/__tests__/timeline-router.test.ts | 130 ++++++++++++++++++ 2 files changed, 241 insertions(+) diff --git a/packages/api/src/__tests__/timeline-allocation.test.ts b/packages/api/src/__tests__/timeline-allocation.test.ts index 9bdcc11..2daa64f 100644 --- a/packages/api/src/__tests__/timeline-allocation.test.ts +++ b/packages/api/src/__tests__/timeline-allocation.test.ts @@ -222,6 +222,117 @@ describe("timeline allocation entry resolution", () => { ); }); + it("falls back to default rules when calculationRule and vacation tables are missing", async () => { + const existingAssignment = { + id: "assignment_legacy_1", + demandRequirementId: null, + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-03-16"), + endDate: new Date("2026-03-20"), + hoursPerDay: 4, + percentage: 50, + role: "Compositor", + roleId: "role_comp", + dailyCostCents: 20000, + status: AllocationStatus.PROPOSED, + metadata: {}, + createdAt: new Date("2026-03-13"), + updatedAt: new Date("2026-03-13"), + resource: { + id: "resource_1", + displayName: "Alice", + eid: "E-001", + lcrCents: 5000, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, + }, + }, + project: { id: "project_1", name: "Project One", shortCode: "PRJ" }, + roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" }, + demandRequirement: null, + }; + const missingVacationTableError = { + code: "P2021", + message: "The table `public.vacation` does not exist in the current database.", + meta: { table: "public.vacation" }, + }; + const missingCalculationRuleTableError = { + code: "P2021", + message: "The table `public.calculation_rule` does not exist in the current database.", + meta: { table: "public.calculation_rule" }, + }; + + const db = { + allocation: { + findUnique: vi.fn().mockResolvedValue(null), + }, + demandRequirement: { + findUnique: vi.fn().mockResolvedValue(null), + }, + assignment: { + findUnique: vi.fn().mockResolvedValue(existingAssignment), + update: vi.fn().mockImplementation(async ({ data }: { data: Record }) => ({ + ...existingAssignment, + ...data, + metadata: data.metadata ?? existingAssignment.metadata, + updatedAt: new Date("2026-03-14"), + })), + }, + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "resource_1", + lcrCents: 5000, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, + }, + }), + }, + vacation: { + findMany: vi.fn().mockRejectedValue(missingVacationTableError), + }, + calculationRule: { + findMany: vi.fn().mockRejectedValue(missingCalculationRuleTableError), + }, + auditLog: { + create: vi.fn().mockResolvedValue({}), + }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + }; + + const caller = createManagerCaller(db); + const result = await caller.updateAllocationInline({ + allocationId: "assignment_legacy_1", + hoursPerDay: 6, + endDate: new Date("2026-03-21"), + includeSaturday: true, + }); + + expect(result.id).toBe("assignment_legacy_1"); + expect(db.vacation.findMany).toHaveBeenCalledTimes(1); + expect(db.calculationRule.findMany).toHaveBeenCalledTimes(1); + expect(db.assignment.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + dailyCostCents: expect.any(Number), + metadata: expect.objectContaining({ includeSaturday: true }), + }), + }), + ); + }); + it("updates an explicit demand row through updateAllocationInline", async () => { const existingDemand = { id: "demand_1", diff --git a/packages/api/src/__tests__/timeline-router.test.ts b/packages/api/src/__tests__/timeline-router.test.ts index 61ab2e2..f82c20e 100644 --- a/packages/api/src/__tests__/timeline-router.test.ts +++ b/packages/api/src/__tests__/timeline-router.test.ts @@ -221,6 +221,38 @@ describe("timeline router detail views", () => { expect(assignmentFindMany).not.toHaveBeenCalled(); }); + it("returns empty self-service holiday overlays when the caller has no linked resource", async () => { + const demandFindMany = vi.fn(); + const assignmentFindMany = vi.fn(); + const resourceFindMany = vi.fn(); + + const caller = createProtectedCaller({ + demandRequirement: { + findMany: demandFindMany, + }, + assignment: { + findMany: assignmentFindMany, + }, + resource: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: resourceFindMany, + }, + project: { + findMany: vi.fn(), + }, + }); + + const result = await caller.getMyHolidayOverlays({ + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-09T00:00:00.000Z"), + }); + + expect(result).toEqual([]); + expect(demandFindMany).not.toHaveBeenCalled(); + expect(assignmentFindMany).not.toHaveBeenCalled(); + expect(resourceFindMany).not.toHaveBeenCalled(); + }); + it("returns a detailed timeline entries view with holiday overlays and summary", async () => { const caller = createAdminCaller({ demandRequirement: { @@ -635,4 +667,102 @@ describe("timeline router detail views", () => { caller.getProjectContextDetail({ projectId: "project_ctx" }), ).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" })); }); + + it("returns budget status details for a project", async () => { + vi.mocked(listAssignmentBookings).mockResolvedValue([ + { + id: "asg_confirmed", + projectId: "project_budget", + resourceId: "res_1", + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-09T00:00:00.000Z"), + hoursPerDay: 8, + dailyCostCents: 10000, + status: "CONFIRMED", + project: null, + resource: null, + }, + { + id: "asg_proposed", + projectId: "project_budget", + resourceId: "res_2", + startDate: new Date("2026-01-12T00:00:00.000Z"), + endDate: new Date("2026-01-16T00:00:00.000Z"), + hoursPerDay: 4, + dailyCostCents: 5000, + status: "PROPOSED", + project: null, + resource: null, + }, + ] as never); + + const projectFindUnique = vi.fn().mockResolvedValue({ + id: "project_budget", + name: "Gelddruckmaschine", + shortCode: "GDM", + budgetCents: 100000, + winProbability: 80, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-31T00:00:00.000Z"), + }); + + const caller = createAdminCaller({ + project: { + findUnique: projectFindUnique, + }, + }); + + const result = await caller.getBudgetStatus({ projectId: "project_budget" }); + + expect(projectFindUnique).toHaveBeenCalledWith({ + where: { id: "project_budget" }, + select: { + id: true, + name: true, + shortCode: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, + }, + }); + expect(listAssignmentBookings).toHaveBeenCalledWith(expect.anything(), { + projectIds: ["project_budget"], + }); + expect(result).toEqual( + expect.objectContaining({ + projectName: "Gelddruckmaschine", + projectCode: "GDM", + totalAllocations: 2, + budgetCents: 100000, + confirmedCents: 50000, + proposedCents: 25000, + allocatedCents: 75000, + remainingCents: 25000, + winProbabilityWeightedCents: 60000, + }), + ); + expect(result.utilizationPercent).toBeCloseTo(75, 5); + expect(result.warnings).toEqual([ + expect.objectContaining({ + level: "info", + code: "BUDGET_INFO", + }), + ]); + }); + + it("returns NOT_FOUND for budget status when the project does not exist", async () => { + const caller = createAdminCaller({ + project: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }); + + await expect( + caller.getBudgetStatus({ projectId: "project_missing" }), + ).rejects.toThrow(expect.objectContaining({ + code: "NOT_FOUND", + message: "Project not found", + })); + }); });