import { describe, expect, test } from "bun:test"; import { createClient, createProject, deleteProject, mergeProjects, updateClient, updateProject, } from "./billing"; import type { CapabilityContext } from "./context"; /** * Thenable stand-in for mergeProjects: every awaited select/where resolves the * next array from `selectQueue`; `update().set()` and `delete()` are captured. */ interface MergeChain { select: () => MergeChain; from: () => MergeChain; where: () => MergeChain; update: () => MergeChain; set: (v: Record) => MergeChain; delete: () => MergeChain; then: (resolve: (rows: unknown[]) => unknown) => unknown; } function makeMergeDb(selectQueue: unknown[][]) { let si = 0; let mode: "select" | "update" | "delete" | null = null; const captured = { updates: [] as Array>, deletes: 0, }; const chain: MergeChain = { select: () => { mode = "select"; return chain; }, from: () => chain, where: () => chain, update: () => { mode = "update"; return chain; }, set: (v) => { captured.updates.push(v); return chain; }, delete: () => { mode = "delete"; captured.deletes++; return chain; }, // biome-ignore lint/suspicious/noThenProperty: a deliberately-awaitable stand-in for a Drizzle query builder then: (resolve) => resolve(mode === "select" ? (selectQueue[si++] ?? []) : []), }; return { chain, captured }; } /** * Select-then-mutate stand-in: `select().…limit()` resolves the next array from * a FIFO queue of `selectResults`; `update().set()` captures its payload and * `returning()` echoes it back with an id. Mirrors the Drizzle chains the * update capabilities use. */ function makeMutateDb(selectResults: unknown[][]) { const captured: { set?: Record } = {}; let i = 0; // biome-ignore lint/suspicious/noExplicitAny: minimal chainable test double const chain: any = { select: () => chain, from: () => chain, leftJoin: () => chain, where: () => chain, limit: () => Promise.resolve(selectResults[i++] ?? []), update: () => chain, set: (v: Record) => { captured.set = v; return chain; }, returning: () => Promise.resolve([{ id: "row-id", ...(captured.set ?? {}) }]), }; return { chain, captured }; } // Minimal insert stand-in: captures the values() payload and resolves // returning() with the row plus a generated id, mirroring Drizzle's // insert().values().returning() chain. function makeInsertDb() { const captured: { values?: Record } = {}; // biome-ignore lint/suspicious/noExplicitAny: minimal chainable test double const chain: any = { insert: () => chain, values: (v: Record) => { captured.values = v; return chain; }, returning: () => Promise.resolve([{ id: "generated-id", ...captured.values }]), }; return { chain, captured }; } function ctxWith( db: unknown, organizationId: string | null, ): CapabilityContext { return { db: db as CapabilityContext["db"], userId: "user1", organizationId, }; } describe("createProject", () => { test("scopes to the org, trims the name, auto-picks a color", async () => { const { chain, captured } = makeInsertDb(); const result = await createProject.execute(ctxWith(chain, "org1"), { name: " Acme ", }); expect(captured.values?.name).toBe("Acme"); expect(captured.values?.organizationId).toBe("org1"); // color auto-picked when omitted expect(typeof captured.values?.color).toBe("string"); expect((captured.values?.color as string).length).toBeGreaterThan(0); expect(result).toMatchObject({ success: true, name: "Acme" }); expect((result as { projectId: string }).projectId).toBe("generated-id"); }); test("respects an explicit color and null org (local mode)", async () => { const { chain, captured } = makeInsertDb(); await createProject.execute(ctxWith(chain, null), { name: "Local", color: "#123456", }); expect(captured.values?.color).toBe("#123456"); expect(captured.values?.organizationId).toBeNull(); }); }); describe("createClient", () => { test("attributes to user + org, converts rate to cents, defaults currency", async () => { const { chain, captured } = makeInsertDb(); const result = await createClient.execute(ctxWith(chain, "org1"), { name: " Globex ", email: "billing@globex.test", defaultRate: 150, }); expect(captured.values?.name).toBe("Globex"); expect(captured.values?.userId).toBe("user1"); expect(captured.values?.organizationId).toBe("org1"); expect(captured.values?.defaultRate).toBe(15000); // dollars → cents expect(captured.values?.currency).toBe("USD"); expect(result).toMatchObject({ success: true, name: "Globex" }); }); test("leaves rate null when omitted and honors an explicit currency", async () => { const { chain, captured } = makeInsertDb(); await createClient.execute(ctxWith(chain, null), { name: "Initech", currency: "EUR", }); expect(captured.values?.defaultRate).toBeNull(); expect(captured.values?.currency).toBe("EUR"); expect(captured.values?.email).toBeNull(); expect(captured.values?.organizationId).toBeNull(); }); }); describe("updateProject", () => { test("associates a client after verifying both project and client are in-org", async () => { // select #1: project exists; select #2: client exists const { chain, captured } = makeMutateDb([[{ id: "p1" }], [{ id: "c1" }]]); const result = await updateProject.execute(ctxWith(chain, "org1"), { projectId: "p1", clientId: "c1", hourlyRate: 200, }); expect(captured.set?.clientId).toBe("c1"); expect(captured.set?.hourlyRate).toBe(20000); // dollars → cents expect(result).toMatchObject({ success: true }); }); test("null clientId dissociates and null hourlyRate clears the override", async () => { const { chain, captured } = makeMutateDb([[{ id: "p1" }]]); await updateProject.execute(ctxWith(chain, "org1"), { projectId: "p1", clientId: null, hourlyRate: null, }); expect(captured.set?.clientId).toBeNull(); expect(captured.set?.hourlyRate).toBeNull(); }); test("rejects a project not in the caller's org", async () => { const { chain } = makeMutateDb([[]]); // project lookup returns nothing const result = await updateProject.execute(ctxWith(chain, "org1"), { projectId: "other", name: "X", }); expect(result).toMatchObject({ error: expect.stringContaining("not found"), }); }); test("rejects a client that is not in the caller's org", async () => { // project found, client lookup empty const { chain } = makeMutateDb([[{ id: "p1" }], []]); const result = await updateProject.execute(ctxWith(chain, "org1"), { projectId: "p1", clientId: "foreign", }); expect(result).toMatchObject({ error: expect.stringContaining("Client foreign not found"), }); }); test("errors when no updatable fields are provided", async () => { const { chain } = makeMutateDb([[{ id: "p1" }]]); const result = await updateProject.execute(ctxWith(chain, "org1"), { projectId: "p1", }); expect(result).toMatchObject({ error: expect.stringContaining("No fields to update"), }); }); }); describe("updateClient", () => { test("updates rate (to cents) and stamps updatedAt", async () => { const { chain, captured } = makeMutateDb([[{ id: "c1" }]]); const result = await updateClient.execute(ctxWith(chain, "org1"), { clientId: "c1", name: " Renamed ", defaultRate: 175, }); expect(captured.set?.name).toBe("Renamed"); expect(captured.set?.defaultRate).toBe(17500); expect(captured.set?.updatedAt).toBeInstanceOf(Date); expect(result).toMatchObject({ success: true }); }); test("rejects a client not in the caller's org", async () => { const { chain } = makeMutateDb([[]]); const result = await updateClient.execute(ctxWith(chain, "org1"), { clientId: "foreign", name: "X", }); expect(result).toMatchObject({ error: expect.stringContaining("not found"), }); }); test("errors when only the id is provided (no real fields)", async () => { const { chain } = makeMutateDb([[{ id: "c1" }]]); const result = await updateClient.execute(ctxWith(chain, "org1"), { clientId: "c1", }); expect(result).toMatchObject({ error: expect.stringContaining("No fields to update"), }); }); }); describe("mergeProjects", () => { test("moves the sources' roots to the target, deletes the sources, renames", async () => { const { chain, captured } = makeMergeDb([ [{ id: "target" }, { id: "src1" }], // in-org verification [{ name: "Scribe" }], // target name after rename ]); const result = await mergeProjects.execute(ctxWith(chain, "org1"), { targetProjectId: "target", sourceProjectIds: ["src1", "target"], // target self-ref is dropped targetName: "Scribe", }); if ("error" in result) throw new Error(result.error as string); expect(result.merged).toBe(1); // First write moves roots to the target (manuallyAssigned on the hub). expect(captured.updates[0]).toMatchObject({ projectId: "target", manuallyAssigned: true, }); // Cascade-delete runs (linear mappings + rollups + projects = 3 deletes). expect(captured.deletes).toBe(3); // Target renamed to the canonical name. expect(captured.updates.some((u) => u.name === "Scribe")).toBe(true); }); test("rejects when no source differs from the target", async () => { const { chain } = makeMergeDb([]); const result = await mergeProjects.execute(ctxWith(chain, "org1"), { targetProjectId: "target", sourceProjectIds: ["target"], }); expect(result).toMatchObject({ error: expect.stringContaining("at least one project other than"), }); }); test("rejects a target not in the caller's org", async () => { const { chain } = makeMergeDb([[{ id: "src1" }]]); // target absent const result = await mergeProjects.execute(ctxWith(chain, "org1"), { targetProjectId: "target", sourceProjectIds: ["src1"], }); expect(result).toMatchObject({ error: expect.stringContaining("Target project target not found"), }); }); }); describe("deleteProject", () => { test("cascade-deletes the project so its work returns to unassigned", async () => { const { chain, captured } = makeMergeDb([ [{ id: "p1", name: "Scribe" }], // in-org verification ]); const result = await deleteProject.execute(ctxWith(chain, "org1"), { projectId: "p1", }); if ("error" in result) throw new Error(result.error as string); expect(result.projectId).toBe("p1"); expect(result.name).toBe("Scribe"); // Cascade re-points roots/github/live-sessions/invoice-lines to null // (4 updates) then deletes mappings + rollups + the project (3 deletes). expect(captured.updates.every((u) => u.projectId === null)).toBe(true); expect(captured.deletes).toBe(3); }); test("rejects a project not in the caller's org", async () => { const { chain, captured } = makeMergeDb([[]]); // project absent const result = await deleteProject.execute(ctxWith(chain, "org1"), { projectId: "ghost", }); expect(result).toMatchObject({ error: expect.stringContaining("Project ghost not found"), }); // Nothing was deleted when the project isn't in the org. expect(captured.deletes).toBe(0); }); });