import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ApplicationRequestError, createApplicationService } from "../src/index.js"; let temporaryDirectory: string | undefined; afterEach(async () => { if (temporaryDirectory) { await rm(temporaryDirectory, { recursive: true, force: true }); } temporaryDirectory = undefined; }); async function createThemeFixture() { temporaryDirectory = await mkdtemp( join(tmpdir(), "md-to-pdf-application-") ); const bundledRoot = join(temporaryDirectory, "bundled"); const localRoot = join(temporaryDirectory, "local"); const themeRoot = join(bundledRoot, "test-theme"); await mkdir(join(themeRoot, "fonts"), { recursive: true }); await mkdir(localRoot, { recursive: true }); await writeFile( join(themeRoot, "theme.json"), JSON.stringify({ manifestVersion: 1, id: "test-theme", name: "测试主题", version: "1.0.0", description: "共享应用服务测试主题", author: "test", license: "MIT", entry: "theme.css", domPreset: "typora", defaultFontSize: "16px", supportedFeatures: ["code", "table"], bundled: true }), "utf8" ); await writeFile( join(themeRoot, "theme.css"), [ "@font-face {", " font-family: Test;", " src: url('./fonts/test.woff2') format('woff2');", "}", "#write { font-family: Test; }" ].join("\n"), "utf8" ); await writeFile( join(themeRoot, "fonts", "test.woff2"), Buffer.from([0, 1, 2, 3]) ); return { bundledRoot, localRoot }; } describe("共享应用服务", () => { it("渲染安全 Markdown 文档", async () => { const roots = await createThemeFixture(); const service = createApplicationService(roots); const document = service.render({ markdown: "# 文档\n\n", language: "zh-CN" }); expect(document.articleHtml).toContain('id="write"'); expect(document.articleHtml).not.toContain(" { const roots = await createThemeFixture(); const service = createApplicationService(roots); expect(() => service.render({ markdown: 42 })).toThrow( expect.objectContaining({ statusCode: 400, code: "INVALID_MARKDOWN" }) ); }); it("列出主题并使用调用方提供的资源 URL", async () => { const roots = await createThemeFixture(); const service = createApplicationService({ ...roots, createAssetUrl: (themeId, assetPath) => `mdpdf://theme/${themeId}/${assetPath}` }); await expect(service.listThemes()).resolves.toEqual({ themes: [ expect.objectContaining({ id: "test-theme", bundled: true, source: "bundled" }) ] }); await expect( service.getThemeCss("test-theme") ).resolves.toContain( "mdpdf://theme/test-theme/fonts/test.woff2" ); }); it("安全读取主题二进制资源", async () => { const roots = await createThemeFixture(); const service = createApplicationService(roots); await expect( service.getThemeAsset("test-theme", "fonts/test.woff2") ).resolves.toEqual({ contentType: "font/woff2", content: Buffer.from([0, 1, 2, 3]) }); await expect( service.getThemeAsset("test-theme", "../test.woff2") ).rejects.toThrow("主题资源路径不安全"); }); it("内置主题优先于同 ID 的旧本地副本", async () => { const roots = await createThemeFixture(); const localThemeRoot = join(roots.localRoot, "test-theme"); await mkdir(localThemeRoot, { recursive: true }); await writeFile( join(localThemeRoot, "theme.json"), JSON.stringify({ manifestVersion: 1, id: "test-theme", name: "旧本地主题", version: "local", description: "迁移前的本地副本", author: "test", license: "local-only", entry: "theme.css", domPreset: "typora", defaultFontSize: "16px", supportedFeatures: ["code", "table"], bundled: false }), "utf8" ); await writeFile( join(localThemeRoot, "theme.css"), "#write { color: red; }", "utf8" ); const onWarning = vi.fn(); const service = createApplicationService({ ...roots, onWarning }); await expect(service.listThemes()).resolves.toEqual({ themes: [ expect.objectContaining({ id: "test-theme", name: "测试主题", bundled: true }) ] }); expect(onWarning).toHaveBeenCalledWith( "已忽略与内置主题同 ID 的本地主题 test-theme" ); }); });