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) => 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 }) => ({ ...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, }, }); }); });