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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
|
||||
import { ReportStore } from "../store/report-store.js";
|
||||
import { renderReportHtml } from "../render/html-template.js";
|
||||
import { renderStandaloneReportHtml, slugifyReportFilename } from "../render/standalone-html.js";
|
||||
|
||||
interface RouteRequest {
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
const reportStoreCache = new WeakMap<object, ReportStore>();
|
||||
|
||||
function getStore(ctx: PluginContext): ReportStore {
|
||||
const taskStoreWithReports = ctx.taskStore as PluginContext["taskStore"] & { getReportStore?: () => ReportStore };
|
||||
if (typeof taskStoreWithReports.getReportStore === "function") {
|
||||
return taskStoreWithReports.getReportStore();
|
||||
}
|
||||
const key = ctx.taskStore as object;
|
||||
const cached = reportStoreCache.get(key);
|
||||
if (cached) return cached;
|
||||
const store = new ReportStore(ctx.taskStore.getDatabase());
|
||||
reportStoreCache.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
function notFound(message: string): PluginRouteResponse {
|
||||
return { status: 404, body: { error: message } };
|
||||
}
|
||||
|
||||
function conflict(message: string): PluginRouteResponse {
|
||||
return { status: 409, body: { error: message } };
|
||||
}
|
||||
|
||||
export function createReportExportRoutes(): PluginRouteDefinition[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/reports/:id/export.html",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const id = request.params.id;
|
||||
const store = getStore(ctx);
|
||||
const record = store.getReport(id);
|
||||
if (!record) return notFound(`Report ${id} not found`);
|
||||
if (record.status === "generating") return conflict(`Report ${id} is not generated yet`);
|
||||
const html = record.renderedHtml ?? renderStandaloneReportHtml(record);
|
||||
if (!record.renderedHtml) {
|
||||
store.setRenderedHtml(id, html);
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: html,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
headers: {
|
||||
"Content-Disposition": `attachment; filename="${slugifyReportFilename(record)}"`,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/reports/:id/preview.html",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const id = request.params.id;
|
||||
const store = getStore(ctx);
|
||||
const record = store.getReport(id);
|
||||
if (!record) return notFound(`Report ${id} not found`);
|
||||
if (record.status === "generating") return conflict(`Report ${id} is not generated yet`);
|
||||
return {
|
||||
status: 200,
|
||||
body: renderReportHtml(record, { includeChrome: false }),
|
||||
contentType: "text/html; charset=utf-8",
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user