/** * T-141/T-142 — page templates E2E (dev-stack, Playwright, single-origin). * * Origin: `https://notion.filps.com` (Caddy → web :3000 / api :4000). A * localhost origin does NOT work — the API's CORS is pinned to the domain * and the web build bakes the same-origin NEXT_PUBLIC_*. * * Verifies the full template loop through the real browser + API: * - snapshot a page (seeded with real blocks) into a template * (POST /pages/:id/templates) * - the template shows in the /w/:workspace/templates list with its name * - "Use" deep-copies the snapshot into a NEW page in THIS workspace * (POST /templates/:id/apply → router.push to the new page) * - the new page's blocks match the source (the deep-copy preserved * content — not just the title) */ import { test, expect } from "../fixtures"; import { createPage, createBlock, getBlocks } from "../utils/api"; import type { PageDto, BlockDto } from "../fixtures"; interface TemplateDto { id: string; name: string; type: string; isBuiltin: boolean; } test.describe("Templates — P1 (T-141/T-142)", () => { test("snapshot → list → apply deep-copies the page with its blocks", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Template Space"); // Source page with real content (two paragraphs). const src = (await createPage(api, ws.id, { title: "Weekly Standup" })) as PageDto; // NOTE: GET /pages/:id returns the `{ page, workspace, ... }` ENVELOPE // (page.controller get → page.service get). The apply response below is a // BARE page row (template.service apply), so the two shapes differ. const BLOCK_TEXT = "Standup source block text"; await createBlock(api, src.id, { type: "PARAGRAPH", content: { text: BLOCK_TEXT } }); await createBlock(api, src.id, { type: "PARAGRAPH", content: { text: "second line" } }); // Snapshot it into a template. const tRes = await api.post(`/api/v1/pages/${src.id}/templates`, { data: { name: "Standup Template", icon: "📋" } }); expect(tRes.status(), await tRes.text()).toBe(201); const tpl = (await tRes.json()) as TemplateDto; expect(tpl.id).toBeTruthy(); expect(tpl.name).toBe("Standup Template"); expect(tpl.isBuiltin).toBe(false); // Open the Templates page. await authPage.goto(`/w/${ws.slug}/templates`); await expect(authPage.getByRole("heading", { name: /templates/i })).toBeVisible(); // The new template is listed (exact id testid — the list is a // cross-workspace union, so select by id, not by position). const item = authPage.getByTestId(`template-item-${tpl.id}`); await expect(item).toBeVisible({ timeout: 15_000 }); await expect(item).toContainText("Standup Template"); // Apply it ("Use"). onSuccess router.pushes to the new page. await item.getByTestId(`template-apply-${tpl.id}`).click(); // Wait for navigation to a page URL (NOT the source page, NOT templates). await authPage.waitForURL(/\/w\/[^/]+\/page\/[^/]+$/, { timeout: 20_000 }); const newId = new URL(authPage.url()).pathname.split("/").pop() ?? ""; expect(newId).toBeTruthy(); expect(newId).not.toBe(src.id); // The applied page is a real page in the same workspace and the // deep-copy preserved the source blocks (not just the title). const newPageRes = await api.get(`/api/v1/pages/${newId}`); expect(newPageRes.status(), await newPageRes.text()).toBe(200); const envelope = (await newPageRes.json()) as { page: PageDto & { workspaceId?: string }; workspace?: { id?: string }; }; expect(envelope.page.id).toBe(newId); // The applied page landed in THIS workspace (workspaceId on the page row, // or the workspace envelope). expect(envelope.page.workspaceId ?? envelope.workspace?.id).toBe(ws.id); const blocks = await getBlocks(api, newId); expect(Array.isArray(blocks) ? blocks.length : 0).toBeGreaterThanOrEqual(2); const texts = (Array.isArray(blocks) ? blocks : []).map( (b: BlockDto) => (b.content as { text?: string } | null)?.text ?? "" ); expect(texts).toContain(BLOCK_TEXT); }); });