video 블록 및 리펙터링
CI / pr_and_merge (push) Has been skipped
CI / test (push) Failing after 46m46s
CI / test (pull_request) Failing after 43m8s
CI / pr_and_merge (pull_request) Has been skipped

This commit is contained in:
2025-11-27 10:07:59 +09:00
parent 672cca5271
commit 48e13d2a75
223 changed files with 8979 additions and 2125 deletions
+730 -2
View File
@@ -73,6 +73,144 @@ describe("/api/export", () => {
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("<video");
});
it("canvasPreset / canvasWidthPx / 배경색은 정적 HTML 캔버스 wrapper 및 body 스타일에 반영되어야 한다", () => {
const blocks: Block[] = [];
@@ -259,6 +397,55 @@ describe("/api/export", () => {
expect(html).not.toMatch(/name=\"plan\"[^>]*required/);
});
it("FormBlock 에 fieldIds/fields 가 모두 없으면 기본 폼/필드를 내보내지 않아야 한다", async () => {
const blocks: Block[] = [
{
id: "form_fallback_1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
// fieldIds, fields 를 모두 생략해 fallback 경로를 타게 한다.
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 fallback 내보내기 테스트",
slug: "form-fallback-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 arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// 빈 FormBlock 은 컨트롤러 역할만 하고, 정적 HTML 에서는 기본 폼/필드를 생성하지 않아야 한다.
expect(html).not.toContain("<form");
expect(html).not.toMatch(/name=\"name\"/);
expect(html).not.toMatch(/name=\"email\"/);
expect(html).not.toMatch(/name=\"message\"/);
});
it("/api/image/:id 기반 이미지 URL은 ZIP 안 images/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
const imageId = `test-image-${Date.now()}`;
const uploadDir = path.join(process.cwd(), "uploads");
@@ -312,6 +499,460 @@ describe("/api/export", () => {
expect(html).not.toContain(`/api/image/${imageId}`);
});
it("비디오 포스터 이미지 /api/image/:id 는 ZIP 안 images/ 로 복사되고 poster 경로가 상대 경로로 치환되어야 한다", async () => {
const posterId = `test-video-poster-${Date.now()}`;
const uploadDir = path.join(process.cwd(), "uploads");
await fs.mkdir(uploadDir, { recursive: true });
await fs.writeFile(path.join(uploadDir, posterId), Buffer.from("dummy-poster"));
const blocks: Block[] = [
{
id: "blk_video_poster",
type: "video" as any,
props: {
sourceUrl: "https://example.com/video.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
posterImageSrc: `/api/image/${posterId}`,
} as any,
},
];
const projectConfig: ProjectConfig = {
title: "비디오 포스터 내보내기 테스트",
slug: "video-poster-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 posterEntry = zip.file(`images/${posterId}`);
expect(posterEntry).toBeTruthy();
const posterBytes = await posterEntry!.async("nodebuffer");
expect(posterBytes.length).toBeGreaterThan(0);
const html = await zip.file("index.html")!.async("string");
expect(html).toContain(`poster=\"./images/${posterId}\"`);
expect(html).not.toContain(`/api/image/${posterId}`);
});
it("HTML5 비디오 startTimeSec/endTimeSec 는 정적 HTML video 태그 data-start-seconds/data-end-seconds 속성으로 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "blk_video_range",
type: "video" as any,
props: {
sourceUrl: "https://example.com/video-range.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
startTimeSec: 5,
endTimeSec: 12,
} as any,
},
];
const projectConfig: ProjectConfig = {
title: "비디오 시작/종료 시점 내보내기 테스트",
slug: "video-range-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");
expect(html).toContain("data-start-seconds=\"5\"");
expect(html).toContain("data-end-seconds=\"12\"");
});
it("섹션 backgroundImageSrc 에 /api/image/:id 가 포함된 경우에도 ZIP images/ 및 상대 경로로 치환되어야 한다", async () => {
const imageId = `test-section-bg-${Date.now()}`;
const uploadDir = path.join(process.cwd(), "uploads");
await fs.mkdir(uploadDir, { recursive: true });
await fs.writeFile(path.join(uploadDir, imageId), Buffer.from("dummy-image"));
const blocks: Block[] = [
{
id: "sec_bg",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_bg_col_1", span: 12 }],
backgroundImageSrc: `/api/image/${imageId}`,
backgroundImageSize: "cover",
backgroundImagePosition: "center",
},
} as any,
{
id: "sec_bg_text",
type: "text",
props: {
text: "섹션 배경 이미지 내보내기 테스트",
align: "left",
size: "base",
},
sectionId: "sec_bg",
columnId: "sec_bg_col_1",
} as any,
];
const projectConfig: ProjectConfig = {
title: "섹션 배경 이미지 내보내기 테스트",
slug: "section-bg-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 imageEntry = zip.file(`images/${imageId}`);
expect(imageEntry).toBeTruthy();
const imageBytes = await imageEntry!.async("nodebuffer");
expect(imageBytes.length).toBeGreaterThan(0);
const html = await zip.file("index.html")!.async("string");
expect(html).toContain(`background-image:url(./images/${imageId})`);
expect(html).not.toContain(`/api/image/${imageId}`);
});
it("섹션 backgroundImageRepeat 가 설정되면 정적 HTML style 에 background-repeat 이 포함되어야 한다", async () => {
const blocks: Block[] = [
{
id: "sec_bg_repeat",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_bg_repeat_col_1", span: 12 }],
backgroundImageSrc: "/images/pattern.png",
backgroundImageSize: "auto",
backgroundImageRepeat: "repeat-x",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "섹션 배경 반복 테스트",
slug: "section-bg-repeat-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");
expect(html).toContain("background-repeat:repeat-x");
});
it("섹션 backgroundImagePositionMode 가 custom 이고 X/Y 퍼센트가 설정되면 정적 HTML background-position 에 'X% Y%' 가 포함되어야 한다", async () => {
const blocks: Block[] = [
{
id: "sec_bg_xy",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_bg_xy_col_1", span: 12 }],
backgroundImageSrc: "/images/section-bg-xy.png",
backgroundImageSize: "cover",
backgroundImagePositionMode: "custom",
backgroundImagePositionXPercent: 10,
backgroundImagePositionYPercent: 90,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "섹션 배경 XY 위치 테스트",
slug: "section-bg-xy-position-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");
expect(html).toContain("background-position:10% 90%");
});
it("HTML5 비디오 ariaLabel/captionText 는 정적 HTML video 태그 aria-label 및 pb-video-caption 으로 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "blk_video_a11y_html5",
type: "video" as any,
props: {
sourceUrl: "https://example.com/video-a11y.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
ariaLabel: "정적 내보내기 접근성 비디오 설명",
captionText: "정적 내보내기 비디오 캡션입니다.",
} as any,
},
];
const projectConfig: ProjectConfig = {
title: "비디오 접근성 HTML5 내보내기 테스트",
slug: "video-a11y-html5-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");
expect(html).toContain('aria-label="정적 내보내기 접근성 비디오 설명"');
expect(html).toContain('<p class="pb-video-caption">정적 내보내기 비디오 캡션입니다.</p>');
});
it("titleText 가 설정된 YouTube 비디오는 정적 HTML iframe title 로 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "blk_video_a11y_youtube",
type: "video" as any,
props: {
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
titleText: "정적 내보내기 YouTube 비디오 제목",
} as any,
},
];
const projectConfig: ProjectConfig = {
title: "비디오 접근성 YouTube 내보내기 테스트",
slug: "video-a11y-youtube-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");
expect(html).toContain('iframe title="정적 내보내기 YouTube 비디오 제목"');
});
it("/api/video/:id 기반 비디오 URL은 ZIP 안 videos/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
const videoId = `test-video-${Date.now()}`;
const uploadDir = path.join(process.cwd(), "uploads");
await fs.mkdir(uploadDir, { recursive: true });
await fs.writeFile(path.join(uploadDir, videoId), Buffer.from("dummy-video"));
const blocks: Block[] = [
{
id: "blk_video",
type: "video" as any,
props: {
sourceUrl: `/api/video/${videoId}`,
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
backgroundColorCustom: "#123456",
cardPaddingPx: 24,
borderRadiusPx: 16,
} as any,
},
];
const projectConfig: ProjectConfig = {
title: "비디오 내보내기 테스트",
slug: "video-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/${videoId}`);
expect(videoEntry).toBeTruthy();
const videoBytes = await videoEntry!.async("nodebuffer");
expect(videoBytes.length).toBeGreaterThan(0);
const html = await zip.file("index.html")!.async("string");
expect(html).toContain(`src="./videos/${videoId}`);
expect(html).not.toContain(`/api/video/${videoId}`);
// 비디오 카드 스타일이 wrapper style 에 반영되어야 한다.
expect(html).toContain("background-color:#123456");
expect(html).toContain("padding:24px");
expect(html).toContain("border-radius:16px");
});
it("섹션 backgroundVideoSrc 가 /api/video/:id 인 경우 ZIP videos/ 및 상대 경로로 치환되고 섹션 안에 배경 비디오 video 태그로 렌더되어야 한다", async () => {
const videoId = `test-section-bg-video-${Date.now()}`;
const uploadDir = path.join(process.cwd(), "uploads");
await fs.mkdir(uploadDir, { recursive: true });
await fs.writeFile(path.join(uploadDir, videoId), Buffer.from("dummy-section-video"));
const blocks: Block[] = [
{
id: "sec_bg_video",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_bg_video_col_1", span: 12 }],
backgroundVideoSrc: `/api/video/${videoId}`,
},
} as any,
{
id: "sec_bg_video_text",
type: "text",
sectionId: "sec_bg_video",
columnId: "sec_bg_video_col_1",
props: {
text: "섹션 배경 비디오 내보내기 테스트",
align: "left",
size: "base",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "섹션 배경 비디오 내보내기 테스트",
slug: "section-bg-video-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/${videoId}`);
expect(videoEntry).toBeTruthy();
const videoBytes = await videoEntry!.async("nodebuffer");
expect(videoBytes.length).toBeGreaterThan(0);
const html = await zip.file("index.html")!.async("string");
expect(html).toContain(`src="./videos/${videoId}`);
expect(html).not.toContain(`/api/video/${videoId}`);
expect(html).toContain("class=\"pb-section-bg-video\"");
});
it("divider 블록은 정적 HTML에서 구분선 요소로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
@@ -976,7 +1617,16 @@ describe("/api/export", () => {
props: {
kind: "contact",
submitTarget: "internal",
fields: [],
// v1 fields 설정이 있는 FormBlock 은 여전히 정적 HTML 폼으로 렌더되어야 한다.
fields: [
{
id: "f1",
name: "email",
label: "이메일",
type: "email",
required: true,
},
],
fieldIds: [],
formWidthMode: "auto",
backgroundColorCustom: "#111111",
@@ -1072,6 +1722,77 @@ describe("/api/export", () => {
expect(html).toContain("background-color:#123456");
});
it("루트/섹션 레이아웃은 pb-root / pb-section-inner / pb-section-columns 구조를 사용해야 한다", async () => {
const blocks: Block[] = [
{
id: "root_layout_text",
type: "text",
props: {
text: "루트 레이아웃 텍스트",
align: "left",
size: "base",
},
} as any,
{
id: "sec_layout_1",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_layout_1_col_1", span: 12 }],
},
} as any,
{
id: "sec_layout_1_text",
type: "text",
sectionId: "sec_layout_1",
columnId: "sec_layout_1_col_1",
props: {
text: "섹션 레이아웃 텍스트",
align: "left",
size: "base",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "레이아웃 구조 테스트",
slug: "layout-structure-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 arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// 루트 텍스트는 pb-root / pb-root-inner 안에 포함되어야 한다.
expect(html).toContain('<section class="pb-root"><div class="pb-root-inner">');
expect(html).toContain("루트 레이아웃 텍스트");
// 섹션 레이아웃 구조: pb-section + pb-section-inner + pb-section-columns + pb-section-column
expect(html).toContain('class="pb-section');
expect(html).toContain('class="pb-section-inner"');
expect(html).toContain('class="pb-section-columns"');
expect(html).toContain('class="pb-section-column"');
expect(html).toContain("섹션 레이아웃 텍스트");
});
it("formInput 블록의 textColorCustom 은 정적 HTML에서 input 텍스트 색상 스타일로 반영되어야 한다", async () => {
const blocks: Block[] = [
{
@@ -1113,7 +1834,7 @@ describe("/api/export", () => {
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"email\"");
expect(html).toContain('name="email"');
expect(html).toContain("color:#123456");
});
@@ -1695,6 +2416,13 @@ describe("/api/export", () => {
expect(css).toContain(".pb-list");
expect(css).toContain(".pb-whitespace-pre-wrap");
expect(css).toContain("color: var(--pb-color-text-strong)");
// 레이아웃 관련 유틸리티 클래스도 고정해 둔다.
expect(css).toContain(".pb-root-inner");
expect(css).toContain("max-width: 72rem");
expect(css).toContain(".pb-section-inner");
expect(css).toContain(".pb-section-columns");
expect(css).toContain("gap: 2rem");
expect(css).toContain(".pb-section-column");
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import "dotenv/config";
import { describe, it, expect, vi } from "vitest";
import { promises as fs } from "fs";
import path from "path";
const BASE_URL = "http://localhost";
// /api/video 업로드 TDD
// - POST /api/video 로 FormData(file) 를 전송하면 업로드 파일이 uploads 디렉터리에 저장되고
// id / servedUrl(`/api/video/:id`) 을 반환해야 한다.
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 목 처리한다.
vi.mock("@/generated/prisma/client", () => {
class PrismaClient {
project = {
upsert: async () => ({ id: "project-mock" }),
};
asset = {
create: async () => ({ id: "asset-mock" }),
};
}
return { PrismaClient };
});
describe("/api/video 업로드", () => {
it("POST /api/video 는 업로드된 비디오 파일을 저장하고 id/servedUrl 을 반환해야 한다", async () => {
const { POST: handleUpload } = await import("@/app/api/video/route");
const formData = new FormData();
const blob = new Blob(["dummy-video"], { type: "video/mp4" });
// FormData 에서는 File/Blob 모두 허용되므로, Blob 을 그대로 사용한다.
formData.append("file", blob as any);
const res = await handleUpload(
new Request(`${BASE_URL}/api/video`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(201);
const json = (await res.json()) as { id: string; servedUrl?: string | null };
expect(json.id).toBeTruthy();
expect(json.servedUrl).toBe(`/api/video/${json.id}`);
const filePath = path.join(process.cwd(), "uploads", json.id);
const stat = await fs.stat(filePath);
expect(stat.isFile()).toBe(true);
});
});