import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; const { createAuditEntry } = vi.hoisted(() => ({ createAuditEntry: vi.fn(), })); vi.mock("../lib/audit.js", () => ({ createAuditEntry, })); import { batchUpdateClientSortOrder, createClient, deleteClient, updateClient, } from "../router/client-procedure-support.js"; function createContext(db: Record) { return { db: db as never, dbUser: { id: "user_1" } as never, }; } describe("client procedure support", () => { beforeEach(() => { createAuditEntry.mockReset(); }); it("creates a client after validating parent and code uniqueness", async () => { const findUnique = vi .fn() .mockResolvedValueOnce({ id: "parent_1", name: "Parent" }) .mockResolvedValueOnce(null); const create = vi.fn().mockResolvedValue({ id: "client_1", name: "Acme", code: "ACME", parentId: "parent_1", sortOrder: 12, tags: ["enterprise"], isActive: true, }); const result = await createClient( createContext({ client: { findUnique, create, }, }), { name: "Acme", code: "ACME", parentId: "parent_1", sortOrder: 12, tags: ["enterprise"], }, ); expect(findUnique).toHaveBeenNthCalledWith(1, { where: { id: "parent_1" }, }); expect(findUnique).toHaveBeenNthCalledWith(2, { where: { code: "ACME" }, }); expect(create).toHaveBeenCalledWith({ data: { name: "Acme", code: "ACME", parentId: "parent_1", sortOrder: 12, tags: ["enterprise"], }, }); expect(result.id).toBe("client_1"); expect(createAuditEntry).toHaveBeenCalledWith( expect.objectContaining({ entityType: "Client", action: "CREATE", entityId: "client_1", userId: "user_1", }), ); }); it("updates a client without rechecking an unchanged code", async () => { const findUnique = vi.fn().mockResolvedValue({ id: "client_1", name: "Acme", code: "ACME", isActive: true, }); const update = vi.fn().mockResolvedValue({ id: "client_1", name: "Acme Updated", code: "ACME", isActive: true, }); const result = await updateClient( createContext({ client: { findUnique, update, }, }), { id: "client_1", data: { name: "Acme Updated", code: "ACME", }, }, ); expect(findUnique).toHaveBeenCalledTimes(1); expect(findUnique).toHaveBeenCalledWith({ where: { id: "client_1" }, }); expect(update).toHaveBeenCalledWith({ where: { id: "client_1" }, data: { name: "Acme Updated", code: "ACME", }, }); expect(result.name).toBe("Acme Updated"); expect(createAuditEntry).toHaveBeenCalledWith( expect.objectContaining({ entityType: "Client", action: "UPDATE", entityId: "client_1", before: expect.objectContaining({ code: "ACME" }), after: expect.objectContaining({ name: "Acme Updated" }), }), ); }); it("checks code availability when the code changes", async () => { const findUnique = vi .fn() .mockResolvedValueOnce({ id: "client_1", name: "Acme", code: "ACME", isActive: true, }) .mockResolvedValueOnce(null); const update = vi.fn().mockResolvedValue({ id: "client_1", name: "Acme", code: "ACME-2", isActive: true, }); await updateClient( createContext({ client: { findUnique, update, }, }), { id: "client_1", data: { code: "ACME-2", }, }, ); expect(findUnique).toHaveBeenNthCalledWith(2, { where: { code: "ACME-2" }, }); expect(update).toHaveBeenCalledWith({ where: { id: "client_1" }, data: { code: "ACME-2" }, }); }); it("rejects deletion while projects or child clients still exist", async () => { const findUnique = vi.fn().mockResolvedValue({ id: "client_1", name: "Acme", _count: { projects: 1, children: 0 }, }); const remove = vi.fn(); await expect( deleteClient( createContext({ client: { findUnique, delete: remove, }, }), { id: "client_1", }, ), ).rejects.toMatchObject({ code: "PRECONDITION_FAILED", message: "Cannot delete client with 1 project(s). Deactivate instead.", }); expect(remove).not.toHaveBeenCalled(); expect(createAuditEntry).not.toHaveBeenCalled(); }); it("deletes a valid client and audits the deletion", async () => { const findUnique = vi.fn().mockResolvedValue({ id: "client_1", name: "Acme", _count: { projects: 0, children: 0 }, }); const remove = vi.fn().mockResolvedValue({ id: "client_1" }); const result = await deleteClient( createContext({ client: { findUnique, delete: remove, }, }), { id: "client_1", }, ); expect(remove).toHaveBeenCalledWith({ where: { id: "client_1" }, }); expect(result).toEqual({ id: "client_1", name: "Acme", _count: { projects: 0, children: 0 }, }); expect(createAuditEntry).toHaveBeenCalledWith( expect.objectContaining({ entityType: "Client", action: "DELETE", entityId: "client_1", before: expect.objectContaining({ name: "Acme" }), }), ); }); it("batch-updates sort order and writes one audit per item", async () => { const update = vi.fn( ({ where, data }: { where: { id: string }; data: { sortOrder: number } }) => Promise.resolve({ id: where.id, sortOrder: data.sortOrder }), ); const $transaction = vi.fn().mockResolvedValue([]); const result = await batchUpdateClientSortOrder( createContext({ $transaction, client: { update, }, }), [ { id: "client_1", sortOrder: 10 }, { id: "client_2", sortOrder: 20 }, ], ); expect(update).toHaveBeenNthCalledWith(1, { where: { id: "client_1" }, data: { sortOrder: 10 }, }); expect(update).toHaveBeenNthCalledWith(2, { where: { id: "client_2" }, data: { sortOrder: 20 }, }); expect($transaction).toHaveBeenCalledTimes(1); expect($transaction.mock.calls[0]?.[0]).toHaveLength(2); expect(createAuditEntry).toHaveBeenCalledTimes(2); expect(createAuditEntry).toHaveBeenNthCalledWith( 1, expect.objectContaining({ entityId: "client_1", summary: "Updated sort order", }), ); expect(createAuditEntry).toHaveBeenNthCalledWith( 2, expect.objectContaining({ entityId: "client_2", summary: "Updated sort order", }), ); expect(result).toEqual({ ok: true }); }); it("surfaces precondition failures as TRPC errors", async () => { const findUnique = vi.fn().mockResolvedValue({ id: "client_1", name: "Acme", _count: { projects: 0, children: 2 }, }); await expect( deleteClient( createContext({ client: { findUnique, delete: vi.fn(), }, }), { id: "client_1", }, ), ).rejects.toBeInstanceOf(TRPCError); }); });