import { afterEach, describe, expect, it, vi } from "vitest"; import type { FastifyInstance } from "fastify"; import { ApplicationRequestError, DocxExportServiceError, type DocxMediaCaptureAdapter } from "@md-to-pdf/application"; import { DOCX_MIME_TYPE, defaultExportConfig, type DocxCapability, type DocxExportResult } from "@md-to-pdf/core"; import { buildApp } from "../src/app.js"; import type { PdfGenerator } from "../src/pdf-engine.js"; import type { ServerDocxMediaCaptureAdapter } from "../src/docx-media-engine.js"; let app: FastifyInstance | undefined; afterEach(async () => { await app?.close(); app = undefined; }); const availableCapability: DocxCapability = { format: "docx", status: "available", expectedVersion: "3.9.0.2", detectedVersion: "3.9.0.2" }; function generatedDocx(): DocxExportResult { return { docx: new Uint8Array([80, 75, 3, 4]), fileName: "测试报告.docx", diagnostics: { warnings: ["字体可能被替换"], echartsErrors: ["图表 1"], mermaidErrors: [] }, timings: { queueMs: 1, probeMs: 2, prepareMs: 3, mediaMs: 4, referenceMs: 5, pandocMs: 6, validationMs: 7, totalMs: 28 } }; } function createDocxService( overrides: { capability?: DocxCapability; generate?: (request: unknown) => Promise; } = {} ) { return { getCapability: vi .fn() .mockResolvedValue( overrides.capability ?? availableCapability ), generate: vi.fn( overrides.generate ?? (async () => generatedDocx()) ), close: vi.fn(async () => undefined) }; } function createMediaAdapter() { return { capture: vi.fn< DocxMediaCaptureAdapter["capture"] >(async () => ({ plan: { targets: [], echartsErrors: [], mermaidErrors: [] }, captures: [] })), close: vi.fn(async () => undefined) } satisfies ServerDocxMediaCaptureAdapter; } function createPdfGenerator() { return { generate: vi.fn(), close: vi.fn(async () => undefined) } as unknown as PdfGenerator; } function createTestApp( service = createDocxService(), mediaAdapter = createMediaAdapter() ) { app = buildApp({ logger: false, pdfGenerator: createPdfGenerator(), docxExportService: service, docxMediaAdapter: mediaAdapter }); return { app, service, mediaAdapter }; } describe("DOCX HTTP API", () => { it("在统一 capability 中报告 DOCX 运行时状态", async () => { const { app: instance, service } = createTestApp(); const response = await instance.inject({ method: "GET", url: "/api/capabilities" }); expect(response.statusCode).toBe(200); expect(response.json().docx).toEqual(availableCapability); expect(response.json().implemented).toContain("docx-export"); expect(service.getCapability).toHaveBeenCalledTimes(1); }); it("通过独立端点返回不可用 capability", async () => { const capability: DocxCapability = { format: "docx", status: "not-found", expectedVersion: "3.9.0.2", message: "未找到 Pandoc 运行时" }; const { app: instance } = createTestApp( createDocxService({ capability }) ); const response = await instance.inject({ method: "GET", url: "/api/docx/capability" }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual(capability); }); it("生成 DOCX 并返回安全文件名、诊断和耗时", async () => { const { app: instance, service, mediaAdapter } = createTestApp(); const payload = { markdown: "# 测试报告", fileName: "目录/测试报告.md", language: "zh-CN", resources: [], exportConfig: defaultExportConfig }; const response = await instance.inject({ method: "POST", url: "/api/docx", payload }); expect(response.statusCode).toBe(200); expect(response.headers["content-type"]).toContain( DOCX_MIME_TYPE ); expect(response.headers["content-disposition"]).toContain( "filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%8A%A5%E5%91%8A.docx" ); expect(response.headers["cache-control"]).toBe("no-store"); expect(response.headers["x-docx-warning-count"]).toBe("1"); expect(response.headers["x-echarts-error-count"]).toBe("1"); expect(response.headers["x-mermaid-error-count"]).toBe("0"); expect(response.headers["server-timing"]).toContain( "runtime-probe;dur=2.0" ); expect(response.headers["server-timing"]).toContain( "pandoc;dur=6.0" ); expect(response.rawPayload).toEqual( Buffer.from([80, 75, 3, 4]) ); expect(service.generate).toHaveBeenCalledWith(payload, { mediaAdapter, signal: expect.any(AbortSignal) }); }); it("映射应用请求错误", async () => { const service = createDocxService({ generate: async () => { throw new ApplicationRequestError( 400, "INVALID_EXPORT_CONFIG", "导出配置无效" ); } }); const { app: instance } = createTestApp(service); const response = await instance.inject({ method: "POST", url: "/api/docx", payload: {} }); expect(response.statusCode).toBe(400); expect(response.json()).toEqual({ error: "INVALID_EXPORT_CONFIG", message: "导出配置无效", retryable: false }); }); it("映射可重试的队列错误", async () => { const service = createDocxService({ generate: async () => { throw new DocxExportServiceError( 429, "DOCX_QUEUE_FULL", "DOCX 生成队列已满", true ); } }); const { app: instance } = createTestApp(service); const response = await instance.inject({ method: "POST", url: "/api/docx", payload: {} }); expect(response.statusCode).toBe(429); expect(response.headers["retry-after"]).toBe("5"); expect(response.json()).toEqual({ error: "DOCX_QUEUE_FULL", message: "DOCX 生成队列已满", retryable: true }); }); it("关闭应用时按顺序释放共享服务与媒体浏览器", async () => { const service = createDocxService(); const mediaAdapter = createMediaAdapter(); const { app: instance } = createTestApp( service, mediaAdapter ); await instance.close(); expect(service.close).toHaveBeenCalledTimes(1); expect(mediaAdapter.close).toHaveBeenCalledTimes(1); app = undefined; }); });