Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/leads/leadService.conversion-auth.test.ts
T

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,
},
});
});
});