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("내보내기 테스트 페이지");
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("