import { SystemRole } from "@capakraken/shared"; import { describe, expect, it, vi } from "vitest"; import { effortRuleRouter } from "../router/effort-rule.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", () => ({ expandScopeToEffort: vi.fn().mockReturnValue({ lines: [ { scopeItemName: "Shot_001", scopeType: "shot", discipline: "Compositing", chapter: null, hours: 16, unitMode: "per_frame" as const, unitCount: 200, hoursPerUnit: 0.08, }, ], warnings: [], unmatchedScopeItems: [], }), aggregateByDiscipline: vi.fn().mockReturnValue({ Compositing: { totalHours: 16, lineCount: 1 }, }), })); const createCaller = createCallerFactory(effortRuleRouter); 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 sampleRuleSet(overrides: Record = {}) { return { id: "ers_1", name: "VFX Standard", description: null, isDefault: true, createdAt: new Date(), updatedAt: new Date(), rules: [ { id: "er_1", ruleSetId: "ers_1", scopeType: "shot", discipline: "Compositing", chapter: null, unitMode: "per_frame", hoursPerUnit: 0.08, description: null, sortOrder: 0, createdAt: new Date(), updatedAt: new Date(), }, ], ...overrides, }; } // ─── list ──────────────────────────────────────────────────────────────────── describe("effortRule.list", () => { it("returns rule sets ordered by isDefault desc, name asc", async () => { const sets = [sampleRuleSet(), sampleRuleSet({ id: "ers_2", name: "Animation", isDefault: false })]; const db = { effortRuleSet: { findMany: vi.fn().mockResolvedValue(sets), }, }; const caller = createControllerCaller(db); const result = await caller.list(); expect(result).toHaveLength(2); expect(db.effortRuleSet.findMany).toHaveBeenCalledWith( expect.objectContaining({ orderBy: [{ isDefault: "desc" }, { name: "asc" }], }), ); }); }); // ─── getById ───────────────────────────────────────────────────────────────── describe("effortRule.getById", () => { it("returns the rule set when found", async () => { const set = sampleRuleSet(); const db = { effortRuleSet: { findUnique: vi.fn().mockResolvedValue(set), }, }; const caller = createControllerCaller(db); const result = await caller.getById({ id: "ers_1" }); expect(result.id).toBe("ers_1"); expect(result.rules).toHaveLength(1); }); it("throws NOT_FOUND when rule set does not exist", async () => { const db = { effortRuleSet: { findUnique: vi.fn().mockResolvedValue(null), }, }; const caller = createControllerCaller(db); await expect(caller.getById({ id: "nonexistent" })).rejects.toThrow("Effort rule set not found"); }); }); // ─── create ────────────────────────────────────────────────────────────────── describe("effortRule.create", () => { it("creates a rule set with rules", async () => { const created = sampleRuleSet(); const db = { effortRuleSet: { 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: "VFX Standard", isDefault: false, rules: [ { scopeType: "shot", discipline: "Compositing", unitMode: "per_frame", hoursPerUnit: 0.08, sortOrder: 0, }, ], }); expect(result.id).toBe("ers_1"); expect(db.effortRuleSet.create).toHaveBeenCalledTimes(1); // isDefault was false, so updateMany should NOT have been called expect(db.effortRuleSet.updateMany).not.toHaveBeenCalled(); }); it("unsets other defaults when creating a new default rule set", async () => { const created = sampleRuleSet({ isDefault: true }); const db = { effortRuleSet: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), create: vi.fn().mockResolvedValue(created), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const caller = createManagerCaller(db); await caller.create({ name: "VFX Standard", isDefault: true, rules: [], }); expect(db.effortRuleSet.updateMany).toHaveBeenCalledWith({ where: { isDefault: true }, data: { isDefault: false }, }); }); }); // ─── update ────────────────────────────────────────────────────────────────── describe("effortRule.update", () => { it("updates name and description without touching rules", async () => { const existing = sampleRuleSet(); const updated = { ...existing, name: "VFX Updated" }; const db = { effortRuleSet: { findUnique: vi.fn().mockResolvedValue(existing), updateMany: vi.fn().mockResolvedValue({ count: 0 }), update: vi.fn().mockResolvedValue(updated), }, effortRule: { deleteMany: vi.fn(), createMany: vi.fn(), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const caller = createManagerCaller(db); const result = await caller.update({ id: "ers_1", name: "VFX Updated" }); expect(result.name).toBe("VFX Updated"); // No rules provided, so rule replacement should not happen expect(db.effortRule.deleteMany).not.toHaveBeenCalled(); expect(db.effortRule.createMany).not.toHaveBeenCalled(); }); it("replaces rules when rules array is provided", async () => { const existing = sampleRuleSet(); const updated = sampleRuleSet({ name: "Updated" }); const db = { effortRuleSet: { findUnique: vi.fn().mockResolvedValue(existing), updateMany: vi.fn().mockResolvedValue({ count: 0 }), update: vi.fn().mockResolvedValue(updated), }, effortRule: { 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: "ers_1", rules: [ { scopeType: "shot", discipline: "Lighting", unitMode: "per_frame", hoursPerUnit: 0.1, sortOrder: 0 }, { scopeType: "asset", discipline: "Modeling", unitMode: "per_item", hoursPerUnit: 8, sortOrder: 1 }, ], }); expect(db.effortRule.deleteMany).toHaveBeenCalledWith({ where: { ruleSetId: "ers_1" } }); expect(db.effortRule.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ expect.objectContaining({ ruleSetId: "ers_1", discipline: "Lighting" }), expect.objectContaining({ ruleSetId: "ers_1", discipline: "Modeling" }), ]), }); }); it("throws NOT_FOUND when rule set does not exist", async () => { const db = { effortRuleSet: { findUnique: vi.fn().mockResolvedValue(null), updateMany: vi.fn(), update: vi.fn(), }, effortRule: { deleteMany: vi.fn(), createMany: vi.fn() }, }; const caller = createManagerCaller(db); await expect(caller.update({ id: "nonexistent", name: "X" })).rejects.toThrow("Effort rule set not found"); }); }); // ─── delete ────────────────────────────────────────────────────────────────── describe("effortRule.delete", () => { it("deletes the rule set and returns its id", async () => { const existing = sampleRuleSet(); const db = { effortRuleSet: { 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: "ers_1" }); expect(result).toEqual({ id: "ers_1" }); expect(db.effortRuleSet.delete).toHaveBeenCalledWith({ where: { id: "ers_1" } }); }); it("throws NOT_FOUND when rule set does not exist", async () => { const db = { effortRuleSet: { findUnique: vi.fn().mockResolvedValue(null), delete: vi.fn(), }, }; const caller = createManagerCaller(db); await expect(caller.delete({ id: "nonexistent" })).rejects.toThrow("Effort rule set not found"); }); }); // ─── preview ───────────────────────────────────────────────────────────────── describe("effortRule.preview", () => { it("returns expansion result with aggregation", async () => { const estimate = { id: "est_1", baseCurrency: "EUR", versions: [ { id: "v_1", versionNumber: 1, status: "WORKING", scopeItems: [ { id: "si_1", name: "Shot_001", scopeType: "shot", frameCount: 200, itemCount: null, unitMode: "per_frame", sortOrder: 0, }, ], }, ], }; const ruleSet = sampleRuleSet(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, }; const caller = createControllerCaller(db); const result = await caller.preview({ estimateId: "est_1", ruleSetId: "ers_1" }); expect(result.lines).toHaveLength(1); expect(result.scopeItemCount).toBe(1); expect(result.ruleCount).toBe(1); expect(result.aggregated).toBeDefined(); }); it("throws NOT_FOUND when estimate does not exist", async () => { const db = { estimate: { findUnique: vi.fn().mockResolvedValue(null) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) }, }; const caller = createControllerCaller(db); await expect(caller.preview({ estimateId: "nope", ruleSetId: "ers_1" })).rejects.toThrow("Estimate not found"); }); it("throws NOT_FOUND when rule set does not exist", async () => { const estimate = { id: "est_1", versions: [{ id: "v_1", scopeItems: [] }] }; const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(null) }, }; const caller = createControllerCaller(db); await expect(caller.preview({ estimateId: "est_1", ruleSetId: "nope" })).rejects.toThrow("Effort rule set not found"); }); it("throws NOT_FOUND when estimate has no versions", async () => { const estimate = { id: "est_1", versions: [] }; const ruleSet = sampleRuleSet(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, }; const caller = createControllerCaller(db); await expect(caller.preview({ estimateId: "est_1", ruleSetId: "ers_1" })).rejects.toThrow("Estimate has no versions"); }); }); // ─── applyRules ────────────────────────────────────────────────────────────── describe("effortRule.applyRules", () => { function makeEstimate(versionStatus: string, demandLines: unknown[] = []) { return { id: "est_1", baseCurrency: "EUR", versions: [ { id: "v_1", versionNumber: 1, status: versionStatus, scopeItems: [ { id: "si_1", name: "Shot_001", scopeType: "shot", frameCount: 200, itemCount: null, unitMode: "per_frame", sortOrder: 0, }, ], demandLines, }, ], }; } it("replaces existing demand lines in replace mode", async () => { const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]); const ruleSet = sampleRuleSet(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, estimateDemandLine: { deleteMany: vi.fn().mockResolvedValue({ count: 1 }), createMany: vi.fn().mockResolvedValue({ count: 1 }), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const caller = createManagerCaller(db); const result = await caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace", }); expect(result.linesGenerated).toBe(1); expect(db.estimateDemandLine.deleteMany).toHaveBeenCalledWith({ where: { estimateVersionId: "v_1" }, }); expect(db.estimateDemandLine.createMany).toHaveBeenCalledTimes(1); expect(db.auditLog.create).toHaveBeenCalledTimes(1); }); it("does not delete existing lines in append mode", async () => { const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]); const ruleSet = sampleRuleSet(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn().mockResolvedValue({ count: 1 }), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const caller = createManagerCaller(db); const result = await caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "append", }); expect(result.linesGenerated).toBe(1); expect(db.estimateDemandLine.deleteMany).not.toHaveBeenCalled(); }); it("rejects applying to a non-WORKING version", async () => { const estimate = makeEstimate("SUBMITTED"); const ruleSet = sampleRuleSet(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() }, auditLog: { create: vi.fn() }, }; const caller = createManagerCaller(db); await expect( caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" }), ).rejects.toThrow("Can only apply rules to a WORKING version"); }); it("throws NOT_FOUND when estimate does not exist", async () => { const db = { estimate: { findUnique: vi.fn().mockResolvedValue(null) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) }, estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() }, auditLog: { create: vi.fn() }, }; const caller = createManagerCaller(db); await expect( caller.applyRules({ estimateId: "nope", ruleSetId: "ers_1", mode: "replace" }), ).rejects.toThrow("Estimate not found"); }); it("throws NOT_FOUND when estimate has no versions", async () => { const estimate = { id: "est_1", baseCurrency: "EUR", versions: [] }; const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) }, estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() }, auditLog: { create: vi.fn() }, }; const caller = createManagerCaller(db); await expect( caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" }), ).rejects.toThrow("Estimate has no versions"); }); it("creates demand lines with correct metadata shape", async () => { const estimate = makeEstimate("WORKING"); const ruleSet = sampleRuleSet(); const db = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, estimateDemandLine: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }), createMany: vi.fn().mockResolvedValue({ count: 1 }), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const caller = createManagerCaller(db); await caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" }); const createManyArg = db.estimateDemandLine.createMany.mock.calls[0][0]; const firstLine = createManyArg.data[0]; expect(firstLine.estimateVersionId).toBe("v_1"); expect(firstLine.lineType).toBe("LABOR"); expect(firstLine.currency).toBe("EUR"); expect(firstLine.costRateCents).toBe(0); expect(firstLine.billRateCents).toBe(0); expect(firstLine.metadata).toEqual( expect.objectContaining({ effortRule: expect.objectContaining({ ruleSetId: "ers_1", ruleSetName: "VFX Standard", }), }), ); }); });