import { beforeEach, describe, expect, it, vi } from "vitest"; import { applyProjectScenario } from "../router/scenario-apply.js"; vi.mock("../lib/audit.js", () => ({ createAuditEntry: vi.fn().mockResolvedValue(undefined), })); function makeDb(overrides: { projectFindUnique?: ReturnType; assignmentUpdate?: ReturnType; assignmentCreate?: ReturnType; resourceFindUnique?: ReturnType; transaction?: ReturnType; }) { const db = { project: { findUnique: overrides.projectFindUnique ?? vi.fn().mockResolvedValue({ id: "project_1", name: "Test Project" }), }, assignment: { update: overrides.assignmentUpdate ?? vi.fn().mockResolvedValue({}), create: overrides.assignmentCreate ?? vi.fn().mockResolvedValue({ id: "new_assignment_1" }), }, resource: { findUnique: overrides.resourceFindUnique ?? vi.fn().mockResolvedValue({ lcrCents: 100 }), }, }; return { ...db, $transaction: overrides.transaction ?? vi.fn(async (cb: (tx: typeof db) => Promise) => cb(db)), } as never; } const baseChange = { startDate: new Date("2026-04-01T00:00:00.000Z"), endDate: new Date("2026-04-30T00:00:00.000Z"), hoursPerDay: 8, }; describe("applyProjectScenario", () => { let assignmentUpdate: ReturnType; let assignmentCreate: ReturnType; let resourceFindUnique: ReturnType; beforeEach(() => { assignmentUpdate = vi.fn().mockResolvedValue({}); assignmentCreate = vi.fn().mockResolvedValue({ id: "new_assignment_1" }); resourceFindUnique = vi.fn().mockResolvedValue({ lcrCents: 100 }); }); it("throws NOT_FOUND when the project does not exist", async () => { const db = makeDb({ projectFindUnique: vi.fn().mockResolvedValue(null), }); await expect( applyProjectScenario(db, { projectId: "missing_project", changes: [] }), ).rejects.toMatchObject({ code: "NOT_FOUND", message: "Project not found" }); }); it("cancels an assignment when remove:true is supplied", async () => { const db = makeDb({ assignmentUpdate }); const result = await applyProjectScenario(db, { projectId: "project_1", changes: [{ ...baseChange, assignmentId: "assignment_1", remove: true }], }); expect(assignmentUpdate).toHaveBeenCalledWith({ where: { id: "assignment_1" }, data: { status: "CANCELLED" }, }); // Cancels continue early without pushing into `created`, so appliedCount is 0 expect(result.appliedCount).toBe(0); }); it("updates dates and hours when assignmentId is present without remove", async () => { const db = makeDb({ assignmentUpdate }); const result = await applyProjectScenario(db, { projectId: "project_1", changes: [{ ...baseChange, assignmentId: "assignment_1" }], }); expect(assignmentUpdate).toHaveBeenCalledWith({ where: { id: "assignment_1" }, data: { startDate: baseChange.startDate, endDate: baseChange.endDate, hoursPerDay: baseChange.hoursPerDay, }, }); expect(result.appliedCount).toBe(1); }); it("skips a change that has neither assignmentId nor resourceId", async () => { const db = makeDb({ assignmentCreate, resourceFindUnique }); const result = await applyProjectScenario(db, { projectId: "project_1", changes: [{ ...baseChange }], }); expect(assignmentCreate).not.toHaveBeenCalled(); expect(result.appliedCount).toBe(0); }); it("creates a new assignment when only resourceId is present and computes dailyCostCents", async () => { resourceFindUnique = vi.fn().mockResolvedValue({ lcrCents: 200 }); const db = makeDb({ assignmentCreate, resourceFindUnique }); const result = await applyProjectScenario(db, { projectId: "project_1", changes: [{ ...baseChange, resourceId: "resource_1", hoursPerDay: 6 }], }); expect(resourceFindUnique).toHaveBeenCalledWith({ where: { id: "resource_1" }, select: { lcrCents: true }, }); // dailyCostCents = Math.round(lcrCents * hoursPerDay) = Math.round(200 * 6) = 1200 expect(assignmentCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ projectId: "project_1", resourceId: "resource_1", hoursPerDay: 6, dailyCostCents: 1200, status: "PROPOSED", }), }); expect(result.appliedCount).toBe(1); }); it("counts correctly across multiple mixed changes", async () => { assignmentCreate = vi.fn() .mockResolvedValueOnce({ id: "new_1" }) .mockResolvedValueOnce({ id: "new_2" }); assignmentUpdate = vi.fn().mockResolvedValue({}); const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique }); const result = await applyProjectScenario(db, { projectId: "project_1", changes: [ // create 1 { ...baseChange, resourceId: "resource_1" }, // create 2 { ...baseChange, resourceId: "resource_2" }, // cancel { ...baseChange, assignmentId: "assignment_1", remove: true }, ], }); expect(assignmentCreate).toHaveBeenCalledTimes(2); // Cancels continue early without pushing into `created`. // appliedCount = 2 creates + 0 cancel = 2. expect(result.appliedCount).toBe(2); }); it("wraps all mutations in a single transaction", async () => { assignmentCreate = vi.fn().mockResolvedValue({ id: "new_1" }); assignmentUpdate = vi.fn().mockResolvedValue({}); // transaction mock that propagates the error so we can verify atomicity const transaction = vi.fn(async (cb: (tx: unknown) => Promise) => cb({ assignment: { update: assignmentUpdate, create: assignmentCreate }, resource: { findUnique: resourceFindUnique }, })); const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique, transaction }); await applyProjectScenario(db, { projectId: "project_1", changes: [{ ...baseChange, resourceId: "resource_1" }], }); expect(transaction).toHaveBeenCalledTimes(1); }); it("propagates transaction error so partial changes are rolled back", async () => { assignmentCreate = vi.fn() .mockResolvedValueOnce({ id: "new_1" }) .mockRejectedValueOnce(new Error("DB constraint violation")); const transaction = vi.fn(async (cb: (tx: unknown) => Promise) => cb({ assignment: { update: assignmentUpdate, create: assignmentCreate }, resource: { findUnique: resourceFindUnique } }), ); const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique, transaction }); await expect( applyProjectScenario(db, { projectId: "project_1", changes: [ { ...baseChange, resourceId: "resource_1" }, { ...baseChange, resourceId: "resource_2" }, ], }), ).rejects.toThrow("DB constraint violation"); }); });