Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/projects/projectService.csv.test.ts
T

316 lines
11 KiB
TypeScript

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> = {}): 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<unknown>) => 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) }],
});
});
});