337 lines
17 KiB
TypeScript
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");
|
|
});
|
|
});
|