import { AllocationStatus } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { describe, expect, it, vi } from "vitest"; import { fillOpenDemand } from "../index.js"; // Full demand requirement shape expected by loadAllocationEntry → buildSplitAllocationReadModel const makeDemandRequirement = (overrides: Record = {}) => ({ id: "demand_1", projectId: "project_1", startDate: new Date("2026-03-16"), endDate: new Date("2026-03-27"), hoursPerDay: 8, percentage: 100, role: "Compositor", roleId: "role_comp", headcount: 1, status: AllocationStatus.PROPOSED, metadata: {}, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), project: { id: "project_1", name: "Project One", shortCode: "PRJ" }, roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" }, ...overrides, }); const makeAssignmentRecord = (overrides: Record = {}) => ({ id: "assignment_1", demandRequirementId: "demand_1", resourceId: "resource_1", projectId: "project_1", startDate: new Date("2026-03-16"), endDate: new Date("2026-03-27"), hoursPerDay: 8, percentage: 100, role: "Compositor", roleId: "role_comp", dailyCostCents: 40000, 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 }, project: { id: "project_1", name: "Project One", shortCode: "PRJ" }, roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" }, demandRequirement: { id: "demand_1", projectId: "project_1", startDate: new Date("2026-03-16"), endDate: new Date("2026-03-27"), hoursPerDay: 8, percentage: 100, role: "Compositor", roleId: "role_comp", headcount: 1, status: AllocationStatus.PROPOSED, }, ...overrides, }); /** * Builds a minimal DB mock for fillOpenDemand. * The allocationId is resolved through findAllocationEntry which calls * demandRequirement.findUnique and assignment.findUnique in parallel. * If the allocationId matches a demand, that path is taken. * If the allocationId matches an assignment (non-null), "already filled" is thrown. */ function makeDb({ demandRecord = makeDemandRequirement(), assignmentRecord = null, }: { demandRecord?: Record | null; assignmentRecord?: Record | null; } = {}) { const assignmentCreate = vi.fn().mockResolvedValue(makeAssignmentRecord()); const demandRequirementUpdate = vi.fn().mockResolvedValue({ id: "demand_1", projectId: "project_1", headcount: 0, status: AllocationStatus.COMPLETED, }); const auditLogCreate = vi.fn().mockResolvedValue({}); const vacationFindMany = vi.fn().mockResolvedValue([]); const assignmentFindMany = vi.fn().mockResolvedValue([]); const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", lcrCents: 5000, availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0, }, }); const projectFindUnique = vi.fn().mockResolvedValue({ id: "project_1" }); return { demandRequirement: { // loadAllocationEntry uses findUnique with include (DEMAND_REQUIREMENT_RELATIONS_INCLUDE) findUnique: vi.fn().mockResolvedValue(demandRecord), }, assignment: { // loadAllocationEntry also probes assignment.findUnique findUnique: vi.fn().mockResolvedValue(assignmentRecord), findMany: assignmentFindMany, }, $transaction: vi.fn(async (callback: (tx: unknown) => Promise) => callback({ project: { findUnique: projectFindUnique }, resource: { findUnique: resourceFindUnique }, demandRequirement: { findUnique: vi.fn().mockResolvedValue({ id: "demand_1", projectId: "project_1" }), update: demandRequirementUpdate, }, assignment: { findMany: assignmentFindMany, create: assignmentCreate, }, vacation: { findMany: vacationFindMany }, auditLog: { create: auditLogCreate }, }), ), _assignmentCreate: assignmentCreate, _demandRequirementUpdate: demandRequirementUpdate, }; } describe("fillOpenDemand", () => { it("happy path: fills an open demand entry and returns demand_requirement strategy", async () => { const db = makeDb(); const result = await fillOpenDemand(db as never, { allocationId: "demand_1", resourceId: "resource_1", }); expect(result.strategy).toBe("demand_requirement"); expect(result.createdAllocation.id).toBe("assignment_1"); expect(result.createdAllocation.projectId).toBe("project_1"); expect(result.createdAllocation.resourceId).toBe("resource_1"); expect(result.updatedAllocation).not.toBeNull(); expect(result.updatedAllocation?.id).toBe("demand_1"); expect(result.updatedAllocation?.resourceId).toBeNull(); }); it("throws NOT_FOUND when the allocationId does not resolve to any record", async () => { const db = makeDb({ demandRecord: null, assignmentRecord: null }); await expect( fillOpenDemand(db as never, { allocationId: "nonexistent", resourceId: "resource_1", }), ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); it("throws BAD_REQUEST when the allocation is already filled (is an assignment)", async () => { // When the allocationId resolves to an assignment (not a demand), it is already filled const db = makeDb({ demandRecord: null, assignmentRecord: makeAssignmentRecord() as unknown as Record, }); await expect( fillOpenDemand(db as never, { allocationId: "assignment_1", resourceId: "resource_2", }), ).rejects.toMatchObject({ code: "BAD_REQUEST" }); expect(db.$transaction).not.toHaveBeenCalled(); }); it("propagates CANCELLED demand rejection from fillDemandRequirement", async () => { const db = makeDb({ demandRecord: makeDemandRequirement({ status: AllocationStatus.CANCELLED }) as Record, }); // fillDemandRequirement will re-fetch the demand by ID and throw BAD_REQUEST // For this we also need the outer findUnique to return cancelled demand // (loadAllocationEntry uses findUnique with include, fillDemandRequirement uses findUnique with select) // We need to handle both calls. The mock returns the same shape for both. await expect( fillOpenDemand(db as never, { allocationId: "demand_1", resourceId: "resource_1", }), ).rejects.toMatchObject({ code: "BAD_REQUEST" }); }); it("passes optional hoursPerDay override to the underlying fill operation", async () => { const db = makeDb(); await fillOpenDemand(db as never, { allocationId: "demand_1", resourceId: "resource_1", hoursPerDay: 4, }); expect(db._assignmentCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ hoursPerDay: 4 }), }), ); }); it("passes optional status override to the underlying fill operation", async () => { const db = makeDb(); await fillOpenDemand(db as never, { allocationId: "demand_1", resourceId: "resource_1", status: AllocationStatus.CONFIRMED, }); expect(db._assignmentCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: AllocationStatus.CONFIRMED }), }), ); }); });