import { SystemRole } from "@capakraken/shared"; import { describe, expect, it, vi } from "vitest"; import { experienceMultiplierRouter } from "../router/experience-multiplier.js"; import { createCallerFactory } from "../trpc.js"; // Mock the engine — we focus on the router/DB layer, not the pure engine logic vi.mock("@capakraken/engine", () => ({ applyExperienceMultipliers: vi.fn().mockReturnValue({ adjustedCostRateCents: 12000, adjustedBillRateCents: 18000, adjustedHours: 110, appliedRules: ["Rate multiplied (chapter=VFX): cost x1.2, bill x1.2"], }), applyExperienceMultipliersBatch: vi.fn().mockReturnValue({ results: [ { adjustedCostRateCents: 12000, adjustedBillRateCents: 18000, adjustedHours: 110, appliedRules: ["Rate multiplied (chapter=VFX): cost x1.2, bill x1.2"], }, ], totalOriginalHours: 100, totalAdjustedHours: 110, linesAdjusted: 1, }), })); const createCaller = createCallerFactory(experienceMultiplierRouter); function createControllerCaller(db: Record) { return createCaller({ session: { user: { email: "ctrl@example.com", name: "Controller", image: null }, expires: "2099-01-01T00:00:00.000Z", }, db: db as never, dbUser: { id: "user_ctrl", systemRole: SystemRole.CONTROLLER, permissionOverrides: null, }, }); } function createManagerCaller(db: Record) { return createCaller({ session: { user: { email: "mgr@example.com", name: "Manager", image: null }, expires: "2099-01-01T00:00:00.000Z", }, db: db as never, dbUser: { id: "user_mgr", systemRole: SystemRole.MANAGER, permissionOverrides: null, }, }); } // ── Sample data factories ──────────────────────────────────────────────────── function sampleMultiplierSet(overrides: Record = {}) { return { id: "ems_1", name: "Standard Multipliers", description: null, isDefault: true, createdAt: new Date(), updatedAt: new Date(), rules: [ { id: "emr_1", multiplierSetId: "ems_1", chapter: "VFX", location: null, level: null, costMultiplier: 1.2, billMultiplier: 1.2, shoringRatio: null, additionalEffortRatio: null, description: null, sortOrder: 0, createdAt: new Date(), updatedAt: new Date(), }, ], ...overrides, }; } function sampleDemandLine(overrides: Record = {}) { return { id: "dl_1", name: "Compositing Senior", chapter: "VFX", costRateCents: 10000, billRateCents: 15000, hours: 100, costTotalCents: 1000000, priceTotalCents: 1500000, metadata: null, staffingAttributes: null, createdAt: new Date(), ...overrides, }; } // ─── list ──────────────────────────────────────────────────────────────────── describe("experienceMultiplier.list", () => { it("returns sets ordered by isDefault desc, name asc", async () => { const sets = [ sampleMultiplierSet(), sampleMultiplierSet({ id: "ems_2", name: "Custom", isDefault: false }), ]; const db = { experienceMultiplierSet: { findMany: vi.fn().mockResolvedValue(sets), }, }; const caller = createControllerCaller(db); const result = await caller.list(); expect(result).toHaveLength(2); expect(db.experienceMultiplierSet.findMany).toHaveBeenCalledWith( expect.objectContaining({ orderBy: [{ isDefault: "desc" }, { name: "asc" }], }), ); }); }); // ─── getById ───────────────────────────────────────────────────────────────── describe("experienceMultiplier.getById", () => { it("returns the multiplier set when found", async () => { const set = sampleMultiplierSet(); const db = { experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(set), }, }; const caller = createControllerCaller(db); const result = await caller.getById({ id: "ems_1" }); expect(result.id).toBe("ems_1"); expect(result.rules).toHaveLength(1); }); it("throws NOT_FOUND when set does not exist", async () => { const db = { experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(null), }, }; const caller = createControllerCaller(db); await expect(caller.getById({ id: "nonexistent" })).rejects.toThrow( "Experience multiplier set not found", ); }); }); // ─── create ────────────────────────────────────────────────────────────────── describe("experienceMultiplier.create", () => { it("creates a set with rules", async () => { const created = sampleMultiplierSet(); const db = { experienceMultiplierSet: { updateMany: vi.fn().mockResolvedValue({ count: 0 }), create: vi.fn().mockResolvedValue(created), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const caller = createManagerCaller(db); const result = await caller.create({ name: "Standard Multipliers", isDefault: false, rules: [ { chapter: "VFX", costMultiplier: 1.2, billMultiplier: 1.2, sortOrder: 0, }, ], }); expect(result.id).toBe("ems_1"); expect(db.experienceMultiplierSet.create).toHaveBeenCalledTimes(1); // isDefault was false, so updateMany should NOT have been called expect(db.experienceMultiplierSet.updateMany).not.toHaveBeenCalled(); }); it("unsets other defaults when creating a new default set", async () => { const created = sampleMultiplierSet({ isDefault: true }); const db = { experienceMultiplierSet: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), create: vi.fn().mockResolvedValue(created), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const caller = createManagerCaller(db); await caller.create({ name: "Standard Multipliers", isDefault: true, rules: [], }); expect(db.experienceMultiplierSet.updateMany).toHaveBeenCalledWith({ where: { isDefault: true }, data: { isDefault: false }, }); }); }); // ─── update ────────────────────────────────────────────────────────────────── describe("experienceMultiplier.update", () => { it("updates name and description without touching rules", async () => { const existing = sampleMultiplierSet(); const updated = { ...existing, name: "Updated Name" }; const db = { experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(existing), updateMany: vi.fn().mockResolvedValue({ count: 0 }), update: vi.fn().mockResolvedValue(updated), }, experienceMultiplierRule: { deleteMany: vi.fn(), createMany: vi.fn(), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const caller = createManagerCaller(db); const result = await caller.update({ id: "ems_1", name: "Updated Name" }); expect(result.name).toBe("Updated Name"); // No rules provided, so rule replacement should not happen expect(db.experienceMultiplierRule.deleteMany).not.toHaveBeenCalled(); expect(db.experienceMultiplierRule.createMany).not.toHaveBeenCalled(); }); it("replaces rules when rules array is provided", async () => { const existing = sampleMultiplierSet(); const updated = sampleMultiplierSet({ name: "Updated" }); const db = { experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(existing), updateMany: vi.fn().mockResolvedValue({ count: 0 }), update: vi.fn().mockResolvedValue(updated), }, experienceMultiplierRule: { deleteMany: vi.fn().mockResolvedValue({ count: 1 }), createMany: vi.fn().mockResolvedValue({ count: 2 }), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const caller = createManagerCaller(db); await caller.update({ id: "ems_1", rules: [ { chapter: "VFX", costMultiplier: 1.3, billMultiplier: 1.3, sortOrder: 0 }, { location: "India", costMultiplier: 0.7, billMultiplier: 0.9, shoringRatio: 0.5, sortOrder: 1 }, ], }); expect(db.experienceMultiplierRule.deleteMany).toHaveBeenCalledWith({ where: { multiplierSetId: "ems_1" }, }); expect(db.experienceMultiplierRule.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ expect.objectContaining({ multiplierSetId: "ems_1", chapter: "VFX" }), expect.objectContaining({ multiplierSetId: "ems_1", location: "India" }), ]), }); }); it("throws NOT_FOUND when set does not exist", async () => { const db = { experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(null), updateMany: vi.fn(), update: vi.fn(), }, experienceMultiplierRule: { deleteMany: vi.fn(), createMany: vi.fn() }, }; const caller = createManagerCaller(db); await expect(caller.update({ id: "nonexistent", name: "X" })).rejects.toThrow( "Experience multiplier set not found", ); }); }); // ─── delete ────────────────────────────────────────────────────────────────── describe("experienceMultiplier.delete", () => { it("deletes the set and returns its id", async () => { const existing = sampleMultiplierSet(); const db = { experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(existing), delete: vi.fn().mockResolvedValue(existing), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const caller = createManagerCaller(db); const result = await caller.delete({ id: "ems_1" }); expect(result).toEqual({ id: "ems_1" }); expect(db.experienceMultiplierSet.delete).toHaveBeenCalledWith({ where: { id: "ems_1" } }); }); it("throws NOT_FOUND when set does not exist", async () => { const db = { experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(null), delete: vi.fn(), }, }; const caller = createManagerCaller(db); await expect(caller.delete({ id: "nonexistent" })).rejects.toThrow( "Experience multiplier set not found", ); }); }); // ─── preview ───────────────────────────────────────────────────────────────── describe("experienceMultiplier.preview", () => { function makeEstimate(demandLines: unknown[] = [sampleDemandLine()]) { return { id: "est_1", versions: [ { id: "v_1", versionNumber: 1, status: "WORKING", demandLines, }, ], }; } it("returns preview results with summary stats", async () => { const estimate = makeEstimate(); const multiplierSet = sampleMultiplierSet(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, }; const caller = createControllerCaller(db); const result = await caller.preview({ estimateId: "est_1", multiplierSetId: "ems_1" }); expect(result.previews).toHaveLength(1); expect(result.demandLineCount).toBe(1); expect(result.multiplierSetName).toBe("Standard Multipliers"); expect(result.ruleCount).toBe(1); // The mock returns adjusted values different from original, so hasChanges = true expect(result.previews[0].hasChanges).toBe(true); expect(result.previews[0].originalCostRateCents).toBe(10000); expect(result.previews[0].adjustedCostRateCents).toBe(12000); expect(result.linesChanged).toBe(1); }); it("throws NOT_FOUND when estimate does not exist", async () => { const db = { estimate: { findUnique: vi.fn().mockResolvedValue(null) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()) }, }; const caller = createControllerCaller(db); await expect(caller.preview({ estimateId: "nope", multiplierSetId: "ems_1" })).rejects.toThrow( "Estimate not found", ); }); it("throws NOT_FOUND when multiplier set does not exist", async () => { const estimate = makeEstimate(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(null) }, }; const caller = createControllerCaller(db); await expect(caller.preview({ estimateId: "est_1", multiplierSetId: "nope" })).rejects.toThrow( "Experience multiplier set not found", ); }); it("throws NOT_FOUND when estimate has no versions", async () => { const estimate = { id: "est_1", versions: [] }; const multiplierSet = sampleMultiplierSet(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, }; const caller = createControllerCaller(db); await expect(caller.preview({ estimateId: "est_1", multiplierSetId: "ems_1" })).rejects.toThrow( "Estimate has no versions", ); }); it("reports no changes when rates are unchanged", async () => { // Import the mock to override for this test const { applyExperienceMultipliers } = await import("@capakraken/engine"); const mockFn = applyExperienceMultipliers as ReturnType; mockFn.mockReturnValueOnce({ adjustedCostRateCents: 10000, adjustedBillRateCents: 15000, adjustedHours: 100, appliedRules: ["No matching rule found -- values unchanged."], }); const estimate = makeEstimate(); const multiplierSet = sampleMultiplierSet(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, }; const caller = createControllerCaller(db); const result = await caller.preview({ estimateId: "est_1", multiplierSetId: "ems_1" }); expect(result.linesChanged).toBe(0); expect(result.previews[0].hasChanges).toBe(false); }); }); // ─── applyRules ────────────────────────────────────────────────────────────── describe("experienceMultiplier.applyRules", () => { function makeEstimate(versionStatus: string, demandLines: unknown[] = [sampleDemandLine()]) { return { id: "est_1", versions: [ { id: "v_1", versionNumber: 1, status: versionStatus, demandLines, }, ], }; } it("updates demand lines with adjusted rates and creates audit log", async () => { const estimate = makeEstimate("WORKING"); const multiplierSet = sampleMultiplierSet(); const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, estimateDemandLine: { update: vi.fn().mockResolvedValue({}), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); const result = await caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1", }); expect(result.linesUpdated).toBe(1); expect(result.totalOriginalHours).toBe(100); expect(result.totalAdjustedHours).toBe(110); expect(db.estimateDemandLine.update).toHaveBeenCalledTimes(1); // Verify the update call contains the adjusted rates and metadata const updateCall = db.estimateDemandLine.update.mock.calls[0][0]; expect(updateCall.where).toEqual({ id: "dl_1" }); expect(updateCall.data.costRateCents).toBe(12000); expect(updateCall.data.billRateCents).toBe(18000); expect(updateCall.data.hours).toBe(110); expect(updateCall.data.costTotalCents).toBe(Math.round(12000 * 110)); expect(updateCall.data.priceTotalCents).toBe(Math.round(18000 * 110)); expect(updateCall.data.metadata.experienceMultiplier).toEqual( expect.objectContaining({ setId: "ems_1", setName: "Standard Multipliers", originalCostRateCents: 10000, originalBillRateCents: 15000, originalHours: 100, }), ); // Audit log should be created expect(db.auditLog.create).toHaveBeenCalledTimes(1); const auditCall = db.auditLog.create.mock.calls[0][0]; expect(auditCall.data.entityType).toBe("Estimate"); expect(auditCall.data.entityId).toBe("est_1"); expect(auditCall.data.action).toBe("UPDATE"); expect(auditCall.data.userId).toBe("user_mgr"); }); it("skips unchanged lines (no update call)", async () => { const { applyExperienceMultipliersBatch } = await import("@capakraken/engine"); const mockFn = applyExperienceMultipliersBatch as ReturnType; mockFn.mockReturnValueOnce({ results: [ { adjustedCostRateCents: 10000, adjustedBillRateCents: 15000, adjustedHours: 100, appliedRules: ["No matching rule found."], }, ], totalOriginalHours: 100, totalAdjustedHours: 100, linesAdjusted: 0, }); const estimate = makeEstimate("WORKING"); const multiplierSet = sampleMultiplierSet(); const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, estimateDemandLine: { update: vi.fn(), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); const result = await caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1", }); expect(result.linesUpdated).toBe(0); expect((db.estimateDemandLine as Record>).update).not.toHaveBeenCalled(); }); it("rejects applying to a non-WORKING version", async () => { const estimate = makeEstimate("SUBMITTED"); const multiplierSet = sampleMultiplierSet(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, estimateDemandLine: { update: vi.fn() }, auditLog: { create: vi.fn() }, }; const caller = createManagerCaller(db); await expect( caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1" }), ).rejects.toThrow("Can only apply multipliers to a WORKING version"); }); it("throws NOT_FOUND when estimate does not exist", async () => { const db = { estimate: { findUnique: vi.fn().mockResolvedValue(null) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()) }, estimateDemandLine: { update: vi.fn() }, auditLog: { create: vi.fn() }, }; const caller = createManagerCaller(db); await expect( caller.applyRules({ estimateId: "nope", multiplierSetId: "ems_1" }), ).rejects.toThrow("Estimate not found"); }); it("throws NOT_FOUND when multiplier set does not exist", async () => { const estimate = makeEstimate("WORKING"); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(null) }, estimateDemandLine: { update: vi.fn() }, auditLog: { create: vi.fn() }, }; const caller = createManagerCaller(db); await expect( caller.applyRules({ estimateId: "est_1", multiplierSetId: "nope" }), ).rejects.toThrow("Experience multiplier set not found"); }); it("throws NOT_FOUND when estimate has no versions", async () => { const estimate = { id: "est_1", versions: [] }; const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()) }, estimateDemandLine: { update: vi.fn() }, auditLog: { create: vi.fn() }, }; const caller = createManagerCaller(db); await expect( caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1" }), ).rejects.toThrow("Estimate has no versions"); }); it("preserves existing metadata when updating demand lines", async () => { const lineWithMetadata = sampleDemandLine({ metadata: { someField: "existing-value", anotherField: 42 }, }); const estimate = makeEstimate("WORKING", [lineWithMetadata]); const multiplierSet = sampleMultiplierSet(); const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, estimateDemandLine: { update: vi.fn().mockResolvedValue({}), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); await caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1" }); const updateCall = db.estimateDemandLine.update.mock.calls[0][0]; // Existing metadata fields should be preserved alongside experienceMultiplier expect(updateCall.data.metadata.someField).toBe("existing-value"); expect(updateCall.data.metadata.anotherField).toBe(42); expect(updateCall.data.metadata.experienceMultiplier).toBeDefined(); }); });