feat(FN-3785): add standalone HTML rendering and export routes for reports

Adds a complete HTML rendering and export pipeline to the fusion-plugin-reports plugin, including a standalone HTML renderer with template and stylesheet support, new export routes (`/api/reports/:id/export/html`) that persist rendered HTML to the store, and plugin-type support for non-JSON route re

Fusion-Task-Id: FN-3785
This commit is contained in:
Fusion
2026-05-10 10:05:07 -07:00
committed by gsxdsm
parent 4b54a7ddbc
commit d0a2d90182
23 changed files with 911 additions and 3 deletions

View File

@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginContext } from "@fusion/core";
import type { Report } from "../../store/report-types.js";
import { createReportExportRoutes } from "../report-export-routes.js";
function report(overrides: Partial<Report> = {}): Report {
return {
id: "rep_1",
cadence: "daily",
periodStart: "2026-05-01",
periodEnd: "2026-05-02",
title: "Demo",
status: "review_complete",
generationStartedAt: "2026-05-02T00:00:00.000Z",
generationCompletedAt: "2026-05-02T00:01:00.000Z",
reviewStartedAt: null,
reviewCompletedAt: null,
approvedAt: null,
approvedBy: null,
publishedAt: null,
archivedAt: null,
failureReason: null,
draftMarkdown: null,
renderedHtmlPath: null,
renderedHtml: null,
renderedHtmlGeneratedAt: null,
metadata: {},
combinedReview: null,
createdAt: "2026-05-02T00:00:00.000Z",
updatedAt: "2026-05-02T00:01:00.000Z",
...overrides,
};
}
function ctxWithStore(store: { getReport: (id: string) => Report | null; setRenderedHtml: (id: string, html: string) => void }): 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(),
createAiSession: undefined,
resolveProjectTaskStore: undefined,
...({} as any),
} as PluginContext;
}
describe("report export routes", () => {
it("returns export html with attachment header", async () => {
const routes = createReportExportRoutes();
const route = routes.find((r) => r.path.endsWith("export.html"))!;
const record = report();
const getReport = vi.fn().mockReturnValue(record);
const setRenderedHtml = vi.fn();
const ctx = ctxWithStore({ getReport, setRenderedHtml });
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(res.status).toBe(200);
expect(res.contentType).toContain("text/html");
expect(res.headers["Content-Disposition"]).toContain("attachment;");
});
it("returns 404 for missing id", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(null), setRenderedHtml: vi.fn() });
const res = await route.handler({ params: { id: "missing" } }, ctx as any) as any;
expect(res.status).toBe(404);
});
it("returns 409 for generating report", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(report({ status: "generating" })), setRenderedHtml: vi.fn() });
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(res.status).toBe(409);
});
it("returns body-only preview html", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("preview.html"))!;
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(report()), setRenderedHtml: vi.fn() });
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(res.status).toBe(200);
expect(res.contentType).toContain("text/html");
expect(res.body).toContain("<article");
expect(res.body).not.toContain("<!doctype html>");
});
it("caches rendered html after first export", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
const mutable = report();
const getReport = vi.fn().mockImplementation(() => mutable);
const setRenderedHtml = vi.fn().mockImplementation((_id: string, html: string) => {
mutable.renderedHtml = html;
});
const ctx = ctxWithStore({ getReport, setRenderedHtml });
const first = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
const second = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(setRenderedHtml).toHaveBeenCalledTimes(1);
});
});