196 lines
6.1 KiB
TypeScript
196 lines
6.1 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
tx: {
|
|
inboxLead: { findFirst: vi.fn(), update: vi.fn() },
|
|
kontakt: { findFirst: vi.fn() },
|
|
projekt: { findFirst: vi.fn() },
|
|
userRole: { findMany: vi.fn() },
|
|
groupUser: { findMany: vi.fn() },
|
|
},
|
|
publishOutboxEvent: vi.fn(),
|
|
publishLeadSearchUpsert: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
|
|
withTenantRLS: async (_tenantId: string, callback: (tx: typeof mocks.tx) => Promise<unknown>) => callback(mocks.tx),
|
|
}));
|
|
vi.mock("~/utils/db.server", () => ({ db: {} }));
|
|
vi.mock("@hivegrid/db/outbox", () => ({ publishOutboxEvent: mocks.publishOutboxEvent }));
|
|
vi.mock("@hivegrid/queue", () => ({ buildWorkflowTriggerOutboxEvent: vi.fn(() => ({})) }));
|
|
vi.mock("~/services/search/searchIndexProjection.server", () => ({
|
|
publishLeadSearchUpsert: mocks.publishLeadSearchUpsert,
|
|
publishProjectSearchUpsert: vi.fn(),
|
|
}));
|
|
|
|
import { completeLeadProjectConversion, requestLeadProjectConversion } from "./leadService.server";
|
|
|
|
const qualifiedLead = {
|
|
id: "lead-a",
|
|
tenantId: "tenant-a",
|
|
createdByUserId: "actor-a",
|
|
zustaendigerUserId: "actor-a",
|
|
quelle: "manual",
|
|
quelleDetail: null,
|
|
sourceId: null,
|
|
sourceOrigin: null,
|
|
submitterIpHash: null,
|
|
status: "qualified",
|
|
weiterverarbeitungFreigabe: "ja",
|
|
vorname: "Marie",
|
|
nachname: "Muster",
|
|
email: "marie@example.test",
|
|
telefon: null,
|
|
message: null,
|
|
rohdaten: null,
|
|
spamScore: 0,
|
|
spamReason: null,
|
|
isQuarantined: false,
|
|
konvertiertProjektId: null,
|
|
verknuepfterKontaktId: null,
|
|
convertedAt: null,
|
|
lostAt: null,
|
|
spamMarkedAt: null,
|
|
autoDeleteAt: null,
|
|
isDemoData: false,
|
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
archivedAt: null,
|
|
} as const;
|
|
|
|
describe("lead conversion target visibility", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.tx.inboxLead.findFirst.mockResolvedValue(qualifiedLead);
|
|
mocks.tx.inboxLead.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
|
|
...qualifiedLead,
|
|
...data,
|
|
}));
|
|
mocks.tx.kontakt.findFirst.mockResolvedValue({ id: "contact-a" });
|
|
mocks.tx.projekt.findFirst.mockResolvedValue({ id: "project-a" });
|
|
mocks.tx.userRole.findMany.mockResolvedValue([{ roleId: "role-a" }]);
|
|
mocks.tx.groupUser.findMany.mockResolvedValue([{ groupId: "group-a" }]);
|
|
});
|
|
|
|
it("returns 404 before mutation when a requested contact is outside crm.read_own", async () => {
|
|
mocks.tx.kontakt.findFirst.mockResolvedValueOnce(null);
|
|
|
|
await expect(
|
|
requestLeadProjectConversion({
|
|
tenantId: "tenant-a",
|
|
leadId: "lead-a",
|
|
kontaktId: " contact-from-tenant-b ",
|
|
ctx: { userId: "actor-a", permissions: ["leads.read_own", "leads.write", "crm.read_own"] },
|
|
})
|
|
).rejects.toMatchObject({ status: 404 });
|
|
|
|
expect(mocks.tx.kontakt.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "contact-from-tenant-b",
|
|
tenantId: "tenant-a",
|
|
archivedAt: null,
|
|
zustaendigerUserId: "actor-a",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
expect(mocks.tx.inboxLead.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 404 before mutation when the project is outside the project read scope", async () => {
|
|
mocks.tx.projekt.findFirst.mockResolvedValueOnce(null);
|
|
|
|
await expect(
|
|
completeLeadProjectConversion({
|
|
tenantId: "tenant-a",
|
|
leadId: "lead-a",
|
|
projektId: "project-from-tenant-b",
|
|
ctx: {
|
|
userId: "actor-a",
|
|
permissions: ["leads.read_own", "leads.write", "projects.read_all"],
|
|
roleIds: ["role-a"],
|
|
groupIds: ["group-a"],
|
|
},
|
|
})
|
|
).rejects.toMatchObject({ status: 404 });
|
|
|
|
expect(mocks.tx.projekt.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "project-from-tenant-b",
|
|
tenantId: "tenant-a",
|
|
archivedAt: null,
|
|
pipeline: {
|
|
accessRules: {
|
|
none: {
|
|
tenantId: "tenant-a",
|
|
kannSehen: false,
|
|
OR: [
|
|
{ principalType: "user", principalId: "actor-a" },
|
|
{ principalType: "role", principalId: "role-a" },
|
|
{ principalType: "group", principalId: "group-a" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
expect(mocks.tx.inboxLead.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 404 when the actor has no project read permission", async () => {
|
|
await expect(
|
|
completeLeadProjectConversion({
|
|
tenantId: "tenant-a",
|
|
leadId: "lead-a",
|
|
projektId: "project-a",
|
|
ctx: { userId: "actor-a", permissions: ["leads.read_own", "leads.write"] },
|
|
})
|
|
).rejects.toMatchObject({ status: 404 });
|
|
|
|
expect(mocks.tx.projekt.findFirst).not.toHaveBeenCalled();
|
|
expect(mocks.tx.inboxLead.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("validates and stores only visible normalized conversion targets", async () => {
|
|
await expect(
|
|
completeLeadProjectConversion({
|
|
tenantId: "tenant-a",
|
|
leadId: "lead-a",
|
|
projektId: " project-a ",
|
|
kontaktId: " contact-a ",
|
|
ctx: {
|
|
userId: "actor-a",
|
|
permissions: ["leads.read_own", "leads.write", "projects.read_all", "crm.read_own"],
|
|
roleIds: [],
|
|
groupIds: [],
|
|
},
|
|
})
|
|
).resolves.toMatchObject({
|
|
status: "converted",
|
|
konvertiertProjektId: "project-a",
|
|
verknuepfterKontaktId: "contact-a",
|
|
});
|
|
|
|
expect(mocks.tx.kontakt.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "contact-a",
|
|
tenantId: "tenant-a",
|
|
archivedAt: null,
|
|
zustaendigerUserId: "actor-a",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
expect(mocks.tx.inboxLead.update).toHaveBeenCalledWith({
|
|
where: { id: "lead-a", tenantId: "tenant-a" },
|
|
data: {
|
|
status: "converted",
|
|
weiterverarbeitungFreigabe: "ja",
|
|
konvertiertProjektId: "project-a",
|
|
verknuepfterKontaktId: "contact-a",
|
|
convertedAt: expect.any(Date),
|
|
autoDeleteAt: null,
|
|
},
|
|
});
|
|
});
|
|
});
|