feat(FN-3787): add approval workflow and share blocks to reports plugin
Added an approval workflow to the reports plugin comprising a state machine (`approval.ts`), share blocks logic (`share-blocks.ts`), API routes for approvals, and two new dashboard panels (ReportApprovalPanel and ShareBlocksPanel), with corresponding tests; also updated the plugin README and added a Fusion-Task-Id: FN-3787
This commit is contained in:
164
plugins/fusion-plugin-reports/src/__tests__/approval.test.ts
Normal file
164
plugins/fusion-plugin-reports/src/__tests__/approval.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyDecision, nextApprovalState, type ApprovalAction, type ApprovalActor, type ApprovalDecision, type ApprovalSettings, type ApprovalState } from "../approval.js";
|
||||
import type { Report } from "../store/report-types.js";
|
||||
|
||||
interface MatrixCase {
|
||||
approvalRequired: boolean;
|
||||
autoPublishOnApproval: boolean;
|
||||
actorIsApprover: boolean;
|
||||
action: ApprovalAction;
|
||||
expected: ApprovalState | "invalid_transition" | "unauthorized";
|
||||
}
|
||||
|
||||
const matrix: MatrixCase[] = [
|
||||
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: false, action: "approve", expected: "approved" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: false, action: "reject", expected: "rejected" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: false, action: "publish", expected: "invalid_transition" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: true, action: "approve", expected: "approved" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: true, action: "reject", expected: "rejected" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: true, action: "publish", expected: "invalid_transition" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: false, action: "approve", expected: "published" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: false, action: "reject", expected: "rejected" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: false, action: "publish", expected: "invalid_transition" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: true, action: "approve", expected: "published" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: true, action: "reject", expected: "rejected" },
|
||||
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: true, action: "publish", expected: "invalid_transition" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: false, action: "approve", expected: "unauthorized" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: false, action: "reject", expected: "unauthorized" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: false, action: "publish", expected: "unauthorized" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: true, action: "approve", expected: "approved" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: true, action: "reject", expected: "rejected" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: true, action: "publish", expected: "invalid_transition" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: false, action: "approve", expected: "unauthorized" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: false, action: "reject", expected: "unauthorized" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: false, action: "publish", expected: "unauthorized" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: true, action: "approve", expected: "published" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: true, action: "reject", expected: "rejected" },
|
||||
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: true, action: "publish", expected: "invalid_transition" },
|
||||
];
|
||||
|
||||
function makeSettings(input: Pick<MatrixCase, "approvalRequired" | "autoPublishOnApproval" | "actorIsApprover">): ApprovalSettings {
|
||||
return {
|
||||
approvalRequired: input.approvalRequired,
|
||||
autoPublishOnApproval: input.autoPublishOnApproval,
|
||||
approverAgentIds: input.actorIsApprover ? ["approver-1"] : ["approver-2"],
|
||||
publishTargets: ["dashboard"],
|
||||
};
|
||||
}
|
||||
|
||||
function makeDecision(action: ApprovalAction): ApprovalDecision {
|
||||
return { action, decidedAt: "2026-05-10T00:00:00.000Z", decidedBy: "approver-1", note: "ship" };
|
||||
}
|
||||
|
||||
function makeReport(state: ApprovalState): Report {
|
||||
return {
|
||||
id: "rep_1",
|
||||
cadence: "daily",
|
||||
periodStart: "2026-05-01",
|
||||
periodEnd: "2026-05-01",
|
||||
title: "Title",
|
||||
status: "review_complete",
|
||||
generationStartedAt: "2026-05-01T00:00:00.000Z",
|
||||
generationCompletedAt: null,
|
||||
reviewStartedAt: null,
|
||||
reviewCompletedAt: "2026-05-01T01:00:00.000Z",
|
||||
approvedAt: null,
|
||||
approvedBy: null,
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
approvalState: state,
|
||||
approvalHistory: [],
|
||||
draftMarkdown: "# x",
|
||||
renderedHtmlPath: null,
|
||||
renderedHtml: null,
|
||||
renderedHtmlGeneratedAt: null,
|
||||
metadata: {},
|
||||
combinedReview: null,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
updatedAt: "2026-05-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("approval state machine", () => {
|
||||
it.each(matrix)("transition matrix %#", (entry) => {
|
||||
const settings = makeSettings(entry);
|
||||
const actor: ApprovalActor = { id: "approver-1", type: "agent" };
|
||||
const result = nextApprovalState("awaiting_approval", entry.action, settings, actor);
|
||||
if ("error" in result) {
|
||||
expect(result.error).toBe(entry.expected);
|
||||
return;
|
||||
}
|
||||
expect(result.next).toBe(entry.expected);
|
||||
});
|
||||
|
||||
it("returns invalid_transition for not_required actions", () => {
|
||||
const settings: ApprovalSettings = {
|
||||
approvalRequired: true,
|
||||
autoPublishOnApproval: false,
|
||||
approverAgentIds: ["approver-1"],
|
||||
publishTargets: [],
|
||||
};
|
||||
const actor: ApprovalActor = { id: "approver-1", type: "agent" };
|
||||
expect(nextApprovalState("not_required", "approve", settings, actor)).toEqual({ error: "invalid_transition" });
|
||||
expect(nextApprovalState("not_required", "reject", settings, actor)).toEqual({ error: "invalid_transition" });
|
||||
expect(nextApprovalState("not_required", "publish", settings, actor)).toEqual({ error: "invalid_transition" });
|
||||
});
|
||||
|
||||
it("allows any human when approverAgentIds is empty", () => {
|
||||
const settings: ApprovalSettings = {
|
||||
approvalRequired: true,
|
||||
autoPublishOnApproval: false,
|
||||
approverAgentIds: [],
|
||||
publishTargets: [],
|
||||
};
|
||||
const human: ApprovalActor = { id: "u-1", type: "human" };
|
||||
expect(nextApprovalState("awaiting_approval", "approve", settings, human)).toEqual({ next: "approved" });
|
||||
});
|
||||
|
||||
it("keeps agents unauthorized when approverAgentIds is empty", () => {
|
||||
const settings: ApprovalSettings = {
|
||||
approvalRequired: true,
|
||||
autoPublishOnApproval: false,
|
||||
approverAgentIds: [],
|
||||
publishTargets: [],
|
||||
};
|
||||
const agent: ApprovalActor = { id: "approver-1", type: "agent" };
|
||||
expect(nextApprovalState("awaiting_approval", "approve", settings, agent)).toEqual({ error: "unauthorized" });
|
||||
});
|
||||
|
||||
it("applyDecision auto-publish chain updates report fields", () => {
|
||||
const report = makeReport("awaiting_approval");
|
||||
const settings: ApprovalSettings = {
|
||||
approvalRequired: true,
|
||||
autoPublishOnApproval: true,
|
||||
approverAgentIds: ["approver-1"],
|
||||
publishTargets: ["dashboard", "html-export"],
|
||||
};
|
||||
|
||||
const result = applyDecision(report, makeDecision("approve"), settings, { id: "approver-1", type: "agent" });
|
||||
expect("error" in result).toBe(false);
|
||||
if ("error" in result) return;
|
||||
expect(result.updatedReport.approvalState).toBe("published");
|
||||
expect(result.updatedReport.status).toBe("published");
|
||||
expect(result.updatedReport.approvedBy).toBe("approver-1");
|
||||
expect(result.sideEffects.publishTargets).toEqual(["dashboard", "html-export"]);
|
||||
});
|
||||
|
||||
it("applyDecision publish action from approved sets published state", () => {
|
||||
const report = makeReport("approved");
|
||||
const settings: ApprovalSettings = {
|
||||
approvalRequired: true,
|
||||
autoPublishOnApproval: false,
|
||||
approverAgentIds: ["approver-1"],
|
||||
publishTargets: ["dashboard"],
|
||||
};
|
||||
|
||||
const result = applyDecision(report, makeDecision("publish"), settings, { id: "approver-1", type: "agent" });
|
||||
expect("error" in result).toBe(false);
|
||||
if ("error" in result) return;
|
||||
expect(result.updatedReport.approvalState).toBe("published");
|
||||
expect(result.updatedReport.status).toBe("published");
|
||||
expect(result.updatedReport.publishedAt).toBe("2026-05-10T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginContext } from "@fusion/core";
|
||||
import type { Report } from "../store/report-types.js";
|
||||
import { createReportApprovalRoutes } from "../routes/report-approval-routes.js";
|
||||
|
||||
function makeReport(overrides: Partial<Report> = {}): Report {
|
||||
return {
|
||||
id: "rep_1",
|
||||
cadence: "daily",
|
||||
periodStart: "2026-05-01",
|
||||
periodEnd: "2026-05-01",
|
||||
title: "Report",
|
||||
status: "review_complete",
|
||||
generationStartedAt: "2026-05-01T00:00:00.000Z",
|
||||
generationCompletedAt: null,
|
||||
reviewStartedAt: null,
|
||||
reviewCompletedAt: null,
|
||||
approvedAt: null,
|
||||
approvedBy: null,
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
approvalState: "awaiting_approval",
|
||||
approvalHistory: [],
|
||||
draftMarkdown: null,
|
||||
renderedHtmlPath: null,
|
||||
renderedHtml: null,
|
||||
renderedHtmlGeneratedAt: null,
|
||||
metadata: {},
|
||||
combinedReview: null,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
updatedAt: "2026-05-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ctxWithStore(store: { getReport: (id: string) => Report | null; updateReport: (id: string, patch: Partial<Report>) => Report }, settings: Record<string, unknown> = {}): PluginContext {
|
||||
return {
|
||||
pluginId: "fusion-plugin-reports",
|
||||
taskStore: { getDatabase: () => ({}), getReportStore: () => store } as any,
|
||||
settings,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
emitEvent: vi.fn(),
|
||||
} as PluginContext;
|
||||
}
|
||||
|
||||
function route(path: string, method: string) {
|
||||
return createReportApprovalRoutes().find((entry) => entry.path === path && entry.method === method)!;
|
||||
}
|
||||
|
||||
describe("report approval routes", () => {
|
||||
it("approve then publish happy path", async () => {
|
||||
let current = makeReport({ approvalState: "awaiting_approval" });
|
||||
const store = {
|
||||
getReport: vi.fn(() => current),
|
||||
updateReport: vi.fn((_id: string, patch: Partial<Report>) => {
|
||||
current = { ...current, ...patch };
|
||||
return current;
|
||||
}),
|
||||
};
|
||||
const ctx = ctxWithStore(store, { approvalRequired: true, autoPublishOnApproval: false, approverAgentIds: ["agent-1"] });
|
||||
|
||||
const approve = await route("/reports/:id/approve", "POST").handler({ params: { id: "rep_1" }, headers: { "x-fusion-actor-type": "agent", "x-fusion-user": "agent-1" } }, ctx as any) as any;
|
||||
expect(approve.status).toBe(200);
|
||||
expect(approve.body.report.approvalState).toBe("approved");
|
||||
|
||||
const publish = await route("/reports/:id/publish", "POST").handler({ params: { id: "rep_1" }, headers: { "x-fusion-actor-type": "agent", "x-fusion-user": "agent-1" } }, ctx as any) as any;
|
||||
expect(publish.status).toBe(200);
|
||||
expect(publish.body.report.approvalState).toBe("published");
|
||||
});
|
||||
|
||||
it("supports reject path", async () => {
|
||||
const store = {
|
||||
getReport: vi.fn(() => makeReport()),
|
||||
updateReport: vi.fn((_id: string, patch: Partial<Report>) => ({ ...makeReport(), ...patch })),
|
||||
};
|
||||
const ctx = ctxWithStore(store, { approvalRequired: true, autoPublishOnApproval: false, approverAgentIds: ["agent-1"] });
|
||||
const reject = await route("/reports/:id/reject", "POST").handler({ params: { id: "rep_1" }, headers: { "x-fusion-actor-type": "agent", "x-fusion-user": "agent-1" } }, ctx as any) as any;
|
||||
expect(reject.status).toBe(200);
|
||||
expect(reject.body.report.approvalState).toBe("rejected");
|
||||
});
|
||||
|
||||
it("returns 403 for unauthorized approver", async () => {
|
||||
const store = {
|
||||
getReport: vi.fn(() => makeReport()),
|
||||
updateReport: vi.fn(),
|
||||
};
|
||||
const ctx = ctxWithStore(store, { approvalRequired: true, autoPublishOnApproval: false, approverAgentIds: ["agent-1"] });
|
||||
const res = await route("/reports/:id/approve", "POST").handler({ params: { id: "rep_1" }, headers: { "x-fusion-actor-type": "agent", "x-fusion-user": "agent-2" } }, ctx as any) as any;
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("share-blocks returns 409 before approval and 200 after", async () => {
|
||||
const current = makeReport({ approvalState: "awaiting_approval", combinedReview: { overallVerdict: "approve", consensusSummary: "ok", mergedHighlights: ["a"], mergedLowlights: [], mergedSuggestions: [], individual: [], failures: [] } });
|
||||
const store = {
|
||||
getReport: vi.fn(() => current),
|
||||
updateReport: vi.fn(),
|
||||
};
|
||||
const ctx = ctxWithStore(store);
|
||||
const locked = await route("/reports/:id/share-blocks", "GET").handler({ params: { id: "rep_1" } }, ctx as any) as any;
|
||||
expect(locked.status).toBe(409);
|
||||
|
||||
const openStore = { ...store, getReport: vi.fn(() => ({ ...current, approvalState: "approved" as const })) };
|
||||
const openCtx = ctxWithStore(openStore);
|
||||
const open = await route("/reports/:id/share-blocks", "GET").handler({ params: { id: "rep_1" } }, openCtx as any) as any;
|
||||
expect(open.status).toBe(200);
|
||||
expect(open.body).toHaveProperty("plainText");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildShareBlocks } from "../share-blocks.js";
|
||||
import type { Report } from "../store/report-types.js";
|
||||
|
||||
function makeReport(): Report {
|
||||
return {
|
||||
id: "rep_1",
|
||||
cadence: "weekly",
|
||||
periodStart: "2026-05-01",
|
||||
periodEnd: "2026-05-07",
|
||||
title: "Weekly <Report> & \"Status\"",
|
||||
status: "published",
|
||||
generationStartedAt: "2026-05-01T00:00:00.000Z",
|
||||
generationCompletedAt: null,
|
||||
reviewStartedAt: null,
|
||||
reviewCompletedAt: null,
|
||||
approvedAt: null,
|
||||
approvedBy: null,
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
approvalState: "published",
|
||||
approvalHistory: [],
|
||||
draftMarkdown: null,
|
||||
renderedHtmlPath: null,
|
||||
renderedHtml: null,
|
||||
renderedHtmlGeneratedAt: null,
|
||||
metadata: {},
|
||||
combinedReview: {
|
||||
overallVerdict: "approve",
|
||||
consensusSummary: "ok",
|
||||
mergedHighlights: ["Win <script>alert(1)</script>", "Win 2"],
|
||||
mergedLowlights: ["Low & bad"],
|
||||
mergedSuggestions: ["Do \"x\""],
|
||||
individual: [],
|
||||
failures: [],
|
||||
},
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
updatedAt: "2026-05-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildShareBlocks", () => {
|
||||
it("builds deterministic outputs", () => {
|
||||
const report = makeReport();
|
||||
const first = buildShareBlocks(report);
|
||||
const second = buildShareBlocks(report);
|
||||
expect(first).toEqual(second);
|
||||
expect(first.markdown).toContain("## Weekly \\\<Report\\\>");
|
||||
expect(first.slack).toContain("*Weekly <Report> & \"Status\"*");
|
||||
});
|
||||
|
||||
it("escapes markdown and html", () => {
|
||||
const blocks = buildShareBlocks(makeReport());
|
||||
expect(blocks.markdown).toContain("\\<Report\\>");
|
||||
expect(blocks.emailHtml).toContain("<script>alert(1)</script>");
|
||||
expect(blocks.emailHtml).toContain("&");
|
||||
expect(blocks.emailHtml).toContain(""Status"");
|
||||
});
|
||||
|
||||
it("omits empty sections", () => {
|
||||
const report = makeReport();
|
||||
report.combinedReview = {
|
||||
overallVerdict: "approve",
|
||||
consensusSummary: "ok",
|
||||
mergedHighlights: [],
|
||||
mergedLowlights: [],
|
||||
mergedSuggestions: [],
|
||||
individual: [],
|
||||
failures: [],
|
||||
};
|
||||
const blocks = buildShareBlocks(report);
|
||||
expect(blocks.plainText).not.toContain("Wins:");
|
||||
expect(blocks.markdown).not.toContain("### Wins");
|
||||
});
|
||||
|
||||
it("slack block avoids markdown-only constructs", () => {
|
||||
const blocks = buildShareBlocks(makeReport());
|
||||
expect(blocks.slack).not.toContain("#");
|
||||
expect(blocks.slack).not.toContain("**");
|
||||
expect(blocks.slack).not.toContain("- ");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user