import { ProjectStatus, SystemRole } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createProjectLifecycleProcedures } from "../router/project-lifecycle.js"; import { createCallerFactory, createTRPCRouter } from "../trpc.js"; // ─── Dependency mocks ───────────────────────────────────────────────────────── const deps = { invalidateDashboardCacheInBackground: vi.fn(), dispatchProjectWebhookInBackground: vi.fn(), }; const procedures = createProjectLifecycleProcedures(deps); const router = createTRPCRouter(procedures); const createCaller = createCallerFactory(router); // ─── Caller factories ───────────────────────────────────────────────────────── 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 }, }); } function createAdminCaller(db: Record) { return createCaller({ session: { user: { email: "admin@example.com", name: "Admin", image: null }, expires: "2099-01-01T00:00:00.000Z", }, db: db as never, dbUser: { id: "user_admin", systemRole: SystemRole.ADMIN, permissionOverrides: null }, }); } // ─── Shared project fixture ─────────────────────────────────────────────────── const baseProject = { id: "proj_1", name: "Alpha Project", shortCode: "ALF-001", status: ProjectStatus.DRAFT, }; // ─── Transaction mock helpers ───────────────────────────────────────────────── function makeTxMock() { return { assignment: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, demandRequirement: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, calculationRule: { updateMany: vi.fn().mockResolvedValue({ count: 0 }) }, project: { update: vi.fn(), delete: vi.fn().mockResolvedValue(undefined), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), }, auditLog: { create: vi.fn().mockResolvedValue(undefined) }, }; } // ─── Tests ──────────────────────────────────────────────────────────────────── describe("project-lifecycle router", () => { beforeEach(() => { vi.clearAllMocks(); }); // ── updateStatus ───────────────────────────────────────────────────────────── describe("updateStatus", () => { it("successfully transitions DRAFT → ACTIVE", async () => { const updated = { ...baseProject, status: ProjectStatus.ACTIVE }; const db = { project: { findUnique: vi .fn() .mockResolvedValue({ id: baseProject.id, status: ProjectStatus.DRAFT }), update: vi.fn().mockResolvedValue(updated), }, }; const caller = createManagerCaller(db); const result = await caller.updateStatus({ id: baseProject.id, status: ProjectStatus.ACTIVE, }); expect(db.project.update).toHaveBeenCalledWith({ where: { id: baseProject.id }, data: { status: ProjectStatus.ACTIVE }, }); expect(result.status).toBe(ProjectStatus.ACTIVE); }); it("calls webhook and cache invalidation after successful status update", async () => { const updated = { ...baseProject, status: ProjectStatus.ACTIVE }; const db = { project: { findUnique: vi .fn() .mockResolvedValue({ id: baseProject.id, status: ProjectStatus.DRAFT }), update: vi.fn().mockResolvedValue(updated), }, }; const caller = createManagerCaller(db); await caller.updateStatus({ id: baseProject.id, status: ProjectStatus.ACTIVE }); expect(deps.invalidateDashboardCacheInBackground).toHaveBeenCalledOnce(); expect(deps.dispatchProjectWebhookInBackground).toHaveBeenCalledOnce(); expect(deps.dispatchProjectWebhookInBackground).toHaveBeenCalledWith( db as never, "project.status_changed", expect.objectContaining({ id: updated.id, status: ProjectStatus.ACTIVE }), ); }); it("is a no-op when the target status equals the current status", async () => { const unchanged = { ...baseProject, status: ProjectStatus.DRAFT }; const db = { project: { findUnique: vi .fn() .mockResolvedValue({ id: baseProject.id, status: ProjectStatus.DRAFT }), update: vi.fn().mockResolvedValue(unchanged), }, }; const caller = createManagerCaller(db); const result = await caller.updateStatus({ id: baseProject.id, status: ProjectStatus.DRAFT }); // update is still called (same status written back), but no transition validation fires expect(db.project.update).toHaveBeenCalledOnce(); expect(result.status).toBe(ProjectStatus.DRAFT); }); it("throws NOT_FOUND when the project does not exist", async () => { const db = { project: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), }, }; const caller = createManagerCaller(db); await expect( caller.updateStatus({ id: "proj_missing", status: ProjectStatus.ACTIVE }), ).rejects.toMatchObject({ code: "NOT_FOUND", message: "Project not found" }); expect(db.project.update).not.toHaveBeenCalled(); }); it("throws BAD_REQUEST for an invalid transition (DRAFT → COMPLETED)", async () => { const db = { project: { findUnique: vi .fn() .mockResolvedValue({ id: baseProject.id, status: ProjectStatus.DRAFT }), update: vi.fn(), }, }; const caller = createManagerCaller(db); await expect( caller.updateStatus({ id: baseProject.id, status: ProjectStatus.COMPLETED }), ).rejects.toMatchObject({ code: "BAD_REQUEST" }); expect(db.project.update).not.toHaveBeenCalled(); }); it("throws BAD_REQUEST for COMPLETED → ON_HOLD (not an allowed transition)", async () => { const db = { project: { findUnique: vi .fn() .mockResolvedValue({ id: baseProject.id, status: ProjectStatus.COMPLETED }), update: vi.fn(), }, }; const caller = createManagerCaller(db); await expect( caller.updateStatus({ id: baseProject.id, status: ProjectStatus.ON_HOLD }), ).rejects.toMatchObject({ code: "BAD_REQUEST" }); expect(db.project.update).not.toHaveBeenCalled(); }); }); // ── batchUpdateStatus ───────────────────────────────────────────────────────── describe("batchUpdateStatus", () => { it("updates multiple projects inside a transaction", async () => { const txMock = makeTxMock(); txMock.project.update .mockResolvedValueOnce({ id: "proj_1", status: ProjectStatus.ON_HOLD }) .mockResolvedValueOnce({ id: "proj_2", status: ProjectStatus.ON_HOLD }); const db = { $transaction: vi .fn() .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), }; const caller = createManagerCaller(db); const result = await caller.batchUpdateStatus({ ids: ["proj_1", "proj_2"], status: ProjectStatus.ON_HOLD, }); expect(txMock.project.update).toHaveBeenCalledTimes(2); expect(result).toEqual({ count: 2 }); }); it("calls invalidateDashboardCacheInBackground after a successful batch update", async () => { const txMock = makeTxMock(); txMock.project.update.mockResolvedValue({ id: "proj_1", status: ProjectStatus.ACTIVE }); const db = { $transaction: vi .fn() .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), }; const caller = createManagerCaller(db); await caller.batchUpdateStatus({ ids: ["proj_1"], status: ProjectStatus.ACTIVE }); expect(deps.invalidateDashboardCacheInBackground).toHaveBeenCalledOnce(); }); it("creates an audit log entry inside the transaction", async () => { const txMock = makeTxMock(); txMock.project.update.mockResolvedValue({ id: "proj_1", status: ProjectStatus.CANCELLED }); const db = { $transaction: vi .fn() .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), }; const caller = createManagerCaller(db); await caller.batchUpdateStatus({ ids: ["proj_1"], status: ProjectStatus.CANCELLED }); expect(txMock.auditLog.create).toHaveBeenCalledOnce(); expect(txMock.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ entityType: "Project", action: "UPDATE", }), }), ); }); }); // ── delete ──────────────────────────────────────────────────────────────────── describe("delete", () => { it("deletes a project with cascade (assignments, demandRequirements, calculationRules)", async () => { const txMock = makeTxMock(); const db = { project: { findUnique: vi.fn().mockResolvedValue({ id: "proj_1", name: "Alpha Project", shortCode: "ALF-001", }), }, $transaction: vi .fn() .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), }; const caller = createAdminCaller(db); await caller.delete({ id: "proj_1" }); expect(txMock.assignment.deleteMany).toHaveBeenCalledWith({ where: { projectId: "proj_1" } }); expect(txMock.demandRequirement.deleteMany).toHaveBeenCalledWith({ where: { projectId: "proj_1" }, }); expect(txMock.calculationRule.updateMany).toHaveBeenCalledWith({ where: { projectId: "proj_1" }, data: { projectId: null }, }); expect(txMock.project.delete).toHaveBeenCalledWith({ where: { id: "proj_1" } }); }); it("throws NOT_FOUND when the project does not exist", async () => { const db = { project: { findUnique: vi.fn().mockResolvedValue(null), }, $transaction: vi.fn(), }; const caller = createAdminCaller(db); await expect(caller.delete({ id: "proj_missing" })).rejects.toMatchObject({ code: "NOT_FOUND", message: "Project not found", }); expect(db.$transaction).not.toHaveBeenCalled(); }); it("calls invalidateDashboardCacheInBackground after a successful delete", async () => { const txMock = makeTxMock(); const db = { project: { findUnique: vi.fn().mockResolvedValue({ id: "proj_1", name: "Alpha Project", shortCode: "ALF-001", }), }, $transaction: vi .fn() .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), }; const caller = createAdminCaller(db); await caller.delete({ id: "proj_1" }); expect(deps.invalidateDashboardCacheInBackground).toHaveBeenCalledOnce(); }); it("returns the id and name of the deleted project", async () => { const txMock = makeTxMock(); const db = { project: { findUnique: vi.fn().mockResolvedValue({ id: "proj_1", name: "Alpha Project", shortCode: "ALF-001", }), }, $transaction: vi .fn() .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), }; const caller = createAdminCaller(db); const result = await caller.delete({ id: "proj_1" }); expect(result).toEqual({ id: "proj_1", name: "Alpha Project" }); }); }); // ── batchDelete ─────────────────────────────────────────────────────────────── describe("batchDelete", () => { it("deletes multiple projects with cascade inside a transaction", async () => { const txMock = makeTxMock(); const projects = [ { id: "proj_1", name: "Alpha", shortCode: "ALF-001" }, { id: "proj_2", name: "Beta", shortCode: "BET-001" }, ]; const db = { project: { findMany: vi.fn().mockResolvedValue(projects), }, $transaction: vi .fn() .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), }; const caller = createAdminCaller(db); const result = await caller.batchDelete({ ids: ["proj_1", "proj_2"] }); expect(txMock.assignment.deleteMany).toHaveBeenCalledWith({ where: { projectId: { in: ["proj_1", "proj_2"] } }, }); expect(txMock.demandRequirement.deleteMany).toHaveBeenCalledWith({ where: { projectId: { in: ["proj_1", "proj_2"] } }, }); expect(txMock.calculationRule.updateMany).toHaveBeenCalledWith({ where: { projectId: { in: ["proj_1", "proj_2"] } }, data: { projectId: null }, }); expect(txMock.project.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ["proj_1", "proj_2"] } }, }); expect(result).toEqual({ count: 2 }); }); it("throws NOT_FOUND when none of the requested projects exist", async () => { const db = { project: { findMany: vi.fn().mockResolvedValue([]), }, $transaction: vi.fn(), }; const caller = createAdminCaller(db); await expect(caller.batchDelete({ ids: ["proj_missing"] })).rejects.toMatchObject({ code: "NOT_FOUND", message: "No projects found", }); expect(db.$transaction).not.toHaveBeenCalled(); }); it("returns the count of deleted projects", async () => { const txMock = makeTxMock(); const projects = [ { id: "proj_1", name: "Alpha", shortCode: "ALF-001" }, { id: "proj_2", name: "Beta", shortCode: "BET-001" }, { id: "proj_3", name: "Gamma", shortCode: "GAM-001" }, ]; const db = { project: { findMany: vi.fn().mockResolvedValue(projects), }, $transaction: vi .fn() .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), }; const caller = createAdminCaller(db); const result = await caller.batchDelete({ ids: ["proj_1", "proj_2", "proj_3"] }); expect(result).toEqual({ count: 3 }); }); }); });