315 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|