575 lines
32 KiB
TypeScript
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 })');
|
|
});
|
|
});
|