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

575 lines
32 KiB
TypeScript

import { existsSync, 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 the restored pipeline admin routes through the projects admin permission", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
// WF-P8 split the board admin into the S07 table and the S04 settings page.
// Both forward to the shared action module, so the intents are asserted
// there while the permission gate stays asserted per route - a route that
// forgot its gate could otherwise reach the intents unguarded.
const boardsRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/admin.pipelines._index.tsx"), "utf8");
const boardSettingsRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/admin.pipelines.$pipelineId._index.tsx"), "utf8");
const adminAction = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/pipelineAdminAction.server.ts"), "utf8");
const lostReasonsRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/admin.pipelines.lost-reasons.tsx"), "utf8");
expect(service).toContain("export async function duplicatePipeline");
expect(service).toContain("export async function upsertPipelineFieldVisibility");
expect(service).toContain("export async function upsertLostReason");
expect(service).toContain("export async function archiveLostReason");
expect(boardsRoute).toContain('"projects.admin"');
expect(boardsRoute).toContain("handlePipelineAdminAction");
expect(boardSettingsRoute).toContain('"projects.admin"');
expect(boardSettingsRoute).toContain("handlePipelineAdminAction");
expect(adminAction).toContain('intent === "upsertFieldVisibility"');
expect(lostReasonsRoute).toContain('"projects.admin"');
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("batches project-volume visibility instead of querying once per card", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
expect(service).toContain("loadProjectVolumeVisibilityPolicyWithTx");
expect(service).toContain("pipelineFieldConfig.findMany");
expect(service).toContain("canViewProjectVolumeFromPolicy(project, input.ctx, volumePolicy)");
expect(service).not.toMatch(/for \(const project of projectsByStage[\s\S]{0,180}await canViewProjectVolumeWithTx/);
});
test("keeps project-board GET loading read-only and selects only a visible fallback pipeline", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
const boardLoader = service.split("export async function loadProjectBoard")[1]!.split("type ProjectListDbRow")[0]!;
expect(boardLoader).not.toContain("ensureDefaultSalesPipelineWithTx");
expect(boardLoader).toContain("tx.projekt.groupBy");
expect(boardLoader).toContain("potentialFallbackTotals");
expect(boardLoader).not.toContain("aggregateValue +=");
expect(boardLoader).not.toContain(".upsert(");
expect(boardLoader).not.toContain(".create(");
expect(boardLoader).toContain("pipelineVisibilityScopeWhere(input.tenantId, input.ctx)");
const lostReasonLoader = service.split("export async function listLostReasons")[1]!.split("export async function upsertLostReason")[0]!;
expect(lostReasonLoader).not.toContain("ensureDefaultLostReasonsWithTx");
const forecastLoader = service.split("export async function getPipelineForecast")[1]!.split("export async function moveProjectStage")[0]!;
expect(forecastLoader).not.toContain("rebuildPipelineAggregateCache");
expect(forecastLoader).toContain("pipelineAggregateSnapshot.findMany");
expect(forecastLoader).toContain("if (!canReadAllProjects(input.ctx))");
const aggregateRebuild = service.split("export async function rebuildPipelineAggregateCache")[1]!.split("export async function getPipelineForecast")[0]!;
expect(aggregateRebuild).toContain('hasPermission(input.ctx, "projects.admin")');
// The stage conversion rate divides by the pipeline total, not by the counter
// that is still accumulating inside the stage loop — dividing by the latter made
// the rate depend on stage iteration order. Entrants are counted per distinct
// project so a project moving back and forth cannot push a stage above 100%.
expect(aggregateRebuild).toContain("stageEntrants.length / pipelineProjectCount");
expect(aggregateRebuild).toContain('by: ["projektId"]');
expect(aggregateRebuild).not.toContain("stageMoves / totalProjectCount");
});
test("quick-create lands on the current board, not the default sales pipeline", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
// createProjectWithTx must resolve the GIVEN pipeline's first open stage when
// only a pipelineId is passed, and only fall back to the default pipeline when
// no pipelineId is provided at all.
const createProject = service.split("async function createProjectWithTx")[1]!.split("async function ")[0]!;
expect(createProject).toMatch(/if \(pipelineId && !stageId\)/);
expect(createProject).toContain('stageKind: "offen"');
expect(createProject).toMatch(/else if \(!pipelineId\)[\s\S]{0,200}ensureDefaultSalesPipelineWithTx/);
// The board handler forwards the viewed board's pipelineId to createProject.
const boardRoute = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectBoardRoute.server.ts"), "utf8");
const createIntent = boardRoute.split("const project = await createProject({")[1]!.split("});")[0]!;
expect(createIntent).toContain('pipelineId: str("pipelineId") || null');
// Both board and (pipeline-filtered) list routes send the current pipelineId.
const boardIndex = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects._index.tsx"), "utf8");
expect(boardIndex).toContain('post("createProject", { ...fields, pipelineId: data.pipelineId })');
const listRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.list.tsx"), "utf8");
expect(listRoute).toContain('if (data.query.pipelineId) fd.set("pipelineId", data.query.pipelineId)');
});
test("populates the aggregate cache after mutations and exposes owner KPIs", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
// Owner scope rows (REQ-projekt-forecast-kpis: "KPIs pro Verantwortlichem") incl. stale-row cleanup.
const aggregateRebuild = service.split("export async function rebuildPipelineAggregateCache")[1]!.split("export async function getPipelineForecast")[0]!;
expect(aggregateRebuild).toContain("`owner:${ownerUserId}`");
expect(aggregateRebuild).toContain('scopeKey: { startsWith: "owner:", notIn: ownerScopeKeys }');
// The refresh helper is derived-data fail-open: log a structured line, never fail the write.
expect(aggregateRebuild).toContain("export async function refreshPipelineAggregatesAfterMutation");
expect(aggregateRebuild).toContain("project-aggregate-refresh-failed");
// Forecast exposes the owner KPI rows; volume strings stay gated by aggregate visibility.
const forecastLoader = service.split("export async function getPipelineForecast")[1]!.split("export async function moveProjectStage")[0]!;
expect(forecastLoader).toContain("ownerKpis");
expect(forecastLoader).toContain('scopeKey.startsWith("owner:")');
// Every forecast-relevant mutation refreshes the cache after its committed transaction.
for (const fn of [
"moveProjectStage",
"bulkMoveProjectStage",
"undoLastProjectStageChange",
"createProject",
"createProjectFromQualifiedLead",
"updateProjectStammdaten",
"upsertPipelineStage",
"archivePipelineStage",
"archiveProject",
"bulkArchiveProjects",
]) {
const section = service.split(`export async function ${fn}(`)[1]!.split("\nexport ")[0]!;
expect(section, `${fn} must refresh the aggregate cache`).toContain("refreshPipelineAggregatesAfterMutation(");
}
});
test("routes provide board actions, list CSV, pipeline forecast, and SSE stream contracts", () => {
// AD-28 interim UI: board loading + stage actions live in the shared board-route
// helper; the `/projects` and pipeline routes wire that helper + the ~/ui screen.
const boardHelper = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectBoardRoute.server.ts"), "utf8");
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 exportRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.export.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(boardHelper).toContain("loadProjectBoard");
expect(boardHelper).toContain('"moveStage"');
expect(boardHelper).toContain('"undoStage"');
expect(boardRoute).toContain("loadKanbanData");
expect(boardRoute).toContain("PipelineKanbanPage");
expect(listRoute).toContain('export") === "csv"');
expect(listRoute).toContain("projectsExportPath");
expect(exportRoute).toContain("exportProjectListCsv");
expect(exportRoute).toContain("text/csv; charset=utf-8");
// Forecast stays a service export (getPipelineForecast, asserted above) consumed as
// P11 widgets; the interim pipeline board route wires the shared board helper.
expect(pipelineRoute).toContain("loadKanbanData");
expect(streamRoute).toContain("eventStream");
expect(streamRoute).toContain("projects.board.snapshot");
});
test("builds project sub-area display values in the loader", () => {
const detailRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.$id.tsx"), "utf8");
const loaderSource = detailRoute.split("export async function loader")[1]!.split("function formString")[0]!;
const componentSource = detailRoute.split("export default function ProjectDetailRoute")[1]!;
expect(loaderSource).toContain("buildProjectSubAreasVM(subAreas");
expect(componentSource).not.toContain("buildProjectSubAreasVM(");
// The "Auftrag & Ausführung" panel no longer hangs under S02, but its VM is
// still built: the S02 rail lists the project documents it carries.
expect(componentSource).not.toContain("<ProjectSubAreasPanel");
expect(componentSource).toContain("data.operationsVM");
});
});
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 project detail actions through the projects module and keeps the template admin reachable", () => {
const detailRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects.$id.tsx"), "utf8");
const auftragService = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/auftragService.server.ts"), "utf8");
expect(detailRoute).toContain("loadProjectSubAreas");
expect(detailRoute).toContain('"projects.read_own_member"');
expect(detailRoute).toContain('intent === "createInvoice"');
expect(detailRoute).toContain('intent === "upsertChecklistItem"');
expect(auftragService).toContain("export async function upsertAuftragPipelineStageConfig");
expect(auftragService).toContain("export async function upsertAcceptanceFormTemplate");
expect(auftragService).toContain("export async function upsertProjectTemplate");
// R-P9a DoD 5 — the template admin routes were dropped in the seat-billing
// recut (5fe3a2c79) while the backend services stayed callable; the
// restwork audit restored them as interim-UI routes. The routes must stay.
//
// Their entry point does not: the S07 acceptance took both links out of the
// board admin, which Davids export does not show. The two screens are now
// routable by URL but linked from nowhere. Giving them a home (sidebar entry
// or a settings hub) is open work, so this stops asserting that the board
// admin links to them — see the S07 note in .planning/ticket-docs/R-P9a.md.
expect(existsSync(resolveRepoPath("apps/web/app/routes/app.$tenant/admin.projects.acceptance-forms.tsx"))).toBe(true);
expect(existsSync(resolveRepoPath("apps/web/app/routes/app.$tenant/admin.projects.templates.tsx"))).toBe(true);
});
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 updateAcceptanceProtocolContent",
"export async function applyOfflineMutation",
].forEach((exportName) => {
expect(service).toContain(exportName);
});
expect(service).toContain("listApprovedAbsencesForRange");
expect(service).toContain("buildPdfExportArtifact");
expect(service).toContain("queueTransactionalEmail");
// R-P9b — lead notification goes through the M011 notify() service inside a
// tenant-bound RLS transaction, never through a raw delivery insert.
expect(service).toContain("notify(");
expect(service).not.toContain("createNotificationDelivery");
expect(service).toContain("createAcceptanceFollowUpTask");
});
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"');
// R-P9b DoD 6 — only completed acceptances (full/with_defects) open the gate.
expect(service).toContain('acceptanceKind: { in: ["full", "with_defects"] }');
});
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}"`);
});
[
'"plantafel.read_own": ["SuperUser", "Admin", "User"]',
'"plantafel.read_all": ["SuperUser", "Admin"]',
'"plantafel.write": ["SuperUser", "Admin", "User"]',
'"plantafel.bulk_reassign": ["SuperUser", "Admin"]',
'"acceptance.read": ["SuperUser", "Admin", "User"]',
'"acceptance.write": ["SuperUser", "Admin", "User"]',
'"acceptance.admin": ["SuperUser", "Admin"]',
].forEach((grant) => {
expect(permissionSeed).toContain(grant);
});
expect(roleDisplay).toContain("bulk_reassign");
expect(roleDisplay).toContain("plantafel");
expect(roleDisplay).toContain("acceptance");
});
});
describe("P8 board delta: staleness tag + context-menu duplicate/archive", () => {
test("resolves in-stage duration in one batch query (no N+1) with createdAt fallback", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
const boardLoader = service.split("export async function loadProjectBoard")[1]!.split("type ProjectListDbRow")[0]!;
expect(boardLoader).toContain("loadInStageSinceWithTx(tx, { tenantId: input.tenantId, projects: boardProjects })");
const helper = service.split("async function loadInStageSinceWithTx")[1]!.split("export async function loadProjectBoard")[0]!;
expect(helper).toContain("projektStageChange.findMany");
expect(helper).toContain('distinct: ["projektId"]');
expect(helper).toContain("undoneAt: null");
expect(helper).toContain("project.createdAt");
// The card raw data carries the ISO timestamp for the VM's stuckDays computation.
expect(service).toContain("inStageSince: inStageSince.toISOString()");
});
test("duplicateProject gates on write, reuses the number sequence, and skips lifecycle state", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
const section = service.split("export async function duplicateProject(")[1]!.split("\nexport function leadProjectName")[0]!;
expect(section).toContain('hasPermission(input.ctx, "projects.write")');
expect(section).toContain("projectReadScopeWhere(input.tenantId, input.ctx)");
expect(section).toContain('reserveNumberWithTx(tx, input.tenantId, "projekt", "P-")');
expect(section).toContain("(Kopie)");
expect(section).toContain("projektKontakt.createMany");
expect(section).toContain("isDemoData: source.isDemoData");
expect(section).toContain("publishProjectSearchUpsert");
expect(section).toContain("refreshPipelineAggregatesAfterMutation(");
// Lifecycle/conversion state must not be copied onto the new project.
expect(section).not.toContain("wonAt:");
expect(section).not.toContain("lostAt:");
expect(section).not.toContain("lostReasonId:");
expect(section).not.toContain("undoStageChangeUntil:");
expect(section).not.toContain("konvertiertAusLeadId:");
expect(section).not.toContain("appEntityBinding");
});
test("server-side project mutations enforce KWP ownership before changing protected fields", () => {
const service = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectService.server.ts"), "utf8");
const guardedOperations = [
{ name: "moveProjectStage", operation: '"stage"' },
{ name: "undoLastProjectStageChange", operation: '"stage"' },
{ name: "archiveProject", operation: '"archive"' },
{ name: "updateProjectStammdaten", operation: '"stammdaten"' },
];
for (const guarded of guardedOperations) {
const section = service.split(`export async function ${guarded.name}(`)[1]!.split("\nexport ")[0]!;
expect(section, guarded.name).toContain("assertKwpProjectMutationAllowed");
expect(section, guarded.name).toContain(`operation: ${guarded.operation}`);
}
const update = service.split("export async function updateProjectStammdaten(")[1]!.split("\nexport ")[0]!;
for (const field of ["projektname", "startDatum", "auftragsvolumen"]) {
expect(update).toContain(`"${field}"`);
}
const bulkMove = service.split("export async function bulkMoveProjectStage(")[1]!.split("\nexport ")[0]!;
const bulkArchive = service.split("export async function bulkArchiveProjects(")[1]!.split("\nexport ")[0]!;
expect(bulkMove).toContain("moveProjectStage({");
expect(bulkArchive).toContain("archiveProject({");
});
test("board routes wire duplicate/archive intents with per-intent permissions", () => {
const boardHelper = readFileSync(resolveRepoPath("apps/web/app/utils/integrations/projects/projectBoardRoute.server.ts"), "utf8");
expect(boardHelper).toContain('case "duplicateProject"');
expect(boardHelper).toContain('case "archiveProject"');
expect(boardHelper).toContain('type: "archived"');
expect(boardHelper).toContain("export function kanbanIntentPermission");
expect(boardHelper).toContain('canDelete: ctx.permissions.includes("projects.delete")');
const boardRoute = readFileSync(resolveRepoPath("apps/web/app/routes/app.$tenant/projects._index.tsx"), "utf8");
expect(boardRoute).toContain("kanbanIntentPermission(intent)");
expect(boardRoute).toContain('post("duplicateProject", { projectId })');
expect(boardRoute).toContain('post("archiveProject", { projectId })');
});
});