import { beforeEach, describe, expect, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ withTenantRLS: vi.fn(), })); vi.mock("~/utils/db/.server/tenantRls.server", () => ({ withTenantRLS: mocks.withTenantRLS, })); import { exportProjectListCsv, filterAggregateReadablePipelineIdsWithTx, normalizeProjectListPage, ownerScopeWhere, projectEditScopeWhere, projectReadScopeWhere, PROJECT_CSV_EXPORT_MAX_ROWS, } from "./projectService.server"; type ProjectFixture = { id: string; tenantId: string; pipelineId: string; stageId: string; projektNummer: string; projektname: string; potenziellerAuftragswert: string | null; auftragsvolumen: string | null; zustaendigePersonId: string | null; projektleiterId: string | null; updatedAt: Date; pipeline: { id: string; name: string }; stage: { id: string; name: string }; }; function projectFixture(index: number, overrides: Partial = {}): ProjectFixture { const ordinal = String(index + 1).padStart(4, "0"); return { id: `project-${ordinal}`, tenantId: "tenant-1", pipelineId: "pipeline-1", stageId: "stage-1", projektNummer: `HG-${ordinal}`, projektname: `Projekt ${ordinal}`, potenziellerAuftragswert: null, auftragsvolumen: `${index + 1}.00`, zustaendigePersonId: "owner-1", projektleiterId: "manager-1", updatedAt: new Date("2026-07-13T12:00:00.000Z"), pipeline: { id: "pipeline-1", name: "Vertrieb" }, stage: { id: "stage-1", name: "Neu" }, ...overrides, }; } function installTenantTransaction(projects: readonly ProjectFixture[]) { const findMany = vi.fn().mockResolvedValue(projects); const pipelineFieldConfigFindMany = vi.fn().mockResolvedValue([]); const tx = { projekt: { findMany }, pipelineFieldConfig: { findMany: pipelineFieldConfigFindMany }, }; mocks.withTenantRLS.mockImplementation(async (_tenantId: string, callback: (client: typeof tx) => Promise) => callback(tx)); return { findMany, pipelineFieldConfigFindMany }; } const context = { userId: "user-1", permissions: ["projects.read_all", "deals.read_volume_all"], roleIds: ["role-1"], groupIds: ["group-1"], }; describe("project CSV export", () => { beforeEach(() => { vi.clearAllMocks(); }); test("exports all 537 matching projects without applying the table pagination clamp", async () => { const projects = Array.from({ length: 537 }, (_, index) => projectFixture(index)); const { findMany, pipelineFieldConfigFindMany } = installTenantTransaction(projects); const csv = await exportProjectListCsv({ tenantId: "tenant-1", ctx: context }); expect(mocks.withTenantRLS).toHaveBeenCalledWith("tenant-1", expect.any(Function)); expect(findMany).toHaveBeenCalledOnce(); expect(findMany).toHaveBeenCalledWith({ where: { tenantId: "tenant-1", archivedAt: null, isDemoData: false, pipeline: { accessRules: { none: { tenantId: "tenant-1", kannSehen: false, OR: [ { principalType: "user", principalId: "user-1" }, { principalType: "role", principalId: "role-1" }, { principalType: "group", principalId: "group-1" }, ], }, }, }, }, select: expect.objectContaining({ id: true, projektNummer: true, pipeline: { select: { name: true } }, stage: { select: { name: true } }, }), orderBy: { updatedAt: "desc" }, take: PROJECT_CSV_EXPORT_MAX_ROWS + 1, }); expect(pipelineFieldConfigFindMany).toHaveBeenCalledOnce(); const lines = csv.trimEnd().split("\n"); expect(lines).toHaveLength(538); expect(lines[1]).toContain('"HG-0001"'); expect(lines.at(-1)).toContain('"HG-0537"'); }); test("neutralizes spreadsheet formulas in every user-controlled CSV cell", async () => { installTenantTransaction([ projectFixture(0, { projektNummer: "+SUM(1,1)", projektname: '=HYPERLINK("https://evil.example","click")', auftragsvolumen: "-5", zustaendigePersonId: "@OWNER", pipeline: { id: "pipeline-1", name: "\t=CMD()" }, stage: { id: "stage-1", name: " -1+2" }, }), ]); const csv = await exportProjectListCsv({ tenantId: "tenant-1", ctx: context }); const row = csv.trimEnd().split("\n")[1]; expect(row).toContain('"\'+SUM(1,1)"'); expect(row).toContain('"\'=HYPERLINK(""https://evil.example"",""click"")"'); expect(row).toContain('"\'\t=CMD()"'); expect(row).toContain('"\' -1+2"'); expect(row).toContain('"\'-5.00"'); expect(row).toContain('"\'@OWNER"'); expect(row).toContain('"2026-07-13T12:00:00.000Z"'); }); test("hides both monetary columns when the potential-value field is denied", async () => { const { pipelineFieldConfigFindMany } = installTenantTransaction([ projectFixture(0, { potenziellerAuftragswert: "95000", auftragsvolumen: null }), ]); pipelineFieldConfigFindMany.mockResolvedValue([ { pipelineId: "pipeline-1", stageId: "stage-1", fieldKey: "potenziellerAuftragswert", sichtbar: false, visibilities: [], }, ]); const csv = await exportProjectListCsv({ tenantId: "tenant-1", ctx: context }); expect(csv.trimEnd().split("\n")[1]).toContain('"—"'); expect(csv).not.toContain("95000.00"); }); test("keeps volume fields hidden when duplicate legacy configs contain an explicit deny", async () => { const { pipelineFieldConfigFindMany } = installTenantTransaction([ projectFixture(0, { potenziellerAuftragswert: "95000", auftragsvolumen: null }), ]); pipelineFieldConfigFindMany.mockResolvedValue([ { pipelineId: "pipeline-1", stageId: "stage-1", fieldKey: "potenziellerAuftragswert", sichtbar: true, visibilities: [{ kannSehen: true, kannBearbeiten: true }], }, { pipelineId: "pipeline-1", stageId: "stage-1", fieldKey: "potenziellerAuftragswert", sichtbar: true, visibilities: [{ kannSehen: false, kannBearbeiten: false }], }, ]); const csv = await exportProjectListCsv({ tenantId: "tenant-1", ctx: context }); expect(csv.trimEnd().split("\n")[1]).toContain('"—"'); expect(csv).not.toContain("95000.00"); }); test("rejects an unbounded export before building field-visibility rows", async () => { const projects = Array.from({ length: PROJECT_CSV_EXPORT_MAX_ROWS + 1 }, (_, index) => projectFixture(index)); const { pipelineFieldConfigFindMany } = installTenantTransaction(projects); await expect(exportProjectListCsv({ tenantId: "tenant-1", ctx: context })).rejects.toMatchObject({ messageKey: "projects.error.exportTooLarge", }); expect(pipelineFieldConfigFindMany).not.toHaveBeenCalled(); }); test("keeps owner and pipeline scopes when a search query adds another AND clause", async () => { const { findMany } = installTenantTransaction([]); await exportProjectListCsv({ tenantId: "tenant-1", ctx: { userId: "user-1", permissions: ["projects.read_own"], roleIds: [], groupIds: [] }, filters: { q: "Dach" }, }); const where = findMany.mock.calls[0]![0].where; expect(where.AND).toEqual( expect.arrayContaining([ { OR: [{ zustaendigePersonId: "user-1" }] }, expect.objectContaining({ pipeline: expect.any(Object) }), { OR: [{ projektname: { contains: "Dach", mode: "insensitive" } }, { projektNummer: { contains: "Dach", mode: "insensitive" } }] }, ]), ); }); }); describe("project list visibility helpers", () => { test("keeps owner, project-lead, and membership scopes additive without broadening", () => { expect(ownerScopeWhere("tenant-1", { userId: "user-1", permissions: ["projects.read_own"] })).toEqual({ OR: [{ zustaendigePersonId: "user-1" }], }); expect(ownerScopeWhere("tenant-1", { userId: "user-1", permissions: ["projects.read_own_pl"] })).toEqual({ OR: [{ projektleiterId: "user-1" }], }); expect( ownerScopeWhere("tenant-1", { userId: "user-1", permissions: ["projects.read_own_member"], groupIds: ["group-1"], }), ).toEqual({ OR: [ { mitglieder: { some: { tenantId: "tenant-1", archivedAt: null, OR: [{ userId: "user-1" }, { groupId: { in: ["group-1"] } }], }, }, }, ], }); expect(ownerScopeWhere("tenant-1", { userId: "user-1", permissions: [] })).toEqual({ id: { in: [] } }); expect(ownerScopeWhere("tenant-1", { userId: "user-1", permissions: ["projects.read_all"] })).toEqual({}); }); test("resolves aggregate field visibility in one query with explicit deny precedence", async () => { const findMany = vi.fn().mockResolvedValue([ { pipelineId: "pipeline-1", sichtbar: true, visibilities: [{ kannSehen: true }], }, { pipelineId: "pipeline-1", sichtbar: true, visibilities: [{ kannSehen: false }], }, { pipelineId: "pipeline-2", sichtbar: true, visibilities: [], }, ]); await expect( filterAggregateReadablePipelineIdsWithTx( { pipelineFieldConfig: { findMany } } as never, { tenantId: "tenant-1", pipelineIds: ["pipeline-1", "pipeline-2", "pipeline-2"], ctx: context, backstopPermissions: ["deals.read_volume_all"], }, ), ).resolves.toEqual(["pipeline-2"]); expect(findMany).toHaveBeenCalledOnce(); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ tenantId: "tenant-1", pipelineId: { in: ["pipeline-1", "pipeline-2"] }, fieldKey: "aggregate:forecast", stageId: null, }), }), ); }); test("normalizes invalid and abusive page values", () => { expect(normalizeProjectListPage("foo")).toBe(1); expect(normalizeProjectListPage(Number.POSITIVE_INFINITY)).toBe(1); expect(normalizeProjectListPage(-5)).toBe(1); expect(normalizeProjectListPage("2")).toBe(2); expect(normalizeProjectListPage(999_999)).toBe(10_000); }); test("keeps project access scopes inside AND so they cannot replace a concrete project id", () => { const ctx = { userId: "user-1", permissions: [] as string[] }; expect(projectReadScopeWhere("tenant-1", ctx)).toMatchObject({ AND: [{ id: { in: [] } }, { pipeline: expect.any(Object) }], }); expect(projectEditScopeWhere("tenant-1", ctx)).toMatchObject({ AND: [{ id: { in: [] } }, { pipeline: expect.any(Object) }], }); }); });