import "dotenv/config"; import { describe, it, expect } from "vitest"; import JSZip from "jszip"; import { promises as fs } from "fs"; import path from "path"; import type { Block, ProjectConfig } from "@/features/editor/state/editorStore"; import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate"; import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate"; import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate"; import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate"; import { buildStaticHtml } from "@/app/api/export/route"; const BASE_URL = "http://localhost"; // /api/export 라우트 TDD: // - blocks + projectConfig 를 받아 HTML/CSS/JS 가 포함된 ZIP 을 반환해야 한다. // - index.html 의 은 projectConfig.title 을 사용해야 한다. // - ZIP 안에 builder.css 와 main.js 파일이 포함되어야 한다. describe("/api/export", () => { it("POST /api/export 로 블록과 프로젝트 설정을 전송하면 기본 정적 ZIP 파일을 반환해야 한다", async () => { const blocks: Block[] = [ { id: "blk_test", type: "text", props: { text: "ZIP 내보내기 테스트", align: "left", size: "base", }, } as any, ]; const projectConfig: ProjectConfig = { title: "내보내기 테스트 페이지", slug: "export-test", canvasPreset: "full", canvasBgColorHex: "#ffffff", bodyBgColorHex: "#111111", }; const payload = { blocks, projectConfig }; const { POST: handleExport } = await import("@/app/api/export/route"); const res = await handleExport( new Request(`${BASE_URL}/api/export`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), ); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toBe("application/zip"); const arrayBuffer = await res.arrayBuffer(); const zip = await JSZip.loadAsync(arrayBuffer); const indexEntry = zip.file("index.html"); const cssEntry = zip.file("builder.css"); const jsEntry = zip.file("main.js"); expect(indexEntry).toBeTruthy(); expect(cssEntry).toBeTruthy(); expect(jsEntry).toBeTruthy(); const mainJs = await jsEntry!.async("string"); expect(mainJs).toContain("pbInitFormControllers"); expect(mainJs).toContain('querySelectorAll("form.pb-form-controller")'); expect(mainJs).toContain("fetch("); const builderCss = await cssEntry!.async("string"); // 버튼 베이스 클래스는 정적 Export 에서도 밑줄이 보이지 않도록 text-decoration:none 을 포함해야 한다. expect(builderCss).toContain(".pb-btn-base"); expect(builderCss).toContain("text-decoration: none"); const html = await indexEntry!.async("string"); expect(html).toContain("<title>내보내기 테스트 페이지"); expect(html).toContain("ZIP 내보내기 테스트"); // 캔버스 래퍼 배경색 expect(html).toContain("background-color:#ffffff"); // body 스타일: 배경색 + margin/padding 0 expect(html).toContain('style="background-color:#111111;margin:0;padding:0;"'); }); it("업로드 디렉터리에 이미지 파일이 없어도 ZIP 생성은 실패하지 않아야 한다", async () => { const missingImageId = `missing-image-${Date.now()}`; const blocks: Block[] = [ { id: "blk_image_missing", type: "image", props: { src: `/api/image/${missingImageId}`, alt: "누락된 이미지", align: "center", widthMode: "auto", borderRadius: "md", }, } as any, ]; const projectConfig: ProjectConfig = { title: "이미지 파일 누락 내보내기 테스트", slug: "image-missing-export-test", canvasPreset: "full", }; const payload = { blocks, projectConfig }; const { POST: handleExport } = await import("@/app/api/export/route"); const res = await handleExport( new Request(`${BASE_URL}/api/export`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), ); expect(res.status).toBe(200); const buffer = await res.arrayBuffer(); const zip = await JSZip.loadAsync(buffer); // 업로드 파일이 없으므로 images/ 디렉터리에 엔트리는 없어야 한다. const imageEntry = zip.file(`images/${missingImageId}`); expect(imageEntry).toBeNull(); const html = await zip.file("index.html")!.async("string"); // HTML 은 원본 /api/image 경로를 그대로 유지하거나, 최소한 오류 없이 렌더되어야 한다. expect(html).toContain(`/api/image/${missingImageId}`); }); it("업로드 디렉터리에 비디오 파일이 없어도 ZIP 생성은 실패하지 않아야 한다", async () => { const missingVideoId = `missing-video-${Date.now()}`; const blocks: Block[] = [ { id: "blk_video_missing", type: "video", props: { sourceUrl: `/api/video/${missingVideoId}`, align: "center", widthMode: "auto", aspectRatio: "16:9", }, } as any, ]; const projectConfig: ProjectConfig = { title: "비디오 파일 누락 내보내기 테스트", slug: "video-missing-export-test", canvasPreset: "full", }; const payload = { blocks, projectConfig }; const { POST: handleExport } = await import("@/app/api/export/route"); const res = await handleExport( new Request(`${BASE_URL}/api/export`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), ); expect(res.status).toBe(200); const buffer = await res.arrayBuffer(); const zip = await JSZip.loadAsync(buffer); const videoEntry = zip.file(`videos/${missingVideoId}`); expect(videoEntry).toBeNull(); const html = await zip.file("index.html")!.async("string"); // HTML 은 원본 /api/video 경로를 그대로 유지하거나, 최소한 오류 없이 렌더되어야 한다. expect(html).toContain(`/api/video/${missingVideoId}`); }); it("video 블록의 sourceUrl 이 비어 있어도 ZIP 생성은 실패하지 않아야 한다", async () => { const blocks: Block[] = [ { id: "blk_video_empty_source", type: "video", props: { sourceUrl: "", align: "center", widthMode: "auto", aspectRatio: "16:9", }, } as any, ]; const projectConfig: ProjectConfig = { title: "비디오 sourceUrl 빈 값 내보내기 테스트", slug: "video-empty-source-export-test", canvasPreset: "full", }; const payload = { blocks, projectConfig }; const { POST: handleExport } = await import("@/app/api/export/route"); const res = await handleExport( new Request(`${BASE_URL}/api/export`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), ); expect(res.status).toBe(200); const buffer = await res.arrayBuffer(); const zip = await JSZip.loadAsync(buffer); const html = await zip.file("index.html")!.async("string"); // sourceUrl 이 비어 있어도 index.html 은 정상 생성되어야 한다. expect(html).toContain(" { const blocks: Block[] = []; const createConfig = (partial: Partial): ProjectConfig => ({ title: "캔버스 프리셋 테스트", slug: "canvas-preset-test", canvasPreset: "full", canvasBgColorHex: "#0f172a", bodyBgColorHex: "#020617", ...partial, }); const htmlMobile = buildStaticHtml(blocks, createConfig({ canvasPreset: "mobile" })); expect(htmlMobile).toContain("max-width:390px;"); const htmlTablet = buildStaticHtml(blocks, createConfig({ canvasPreset: "tablet" })); expect(htmlTablet).toContain("max-width:768px;"); const htmlDesktop = buildStaticHtml(blocks, createConfig({ canvasPreset: "desktop" })); expect(htmlDesktop).toContain("max-width:1200px;"); const htmlCustom = buildStaticHtml(blocks, createConfig({ canvasPreset: "custom", canvasWidthPx: 1024 })); expect(htmlCustom).toContain("max-width:1024px;"); const htmlFull = buildStaticHtml(blocks, createConfig({ canvasPreset: "full" })); // full 프리셋에서 canvasWidthPx 가 없으면 max-width 스타일이 없어야 한다. expect(htmlFull).not.toContain("max-width:"); // bodyBgColorHex / canvasBgColorHex 도 각각 body 및 캔버스 wrapper 스타일에 반영된다. const htmlColors = buildStaticHtml( blocks, createConfig({ canvasBgColorHex: "#111111", bodyBgColorHex: "#222222" }), ); expect(htmlColors).toContain("background-color:#111111;"); expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"'); }); it("bodyBgColorHex 가 빈 문자열이면 buildStaticHtml 의 body style 에 background-color 가 포함되지 않아야 한다", () => { const blocks: Block[] = []; const projectConfig: ProjectConfig = { title: "배경색 비어 있음 테스트", slug: "body-bg-empty-test", canvasPreset: "full", canvasBgColorHex: "#0f172a", bodyBgColorHex: "", } as ProjectConfig; const html = buildStaticHtml(blocks, projectConfig); // body style 에 background-color 가 없어야 한다. expect(html).toContain(' { const blocks: Block[] = []; const projectConfig: ProjectConfig = { title: "헤드/트래킹 스크립트 테스트", slug: "head-tracking-test", canvasPreset: "full", headHtml: '', trackingScript: '