183 lines
8.9 KiB
TypeScript
183 lines
8.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ApprovalState, DeliveryStatus, DeliveryValidationStatus } from "@prisma/client";
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const tx = {
|
|
deliveryPlan: { create: vi.fn(), update: vi.fn() },
|
|
initiative: { update: vi.fn() },
|
|
outboxEvent: { create: vi.fn() },
|
|
deliveryRun: { create: vi.fn(), update: vi.fn() },
|
|
deliveryValidation: { create: vi.fn(), deleteMany: vi.fn() },
|
|
findingInvestigation: { findUnique: vi.fn(), update: vi.fn() },
|
|
repositoryFinding: { update: vi.fn() },
|
|
};
|
|
const prisma = {
|
|
project: { findFirst: vi.fn() },
|
|
initiative: { findFirstOrThrow: vi.fn() },
|
|
deliveryPlan: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
giteaRepositoryLink: { findFirst: vi.fn() },
|
|
deliveryRun: { findFirst: vi.fn() },
|
|
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
|
};
|
|
return {
|
|
prisma,
|
|
tx,
|
|
createInitiativeForProject: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("../src/db/prisma.js", () => ({ prisma: mocks.prisma }));
|
|
vi.mock("../src/repositories/initiativesRepository.js", () => ({ createInitiativeForProject: mocks.createInitiativeForProject }));
|
|
|
|
import { createFeatureDelivery, startFeatureDelivery, recordFeatureDeliveryActionValidation, reportedValidationResult, type FeatureDeliveryInput } from "../src/repositories/featureDeliveryRepository.js";
|
|
|
|
const baseInput = (riskLevel: FeatureDeliveryInput["riskLevel"]): FeatureDeliveryInput => ({
|
|
title: "Intent-first onboarding",
|
|
intent: "Make onboarding understandable for nontechnical users.",
|
|
priority: "normal",
|
|
problem: "The current onboarding exposes too many technical concepts.",
|
|
desiredOutcome: "A short intent-first onboarding flow.",
|
|
users: ["new users"],
|
|
goals: ["reduce cognitive load"],
|
|
nonGoals: ["remove technical capabilities"],
|
|
acceptanceCriteria: ["normal users can create a project without understanding MCP or Git internals"],
|
|
implementationPlan: ["simplify the primary flow", "preserve technical drill-downs"],
|
|
risks: ["navigation changes can affect deep links"],
|
|
validationCommands: ["pnpm test"],
|
|
riskLevel,
|
|
});
|
|
|
|
const plan = (approvalState: ApprovalState, status: DeliveryStatus) => ({
|
|
id: "delivery-db-1",
|
|
slug: "delivery-1",
|
|
projectId: "project-db-1",
|
|
initiativeId: "initiative-db-1",
|
|
title: "Intent-first onboarding",
|
|
intent: "Make onboarding understandable for nontechnical users.",
|
|
priority: "normal",
|
|
problem: "The current onboarding exposes too many technical concepts.",
|
|
desiredOutcome: "A short intent-first onboarding flow.",
|
|
usersJson: ["new users"],
|
|
goalsJson: ["reduce cognitive load"],
|
|
nonGoalsJson: [],
|
|
acceptanceCriteriaJson: ["normal users understand the flow"],
|
|
implementationPlanJson: ["simplify the primary flow"],
|
|
risksJson: ["navigation changes"],
|
|
validationCommandsJson: ["pnpm test"],
|
|
status,
|
|
approvalState,
|
|
approvedAt: approvalState === ApprovalState.approved ? new Date("2026-08-31T20:00:00.000Z") : null,
|
|
approvedBy: approvalState === ApprovalState.approved ? "chatgpt" : null,
|
|
reviewFeedback: null,
|
|
version: 1,
|
|
createdBy: "chatgpt",
|
|
updatedBy: "chatgpt",
|
|
createdAt: new Date("2026-08-31T20:00:00.000Z"),
|
|
updatedAt: new Date("2026-08-31T20:00:00.000Z"),
|
|
initiative: null,
|
|
runs: [],
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.prisma.project.findFirst.mockResolvedValue({ id: "project-db-1", slug: "projectbrain" });
|
|
mocks.createInitiativeForProject.mockResolvedValue({ id: "initiative-1" });
|
|
mocks.prisma.initiative.findFirstOrThrow.mockResolvedValue({ id: "initiative-db-1", slug: "initiative-1" });
|
|
mocks.tx.initiative.update.mockResolvedValue({});
|
|
mocks.tx.outboxEvent.create.mockResolvedValue({});
|
|
mocks.tx.findingInvestigation.findUnique.mockResolvedValue(null);
|
|
});
|
|
|
|
describe("feature delivery risk authorization", () => {
|
|
it.each([
|
|
["low", "AUTO_ALLOWED"],
|
|
["medium", "AUTO_ALLOWED_WITH_AUDIT"],
|
|
] as const)("auto-authorizes %s reversible work", async (riskLevel, policyDecision) => {
|
|
const created = plan(ApprovalState.approved, DeliveryStatus.approved);
|
|
mocks.tx.deliveryPlan.create.mockResolvedValue(created);
|
|
|
|
const result = await createFeatureDelivery("projectbrain", baseInput(riskLevel), "chatgpt");
|
|
|
|
expect(result.approvalState).toBe(ApprovalState.approved);
|
|
expect(result.spec.status).toBe(DeliveryStatus.approved);
|
|
expect(mocks.tx.deliveryPlan.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
approvalState: ApprovalState.approved,
|
|
status: DeliveryStatus.approved,
|
|
}),
|
|
}));
|
|
expect(mocks.tx.outboxEvent.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
eventType: "delivery.plan.auto_authorized",
|
|
payloadJson: expect.objectContaining({ riskLevel, policyDecision }),
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it.each(["high", "destructive"] as const)("requires a human decision for %s work", async (riskLevel) => {
|
|
const created = plan(ApprovalState.pending, DeliveryStatus.awaiting_approval);
|
|
mocks.tx.deliveryPlan.create.mockResolvedValue(created);
|
|
|
|
const result = await createFeatureDelivery("projectbrain", baseInput(riskLevel), "chatgpt");
|
|
|
|
expect(result.approvalState).toBe(ApprovalState.pending);
|
|
expect(result.spec.status).toBe(DeliveryStatus.awaiting_approval);
|
|
expect(mocks.tx.deliveryPlan.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
approvalState: ApprovalState.pending,
|
|
status: DeliveryStatus.awaiting_approval,
|
|
approvedAt: null,
|
|
approvedBy: null,
|
|
}),
|
|
}));
|
|
expect(mocks.tx.outboxEvent.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
eventType: "delivery.plan.user_decision_required",
|
|
payloadJson: expect.objectContaining({ riskLevel, policyDecision: "USER_DECISION_REQUIRED" }),
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it("fails closed when a pending high-risk delivery tries to start repository execution", async () => {
|
|
mocks.prisma.deliveryPlan.findFirst.mockResolvedValue(plan(ApprovalState.pending, DeliveryStatus.awaiting_approval));
|
|
|
|
await expect(startFeatureDelivery("projectbrain", "delivery-1", "chatgpt/high-risk-change"))
|
|
.rejects.toThrow("FEATURE_DELIVERY_APPROVAL_REQUIRED");
|
|
|
|
expect(mocks.prisma.giteaRepositoryLink.findFirst).not.toHaveBeenCalled();
|
|
expect(mocks.tx.deliveryRun.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Actions evidence scope", () => {
|
|
it("records only the observed workflow, preserving separately reported checks", async () => {
|
|
const current = { ...plan(ApprovalState.approved, DeliveryStatus.validating),
|
|
validationCommandsJson: ["dotnet test", "CI / build-test (pull_request)", "Live disposable provider smoke"],
|
|
runs: [{ id: "run-1", validations: [], changes: [] }] };
|
|
mocks.prisma.deliveryPlan.findFirst.mockResolvedValue(current);
|
|
mocks.tx.deliveryPlan.update.mockResolvedValue(current);
|
|
await recordFeatureDeliveryActionValidation("projectbrain", "delivery-1", {
|
|
provider: "gitea_actions", runId: 9205, runNumber: 500, url: "https://example.test/actions/runs/9205",
|
|
workflowPath: "managed-validation.yml@refs/heads/chatgpt/work", headSha: "a".repeat(40),
|
|
conclusion: "success", verifiedAt: "2026-09-08T00:00:00Z",
|
|
});
|
|
expect(mocks.tx.deliveryValidation.create).toHaveBeenCalledTimes(1);
|
|
expect(mocks.tx.deliveryValidation.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
|
command: "Gitea Actions: managed-validation.yml@refs/heads/chatgpt/work", status: "passed", headSha: "a".repeat(40),
|
|
}) });
|
|
expect(mocks.tx.deliveryValidation.deleteMany).toHaveBeenCalledWith({ where: { deliveryRunId: "run-1", provider: "gitea_actions" } });
|
|
});
|
|
|
|
it.each(["CI / build-test (pull_request)", "Live provider smoke", "dotnet test"])("does not present legacy inferred %s as execution", (command) => {
|
|
expect(reportedValidationResult({ command, status: DeliveryValidationStatus.passed, provider: "gitea_actions", summary: "managed-validation.yml" }))
|
|
.toEqual({ command, status: "not_run", summary: expect.stringContaining("Legacy inferred result") });
|
|
});
|
|
|
|
it("preserves explicit reports and directly observed workflow results", () => {
|
|
expect(reportedValidationResult({ command: "Live provider smoke", status: DeliveryValidationStatus.skipped, provider: null, summary: "Credentials unavailable" }).status).toBe("not_run");
|
|
expect(reportedValidationResult({ command: "Local contract suite", status: DeliveryValidationStatus.failed, provider: null, summary: "Failed" }).status).toBe("failed");
|
|
expect(reportedValidationResult({ command: "CI / build-test (pull_request)", status: DeliveryValidationStatus.failed, provider: "gitea_actions", summary: "ci.yml" }).status).toBe("failed");
|
|
expect(reportedValidationResult({ command: "Gitea Actions: ci.yml", status: DeliveryValidationStatus.passed, provider: "gitea_actions", summary: "ci.yml" }).status).toBe("passed");
|
|
});
|
|
});
|