Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/projects/projectService.test.ts
T
2026-07-08 16:15:37 +02:00

337 lines
17 KiB
TypeScript

import { readFileSync } from "node:fs";
import { resolveRepoPath } from "~/utils/testing/repoPaths";
import { describe, expect, test } from "vitest";
import { ProjectServiceError, validatePipelineStages } from "./projectService.server";
describe("project pipeline validation", () => {
test("accepts a pipeline with at least one won and lost stage", () => {
expect(() =>
validatePipelineStages([
{ name: "Neu", stageKind: "offen" },
{ name: "Gewonnen", stageKind: "gewonnen" },
{ name: "Verloren", stageKind: "verloren" },
]),
).not.toThrow();
});
test("rejects pipelines without a won stage", () => {
expect(() =>
validatePipelineStages([
{ name: "Neu", stageKind: "offen" },
{ name: "Verloren", stageKind: "verloren" },
]),
).toThrow(ProjectServiceError);
});
test("rejects pipelines without a lost stage", () => {
expect(() =>
validatePipelineStages([
{ name: "Neu", stageKind: "offen" },
{ name: "Gewonnen", stageKind: "gewonnen" },
]),
).toThrow(ProjectServiceError);
});
});
describe("R-P8a project backend artifacts", () => {
test("reserves project numbers with a row lock and without database extensions", () => {
const source = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
expect(source).toContain('FROM "HiveNumberSeq"');
expect(source).toContain("FOR UPDATE");
expect(source).not.toContain("gen_random_uuid");
});
test("keeps all project models in the RLS rollout artifact", () => {
const source = readFileSync(resolveRepoPath("prisma/rls/projekt-isolation.sql"), "utf8");
[
"HiveNumberSeq",
"Pipeline",
"PipelineStage",
"PipelineAccess",
"PipelineFieldConfig",
"PipelineFieldVisibility",
"LostReason",
"Projekt",
"ProjektStageChange",
"PipelineAggregateSnapshot",
"ProjektKontakt",
].forEach((model) => {
expect(source).toContain(`'${model}'`);
});
expect(source).toContain("ENABLE ROW LEVEL SECURITY");
expect(source).toContain("FORCE ROW LEVEL SECURITY");
expect(source).toContain("hivegrid_app");
});
test("registers the project tenant models for snapshot and restore", () => {
const source = readFileSync(resolveRepoPath("packages/cli/src/commands/tenant/models.ts"), "utf8");
["HiveNumberSeq", "Pipeline", "PipelineStage", "Projekt", "ProjektStageChange", "PipelineAggregateSnapshot", "ProjektKontakt"].forEach((model) => {
expect(source).toContain(`model: "${model}"`);
expect(source).toContain('schema: "projekt.prisma"');
});
});
test("exposes server-side pipeline and lost-reason mutations for the later screen layer", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
[
"export async function createPipeline",
"export async function updatePipeline",
"export async function archivePipeline",
"export async function duplicatePipeline",
"export async function upsertPipelineAccess",
"export async function upsertPipelineFieldConfig",
"export async function upsertPipelineFieldVisibility",
"export async function upsertLostReason",
"export async function archiveLostReason",
].forEach((exportName) => {
expect(service).toContain(exportName);
});
});
test("gates pipeline admin actions through the projects admin permission", () => {
const pipelineRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/admin.pipelines._index.tsx"), "utf8");
const lostReasonsRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/admin.pipelines.lost-reasons.tsx"), "utf8");
expect(pipelineRoute).toContain('"projects.admin"');
expect(pipelineRoute).toContain('intent === "duplicatePipeline"');
expect(pipelineRoute).toContain('intent === "upsertFieldVisibility"');
expect(lostReasonsRoute).toContain('"projects.admin"');
expect(lostReasonsRoute).toContain('intent === "upsertLostReason"');
expect(lostReasonsRoute).toContain('intent === "archiveLostReason"');
});
test("wires the lead conversion route into the P8 auto-project service", () => {
const route = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/leads.$id.tsx"), "utf8");
expect(route).toContain("createProjectFromQualifiedLead");
expect(route).toContain('intent === "convert"');
});
});
describe("R-P8b project board and forecast backend artifacts", () => {
test("persists stage history and aggregate cache in the project schema", () => {
const source = readFileSync(resolveRepoPath("prisma/schema/projekt.prisma"), "utf8");
expect(source).toContain("model ProjektStageChange");
expect(source).toContain("undoUntil");
expect(source).toContain("model PipelineAggregateSnapshot");
expect(source).toContain("weightedForecast");
expect(source).toContain("@@unique([tenantId, pipelineId, scopeKey])");
});
test("exposes board, list, forecast, stage move, undo, and CSV service contracts", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
[
"export async function loadProjectBoard",
"export async function listProjectsForTable",
"export async function exportProjectListCsv",
"export async function rebuildPipelineAggregateCache",
"export async function getPipelineForecast",
"export async function moveProjectStage",
"export async function undoLastProjectStageChange",
].forEach((exportName) => {
expect(service).toContain(exportName);
});
expect(service).toContain('event: "deal.won"');
expect(service).toContain('"projects.error.lostReasonRequired"');
expect(service).toContain("pipelineFieldVisibility");
expect(service).toContain("deals.read_aggregates");
});
test("routes provide board actions, list CSV, pipeline forecast, and SSE stream contracts", () => {
const boardRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects._index.tsx"), "utf8");
const listRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.list.tsx"), "utf8");
const pipelineRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.pipelines.$pipelineId.tsx"), "utf8");
const streamRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.pipelines.$pipelineId.stream.tsx"), "utf8");
expect(boardRoute).toContain("loadProjectBoard");
expect(boardRoute).toContain('intent === "moveStage"');
expect(boardRoute).toContain('intent === "undoStage"');
expect(listRoute).toContain('export") === "csv"');
expect(listRoute).toContain("text/csv; charset=utf-8");
expect(pipelineRoute).toContain("getPipelineForecast");
expect(streamRoute).toContain("eventStream");
expect(streamRoute).toContain("projects.board.snapshot");
});
});
describe("R-P9a order view and project sub-area backend artifacts", () => {
test("keeps order work as sub-areas on the existing project core", () => {
const source = readFileSync(resolveRepoPath("prisma/schema/auftrag.prisma"), "utf8");
[
"model ProjektMitglied",
"model ProjektTermin",
"model Rechnung",
"model ProjektChecklist",
"model ProjektChecklistItem",
"model AuftragPipelineStageConfig",
"model AcceptanceFormTemplate",
"model ProjectTemplate",
].forEach((model) => {
expect(source).toContain(model);
});
expect(source).toContain("projektId");
expect(source).not.toMatch(/^model\s+Auftrag\s+\{/m);
expect(source).not.toContain("ProjektExternesFeld");
});
test("reserves invoice numbers through HiveNumberSeq with a row lock", () => {
const source = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/auftragService.server.ts"), "utf8");
expect(source).toContain('FROM "HiveNumberSeq"');
expect(source).toContain("FOR UPDATE");
expect(source).toContain("entityType");
expect(source).toContain("rechnung");
expect(source).toContain('padStart(padding, "0")');
});
test("keeps all order sub-area models in the RLS rollout artifact", () => {
const source = readFileSync(resolveRepoPath("prisma/rls/auftrag-isolation.sql"), "utf8");
["ProjektMitglied", "ProjektTermin", "Rechnung", "ProjektChecklist", "ProjektChecklistItem", "AuftragPipelineStageConfig", "AcceptanceFormTemplate", "ProjectTemplate"].forEach(
(model) => {
expect(source).toContain(`'${model}'`);
},
);
expect(source).toContain("ENABLE ROW LEVEL SECURITY");
expect(source).toContain("FORCE ROW LEVEL SECURITY");
expect(source).toContain("hivegrid_app");
});
test("registers order sub-area tenant models for snapshot and restore", () => {
const source = readFileSync(resolveRepoPath("packages/cli/src/commands/tenant/models.ts"), "utf8");
["ProjektMitglied", "ProjektTermin", "Rechnung", "ProjektChecklist", "ProjektChecklistItem", "AuftragPipelineStageConfig", "AcceptanceFormTemplate", "ProjectTemplate"].forEach(
(model) => {
expect(source).toContain(`model: "${model}"`);
expect(source).toContain('schema: "auftrag.prisma"');
},
);
});
test("exposes order sub-area service contracts for the later screen layer", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/auftragService.server.ts"), "utf8");
[
"export async function loadProjectSubAreas",
"export async function addProjectMember",
"export async function createProjectAppointment",
"export async function createProjectInvoice",
"export async function updateProjectInvoiceStatus",
"export async function createProjectChecklist",
"export async function upsertProjectChecklistItem",
"export async function upsertAuftragPipelineStageConfig",
"export async function listAcceptanceFormTemplates",
"export async function upsertAcceptanceFormTemplate",
"export async function listProjectTemplates",
"export async function upsertProjectTemplate",
"export async function autoArchiveProjectsForStageConfigs",
].forEach((exportName) => {
expect(service).toContain(exportName);
});
});
test("routes gate sub-area and template actions through the projects module and permissions", () => {
const detailRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.$id.tsx"), "utf8");
const pipelineAdminRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/admin.pipelines._index.tsx"), "utf8");
const acceptanceFormsRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/admin.projects.acceptance-forms.tsx"), "utf8");
const templatesRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/admin.projects.templates.tsx"), "utf8");
expect(detailRoute).toContain("loadProjectSubAreas");
expect(detailRoute).toContain('"projects.read_own_member"');
expect(detailRoute).toContain('intent === "createInvoice"');
expect(detailRoute).toContain('intent === "upsertChecklistItem"');
expect(pipelineAdminRoute).toContain('intent === "upsertAuftragStageConfig"');
expect(acceptanceFormsRoute).toContain('"projects.admin"');
expect(acceptanceFormsRoute).toContain('intent === "upsertAcceptanceFormTemplate"');
expect(templatesRoute).toContain('"projects.admin"');
expect(templatesRoute).toContain('intent === "upsertProjectTemplate"');
});
test("seeds the additional projects permission needed by order memberships", () => {
const defaultPermissions = readFileSync(resolveRepoPath("apps/web/app/application/dtos/shared/DefaultPermissions.ts"), "utf8");
const permissionSeed = readFileSync(resolveRepoPath("packages/db/src/permissions-seed.server.ts"), "utf8");
expect(defaultPermissions).toContain('"projects.read_own_member"');
expect(permissionSeed).toContain('"projects.read_own_member"');
});
});
describe("R-P9b schedule board and acceptance backend artifacts", () => {
test("persists plantafel, acceptance, signature, and offline-sync models in the order schema", () => {
const source = readFileSync(resolveRepoPath("prisma/schema/auftrag.prisma"), "utf8");
[
"model ScheduleEntry",
"model ScheduleConflict",
"model AbnahmeProtokoll",
"model AbnahmeSignatur",
"model OfflineSyncMutation",
"enum AcceptanceKind",
"enum ScheduleEntryStatus",
].forEach((needle) => {
expect(source).toContain(needle);
});
expect(source).toContain("gesperrtAt");
expect(source).toContain("signerIpHash");
expect(source).toContain("@@unique([tenantId, clientMutationId])");
});
test("keeps all R-P9b models in RLS and tenant snapshot artifacts", () => {
const rls = readFileSync(resolveRepoPath("prisma/rls/auftrag-isolation.sql"), "utf8");
const tenantModels = readFileSync(resolveRepoPath("packages/cli/src/commands/tenant/models.ts"), "utf8");
["ScheduleEntry", "ScheduleConflict", "AbnahmeProtokoll", "AbnahmeSignatur", "OfflineSyncMutation"].forEach((model) => {
expect(rls).toContain(`'${model}'`);
expect(tenantModels).toContain(`model: "${model}"`);
expect(tenantModels).toContain('schema: "auftrag.prisma"');
});
});
test("exposes plantafel, acceptance, offline sync, PDF, and notification contracts", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/plantafelAcceptanceService.server.ts"), "utf8");
[
"export async function loadPlantafel",
"export async function upsertScheduleEntry",
"export async function updateScheduleEntryStatus",
"export async function bulkReassignScheduleEntries",
"export async function listAcceptanceProtocols",
"export async function submitAcceptanceProtocol",
"export async function archiveAcceptanceProtocol",
"export async function hardDeleteAcceptanceProtocol",
"export async function applyOfflineMutation",
].forEach((exportName) => {
expect(service).toContain(exportName);
});
expect(service).toContain("listApprovedAbsencesForRange");
expect(service).toContain("buildPdfExportArtifact");
expect(service).toContain("queueTransactionalEmail");
expect(service).toContain("createNotificationDelivery");
expect(service).toContain("createTask");
});
test("blocks stage moves when a target stage requires an acceptance protocol", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
expect(service).toContain("requiresAcceptanceProtocol");
expect(service).toContain("abnahmeProtokoll.findFirst");
expect(service).toContain('"projects.error.acceptanceProtocolRequired"');
});
test("routes expose plantafel, acceptance list, and acceptance submit contracts behind module gates", () => {
const plantafelRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.plantafel.tsx"), "utf8");
const acceptanceRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.$id.acceptance._index.tsx"), "utf8");
const acceptanceNewRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.$id.acceptance.new.tsx"), "utf8");
expect(plantafelRoute).toContain("loadPlantafel");
expect(plantafelRoute).toContain('intent === "upsertSchedule"');
expect(plantafelRoute).toContain('intent === "offlineSync"');
expect(plantafelRoute).toContain('"plantafel.bulk_reassign"');
expect(acceptanceRoute).toContain("listAcceptanceProtocols");
expect(acceptanceRoute).toContain('intent === "archiveAcceptanceProtocol"');
expect(acceptanceNewRoute).toContain("submitAcceptanceProtocol");
expect(acceptanceNewRoute).toContain('"acceptance.write"');
});
test("registers R-P9b permissions and labels", () => {
const defaultPermissions = readFileSync(resolveRepoPath("apps/web/app/application/dtos/shared/DefaultPermissions.ts"), "utf8");
const permissionSeed = readFileSync(resolveRepoPath("packages/db/src/permissions-seed.server.ts"), "utf8");
const roleDisplay = readFileSync(resolveRepoPath("apps/web/app/components/core/roles/roleDisplay.ts"), "utf8");
["plantafel.read_own", "plantafel.read_all", "plantafel.write", "plantafel.bulk_reassign", "acceptance.read", "acceptance.write", "acceptance.admin"].forEach(
(permission) => {
expect(defaultPermissions).toContain(`"${permission}"`);
expect(permissionSeed).toContain(`"${permission}"`);
},
);
expect(roleDisplay).toContain("bulk_reassign");
expect(roleDisplay).toContain("plantafel");
expect(roleDisplay).toContain("acceptance");
});
});