Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/leads/convertLead.test.ts
T

315 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildLeadConversionEventType, LeadAlreadyConvertedError, LeadNotFoundError } from "./convertLead.server";
vi.mock("~/utils/db.server", () => {
return { db: { $transaction: vi.fn() } };
});
vi.mock("@toolctrl/db/outbox", () => {
return {
publishOutboxEvent: vi.fn(),
};
});
describe("buildLeadConversionEventType", () => {
it("returns convertedtotrial for trial status", () => {
expect(buildLeadConversionEventType("trial")).toBe("marketing.lead.convertedtotrial");
});
it("returns convertedtocustomer for customer status", () => {
expect(buildLeadConversionEventType("customer")).toBe("marketing.lead.convertedtocustomer");
});
});
describe("convertLeadToTenant", () => {
let dbMock: any;
let publishMock: any;
let convertLeadToTenant: typeof import("./convertLead.server").convertLeadToTenant;
beforeEach(async () => {
vi.resetModules();
dbMock = (await import("~/utils/db.server")).db as any;
publishMock = (await import("@toolctrl/db/outbox")).publishOutboxEvent as any;
convertLeadToTenant = (await import("./convertLead.server")).convertLeadToTenant;
});
afterEach(() => {
vi.clearAllMocks();
});
function buildTx(leadOverrides: Record<string, unknown> = {}) {
const tx: any = {
lead: {
findUnique: vi.fn().mockResolvedValue({
id: "lead_1",
email: "test@example.com",
firstName: "Test",
lastName: "User",
company: "Acme",
source: "landing-pro",
utmSource: "google",
utmMedium: "cpc",
utmCampaign: "q2-launch",
utmTerm: null,
utmContent: null,
gclid: null,
fbclid: null,
referrer: null,
landingPage: "/pricing",
firstSeenAt: new Date("2026-05-10T00:00:00Z"),
status: "new",
convertedToTenantId: null,
...leadOverrides,
}),
update: vi.fn().mockResolvedValue({}),
},
tenant: {
create: vi.fn().mockResolvedValue({ id: "tenant_1" }),
},
leadEvent: {
create: vi.fn().mockResolvedValue({}),
},
tenantAcquisition: {
create: vi.fn().mockResolvedValue({}),
upsert: vi.fn().mockResolvedValue({}),
},
};
return tx;
}
it("happy-path creates tenant, updates lead, inserts acquisition, publishes outbox", async () => {
const tx = buildTx();
dbMock.$transaction.mockImplementation(async (fn: any) => fn(tx));
publishMock.mockResolvedValue({ id: "outbox_1" });
const result = await convertLeadToTenant({
leadId: "lead_1",
tenant: { name: "Acme", slug: "acme", inboundAddress: "acme@inbox.local" },
});
expect(result.tenantId).toBe("tenant_1");
expect(result.status).toBe("trial");
expect(result.alreadyConverted).toBe(false);
expect(result.outboxEventId).toBe("outbox_1");
expect(tx.tenant.create).toHaveBeenCalledOnce();
expect(tx.lead.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "lead_1" },
data: expect.objectContaining({ status: "trial", convertedToTenantId: "tenant_1" }),
})
);
expect(tx.leadEvent.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ type: "converted" }) })
);
expect(tx.tenantAcquisition.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
tenantId: "tenant_1",
leadId: "lead_1",
utmSource: "google",
utmCampaign: "q2-launch",
}),
})
);
expect(publishMock).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: "marketing.lead.convertedtotrial",
tenantId: "tenant_1",
dispatchKey: "lead-convert:lead_1",
})
);
});
it("publishes customer event when targetStatus=customer", async () => {
const tx = buildTx();
dbMock.$transaction.mockImplementation(async (fn: any) => fn(tx));
publishMock.mockResolvedValue({ id: "outbox_2" });
await convertLeadToTenant({
leadId: "lead_1",
targetStatus: "customer",
tenant: { name: "Acme", slug: "acme", inboundAddress: "acme@inbox.local" },
});
expect(publishMock).toHaveBeenCalledWith(
tx,
expect.objectContaining({ type: "marketing.lead.convertedtocustomer" })
);
});
it("returns alreadyConverted=true without re-creating tenant when lead already converted", async () => {
const tx = buildTx({ convertedToTenantId: "tenant_existing" });
dbMock.$transaction.mockImplementation(async (fn: any) => fn(tx));
const result = await convertLeadToTenant({
leadId: "lead_1",
tenant: { name: "Acme", slug: "acme", inboundAddress: "acme@inbox.local" },
});
expect(result.alreadyConverted).toBe(true);
expect(result.tenantId).toBe("tenant_existing");
expect(tx.tenant.create).not.toHaveBeenCalled();
expect(publishMock).not.toHaveBeenCalled();
});
it("throws LeadNotFoundError when lead missing", async () => {
const tx = buildTx();
tx.lead.findUnique.mockResolvedValue(null);
dbMock.$transaction.mockImplementation(async (fn: any) => fn(tx));
await expect(
convertLeadToTenant({
leadId: "lead_unknown",
tenant: { name: "Acme", slug: "acme", inboundAddress: "acme@inbox.local" },
})
).rejects.toMatchObject({ name: "LeadNotFoundError" });
expect(LeadNotFoundError.name).toBe("LeadNotFoundError");
});
it("admin-override path stamps conversionPath=admin-override + actorId on LeadEvent", async () => {
const tx = buildTx();
dbMock.$transaction.mockImplementation(async (fn: any) => fn(tx));
publishMock.mockResolvedValue({ id: "outbox_3" });
await convertLeadToTenant({
leadId: "lead_1",
conversionPath: "admin-override",
actorId: "user_admin",
tenant: { name: "Acme", slug: "acme", inboundAddress: "acme@inbox.local" },
});
expect(tx.leadEvent.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ actorId: "user_admin" }),
})
);
expect(tx.tenantAcquisition.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ conversionPath: "admin-override" }),
})
);
});
it("respects LEAD_CONVERSION_ENABLED=false rollback flag", async () => {
const originalEnv = process.env.LEAD_CONVERSION_ENABLED;
process.env.LEAD_CONVERSION_ENABLED = "false";
try {
await expect(
convertLeadToTenant({
leadId: "lead_1",
tenant: { name: "Acme", slug: "acme", inboundAddress: "acme@inbox.local" },
})
).rejects.toThrow(/disabled/i);
} finally {
if (originalEnv === undefined) delete process.env.LEAD_CONVERSION_ENABLED;
else process.env.LEAD_CONVERSION_ENABLED = originalEnv;
}
});
});
describe("recordExistingTenantLeadConversion", () => {
let dbMock: any;
let publishMock: any;
let recordExistingTenantLeadConversion: typeof import("./convertLead.server").recordExistingTenantLeadConversion;
beforeEach(async () => {
vi.resetModules();
dbMock = (await import("~/utils/db.server")).db as any;
publishMock = (await import("@toolctrl/db/outbox")).publishOutboxEvent as any;
recordExistingTenantLeadConversion = (await import("./convertLead.server")).recordExistingTenantLeadConversion;
});
afterEach(() => {
vi.clearAllMocks();
});
function buildExistingTenantTx(leadOverrides: Record<string, unknown> = {}) {
const tx: any = {
lead: {
findUnique: vi.fn().mockResolvedValue({
id: "lead_1",
email: "test@example.com",
firstName: "Test",
lastName: "User",
company: "Acme",
source: "landing-pro",
utmSource: "google",
utmMedium: "cpc",
utmCampaign: "q2-launch",
utmTerm: null,
utmContent: null,
gclid: null,
fbclid: null,
referrer: null,
landingPage: "/pricing",
firstSeenAt: new Date("2026-05-10T00:00:00Z"),
convertedToTenantId: null,
...leadOverrides,
}),
update: vi.fn().mockResolvedValue({}),
},
leadEvent: {
create: vi.fn().mockResolvedValue({}),
},
tenantAcquisition: {
upsert: vi.fn().mockResolvedValue({}),
},
};
return tx;
}
it("links an already-created registration tenant to the lead attribution and outbox event", async () => {
const tx = buildExistingTenantTx();
dbMock.$transaction.mockImplementation(async (fn: any) => fn(tx));
publishMock.mockResolvedValue({ id: "outbox_4" });
const result = await recordExistingTenantLeadConversion({
leadId: "lead_1",
tenantId: "tenant_registered",
planId: "plan_pro",
conversionPath: "self-service",
});
expect(result).toMatchObject({ tenantId: "tenant_registered", leadId: "lead_1", alreadyConverted: false, outboxEventId: "outbox_4" });
expect(tx.lead.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "lead_1" },
data: expect.objectContaining({ status: "trial", convertedToTenantId: "tenant_registered" }),
})
);
expect(tx.tenantAcquisition.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { tenantId: "tenant_registered" },
create: expect.objectContaining({ tenantId: "tenant_registered", leadId: "lead_1", conversionPath: "self-service" }),
update: expect.objectContaining({ leadId: "lead_1", conversionPath: "self-service" }),
})
);
expect(publishMock).toHaveBeenCalledWith(
tx,
expect.objectContaining({
tenantId: "tenant_registered",
type: "marketing.lead.convertedtotrial",
dispatchKey: "lead-convert:lead_1",
payload: expect.objectContaining({ leadId: "lead_1", tenantId: "tenant_registered", planId: "plan_pro" }),
})
);
});
it("is idempotent when the same tenant already owns the lead conversion", async () => {
const tx = buildExistingTenantTx({ convertedToTenantId: "tenant_registered" });
dbMock.$transaction.mockImplementation(async (fn: any) => fn(tx));
const result = await recordExistingTenantLeadConversion({ leadId: "lead_1", tenantId: "tenant_registered" });
expect(result.alreadyConverted).toBe(true);
expect(tx.lead.update).not.toHaveBeenCalled();
expect(tx.tenantAcquisition.upsert).not.toHaveBeenCalled();
expect(publishMock).not.toHaveBeenCalled();
});
it("rejects a lead already converted to a different tenant", async () => {
const tx = buildExistingTenantTx({ convertedToTenantId: "tenant_other" });
dbMock.$transaction.mockImplementation(async (fn: any) => fn(tx));
await expect(recordExistingTenantLeadConversion({ leadId: "lead_1", tenantId: "tenant_registered" })).rejects.toMatchObject({
name: "LeadAlreadyConvertedError",
convertedToTenantId: "tenant_other",
});
expect(LeadAlreadyConvertedError.name).toBe("LeadAlreadyConvertedError");
expect(tx.lead.update).not.toHaveBeenCalled();
});
});