import { SystemRole } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@capakraken/application", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, listAssignmentBookings: vi.fn(), }; }); vi.mock("../lib/anonymization.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getAnonymizationDirectory: vi.fn().mockResolvedValue(null), }; }); import { listAssignmentBookings } from "@capakraken/application"; import { timelineRouter } from "../router/timeline.js"; import { createCallerFactory } from "../trpc.js"; const createCaller = createCallerFactory(timelineRouter); function createAdminCaller(db: Record) { return createCaller({ session: { user: { email: "admin@example.com", name: "Admin", image: null }, expires: "2026-03-29T00:00:00.000Z", }, db: db as never, dbUser: { id: "user_admin", systemRole: SystemRole.ADMIN, permissionOverrides: null, }, roleDefaults: null, }); } function createProtectedCaller(db: Record) { return createCaller({ session: { user: { email: "user@example.com", name: "User", image: null }, expires: "2026-03-29T00:00:00.000Z", }, db: db as never, dbUser: { id: "user_1", systemRole: SystemRole.USER, permissionOverrides: null, }, roleDefaults: null, }); } describe("timeline router detail views", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns a self-service timeline view scoped to the caller's linked resource", async () => { const demandFindMany = vi.fn(); const assignmentFindMany = vi.fn().mockResolvedValue([ { id: "asg_self", projectId: "project_1", resourceId: "res_self", role: "Artist", hoursPerDay: 6, startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-09T00:00:00.000Z"), status: "CONFIRMED", metadata: null, resource: { id: "res_self", displayName: "Alice", eid: "EMP-SELF", chapter: "Delivery", lcrCents: 10000, }, project: { id: "project_1", name: "Gelddruckmaschine", shortCode: "GDM", clientId: "client_1", budgetCents: 100000, winProbability: 100, status: "ACTIVE", startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-16T00:00:00.000Z"), staffingReqs: null, responsiblePerson: "Larissa", color: "#fff", orderType: "CHARGEABLE", }, roleEntity: null, }, ]); const caller = createProtectedCaller({ demandRequirement: { findMany: demandFindMany, }, assignment: { findMany: assignmentFindMany, }, resource: { findFirst: vi.fn().mockResolvedValue({ id: "res_self" }), }, project: { findMany: vi.fn(), }, }); const result = await caller.getMyEntriesView({ startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-09T00:00:00.000Z"), resourceIds: ["res_other"], chapters: ["Finance"], eids: ["EMP-OTHER"], countryCodes: ["US"], }); expect(result.assignments).toHaveLength(1); expect(result.assignments[0]?.resourceId).toBe("res_self"); expect(demandFindMany).not.toHaveBeenCalled(); expect(assignmentFindMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ resourceId: { in: ["res_self"] }, }), })); }); it("returns self-service holiday overlays for the caller's linked resource", async () => { const demandFindMany = vi.fn(); const assignmentFindMany = vi.fn().mockResolvedValue([]); const resourceFindMany = vi.fn().mockResolvedValue([ { id: "res_self", countryId: "country_de", federalState: "BY", metroCityId: "city_munich", country: { code: "DE" }, metroCity: { name: "Muenchen" }, }, ]); const caller = createProtectedCaller({ demandRequirement: { findMany: demandFindMany, }, assignment: { findMany: assignmentFindMany, }, resource: { findFirst: vi.fn().mockResolvedValue({ id: "res_self" }), 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"), resourceIds: ["res_other"], chapters: ["Finance"], eids: ["EMP-OTHER"], countryCodes: ["US"], }); expect(result).toEqual([ expect.objectContaining({ resourceId: "res_self", note: "Heilige Drei Könige", scope: "STATE", }), ]); expect(demandFindMany).not.toHaveBeenCalled(); expect(assignmentFindMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ resourceId: { in: ["res_self"] }, }), })); expect(resourceFindMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: ["res_self"] } }, })); }); it("returns empty self-service timeline data when the caller has no linked resource", async () => { const demandFindMany = vi.fn(); const assignmentFindMany = vi.fn(); const caller = createProtectedCaller({ demandRequirement: { findMany: demandFindMany, }, assignment: { findMany: assignmentFindMany, }, resource: { findFirst: vi.fn().mockResolvedValue(null), }, project: { findMany: vi.fn(), }, }); const result = await caller.getMyEntriesView({ startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-09T00:00:00.000Z"), }); expect(result.allocations).toEqual([]); expect(result.demands).toEqual([]); expect(result.assignments).toEqual([]); expect(demandFindMany).not.toHaveBeenCalled(); 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: { findMany: vi.fn().mockResolvedValue([ { id: "dem_1", projectId: "project_1", resourceId: null, role: "Artist", hoursPerDay: 6, startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-09T00:00:00.000Z"), status: "OPEN", metadata: null, project: { id: "project_1", name: "Gelddruckmaschine", shortCode: "GDM", clientId: "client_1", budgetCents: 100000, winProbability: 100, status: "ACTIVE", startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-16T00:00:00.000Z"), staffingReqs: null, responsiblePerson: "Larissa", color: "#fff", orderType: "CHARGEABLE", }, roleEntity: null, }, ]), }, assignment: { findMany: vi.fn().mockResolvedValue([ { id: "asg_by", projectId: "project_1", resourceId: "res_by", role: "Artist", hoursPerDay: 6, startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-09T00:00:00.000Z"), status: "CONFIRMED", metadata: null, resource: { id: "res_by", displayName: "Alice", eid: "EMP-BY", chapter: "Delivery", lcrCents: 10000, }, project: { id: "project_1", name: "Gelddruckmaschine", shortCode: "GDM", clientId: "client_1", budgetCents: 100000, winProbability: 100, status: "ACTIVE", startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-16T00:00:00.000Z"), staffingReqs: null, responsiblePerson: "Larissa", color: "#fff", orderType: "CHARGEABLE", }, roleEntity: null, }, { id: "asg_hh", projectId: "project_1", resourceId: "res_hh", role: "Artist", hoursPerDay: 6, startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-09T00:00:00.000Z"), status: "CONFIRMED", metadata: null, resource: { id: "res_hh", displayName: "Bob", eid: "EMP-HH", chapter: "Delivery", lcrCents: 10000, }, project: { id: "project_1", name: "Gelddruckmaschine", shortCode: "GDM", clientId: "client_1", budgetCents: 100000, winProbability: 100, status: "ACTIVE", startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-16T00:00:00.000Z"), staffingReqs: null, responsiblePerson: "Larissa", color: "#fff", orderType: "CHARGEABLE", }, roleEntity: null, }, ]), }, resource: { findMany: vi.fn().mockResolvedValue([ { id: "res_by", countryId: "country_de", federalState: "BY", metroCityId: "city_munich", country: { code: "DE" }, metroCity: { name: "Muenchen" }, }, { id: "res_hh", countryId: "country_de", federalState: "HH", metroCityId: "city_hamburg", country: { code: "DE" }, metroCity: { name: "Hamburg" }, }, ]), }, project: { findMany: vi.fn(), }, }); const result = await caller.getEntriesDetail({ startDate: "2026-01-05", endDate: "2026-01-09", projectIds: ["project_1"], }); expect(result.period).toEqual({ startDate: "2026-01-05", endDate: "2026-01-09", }); expect(result.summary).toEqual( expect.objectContaining({ demandCount: 1, assignmentCount: 2, overlayCount: 1, resourceCount: 2, }), ); expect(result.demands).toHaveLength(1); expect(result.assignments).toHaveLength(2); expect(result.holidayOverlays).toEqual([ expect.objectContaining({ resourceId: "res_by", startDate: "2026-01-06", note: "Heilige Drei Könige", scope: "STATE", }), ]); }); it("returns detailed project timeline context with overlap summaries", async () => { vi.mocked(listAssignmentBookings).mockResolvedValue([ { id: "asg_project", projectId: "project_ctx", resourceId: "res_1", startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-16T00:00:00.000Z"), hoursPerDay: 6, dailyCostCents: 0, status: "CONFIRMED", project: { id: "project_ctx", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null }, resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" }, }, { id: "asg_other", projectId: "project_other", resourceId: "res_1", startDate: new Date("2026-01-08T00:00:00.000Z"), endDate: new Date("2026-01-10T00:00:00.000Z"), hoursPerDay: 4, dailyCostCents: 0, status: "CONFIRMED", project: { id: "project_other", name: "Other Project", shortCode: "OTH", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null }, resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" }, }, ]); const project = { id: "project_ctx", name: "Gelddruckmaschine", shortCode: "GDM", orderType: "CHARGEABLE", budgetCents: 100000, winProbability: 100, status: "ACTIVE", startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-16T00:00:00.000Z"), staffingReqs: null, }; const caller = createAdminCaller({ project: { findUnique: vi.fn().mockResolvedValue(project), }, demandRequirement: { findMany: vi.fn().mockResolvedValue([ { id: "dem_ctx", projectId: "project_ctx", resourceId: null, role: "Artist", hoursPerDay: 6, startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-16T00:00:00.000Z"), status: "OPEN", metadata: null, project, roleEntity: null, }, ]), }, assignment: { findMany: vi.fn().mockResolvedValue([ { id: "asg_project", projectId: "project_ctx", resourceId: "res_1", role: "Artist", hoursPerDay: 6, startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-16T00:00:00.000Z"), status: "CONFIRMED", metadata: null, resource: { id: "res_1", displayName: "Alice", eid: "EMP-1", chapter: "Delivery", lcrCents: 10000, }, project, roleEntity: null, }, ]), }, resource: { findMany: vi.fn().mockResolvedValue([ { id: "res_1", countryId: "country_de", federalState: "BY", metroCityId: "city_munich", country: { code: "DE" }, metroCity: { name: "Muenchen" }, }, ]), }, }); const result = await caller.getProjectContextDetail({ projectId: "project_ctx", }); expect(result.project).toEqual( expect.objectContaining({ id: "project_ctx", shortCode: "GDM", }), ); expect(result.summary).toEqual( expect.objectContaining({ demandCount: 1, assignmentCount: 1, conflictedAssignmentCount: 1, overlayCount: 1, }), ); expect(result.assignmentConflicts).toEqual([ expect.objectContaining({ assignmentId: "asg_project", crossProjectOverlapCount: 1, overlaps: expect.arrayContaining([ expect.objectContaining({ projectShortCode: "OTH", sameProject: false, }), ]), }), ]); expect(result.holidayOverlays).toEqual([ expect.objectContaining({ startDate: "2026-01-06", }), ]); }); it("returns detailed project shift preview metadata and validation", async () => { const projectFindUnique = vi.fn().mockImplementation((args: { select?: Record }) => { if (args.select && "budgetCents" in args.select) { return Promise.resolve({ id: "project_shift", budgetCents: 100000, winProbability: 100, startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-16T00:00:00.000Z"), }); } return Promise.resolve({ id: "project_shift", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", responsiblePerson: "Larissa", startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-16T00:00:00.000Z"), }); }); const caller = createAdminCaller({ project: { findUnique: projectFindUnique, }, demandRequirement: { findMany: vi.fn().mockResolvedValue([]), }, assignment: { findMany: vi.fn().mockResolvedValue([]), }, }); const result = await caller.getShiftPreviewDetail({ projectId: "project_shift", newStartDate: new Date("2026-01-19T00:00:00.000Z"), newEndDate: new Date("2026-01-30T00:00:00.000Z"), }); expect(result.project).toEqual({ id: "project_shift", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", responsiblePerson: "Larissa", startDate: "2026-01-05", endDate: "2026-01-16", }); expect(result.requestedShift).toEqual({ newStartDate: "2026-01-19", newEndDate: "2026-01-30", }); expect(result.preview).toEqual({ valid: true, errors: [], warnings: [], conflictDetails: [], costImpact: { currentTotalCents: 0, newTotalCents: 0, deltaCents: 0, budgetCents: 100000, budgetUtilizationBefore: 0, budgetUtilizationAfter: 0, wouldExceedBudget: false, }, }); }); it("blocks USER role from broad timeline detail reads", async () => { const db = { demandRequirement: { findMany: vi.fn(), }, assignment: { findMany: vi.fn(), }, resource: { findMany: vi.fn(), }, project: { findMany: vi.fn(), }, }; const caller = createProtectedCaller(db); await expect( caller.getEntriesDetail({ startDate: "2026-01-05", endDate: "2026-01-09", }), ).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" })); }); it("blocks USER role from project timeline context reads", async () => { const db = { project: { findUnique: vi.fn(), }, demandRequirement: { findMany: vi.fn(), }, assignment: { findMany: vi.fn(), }, resource: { findMany: vi.fn(), }, }; const caller = createProtectedCaller(db); await expect( 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", })); }); });