/** * T-180 (Phase 8) — page/content Markdown export (journey #13). * * 1. Happy path: the topbar "More" → "Export as Markdown…" menu item * triggers a real browser download (Playwright `download` event); the * downloaded bytes are the rendered markdown (title, heading, body, in * position order) and the filename is the sanitized page title. * 2. Server authz: a user with NO membership (NONE) gets 403 on the export * endpoint — the same VIEW gate that guards `GET /pages/:id`. * * Server rendering + the VIEW gate + the `page.exported` audit row are covered * by the unit suite (page-export.test.ts) and the integration suite * (pages.integration.test.ts); this spec proves the browser download round-trip * and the API authz boundary. */ import * as fs from "node:fs"; import { test, expect } from "../fixtures"; import { createPage, createBlock } from "../utils/api"; import type { PageDto } from "../fixtures"; test.describe("T-180 page Markdown export", () => { test("topbar More → Export as Markdown downloads the rendered markdown", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Export Space"); const page = await createPage(api, ws.id, { title: "Export Doc" }); const heading = await createBlock(api, page.id, { type: "HEADING_1", content: { text: "Section" } }); await createBlock(api, page.id, { type: "PARAGRAPH", content: { text: "Body text" }, afterBlockId: heading.id }); await authPage.goto(`/w/${ws.slug}/page/${page.id}`); // Wait for the page to load (topbar present). await authPage.getByRole("button", { name: "More actions" }).waitFor(); const [download] = await Promise.all([ authPage.waitForEvent("download", { timeout: 20_000 }), (async () => { await authPage.getByRole("button", { name: "More actions" }).click(); await authPage.getByRole("menuitem", { name: "Export as Markdown…" }).click(); })() ]); // The download is a .md file named after the sanitized page title. expect(download.suggestedFilename()).toBe("Export-Doc.md"); const dlPath = await download.path(); expect(dlPath, "Playwright captures the download to a temp file").toBeTruthy(); const md = await fs.promises.readFile(dlPath!, "utf8"); expect(md).toContain("# Export Doc"); expect(md).toContain("# Section"); expect(md).toContain("Body text"); // position order: heading before body expect(md.indexOf("Section")).toBeLessThan(md.indexOf("Body text")); }); test("a non-member (NONE) is denied the export (403)", async ({ api, secondUser, freshWorkspace }) => { const ws = await freshWorkspace("Export Deny"); const page = (await createPage(api, ws.id, { title: "Restricted" })) as PageDto; await createBlock(api, page.id, { type: "PARAGRAPH", content: { text: "secret" } }); // A freshly-registered user with no membership → NONE on the page. const { api: outsider } = await secondUser(); const res = await outsider.get(`/api/v1/pages/${page.id}/export/markdown`); expect(res.status()).toBe(403); }); });