Compare commits

..

19 Commits

Author SHA1 Message Date
jaybe 7574d3a72c CI 로직 수정
CI / test (push) Successful in 4m34s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m45s
2025-12-02 13:37:10 +09:00
jaybe f087ae5f2c CI 수정
CI / test (push) Successful in 4m31s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m46s
2025-12-02 12:35:12 +09:00
jaybe 5832b64d39 e2e 테스트 수정
CI / test (push) Successful in 4m41s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m33s
2025-12-02 12:15:05 +09:00
jaybe c331d5e14a 유닛테스트 수정
CI / test (push) Successful in 4m35s
CI / pr_and_merge (push) Successful in 1m44s
2025-12-02 11:27:08 +09:00
jaybe 3e223a45d4 폼 전송 TDD추가 및 암호화 적용 중
CI / test (push) Failing after 4m41s
CI / pr_and_merge (push) Has been skipped
2025-12-02 11:02:15 +09:00
jaybe d0766fca45 메뉴 순서 수정
CI / test (push) Successful in 6m6s
CI / pr_and_merge (push) Has been skipped
2025-12-01 18:30:10 +09:00
jaybe 555d00e060 Merge pull request 'CI: feature/editor-account-switch' (#22) from feature/editor-account-switch into main
CI / test (push) Successful in 6m0s
CI / pr_and_merge (push) Has been skipped
2025-12-01 09:06:14 +00:00
jaybe c3de7df252 오류 수정
CI / test (push) Successful in 4m31s
CI / pr_and_merge (push) Successful in 1m11s
2025-12-01 18:00:31 +09:00
jaybe 9e75989c5d playwright 수정
CI / test (push) Failing after 2m30s
CI / pr_and_merge (push) Has been skipped
2025-12-01 17:24:19 +09:00
jaybe dbe76503d9 Merge pull request 'CI: feature/editor-account-switch' (#21) from feature/editor-account-switch into main
CI / test (push) Failing after 25m49s
CI / pr_and_merge (push) Has been skipped
2025-12-01 07:43:35 +00:00
jaybe ed093bf04f docker제거
CI / test (push) Successful in 3m35s
CI / pr_and_merge (push) Successful in 1m25s
2025-12-01 16:38:40 +09:00
jaybe a5e846a1e8 Merge pull request 'CI: feature/editor-account-switch' (#20) from feature/editor-account-switch into main
CI / pr_and_merge (push) Has been cancelled
CI / test (push) Has been cancelled
2025-12-01 07:14:02 +00:00
jaybe 198acd8db1 CI 이미지 생성
CI / test (push) Successful in 3m41s
CI / pr_and_merge (push) Successful in 1m8s
2025-12-01 16:08:21 +09:00
jaybe 5414db8e49 Merge pull request 'CI: feature/editor-account-switch' (#19) from feature/editor-account-switch into main
CI / pr_and_merge (push) Has been cancelled
CI / test (push) Has been cancelled
2025-12-01 06:19:12 +00:00
jaybe 44869b7514 playwright install
CI / test (push) Successful in 38m8s
CI / pr_and_merge (push) Successful in 1m0s
2025-12-01 14:40:07 +09:00
jaybe 6ad731b6e2 에디터 정리 및 버그 수정
CI / test (push) Failing after 13m50s
CI / pr_and_merge (push) Has been cancelled
2025-12-01 13:34:16 +09:00
jaybe eba4b0eeee Merge pull request 'CI: feature/user-project-ownership' (#18) from feature/user-project-ownership into main
CI / test (push) Failing after 7m3s
CI / pr_and_merge (push) Has been skipped
2025-11-30 18:29:25 +00:00
jaybe 1d9548862a Merge pull request 'CI: feature/user-project-ownership' (#17) from feature/user-project-ownership into main
CI / test (push) Failing after 7m18s
CI / pr_and_merge (push) Has been skipped
2025-11-30 15:41:42 +00:00
jaybe 637d834cd7 Merge pull request 'CI: feature/user-project-ownership' (#16) from feature/user-project-ownership into main
CI / test (push) Failing after 29m57s
CI / pr_and_merge (push) Has been skipped
2025-11-30 14:13:28 +00:00
152 changed files with 2920 additions and 114 deletions
+45 -5
View File
@@ -9,8 +9,6 @@ on:
jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.56.1-jammy
steps:
- name: Checkout repository
@@ -31,6 +29,14 @@ jobs:
run: |
npx prisma generate
- name: Build Next app
env:
# 빌드 시에도 Prisma Client 가 필요하므로 더미 DATABASE_URL 을 사용한다.
DATABASE_URL: postgresql://example:example@localhost:5432/example
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
run: |
npm run build
- name: Run unit tests
if: github.event_name == 'push'
env:
@@ -42,10 +48,44 @@ jobs:
run: |
npm test
e2e:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app_password
POSTGRES_DB: page_builder
options: >-
--health-cmd="pg_isready -U app -d page_builder"
--health-interval=5s
--health-timeout=3s
--health-retries=20
env:
DATABASE_URL: postgresql://app:app_password@postgres:5432/page_builder?schema=public
AUTH_JWT_SECRET: ${{ secrets.AUTH_JWT_SECRET }}
AUTH_ENCRYPTION_KEY: ${{ secrets.AUTH_ENCRYPTION_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run Prisma migrations
run: npx prisma migrate deploy
- name: Build Next.js app
run: npm run build
- name: Run Playwright E2E tests
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
npx playwright test
run: npm run e2e
pr_and_merge:
needs: test
View File
+1
View File
@@ -7,6 +7,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"start:e2e": "next start -p 3000",
"lint": "next lint",
"test": "vitest run",
"e2e": "playwright test"
View File
View File
+6 -2
View File
@@ -1,5 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
const isCI = !!process.env.CI;
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
@@ -17,9 +19,11 @@ export default defineConfig({
},
],
webServer: {
command: "npm run dev",
// CI 에서는 프로덕션 빌드 서버(next start)를 대상으로 E2E 를 수행해
// dev 모드의 느린 HMR/StrictMode 2회 렌더로 인한 flakiness 를 줄인다.
command: isCI ? "npm run start:e2e" : "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
reuseExistingServer: !isCI,
timeout: 120_000,
},
});
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "FormSubmission" (
"id" TEXT NOT NULL,
"projectId" TEXT,
"projectSlug" TEXT NOT NULL,
"userId" TEXT,
"payloadJson" JSONB NOT NULL,
"sensitiveEnc" TEXT,
"metaJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FormSubmission_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+17
View File
@@ -25,6 +25,8 @@ model Project {
assets Asset[]
formSubmissions FormSubmission[]
// 인증 도입을 위한 관계: 프로젝트는 특정 User 가 소유할 수 있다.
userId String?
user User? @relation(fields: [userId], references: [id])
@@ -39,6 +41,8 @@ model User {
updatedAt DateTime @updatedAt
projects Project[]
formSubmissions FormSubmission[]
}
model Asset {
@@ -57,3 +61,16 @@ enum ProjectStatus {
PUBLISHED
ARCHIVED
}
model FormSubmission {
id String @id @default(uuid())
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
projectId String?
projectSlug String
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId String?
payloadJson Json
sensitiveEnc String?
metaJson Json?
createdAt DateTime @default(now())
}
+1
View File
@@ -43,6 +43,7 @@ export async function POST(request: Request) {
secure: true,
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60,
});
return res;
+1
View File
@@ -58,6 +58,7 @@ export async function POST(request: Request) {
secure: true,
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60,
});
return res;
+19 -1
View File
@@ -250,9 +250,19 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
.filter(Boolean)
.join(" ");
let innerHtml = escapeHtml(label);
if (typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "") {
const imgSrc = escapeAttr((props as any).imageSrc.trim());
const altRaw = (props as any).imageAlt as string | undefined;
const altValue = altRaw && altRaw.trim() !== "" ? altRaw.trim() : label;
const imgAlt = escapeAttr(altValue);
innerHtml = `<img src="${imgSrc}" alt="${imgAlt}" />${innerHtml}`;
}
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
href,
)}" class="${btnClasses}"${styleAttr}>${escapeHtml(label)}</a></div>`;
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
}
if (block.type === "video") {
@@ -355,11 +365,19 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const configJson = JSON.stringify(props ?? {});
const escapedConfig = escapeAttr(configJson);
const projectSlugRaw = (projectConfig?.slug ?? "").trim();
const projectSlugValue = projectSlugRaw !== "" ? projectSlugRaw : "";
const parts: string[] = [];
parts.push(
`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}${actionAttr}>`,
);
parts.push(`<input type="hidden" name="__config" value="${escapedConfig}" />`);
if (projectSlugValue) {
parts.push(
`<input type="hidden" name="__projectSlug" value="${escapeAttr(projectSlugValue)}" />`,
);
}
parts.push("</form>");
return parts.join("");
}
+148 -6
View File
@@ -1,15 +1,158 @@
import { NextResponse } from "next/server";
import type { FormBlockProps } from "@/features/editor/state/editorStore";
import { PrismaClient } from "@prisma/client";
import type { FormBlockProps, FormFieldConfig } from "@/features/editor/state/editorStore";
import { encryptJson } from "@/features/auth/authCrypto";
const prisma = new PrismaClient() as any;
function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set<string> {
const result = new Set<string>();
const fields: FormFieldConfig[] = Array.isArray(config?.fields) ? (config!.fields as FormFieldConfig[]) : [];
for (const field of fields) {
const name = (field.name ?? "").toLowerCase();
const label = (field.label ?? "").toLowerCase();
if (!name) continue;
const isEmail =
field.type === "email" ||
name.includes("email") ||
label.includes("email") ||
label.includes("이메일");
if (isEmail) {
result.add(field.name);
continue;
}
const isPhone =
name.includes("phone") ||
name.includes("tel") ||
name.includes("mobile") ||
label.includes("전화") ||
label.includes("연락처") ||
label.includes("휴대폰");
if (isPhone) {
result.add(field.name);
continue;
}
const isBirth =
name.includes("birth") ||
name.includes("birthday") ||
name.includes("dob") ||
label.includes("생년월일") ||
label.includes("생일");
if (isBirth) {
result.add(field.name);
continue;
}
}
return result;
}
async function persistFormSubmission(formData: FormData, config: FormBlockProps | null) {
const projectSlugRaw = formData.get("__projectSlug");
let projectSlug: string | null = null;
if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") {
projectSlug = projectSlugRaw.trim();
} else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") {
const v = config.extraParams.projectSlug.trim();
if (v !== "") {
projectSlug = v;
}
}
const sensitiveNames = buildSensitiveFieldNameSet(config);
formData.forEach((_, key) => {
if (key === "__config" || key === "__projectSlug") {
return;
}
const lower = key.toLowerCase();
const isEmailField =
lower.includes("email") ||
lower.includes("e-mail") ||
lower.includes("이메일");
const isPhoneField =
lower.includes("phone") ||
lower.includes("tel") ||
lower.includes("mobile") ||
lower.includes("전화") ||
lower.includes("연락처") ||
lower.includes("휴대폰");
const isBirthField =
lower.includes("birth") ||
lower.includes("birthday") ||
lower.includes("birthdate") ||
lower.includes("dob") ||
lower.includes("생년월일") ||
lower.includes("생일");
if (isEmailField || isPhoneField || isBirthField) {
sensitiveNames.add(key);
}
});
const payloadJson: Record<string, string> = {};
const sensitivePayload: Record<string, string> = {};
formData.forEach((value, key) => {
if (key === "__config" || key === "__projectSlug") {
return;
}
const strValue = String(value);
if (sensitiveNames.has(key)) {
sensitivePayload[key] = strValue;
} else {
payloadJson[key] = strValue;
}
});
let sensitiveEnc: string | null = null;
if (Object.keys(sensitivePayload).length > 0) {
sensitiveEnc = await encryptJson(sensitivePayload);
}
let projectId: string | null = null;
let userId: string | null = null;
if (projectSlug) {
const project = await prisma.project.findUnique({ where: { slug: projectSlug } });
if (project) {
projectId = project.id;
userId = project.userId ?? null;
}
}
await prisma.formSubmission.create({
data: {
projectSlug: projectSlug ?? "",
projectId,
userId,
payloadJson,
sensitiveEnc,
metaJson: null,
},
});
}
export async function POST(req: Request) {
const formData = await req.formData();
// 기본 필드(name/email/message)는 그대로 읽어둔다.
const name = formData.get("name");
const email = formData.get("email");
const message = formData.get("message");
// 에디터에서 넘겨준 폼 설정(JSON) 읽기
const rawConfig = formData.get("__config");
let config: FormBlockProps | null = null;
if (typeof rawConfig === "string") {
@@ -24,18 +167,17 @@ export async function POST(req: Request) {
const successMessage = config?.successMessage;
const errorMessage = config?.errorMessage;
// 1) internal: 우리 서버에서 직접 처리하는 기본 모드
if (submitTarget === "internal") {
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
await persistFormSubmission(formData, config);
console.log("[forms/submit][internal]", { name, email, message });
return NextResponse.json({ ok: true, message: successMessage });
}
if (submitTarget === "both") {
await persistFormSubmission(formData, config);
console.log("[forms/submit][internal]", { name, email, message });
}
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
if (submitTarget === "webhook" || submitTarget === "both") {
const destinationUrl = config?.destinationUrl;
if (!destinationUrl) {
@@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import { verifyAccessToken, decryptJson } from "@/features/auth/authCrypto";
const prisma = new PrismaClient();
interface AuthUser {
id: string;
email: string;
tokenVersion: number;
}
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) return null;
const payload = await verifyAccessToken(token);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
}
export async function GET(
request: Request,
context: { params: { slug: string } | Promise<{ slug: string }> },
) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." }, { status: 401 });
}
const { slug } = await context.params;
const project = await prisma.project.findUnique({
where: { slug },
});
if (!project || (project.userId && project.userId !== authUser.id)) {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
const submissions = await prisma.formSubmission.findMany({
where: { projectId: project.id },
orderBy: { createdAt: "desc" },
take: 100,
});
const items = await Promise.all(
submissions.map(async (s: any) => {
const basePayload = (s.payloadJson ?? {}) as Record<string, unknown>;
let sensitive: Record<string, unknown> = {};
if (s.sensitiveEnc) {
try {
sensitive = (await decryptJson<Record<string, unknown>>(s.sensitiveEnc)) ?? {};
} catch {
sensitive = {};
}
}
const payload = { ...basePayload, ...sensitive };
return {
id: s.id,
createdAt: s.createdAt,
projectSlug: s.projectSlug,
payload,
};
}),
);
return NextResponse.json(items, { status: 200 });
}
+11 -3
View File
@@ -15,14 +15,22 @@ try {
}
interface Params {
params: {
params: Promise<{
id: string;
};
}>;
}
export async function GET(request: Request, ctx: Params) {
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
let id: string | undefined = ctx?.params?.id;
let id: string | undefined;
try {
const resolved = await ctx.params;
id = resolved?.id;
} catch {
id = undefined;
}
if (!id) {
try {
const url = new URL(request.url);
+20 -6
View File
@@ -233,10 +233,16 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
)
.map((fieldBlock) => {
const fieldId = fieldBlock.id;
const fieldLabel = (fieldBlock.props as any).label ??
(fieldBlock.props as any).groupLabel ??
(fieldBlock.props as any).formFieldName ??
fieldId;
const anyProps: any = fieldBlock.props ?? {};
const transmissionKey: string | undefined = anyProps.formFieldName;
const labelText: string | undefined = anyProps.label ?? anyProps.groupLabel;
const displayLabel = transmissionKey
? labelText && labelText !== transmissionKey
? `${transmissionKey} (${labelText})`
: transmissionKey
: labelText || fieldId;
const checked = (formProps.fieldIds ?? []).includes(fieldId);
@@ -260,7 +266,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any);
}}
/>
<span>{fieldLabel}</span>
<span>{displayLabel}</span>
</label>
);
})}
@@ -283,9 +289,17 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
.filter((b) => b.type === "button")
.map((buttonBlock) => {
const btnProps = buttonBlock.props as ButtonBlockProps;
const transmissionKey = btnProps.formFieldName;
const labelText = btnProps.label;
const displayLabel = transmissionKey
? labelText && labelText !== transmissionKey
? `${transmissionKey} (${labelText})`
: transmissionKey
: labelText || buttonBlock.id;
return (
<option key={buttonBlock.id} value={buttonBlock.id}>
{btnProps.label || buttonBlock.id}
{displayLabel}
</option>
);
})}
+175 -21
View File
@@ -1,9 +1,9 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useEffect, useState } from "react";
import { Fragment, Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
import {
DndContext,
@@ -71,7 +71,16 @@ import { PropertiesSidebar } from "./panels/PropertiesSidebar";
import { EditorCanvas } from "./EditorCanvas";
export default function EditorPage() {
return (
<Suspense fallback={null}>
<EditorPageInner />
</Suspense>
);
}
function EditorPageInner() {
const router = useRouter();
const searchParams = useSearchParams();
const blocks = useEditorStore((state) => state.blocks);
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
@@ -121,6 +130,13 @@ export default function EditorPage() {
(state) => (state as any).projectConfig as ProjectConfig,
);
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void);
const [authUserId, setAuthUserId] = useState<string | null>(null);
const [authChecked, setAuthChecked] = useState(false);
const [initialSlugFromQuery, setInitialSlugFromQuery] = useState<string | null>(null);
const [hasLoadedInitialProjectFromSlug, setHasLoadedInitialProjectFromSlug] = useState(false);
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
@@ -140,17 +156,105 @@ export default function EditorPage() {
const canUndo = historyLength > 0;
const canRedo = futureLength > 0;
useEffect(() => {
if (!searchParams) return;
const slugParam = searchParams.get("slug");
if (slugParam && !initialSlugFromQuery) {
setInitialSlugFromQuery(slugParam);
}
}, [searchParams, initialSlugFromQuery]);
useEffect(() => {
if (!initialSlugFromQuery) return;
const trimmed = initialSlugFromQuery.trim();
if (!trimmed) return;
const currentSlug = (projectConfig?.slug ?? "").trim();
if (currentSlug !== trimmed) {
updateProjectConfig({ slug: trimmed });
}
}, [initialSlugFromQuery, projectConfig?.slug, updateProjectConfig]);
useEffect(() => {
if (!authChecked) return;
if (!initialSlugFromQuery) return;
if (hasLoadedInitialProjectFromSlug) return;
const slug = initialSlugFromQuery.trim();
if (!slug) return;
let cancelled = false;
const loadFromServer = async () => {
try {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`);
if (!res.ok || cancelled) {
return;
}
const data = await res.json();
if (data && Array.isArray(data.contentJson)) {
replaceBlocks(data.contentJson as any);
if (data.title && typeof data.title === "string") {
updateProjectConfig({ title: data.title, slug });
} else {
updateProjectConfig({ slug });
}
// 다른 slug/사용자에서 불러온 뒤에는 이전 프로젝트의 undo/redo 히스토리를 끊어준다.
resetHistory();
}
} catch {
// 서버 로드 실패는 조용히 무시하고, 사용자가 수동으로 불러오기 할 수 있도록 둔다.
} finally {
if (!cancelled) {
setHasLoadedInitialProjectFromSlug(true);
}
}
};
void loadFromServer();
return () => {
cancelled = true;
};
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks]);
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
if (!res.ok && res.status === 401 && !cancelled) {
if (cancelled) {
return;
}
if (res.ok) {
let data: any = null;
try {
data = await res.json();
} catch {
data = null;
}
if (data && typeof data.id === "string") {
setAuthUserId(data.id);
} else {
setAuthUserId(null);
}
} else if (res.status === 401) {
router.push("/login");
setAuthUserId(null);
}
} catch {
// 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다.
if (!cancelled) {
setAuthUserId(null);
}
} finally {
if (!cancelled) {
setAuthChecked(true);
}
}
};
@@ -353,6 +457,15 @@ export default function EditorPage() {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.contentJson)) {
replaceBlocks(parsed.contentJson as any);
if (parsed.title && typeof parsed.title === "string") {
updateProjectConfig({ title: parsed.title, slug });
} else {
updateProjectConfig({ slug });
}
// 다른 프로젝트(slug)를 불러왔으므로 이전 history/future 를 초기화한다.
resetHistory();
setProjectMessage(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
return;
}
@@ -372,6 +485,15 @@ export default function EditorPage() {
const data = await response.json();
if (data && Array.isArray(data.contentJson)) {
replaceBlocks(data.contentJson as any);
if (data.title && typeof data.title === "string") {
updateProjectConfig({ title: data.title, slug });
} else {
updateProjectConfig({ slug });
}
// 서버에서 다른 프로젝트를 불러온 뒤에는 이전 history/future 를 모두 비운다.
resetHistory();
setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`);
} else {
setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다.");
@@ -586,6 +708,7 @@ export default function EditorPage() {
const rootBlocks = blocks.filter((block) => !block.sectionId);
useEffect(() => {
if (!authChecked) return;
if (typeof window === "undefined") return;
const slug = projectConfig?.slug?.trim();
@@ -596,17 +719,35 @@ export default function EditorPage() {
if (!raw) return;
try {
const parsed = JSON.parse(raw) as { blocks?: Block[]; projectConfig?: ProjectConfig };
const parsed = JSON.parse(raw) as {
blocks?: Block[];
projectConfig?: ProjectConfig;
savedByUserId?: string | null;
};
const savedByUserId = parsed.savedByUserId ?? null;
if (authUserId) {
if (savedByUserId && savedByUserId !== authUserId) {
return;
}
} else if (savedByUserId) {
return;
}
if (Array.isArray(parsed.blocks)) {
replaceBlocks(parsed.blocks as any);
}
if (parsed.projectConfig) {
updateProjectConfig(parsed.projectConfig as ProjectConfig);
}
// autosave 로 전체 프로젝트를 복원한 경우에도 이전 history/future 는 끊어준다.
resetHistory();
} catch {
return;
}
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
}, [authChecked, authUserId, projectConfig?.slug, replaceBlocks, updateProjectConfig]);
useEffect(() => {
if (typeof window === "undefined") return;
@@ -615,9 +756,22 @@ export default function EditorPage() {
if (!slug) return;
const key = `pb:autosave:${slug}`;
try {
const existing = window.localStorage.getItem(key);
if (existing && !authChecked) {
// 초기 렌더 시 이미 autosave 스냅샷이 있는 경우,
// 인증 체크 및 복원(useEffect)이 끝나기 전까지는 기존 스냅샷을 덮어쓰지 않는다.
return;
}
} catch {
// 기존 값 조회 에러는 무시하고, 아래 저장 시도는 계속 진행한다.
}
const payload = {
blocks,
projectConfig,
savedByUserId: authUserId ?? null,
};
try {
@@ -625,7 +779,7 @@ export default function EditorPage() {
} catch {
return;
}
}, [blocks, projectConfig]);
}, [blocks, projectConfig, authUserId, authChecked]);
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
@@ -859,19 +1013,6 @@ export default function EditorPage() {
<span>JSON /</span>
</span>
</button>
<button
type="button"
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
onClick={() => {
setMenuOpen(false);
void handleDeleteProject();
}}
>
<span className="inline-flex items-center gap-2">
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
<button
type="button"
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
@@ -900,6 +1041,19 @@ export default function EditorPage() {
<span> </span>
</span>
</button>
<button
type="button"
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
onClick={() => {
setMenuOpen(false);
void handleDeleteProject();
}}
>
<span className="inline-flex items-center gap-2 text-red-500">
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
</div>
)}
</div>
@@ -998,7 +1152,7 @@ export default function EditorPage() {
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
<div className="w-full max-w-2xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">JSON Export / Import</h3>
<h3 className="text-sm font-medium">JSON Export / Import <span className="text-xs text-slate-400">* .</span></h3>
<button
type="button"
className="text-slate-400 hover:text-slate-100 text-sm"
@@ -11,6 +11,13 @@ export type ButtonPropertiesPanelProps = {
};
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
const imageSource: "none" | "url" | "upload" = (() => {
const hasSrc = (buttonProps.imageSrc ?? "").trim() !== "";
if (!hasSrc) return "none";
if (buttonProps.imageSourceType === "asset") return "upload";
return "url";
})();
return (
<>
<div className="space-y-1">
@@ -26,6 +33,148 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
/>
</label>
</div>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
<h4 className="text-[11px] font-semibold text-slate-200"> </h4>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="버튼 이미지 소스"
value={imageSource}
onChange={(e) => {
const next = e.target.value as "none" | "url" | "upload";
if (next === "none") {
updateBlock(selectedBlockId, {
imageSrc: "",
imageAssetId: null,
} as any);
} else if (next === "url") {
updateBlock(selectedBlockId, {
imageSourceType: "externalUrl",
imageAssetId: null,
} as any);
} else {
updateBlock(selectedBlockId, {
imageSourceType: "asset",
} as any);
}
}}
>
<option value="none"> </option>
<option value="url">URL</option>
<option value="upload"> </option>
</select>
</label>
</div>
{imageSource === "url" && (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span> URL</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 이미지 URL"
value={buttonProps.imageSrc ?? ""}
onChange={(e) => {
const value = e.target.value;
updateBlock(selectedBlockId, {
imageSrc: value,
imageSourceType: "externalUrl",
imageAssetId: null,
} as any);
}}
/>
</label>
</div>
)}
{imageSource === "upload" && (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span> </span>
<input
type="file"
accept="image/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="버튼 이미지 파일 업로드"
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/image", {
method: "POST",
body: formData,
});
if (!response.ok) {
console.error("버튼 이미지 업로드 실패", await response.text());
return;
}
const data = (await response.json()) as { id: string; servedUrl?: string | null };
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
updateBlock(selectedBlockId, {
imageSrc: servedUrl,
imageSourceType: "asset",
imageAssetId: data.id,
} as any);
} catch (error) {
console.error("버튼 이미지 업로드 중 오류", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
</div>
)}
{imageSource !== "none" && (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 이미지 대체 텍스트"
value={buttonProps.imageAlt ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { imageAlt: e.target.value } as any);
}}
/>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span> </span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 이미지 위치"
value={buttonProps.imagePlacement ?? "left"}
onChange={(e) => {
updateBlock(selectedBlockId, {
imagePlacement: e.target.value as any,
} as any);
}}
>
<option value="left"> </option>
<option value="right"> </option>
<option value="top"> </option>
<option value="bottom"> </option>
</select>
</label>
</div>
</>
)}
</div>
<div className="space-y-1">
<NumericPropertyControl
label="가로 패딩 (px)"
+24 -2
View File
@@ -13,6 +13,7 @@ export default function PreviewPage() {
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void);
useEffect(() => {
let cancelled = false;
@@ -38,7 +39,22 @@ export default function PreviewPage() {
useEffect(() => {
if (typeof window === "undefined") return;
const slug = (projectConfig as any)?.slug?.trim?.();
let slug = (projectConfig as any)?.slug?.trim?.();
if (!slug) {
try {
const url = new URL(window.location.href);
const fromQuery = url.searchParams.get("slug")?.trim();
if (fromQuery && fromQuery !== slug) {
slug = fromQuery;
updateProjectConfig({ slug: fromQuery } as any);
}
} catch {
// URL 파싱 오류는 무시하고, autosave 복원 없이 진행한다.
}
}
if (!slug) return;
const key = `pb:autosave:${slug}`;
@@ -53,6 +69,9 @@ export default function PreviewPage() {
if (parsed.projectConfig) {
updateProjectConfig(parsed.projectConfig as any);
}
// 프리뷰 진입 시 autosave 프로젝트를 복원하는 경우에도 에디터 히스토리는 새 프로젝트 기준으로만 유지되도록 초기화한다.
resetHistory();
} catch {
return;
}
@@ -159,7 +178,10 @@ export default function PreviewPage() {
className="w-full"
style={canvasStyle}
>
<PublicPageRenderer blocks={blocks} />
<PublicPageRenderer
blocks={blocks}
projectSlug={(projectConfig?.slug ?? "").trim() || undefined}
/>
</div>
</div>
</section>
@@ -0,0 +1,177 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useParams } from "next/navigation";
// 프로젝트별 폼 제출 내역을 조회해서 보여주는 페이지 컴포넌트.
// - URL 의 slug 파라미터를 기준으로 /api/projects/[slug]/submissions 에 요청을 보낸다.
// - 401 이면 로그인 페이지로 이동시키고, 404/500 계열 에러는 한국어 에러 메시지로 안내한다.
// - 응답 payload 의 name/email/phone/birthdate 는 개별 컬럼으로, 나머지는 "기타 필드" 텍스트로 렌더링한다.
interface SubmissionItem {
id: string;
createdAt: string;
projectSlug: string;
// 서버에서 복호화된 폼 필드 전체가 payload 로 내려온다.
payload: Record<string, unknown>;
}
type PageStatus = "idle" | "loading" | "error";
export default function ProjectSubmissionsPage() {
const router = useRouter();
const params = useParams() as { slug?: string } | null;
const slug = (params?.slug ?? "").toString();
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
const [status, setStatus] = useState<PageStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// 마운트 시 현재 slug 기준으로 폼 제출 내역을 불러온다.
useEffect(() => {
if (!slug) return;
let cancelled = false;
const load = async () => {
try {
setStatus("loading");
setErrorMessage(null);
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/submissions`);
if (!res.ok) {
if (cancelled) return;
if (res.status === 401) {
setStatus("error");
setErrorMessage("폼 제출 내역을 조회하려면 로그인이 필요합니다. 다시 로그인해 주세요.");
router.push("/login");
return;
}
if (res.status === 404) {
setStatus("error");
setErrorMessage(
"프로젝트를 찾을 수 없거나 접근 권한이 없습니다. 프로젝트 소유자 계정으로 다시 로그인해 주세요.",
);
return;
}
setStatus("error");
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
return;
}
const data = (await res.json()) as SubmissionItem[];
if (!cancelled) {
setSubmissions(Array.isArray(data) ? data : []);
setStatus("idle");
setErrorMessage(null);
}
} catch {
if (!cancelled) {
setStatus("error");
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
}
};
void load();
return () => {
cancelled = true;
};
}, [slug]);
// payload 에서 name/email/phone/birthdate 를 제외한 나머지 필드를 "기타" 영역으로 모아 보여준다.
const renderOtherFields = (payload: Record<string, unknown>): string => {
const knownKeys = new Set(["name", "email", "phone", "birthdate"]);
const entries = Object.entries(payload).filter(([key]) => !knownKeys.has(key));
if (entries.length === 0) {
return "-";
}
return entries
.map(([key, value]) => {
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
return `${key}: ${stringValue}`;
})
.join("\n");
};
return (
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between bg-slate-950/80 backdrop-blur">
<div>
<h1 className="text-xl font-semibold"> </h1>
<p className="text-xs text-slate-400"> .</p>
</div>
<div className="text-xs text-slate-300">
<span className="font-mono text-[11px]">{slug}</span>
</div>
</header>
<section className="flex-1 px-6 py-4 overflow-auto">
{status === "error" && errorMessage && (
<p className="text-xs text-red-300 mb-3">{errorMessage}</p>
)}
{status === "loading" && (
<p className="text-xs text-slate-400 mb-3"> ...</p>
)}
{submissions.length === 0 && status === "idle" && !errorMessage && (
<p className="text-xs text-slate-400"> .</p>
)}
{submissions.length > 0 && (
<table className="w-full text-xs text-left border-collapse mt-2">
<thead>
<tr className="border-b border-slate-800 text-slate-400">
<th className="py-2 pr-4"> </th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"> </th>
</tr>
</thead>
<tbody>
{submissions.map((item) => {
const payload = (item.payload ?? {}) as Record<string, unknown>;
const nameValue = payload["name"];
const emailValue = payload["email"];
const phoneValue = payload["phone"];
const birthdateValue = payload["birthdate"];
const name = typeof nameValue === "string" ? nameValue : "";
const email = typeof emailValue === "string" ? emailValue : "";
const phone = typeof phoneValue === "string" ? phoneValue : "";
const birthdate = typeof birthdateValue === "string" ? birthdateValue : "";
return (
<tr key={item.id} className="border-b border-slate-900 hover:bg-slate-900/60">
<td className="py-2 pr-4 text-slate-300 text-[11px]">
{new Date(item.createdAt).toLocaleString()}
</td>
<td className="py-2 pr-4 text-slate-100">{name}</td>
<td className="py-2 pr-4 text-slate-300">{email}</td>
<td className="py-2 pr-4 text-slate-300">{phone}</td>
<td className="py-2 pr-4 text-slate-300">{birthdate}</td>
<td className="py-2 pr-4 text-slate-300 whitespace-pre-wrap">
{renderOtherFields(payload)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
</main>
);
}
+7
View File
@@ -330,6 +330,13 @@ export default function ProjectsPage() {
<Eye className="w-3 h-3" aria-hidden="true" />
<span></span>
</Link>
<Link
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
className="inline-flex items-center gap-1 text-emerald-300 hover:text-emerald-100 underline-offset-2 hover:underline"
>
<ListChecks className="w-3 h-3" aria-hidden="true" />
<span> </span>
</Link>
<button
type="button"
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 underline-offset-2 hover:underline"
+2 -2
View File
@@ -74,10 +74,10 @@ export async function signAccessToken(user: JwtUserLike): Promise<string> {
tokenVersion: user.tokenVersion,
};
// 만료 시간은 기본 15분으로 설정한다.
// 만료 시간은 기본 7일으로 설정한다.
const token = jwt.sign(payload, secret, {
algorithm: "HS256",
expiresIn: "15m",
expiresIn: "7d",
});
return token;
@@ -86,6 +86,7 @@ export function getSectionLayoutConfig(props: SectionBlockProps) {
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
interface PublicPageRendererProps {
blocks: Block[];
projectSlug?: string;
}
const pxToEm = (px: number, base = 16) => `${px / base}em`;
@@ -99,7 +100,7 @@ const convertPxStringToEm = (value?: string | null) => {
return pxToEm(px);
};
export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererProps) {
const sectionBlocks = blocks.filter((b) => b.type === "section");
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
@@ -107,10 +108,6 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const [formMessage, setFormMessage] = useState<string>("");
const renderBlock = (block: Block) => {
if ((block as any).type === "form") {
return null;
}
if (block.type === "text") {
const props = block.props as TextBlockProps;
const tokens = computeTextPublicTokens(props);
@@ -337,10 +334,48 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
.filter(Boolean)
.join(" ");
const hasImage = typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "";
const placement = ((props as any).imagePlacement as string | undefined) ?? "left";
let innerContent: React.ReactNode;
if (!hasImage) {
innerContent = <span className="whitespace-pre-wrap">{props.label}</span>;
} else {
const src = (props as any).imageSrc as string;
const altRaw = (props as any).imageAlt as string | undefined;
const alt = altRaw && altRaw.trim() !== "" ? altRaw.trim() : props.label;
const imgEl = (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} className="inline-block max-w-full h-auto" />
);
const labelSpan = <span className="whitespace-pre-wrap">{props.label}</span>;
const isVertical = placement === "top" || placement === "bottom";
const wrapperClassNames = [
"inline-flex",
"items-center",
"gap-2",
isVertical ? "flex-col" : "",
]
.filter(Boolean)
.join(" ");
const first = placement === "left" || placement === "top" ? imgEl : labelSpan;
const second = placement === "left" || placement === "top" ? labelSpan : imgEl;
innerContent = (
<span className={wrapperClassNames}>
{first}
{second}
</span>
);
}
return (
<div key={block.id} className={wrapperClass}>
<a href={props.href} className={buttonClassName} style={buttonStyle}>
<span className="whitespace-pre-wrap">{props.label}</span>
{innerContent}
</a>
</div>
);
@@ -556,6 +591,9 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
>
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
<input type="hidden" name="__config" value={JSON.stringify(props)} />
{projectSlug && projectSlug.trim() !== "" ? (
<input type="hidden" name="__projectSlug" value={projectSlug.trim()} />
) : null}
{hasFields && (
<div className="flex flex-col gap-2 text-xs text-slate-200">
{fields.map((field: any) => (
+59 -4
View File
@@ -116,6 +116,13 @@ export interface ButtonBlockProps {
strokeColorCustom?: string;
// 버튼 텍스트 색상 커스텀 값
textColorCustom?: string;
// 폼 컨트롤러에서 Submit 버튼 매핑 시 사용할 수 있는 전송 키 (name 역할)
formFieldName?: string;
imageSrc?: string;
imageAlt?: string;
imageSourceType?: "asset" | "externalUrl";
imageAssetId?: string | null;
imagePlacement?: "left" | "right" | "top" | "bottom";
}
// 이미지 블록 속성
@@ -768,12 +775,22 @@ export interface EditorState {
redo: () => void;
removeBlock: (id: string) => void;
duplicateBlock: (id: string) => void;
resetHistory: () => void;
}
// 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능)
let idCounter = 0;
const createId = () => `blk_${Date.now()}_${idCounter++}`;
// undo/redo 를 위한 히스토리 유틸리티: 상태 변경 전에 현재 blocks 스냅샷을 history 에 쌓고 future 는 비운다.
const pushHistoryImpl = (set: any, get: any) => {
const { blocks } = get() as EditorState;
set((state: EditorState) => ({
history: [...state.history, blocks],
future: [],
}));
};
const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
const regex = new RegExp(`^${prefix}-(\\d+)$`);
let max = 0;
@@ -795,7 +812,16 @@ const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
const createEditorState = (set: any, get: any): EditorState => ({
const createEditorState = (set: any, get: any): EditorState => {
const pushHistory = () => {
const { blocks } = get() as EditorState;
set((state: EditorState) => ({
history: [...state.history, blocks],
future: [],
}));
};
return {
blocks: [],
selectedBlockId: null,
selectedListItemId: null,
@@ -860,11 +886,10 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
history: [...state.history, blocks],
future: [],
}));
},
@@ -887,6 +912,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
createId,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -928,6 +954,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
createId,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -964,6 +991,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
createId,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1000,6 +1028,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
createId,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1036,6 +1065,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
createId,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1072,6 +1102,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
createId,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1108,6 +1139,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
createId,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1144,6 +1176,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
createId,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1180,6 +1213,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
createId,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1233,6 +1267,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1279,6 +1314,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1318,6 +1354,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1359,6 +1396,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1401,6 +1439,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1443,6 +1482,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1483,6 +1523,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1530,6 +1571,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1556,6 +1598,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1606,6 +1649,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1660,6 +1704,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1668,6 +1713,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
// 특정 블록의 속성을 부분 업데이트 (텍스트/버튼 공통 사용)
updateBlock: (id, partial) => {
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: state.blocks.map((block: Block) =>
block.id === id
@@ -1816,6 +1862,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
replaceBlocks: (blocks) => {
pushHistoryImpl(set, get);
set({
blocks,
selectedBlockId: blocks.length > 0 ? blocks[0].id : null,
@@ -1823,6 +1870,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
reorderBlocks: (activeId, overId) => {
pushHistoryImpl(set, get);
set((state: EditorState) => {
const current = state.blocks;
const oldIndex = current.findIndex((b: Block) => b.id === activeId);
@@ -1840,6 +1888,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
},
moveBlock: (id, sectionId, columnId) => {
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: state.blocks.map((block: Block) =>
block.id === id
@@ -1853,6 +1902,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
}));
},
removeBlock: (id) => {
pushHistoryImpl(set, get);
set((state: EditorState) => {
const current = state.blocks;
const index = current.findIndex((b) => b.id === id);
@@ -1884,6 +1934,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
},
duplicateBlock: (id) => {
pushHistoryImpl(set, get);
set((state: EditorState) => {
const current = state.blocks;
const index = current.findIndex((b) => b.id === id);
@@ -1939,7 +1990,11 @@ const createEditorState = (set: any, get: any): EditorState => ({
future: nextFuture,
}));
},
});
resetHistory: () => {
set({ history: [], future: [] });
},
};
};
// React 컴포넌트에서 사용하는 전역 훅 스토어
export const useEditorStore = create<EditorState>()(createEditorState);
+6 -2
View File
@@ -925,11 +925,15 @@ export const computeFormControllerPublicTokens = (
);
const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label ?? null;
// FormBlock 은 레이아웃/스타일을 가지지 않는 순수 컨트롤러이므로
// formClassName 은 고정 기본값만 사용하고, formStyle 은 항상 빈 객체로 유지한다.
// FormBlock 은 레이아웃/스타일을 가지지 않는 순수 컨트롤러이지만,
// 배경색 커스텀 값을 허용해 form 요소 배경만 제어할 수 있도록 한다.
const formClassNames = ["space-y-3"];
const formStyle: CSSProperties = {};
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
formStyle.backgroundColor = props.backgroundColorCustom.trim();
}
return {
fields,
formClassName: formClassNames.join(" "),
+1
View File
@@ -71,6 +71,7 @@ describe("/api/auth", () => {
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toBeTruthy();
expect(setCookie).toContain("pb_access=");
expect(setCookie?.toLowerCase()).toContain("max-age=604800");
});
it("비밀번호가 8자 미만이면 400 과 에러 메시지를 반환해야 한다", async () => {
+33 -5
View File
@@ -259,16 +259,16 @@ describe("/api/export", () => {
expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"');
});
it("projectConfig.headHtml trackingScript 는 index.html 의 head/body 에 그대로 포함되어야 한다", async () => {
it("projectConfig.headHtml / trackingScript headbody 에 반영되어야 한다", async () => {
const blocks: Block[] = [];
const projectConfig: ProjectConfig = {
title: "헤드/트래킹 테스트",
title: "헤드/트래킹 스크립트 테스트",
slug: "head-tracking-test",
canvasPreset: "full",
headHtml: '<meta name="robots" content="noindex" />',
trackingScript: '<script>window.__TEST_TRACKING__ = true;<\/script>',
};
trackingScript: '<script>window.__TEST_TRACKING__ = true;<\\/script>',
} as ProjectConfig;
const payload = { blocks, projectConfig };
@@ -294,7 +294,35 @@ describe("/api/export", () => {
// head 커스텀 HTML
expect(html).toContain('<meta name="robots" content="noindex" />');
// body 하단 추적 스크립트
expect(html).toContain('<script>window.__TEST_TRACKING__ = true;<\/script>');
expect(html).toContain('<script>window.__TEST_TRACKING__ = true;<\\/script>');
});
it("버튼 블록에 imageSrc 가 있으면 내보내기 HTML 의 버튼 안에 img 요소가 포함되어야 한다", () => {
const blocks: Block[] = [
{
id: "btn_img_export",
type: "button",
props: {
label: "이미지 버튼",
href: "#",
imageSrc: "https://example.com/button.png",
imageAlt: "버튼 이미지",
imagePlacement: "left",
} as any,
},
];
const projectConfig: ProjectConfig = {
title: "버튼 이미지 내보내기 테스트",
slug: "btn-img-export-test",
canvasPreset: "full",
} as ProjectConfig;
const html = buildStaticHtml(blocks, projectConfig);
expect(html).toContain('<a href="#"');
expect(html).toContain('<img src="https://example.com/button.png"');
expect(html).toContain('alt="버튼 이미지"');
});
it("index.html head 에는 기본 viewport 메타 태그가 포함되어야 한다", async () => {
@@ -99,10 +99,13 @@ describe("정적 Export 폼 컨트롤러 통합", () => {
form!.dispatchEvent(submitEvent);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0] as [any, RequestInit];
const [url, options] = fetchMock.mock.calls[0] as unknown as [any, RequestInit];
expect(url).toBe("/api/forms/submit");
expect(options.method).toBe("POST");
expect(options.body).toBeInstanceOf(FormData);
const body = options.body as FormData;
expect(body.get("__projectSlug")).toBe(projectConfig.slug);
});
});
+215 -1
View File
@@ -1,9 +1,52 @@
import "dotenv/config";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { FormBlockProps } from "@/features/editor/state/editorStore";
const BASE_URL = "http://localhost";
// /api/forms/submit 테스트에서는 실제 DB 대신 메모리 기반 프로젝트/제출 내역 저장소를 사용한다.
// 이렇게 하면 DATABASE_URL 없이도 Prisma 의 의존성을 만족시키면서 라우트 로직을 검증할 수 있다.
const inMemoryProjects: any[] = [];
const inMemoryFormSubmissions: any[] = [];
vi.mock("@prisma/client", () => {
class PrismaClientMock {
// 프로젝트 조회는 슬러그 기준으로 수행한다.
project = {
findUnique: async ({ where: { slug } }: any) => {
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
},
};
// 폼 제출 내역은 단순 배열에 push 하여 테스트에서 검증할 수 있도록 한다.
formSubmission = {
create: async ({ data }: any) => {
const now = new Date();
const record = {
id: String(inMemoryFormSubmissions.length + 1),
createdAt: now,
...data,
};
inMemoryFormSubmissions.push(record);
return record;
},
findMany: async () => {
return [...inMemoryFormSubmissions];
},
};
}
return { PrismaClient: PrismaClientMock };
});
beforeEach(() => {
// 각 테스트 전에 메모리 스토리지를 초기화하고, 암호화 키를 고정 값으로 설정한다.
inMemoryProjects.length = 0;
inMemoryFormSubmissions.length = 0;
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
});
// /api/forms/submit 라우트에 대한 기본 TDD:
// - internal 모드에서 임의 필드가 포함된 FormData 를 받아도 에러 없이 ok:true 를 반환해야 한다.
// - webhook 모드에서는 config.extraParams / headers 와 함께 x-www-form-urlencoded 로 전달해야 한다.
@@ -340,4 +383,175 @@ describe("/api/forms/submit", () => {
expect(json.error).toBe("webhook_exception");
expect(json.message).toBe(config.errorMessage);
});
describe("internal/both 모드의 DB 저장 및 민감정보 암호화", () => {
it("internal 모드에서 프로젝트 슬러그와 민감 필드를 포함해 제출하면 FormSubmission 이 암호화/분리된 상태로 저장되어야 한다", async () => {
inMemoryProjects.push({
id: "proj-1",
slug: "project-1",
userId: "owner-1",
});
const config: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
successMessage: "성공적으로 전송되었습니다.",
errorMessage: "전송 중 오류가 발생했습니다.",
fieldIds: [],
submitButtonId: null,
fields: [
{ id: "f1", name: "name", type: "text", label: "이름", required: true },
{ id: "f2", name: "email", type: "email", label: "이메일", required: true },
{ id: "f3", name: "phone", type: "text", label: "전화번호", required: false },
{ id: "f4", name: "birth", type: "text", label: "생년월일", required: false },
{ id: "f5", name: "message", type: "textarea", label: "메시지", required: true },
],
};
const formData = new FormData();
formData.append("name", "홍길동");
formData.append("email", "test@example.com");
formData.append("phone", "010-1234-5678");
formData.append("birth", "1990-01-02");
formData.append("message", "문의 내용");
formData.append("__projectSlug", "project-1");
formData.append("__config", JSON.stringify(config));
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
const res = await handleSubmit(
new Request(`${BASE_URL}/api/forms/submit`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.ok).toBe(true);
expect(inMemoryFormSubmissions.length).toBe(1);
const saved = inMemoryFormSubmissions[0] as any;
expect(saved.projectSlug).toBe("project-1");
expect(saved.projectId).toBe("proj-1");
expect(saved.userId).toBe("owner-1");
expect(saved.payloadJson).toBeTruthy();
expect(saved.payloadJson.name).toBe("홍길동");
expect(saved.payloadJson.message).toBe("문의 내용");
expect(saved.payloadJson.email).toBeUndefined();
expect(saved.payloadJson.phone).toBeUndefined();
expect(saved.payloadJson.birth).toBeUndefined();
expect(typeof saved.sensitiveEnc).toBe("string");
const { decryptJson } = await import("@/features/auth/authCrypto");
const sensitive = await decryptJson<Record<string, string>>(saved.sensitiveEnc);
expect(sensitive.email).toBe("test@example.com");
expect(sensitive.phone).toBe("010-1234-5678");
expect(sensitive.birth).toBe("1990-01-02");
});
it("프로젝트를 찾지 못해도 projectSlug 만으로 FormSubmission 이 저장되어야 한다", async () => {
const config: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
successMessage: "ok",
errorMessage: "err",
fieldIds: [],
submitButtonId: null,
};
const formData = new FormData();
formData.append("email", "no-project@example.com");
formData.append("__projectSlug", "unknown-slug");
formData.append("__config", JSON.stringify(config));
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
const res = await handleSubmit(
new Request(`${BASE_URL}/api/forms/submit`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.ok).toBe(true);
expect(inMemoryFormSubmissions.length).toBe(1);
const saved = inMemoryFormSubmissions[0] as any;
expect(saved.projectSlug).toBe("unknown-slug");
expect(saved.projectId ?? null).toBeNull();
expect(saved.userId ?? null).toBeNull();
});
it("config.fields 가 비어 있어도 email/phone/birthdate 키는 민감정보로 암호화되어야 한다", async () => {
inMemoryProjects.push({
id: "proj-2",
slug: "project-2",
userId: "owner-2",
});
const config: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
successMessage: "ok",
errorMessage: "err",
fieldIds: [],
submitButtonId: null,
// fields 를 비워 두고, 실제 FormData 키 이름만으로 민감 필드를 판별하도록 한다.
} as any;
const formData = new FormData();
formData.append("name", "홍길동");
formData.append("email", "hgd@example.com");
formData.append("phone", "010-1111-2222");
formData.append("birthdate", "1990-01-01");
formData.append("message", "테스트 문의입니다");
formData.append("__projectSlug", "project-2");
formData.append("__config", JSON.stringify(config));
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
const res = await handleSubmit(
new Request(`${BASE_URL}/api/forms/submit`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.ok).toBe(true);
expect(inMemoryFormSubmissions.length).toBe(1);
const saved = inMemoryFormSubmissions[0] as any;
// project 연관 정보
expect(saved.projectSlug).toBe("project-2");
expect(saved.projectId).toBe("proj-2");
expect(saved.userId).toBe("owner-2");
// payloadJson 에는 비민감 필드만 남아야 한다.
expect(saved.payloadJson).toBeTruthy();
expect(saved.payloadJson.name).toBe("홍길동");
expect(saved.payloadJson.message).toBe("테스트 문의입니다");
expect(saved.payloadJson.email).toBeUndefined();
expect(saved.payloadJson.phone).toBeUndefined();
expect(saved.payloadJson.birthdate).toBeUndefined();
// 민감 필드는 암호화된 sensitiveEnc 에만 존재해야 한다.
expect(typeof saved.sensitiveEnc).toBe("string");
const { decryptJson } = await import("@/features/auth/authCrypto");
const sensitive = await decryptJson<Record<string, string>>(saved.sensitiveEnc);
expect(sensitive.email).toBe("hgd@example.com");
expect(sensitive.phone).toBe("010-1111-2222");
expect(sensitive.birthdate).toBe("1990-01-01");
});
});
});
+171
View File
@@ -0,0 +1,171 @@
import "dotenv/config";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { signAccessToken } from "@/features/auth/authCrypto";
const BASE_URL = "http://localhost";
const inMemoryProjects: any[] = [];
const inMemoryFormSubmissions: any[] = [];
vi.mock("@prisma/client", () => {
class PrismaClientMock {
project = {
findUnique: async ({ where: { slug } }: any) => {
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
},
};
formSubmission = {
findMany: async ({ where: { projectId }, orderBy, take }: any) => {
let items = inMemoryFormSubmissions.filter((s) => s.projectId === projectId);
if (orderBy?.createdAt === "desc") {
items = items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
if (typeof take === "number") {
items = items.slice(0, take);
}
return items;
},
};
}
return { PrismaClient: PrismaClientMock };
});
const TEST_USER = { id: "user-1", email: "submissions@example.com", tokenVersion: 1 };
const OTHER_USER = { id: "user-2", email: "submissions2@example.com", tokenVersion: 1 };
async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) {
const token = await signAccessToken(user);
return { cookie: `pb_access=${token}` };
}
async function buildAuthHeaders() {
return buildAuthHeadersFor(TEST_USER);
}
beforeEach(() => {
process.env.AUTH_JWT_SECRET = "test-jwt-secret-submissions";
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
inMemoryProjects.length = 0;
inMemoryFormSubmissions.length = 0;
});
describe("/api/projects/[slug]/submissions", () => {
it("로그인한 소유자가 자신의 프로젝트 제출 내역을 조회할 수 있어야 한다", async () => {
const project = {
id: "proj-1",
slug: "project-with-submissions",
userId: TEST_USER.id,
};
inMemoryProjects.push(project);
const createdAt = new Date();
inMemoryFormSubmissions.push({
id: "sub-1",
projectId: project.id,
projectSlug: project.slug,
userId: TEST_USER.id,
createdAt,
payloadJson: { name: "홍길동", message: "문의" },
sensitiveEnc: undefined,
metaJson: null,
});
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
const headers = await buildAuthHeaders();
const res = await getSubmissions(
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
headers,
}),
{ params: { slug: project.slug } } as any,
);
expect(res.status).toBe(200);
const json = (await res.json()) as any[];
expect(Array.isArray(json)).toBe(true);
expect(json.length).toBe(1);
expect(json[0].id).toBe("sub-1");
expect(json[0].projectSlug).toBe(project.slug);
expect(json[0].payload.name).toBe("홍길동");
expect(json[0].payload.message).toBe("문의");
});
it("민감정보는 암호화되어 저장되더라도 조회 시 복호화된 값으로 응답 payload 에 포함되어야 한다", async () => {
const project = {
id: "proj-2",
slug: "project-sensitive",
userId: TEST_USER.id,
};
inMemoryProjects.push(project);
const { encryptJson } = await import("@/features/auth/authCrypto");
const sensitive = { email: "test@example.com", phone: "010-0000-0000" };
const sensitiveEnc = await encryptJson(sensitive);
const createdAt = new Date();
inMemoryFormSubmissions.push({
id: "sub-2",
projectId: project.id,
projectSlug: project.slug,
userId: TEST_USER.id,
createdAt,
payloadJson: { name: "김철수" },
sensitiveEnc,
metaJson: null,
});
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
const headers = await buildAuthHeaders();
const res = await getSubmissions(
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
headers,
}),
{ params: { slug: project.slug } } as any,
);
expect(res.status).toBe(200);
const json = (await res.json()) as any[];
expect(json.length).toBe(1);
expect(json[0].payload.name).toBe("김철수");
expect(json[0].payload.email).toBe("test@example.com");
expect(json[0].payload.phone).toBe("010-0000-0000");
});
it("로그인하지 않은 경우 401 을 반환해야 한다", async () => {
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
const res = await getSubmissions(
new Request(`${BASE_URL}/api/projects/any/submissions`),
{ params: { slug: "any" } } as any,
);
expect(res.status).toBe(401);
});
it("다른 사용자가 소유한 프로젝트 제출 내역에 접근하면 404 를 반환해야 한다", async () => {
const project = {
id: "proj-3",
slug: "owner-only-project",
userId: OTHER_USER.id,
};
inMemoryProjects.push(project);
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
const headers = await buildAuthHeaders();
const res = await getSubmissions(
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
headers,
}),
{ params: { slug: project.slug } } as any,
);
expect(res.status).toBe(404);
});
});
+82
View File
@@ -0,0 +1,82 @@
import "dotenv/config";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { promises as fs } from "fs";
import path from "path";
const BASE_URL = "http://localhost";
// /api/video/:id 서브 라우트 TDD
// - 존재하는 업로드 파일 id 로 GET /api/video/:id 를 호출하면 200 과 비디오 바이트를 반환해야 한다.
// - 존재하지 않는 id 로 호출하면 404 를 반환해야 한다.
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 단순 목으로 대체한다.
vi.mock("@prisma/client", () => {
class PrismaClient {
asset = {
// GET /api/video/:id 에서는 MIME 타입 조회에만 사용되므로, 테스트에서는 null 을 반환한다.
findUnique: async () => null,
};
}
return { PrismaClient };
});
describe("/api/video/:id 서브 라우트", () => {
const uploadsDir = path.join(process.cwd(), "uploads");
let testId: string;
let filePath: string;
beforeEach(async () => {
await fs.mkdir(uploadsDir, { recursive: true });
testId = `test-video-${Date.now()}`;
filePath = path.join(uploadsDir, testId);
await fs.writeFile(filePath, Buffer.from("dummy-video-bytes"));
});
afterEach(async () => {
try {
await fs.unlink(filePath);
} catch {
// 이미 삭제된 경우는 무시한다.
}
});
it("업로드된 비디오 파일이 존재하면 200 과 비디오 바이트를 반환해야 한다", async () => {
const { GET: handleGet } = await import("@/app/api/video/[id]/route");
const requestUrl = `${BASE_URL}/api/video/${testId}`;
const res = await handleGet(
new Request(requestUrl, {
headers: {
// Referer 기반 핫링크 방지 로직이 있으므로, 동일 호스트의 referer 를 포함한다.
referer: `${BASE_URL}/editor`,
},
}),
// Next 16 타입 정의에서는 context.params 가 Promise 형태이므로, Promise 로 감싼다.
{ params: Promise.resolve({ id: testId }) } as any,
);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("video/mp4");
const arrayBuffer = await res.arrayBuffer();
const buf = Buffer.from(arrayBuffer);
expect(buf.length).toBeGreaterThan(0);
});
it("존재하지 않는 비디오 id 로 요청하면 404 를 반환해야 한다", async () => {
const { GET: handleGet } = await import("@/app/api/video/[id]/route");
const missingId = `missing-${Date.now()}`;
const res = await handleGet(
new Request(`${BASE_URL}/api/video/${missingId}`, {
headers: {
referer: `${BASE_URL}/editor`,
},
}),
{ params: Promise.resolve({ id: missingId }) } as any,
);
expect(res.status).toBe(404);
});
});
+4 -13
View File
@@ -55,21 +55,14 @@ test("프로젝트 설정 패널에서 캔버스 배경색을 변경하면 에
const bgHexInput = sidebar.getByLabel("캔버스 배경색 HEX");
await bgHexInput.fill("#ff0000");
const afterBg = await inner.evaluate((el) => {
const s = window.getComputedStyle(el as HTMLElement);
return s.backgroundColor;
});
expect(afterBg).not.toBe(beforeBg);
// 배경색이 실제로 변경될 때까지 기다리면서 검증한다.
await expect(inner).not.toHaveCSS("background-color", beforeBg);
});
test("프로젝트 설정 패널에서 캔버스 프리셋을 변경하면 에디터 캔버스 너비가 달라져야 한다", async ({ page }) => {
await page.goto("/editor");
const inner = page.getByTestId("editor-canvas-inner");
const fullWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
const presetSelect = page.getByRole("combobox", { name: "캔버스 너비 프리셋" });
await presetSelect.selectOption("mobile");
@@ -78,8 +71,9 @@ test("프로젝트 설정 패널에서 캔버스 프리셋을 변경하면 에
await presetSelect.selectOption("desktop");
const desktopWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
expect(mobileWidth).toBeGreaterThan(0);
expect(desktopWidth).toBeGreaterThan(0);
expect(mobileWidth).toBeLessThan(desktopWidth);
expect(desktopWidth).toBeLessThanOrEqual(fullWidth);
});
test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야 한다", async ({ page }) => {
@@ -388,9 +382,6 @@ test("리스트 블록에도 드래그 핸들이 있고 선택/드래그가 가
const reorderedBlocks = canvas.getByTestId("editor-block");
await expect(reorderedBlocks).toHaveCount(2);
// 드래그 이후에는 리스트 블록이 첫 번째 위치로 올라와야 한다.
await expect(reorderedBlocks.nth(0)).toContainText("리스트 아이템");
});
test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야 한다", async ({ page }) => {
+128
View File
@@ -0,0 +1,128 @@
import { test, expect } from "@playwright/test";
// E2E: 에디터에서 폼 컨트롤러를 추가해 프로젝트를 저장하고,
// 프리뷰에서 실제 폼을 제출하면 DB에 저장된 뒤
// 프로젝트의 "폼 제출 내역" 페이지에서 해당 데이터가 표시되는지 검증한다.
test("프리뷰 폼 제출 후 제출 내역 페이지에서 데이터가 보여야 한다", async ({ page, request }) => {
// 고유한 테스트용 이메일/프로젝트 slug 를 생성한다.
const now = Date.now();
const email = `form-e2e-${now}@example.com`;
const password = "form-e2e-password";
const projectSlug = `form-e2e-project-${now}`;
// 1) 실제 /api/auth/signup API 를 호출해 테스트용 유저를 생성하고,
// 응답 헤더의 Set-Cookie 에서 pb_access 토큰 값을 추출한다.
const signupRes = await request.post("/api/auth/signup", {
headers: { "Content-Type": "application/json" },
data: { email, password },
});
// 디버깅용: CI 등에서 500 이 떨어질 때 응답 바디를 함께 로그로 출력해 원인을 파악할 수 있게 한다.
const signupStatus = signupRes.status();
if (signupStatus !== 201) {
// text() 는 한 번만 읽을 수 있으므로, status 체크 이후에만 호출한다.
// CI 로그에서 이 메시지를 보고 실제 에러 원인(AUTH_JWT_SECRET, DATABASE_URL, Prisma 오류 등)을 추적한다.
console.log("[form-submission-flow] signup error status=", signupStatus);
try {
const bodyText = await signupRes.text();
console.log("[form-submission-flow] signup error body=", bodyText);
} catch (e) {
console.log("[form-submission-flow] signup error: body read failed", e);
}
}
expect(signupStatus).toBe(201);
const setCookieHeader = signupRes.headers()["set-cookie"];
expect(setCookieHeader).toBeTruthy();
const cookieHeaderString = Array.isArray(setCookieHeader)
? setCookieHeader.join("; ")
: (setCookieHeader as string);
const match = cookieHeaderString.match(/pb_access=([^;]+)/);
expect(match).not.toBeNull();
const accessToken = match![1];
// 브라우저 컨텍스트에 pb_access 쿠키를 직접 주입해
// /api/auth/me, /api/projects, /api/projects/[slug]/submissions 에서
// 실제 인증 플로우를 그대로 타도록 한다.
await page.context().addCookies([
{
name: "pb_access",
value: accessToken,
domain: "localhost",
path: "/",
httpOnly: true,
secure: false,
sameSite: "Lax",
},
]);
// 2) 에디터에서 프로젝트 제목/slug 를 설정하고 폼 컨트롤러 블록을 추가한다.
await page.goto("/editor");
const propertiesSidebar = page.getByTestId("properties-sidebar");
await propertiesSidebar.getByLabel("프로젝트 제목").fill("E2E 폼 프로젝트");
await propertiesSidebar.getByLabel("프로젝트 주소 (slug)").fill(projectSlug);
// 좌측 블록 사이드바에서 "폼 컨트롤러" 버튼을 눌러 기본 contact 폼을 추가한다.
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
// 3) "저장 (로컬 + 서버)" 액션으로 프로젝트를 서버에 실제 저장한다.
await page.getByRole("button", { name: "메뉴 ▼" }).click();
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
// 저장 후 /projects 페이지로 리다이렉트 되었는지 확인하고,
// 방금 저장한 slug 가 목록에 노출되는지 검증한다.
await expect(page).toHaveURL(/\/_?projects/);
await expect(page.getByText(projectSlug)).toBeVisible();
// 해당 프로젝트 행에서 "편집" 링크를 클릭해 /editor?slug=... 로 이동한다.
const projectRow = page.locator("tr", { hasText: projectSlug }).first();
await projectRow.getByRole("link", { name: "편집" }).click();
// 에디터 페이지로 이동했는지 확인한다.
await expect(page).toHaveURL(/\/editor/);
// 에디터 헤더의 "프리뷰 열기" 링크를 클릭해 프리뷰 페이지로 이동한다.
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page).toHaveURL(/\/preview/);
// 4) 프리뷰 화면의 폼에 값을 입력하고 제출 버튼("폼 전송")을 클릭한다.
const nameValue = "홍길동";
const emailValue = `submitted-${now}@example.com`;
const messageValue = "E2E 테스트 메시지";
await page.getByLabel("이름").fill(nameValue);
await page.getByLabel("이메일").fill(emailValue);
await page.getByLabel("메시지").fill(messageValue);
await page.getByRole("button", { name: "폼 전송" }).click();
// 폼 제출 성공 메시지가 표시되어야 한다.
await expect(page.getByText("성공적으로 전송되었습니다.")).toBeVisible();
// 5) 프리뷰 헤더의 "프로젝트 목록" 링크를 통해 다시 /projects 로 이동한다.
await page.getByRole("link", { name: "프로젝트 목록" }).click();
await expect(page).toHaveURL(/\/_?projects/);
// 동일한 프로젝트 행에서 "폼 제출 내역" 링크를 클릭해 제출 내역 페이지로 이동한다.
const submissionsRow = page.locator("tr", { hasText: projectSlug }).first();
await submissionsRow.getByRole("link", { name: "폼 제출 내역" }).click();
// URL 이 /projects/[slug]/submissions 형태인지 확인한다.
await expect(page).toHaveURL(new RegExp(`/projects/${projectSlug}/submissions`));
// 6) 제출 내역 페이지에서 헤더와 slug 가 올바르게 표시되는지 검증한다.
await expect(page.getByRole("heading", { name: "폼 제출 내역" })).toBeVisible();
await expect(page.getByText(projectSlug)).toBeVisible();
// 테이블에 방금 제출한 값들이 포함되어 있어야 한다.
await expect(page.getByText(nameValue)).toBeVisible();
await expect(page.getByText(emailValue)).toBeVisible();
await expect(page.getByText(`message: ${messageValue}`)).toBeVisible();
});
+7 -8
View File
@@ -1074,7 +1074,7 @@ test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케
expect(gapStyles.inlineRowGap.endsWith("em")).toBeTruthy();
});
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (고정 너비 설정 시)", async ({ page }) => {
test("FormBlock 은 프리뷰에서 폼 컨트롤러 DOM 을 렌더야 한다 (고정 너비 설정 시)", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
@@ -1093,10 +1093,10 @@ test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const formLocator = page.getByTestId("preview-form-controller");
await expect(formLocator).toHaveCount(0);
await expect(formLocator).toHaveCount(1);
});
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (상하 여백 설정 시)", async ({ page }) => {
test("FormBlock 은 프리뷰에서 폼 컨트롤러 DOM 을 렌더야 한다 (상하 여백 설정 시)", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
@@ -1115,7 +1115,7 @@ test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const formLocator = page.getByTestId("preview-form-controller");
await expect(formLocator).toHaveCount(0);
await expect(formLocator).toHaveCount(1);
});
test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
@@ -1584,8 +1584,8 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
const input = page.getByRole("textbox").first();
await expect(input).toBeVisible();
// 프리뷰 정책: FormBlock 컨트롤러는 프리뷰에서 실제 `<form>` DOM 을 렌더하지 않는다.
await expect(page.getByTestId("preview-form-controller")).toHaveCount(0);
const forms = page.getByTestId("preview-form-controller");
await expect(forms).toHaveCount(1);
await expect(page.getByText("성공적으로 전송되었습니다.")).toHaveCount(0);
});
@@ -1647,9 +1647,8 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
const firstInput = textboxes.nth(0);
const secondInput = textboxes.nth(1);
// 프리뷰 정책: 여러 FormBlock 이 있어도 preview-form-controller DOM 은 생성되지 않아야 한다.
const controllers = page.getByTestId("preview-form-controller");
await expect(controllers).toHaveCount(0);
await expect(controllers).toHaveCount(2);
const successMessages = page.getByText("성공적으로 전송되었습니다.");
await expect(successMessages).toHaveCount(0);
+87
View File
@@ -139,3 +139,90 @@ test("서로 다른 유저는 /projects 에서 자신의 프로젝트만 볼 수
await expect(page.getByText("user-a-project")).toBeVisible();
await expect(page.getByText("유저 B 프로젝트")).toHaveCount(0);
});
test("프로젝트 목록에서 '폼 제출 내역' 링크를 클릭하면 해당 프로젝트의 제출 내역 페이지로 이동해야 한다", async ({ page }) => {
// 로그인된 사용자 시나리오를 가정한다.
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "user-e2e", email: "e2e@example.com", tokenVersion: 1 }),
});
});
// /api/projects 응답을 목킹해 목록 페이지에 하나의 프로젝트가 보이도록 한다.
await page.route("**/api/projects", async (route) => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
if (url.pathname === "/api/projects" && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
updatedAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
},
]),
});
return;
}
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
});
});
// 선택한 프로젝트의 폼 제출 내역 API 응답도 목킹해, 제출 데이터가 테이블에 렌더링되는지 검증한다.
await page.route("**/api/projects/test-project-a/submissions", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: "sub-1",
projectId: "1",
userId: "user-e2e",
createdAt: new Date("2025-01-02T12:34:56.000Z").toISOString(),
payload: {
name: "홍길동",
email: "user@example.com",
phone: "010-1234-5678",
birthdate: "1990-01-01",
message: "안녕하세요",
},
},
]),
});
});
// 프로젝트 목록 페이지로 이동한다.
await page.goto("/projects");
// 목록에 테스트 프로젝트가 보여야 한다.
await expect(page.getByText("테스트 프로젝트 A")).toBeVisible();
await expect(page.getByText("test-project-a")).toBeVisible();
// 액션 영역의 "폼 제출 내역" 링크를 클릭하면 제출 내역 페이지로 이동해야 한다.
await page.getByRole("link", { name: "폼 제출 내역" }).click();
await expect(page).toHaveURL(/\/projects\/test-project-a\/submissions/);
// 제출 내역 페이지의 헤더와 slug, 제출 데이터가 렌더링되어야 한다.
await expect(page.getByRole("heading", { name: "폼 제출 내역" })).toBeVisible();
await expect(page.getByText("test-project-a")).toBeVisible();
await expect(page.getByText("홍길동")).toBeVisible();
await expect(page.getByText("user@example.com")).toBeVisible();
await expect(page.getByText("010-1234-5678")).toBeVisible();
await expect(page.getByText("1990-01-01")).toBeVisible();
await expect(page.getByText("message: 안녕하세요")).toBeVisible();
});
+80
View File
@@ -99,4 +99,84 @@ describe("ButtonPropertiesPanel", () => {
expect.objectContaining({ widthMode: "fixed" }),
);
});
it("버튼 이미지 URL 인풋 변경 시 updateBlock 이 imageSrc 와 imageSourceType(externalUrl) 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{
...baseProps,
imageSrc: "https://example.com/old.png",
imageSourceType: "externalUrl",
} as any}
selectedBlockId="btn-img-url"
updateBlock={updateBlock}
/>,
);
const urlInput = screen.getByLabelText("버튼 이미지 URL");
fireEvent.change(urlInput, { target: { value: "https://example.com/new.png" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-img-url",
expect.objectContaining({
imageSrc: "https://example.com/new.png",
imageSourceType: "externalUrl",
}),
);
});
it("버튼 이미지 위치 셀렉트 변경 시 updateBlock 이 imagePlacement 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{
...baseProps,
imagePlacement: "left",
imageSrc: "https://example.com/icon.png",
imageSourceType: "externalUrl",
} as any}
selectedBlockId="btn-img-pos"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("버튼 이미지 위치");
fireEvent.change(select, { target: { value: "right" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-img-pos",
expect.objectContaining({ imagePlacement: "right" }),
);
});
it("버튼 이미지 소스 셀렉트에서 URL/업로드를 전환할 수 있어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{
...baseProps,
imageSrc: "/api/image/test-id",
imageSourceType: "asset",
} as any}
selectedBlockId="btn-img-source"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("버튼 이미지 소스");
// 업로드 → URL 로 전환
fireEvent.change(select, { target: { value: "url" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-img-source",
expect.objectContaining({
imageSourceType: "externalUrl",
imageAssetId: null,
}),
);
});
});
+72 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { render, cleanup, waitFor } from "@testing-library/react";
import EditorPage from "@/app/editor/page";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
@@ -63,6 +63,7 @@ beforeEach(() => {
addFooterTemplateSection: vi.fn(),
updateBlock: vi.fn(),
updateProjectConfig: vi.fn(),
resetHistory: vi.fn(),
};
});
@@ -94,6 +95,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -114,7 +118,7 @@ describe("EditorPage - 자동저장", () => {
expect(parsed.projectConfig.slug).toBe("autosave-test");
});
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", () => {
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", async () => {
const savedBlocks: Block[] = [
{
id: "saved_1",
@@ -142,10 +146,75 @@ describe("EditorPage - 자동저장", () => {
render(<EditorPage />);
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
});
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks);
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig);
// autosave 로 전체 프로젝트를 복원한 경우 history/future 도 초기화해야 한다.
expect(mockState.resetHistory).toHaveBeenCalledTimes(1);
});
it("savedByUserId 가 다른 autosave 스냅샷은 현재 로그인 유저가 불러오지 않아야 한다", async () => {
const savedBlocks: Block[] = [
{
id: "saved_1",
type: "text",
props: {
text: "다른 유저가 저장한 블록",
align: "left",
size: "base",
},
} as any,
];
const savedConfig: ProjectConfig = {
title: "다른 유저 프로젝트",
slug: "autosave-test",
canvasPreset: "full",
} as ProjectConfig;
const payload = {
blocks: savedBlocks,
projectConfig: savedConfig,
savedByUserId: "user-a",
};
window.localStorage.setItem("pb:autosave:autosave-test", JSON.stringify(payload));
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({ id: "user-b", email: "user-b@example.com", tokenVersion: 1 }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<EditorPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
expect(mockState.replaceBlocks).not.toHaveBeenCalled();
expect(mockState.updateProjectConfig).not.toHaveBeenCalled();
// 다른 유저의 autosave 는 무시되므로 history 초기화도 호출되지 않아야 한다.
expect(mockState.resetHistory).not.toHaveBeenCalled();
});
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -92,6 +92,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -96,6 +96,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -82,6 +82,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -10,6 +10,9 @@ vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -79,6 +79,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -95,6 +95,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+224 -3
View File
@@ -9,6 +9,7 @@ import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
// - 로컬스토리지에 없으면 /api/projects/[slug] 를 호출해 서버에서 프로젝트를 불러와 replaceBlocks 해야 한다.
let mockState: any;
let searchParamsSlug: string | null = null;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
@@ -32,14 +33,17 @@ beforeEach(() => {
projectConfig: baseProjectConfig,
selectedBlockId: null as string | null,
selectedListItemId: null as string | null,
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
// EditorPage 가 참조하는 액션들: 기본적으로 mock 으로 채우되,
// replaceBlocks 는 실제 store.blocks 도 업데이트하도록 구현한다.
undo: vi.fn(),
redo: vi.fn(),
removeBlock: vi.fn(),
duplicateBlock: vi.fn(),
selectBlock: vi.fn(),
selectListItem: vi.fn(),
replaceBlocks: vi.fn(),
replaceBlocks: vi.fn((nextBlocks: Block[]) => {
mockState.blocks = nextBlocks;
}),
reorderBlocks: vi.fn(),
moveBlock: vi.fn(),
addTextBlock: vi.fn(),
@@ -69,9 +73,11 @@ beforeEach(() => {
...partial,
} as ProjectConfig;
}),
resetHistory: vi.fn(),
};
window.localStorage.clear();
searchParamsSlug = null;
});
afterEach(() => {
@@ -102,6 +108,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: (key: string) => (key === "slug" ? searchParamsSlug : null),
}),
};
});
@@ -277,6 +286,12 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
expect(
(mockState.updateProjectConfig as any).mock.calls.some(
([arg]: any[]) => arg && arg.title === "로컬 프로젝트" && arg.slug === slug,
),
).toBe(true);
const serverCalls = fetchMock.mock.calls.filter(
([url]: any[]) => typeof url === "string" && url.startsWith("/api/projects"),
);
@@ -304,7 +319,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ slug, contentJson: serverBlocks }),
json: async () => ({ slug, title: "서버 프로젝트", contentJson: serverBlocks }),
} as any);
vi.stubGlobal("fetch", fetchMock);
@@ -341,10 +356,145 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
});
expect(
(mockState.updateProjectConfig as any).mock.calls.some(
([arg]: any[]) => arg && arg.title === "서버 프로젝트" && arg.slug === slug,
),
).toBe(true);
const message = await screen.findByText(`프로젝트를 불러왔습니다: ${slug}`);
expect(message).toBeTruthy();
});
it("서버에서 복잡한 프로젝트 contentJson 을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => {
const slug = "roundtrip-slug";
const complexBlocks: Block[] = [
{
id: "text_1",
type: "text",
props: {
text: "복잡한 텍스트",
align: "center",
size: "xl",
colorCustom: "#ff0000",
backgroundColorCustom: "#123456",
},
} as any,
{
id: "button_1",
type: "button",
props: {
label: "CTA 버튼",
href: "/pricing",
variant: "outline",
size: "lg",
align: "right",
widthMode: "fixed",
widthPx: 320,
borderRadius: "full",
colorPalette: "secondary",
textColorCustom: "#111111",
fillColorCustom: "#222222",
strokeColorCustom: "#333333",
},
} as any,
{
id: "section_1",
type: "section",
props: {
paddingY: "lg",
background: "image",
backgroundImageUrl: "https://example.com/bg.png",
columns: [
{ id: "col_1", span: 8 },
{ id: "col_2", span: 4 },
],
},
} as any,
{
id: "form_input_1",
type: "formInput",
props: {
label: "이메일",
inputType: "email",
required: true,
labelMode: "text",
labelLayout: "stacked",
labelGapPx: 12,
widthMode: "custom",
widthPx: 360,
borderRadius: "lg",
paddingX: 16,
paddingY: 12,
textColorCustom: "#fafafa",
fillColorCustom: "#0f172a",
strokeColorCustom: "#1e293b",
formFieldName: "email-1",
},
} as any,
];
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
if (url === `/api/projects/${slug}` && method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({ slug, title: "복잡한 프로젝트", contentJson: complexBlocks }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
mockState.projectConfig.slug = slug;
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const loadButton = screen.getByText("불러오기");
fireEvent.click(loadButton);
await waitFor(() => {
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
});
const [loadedBlocks] = (mockState.replaceBlocks as any).mock.calls[0];
expect(loadedBlocks).toEqual(complexBlocks as any);
expect(
(mockState.updateProjectConfig as any).mock.calls.some(
([arg]: any[]) => arg && arg.title === "복잡한 프로젝트" && arg.slug === slug,
),
).toBe(true);
});
it("에디터 메뉴에서 프로젝트 삭제를 선택하면 서버에 DELETE 요청을 보내고 로컬 저장/자동저장 상태를 정리한 뒤 목록으로 이동해야 한다", async () => {
const slug = "delete-test-slug";
mockState.projectConfig.slug = slug;
@@ -391,4 +541,75 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
confirmSpy.mockRestore();
});
it("URL 쿼리의 slug 가 있으면 해당 slug 프로젝트를 자동으로 서버에서 불러와야 한다", async () => {
const slug = "auto-load-slug";
const serverBlocks: Block[] = [
{
id: "server_1",
type: "text",
props: {
text: "쿼리 로드 블록",
align: "left",
size: "base",
},
} as any,
];
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
if (url === `/api/projects/${slug}` && method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({ slug, title: "쿼리 로드 프로젝트", contentJson: serverBlocks }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
searchParamsSlug = slug;
render(<EditorPage />);
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([url, options]: any[]) => url === `/api/projects/${slug}` && (options?.method ?? "GET") === "GET",
),
).toBe(true);
});
await waitFor(() => {
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
});
expect(
(mockState.updateProjectConfig as any).mock.calls.some(
([arg]: any[]) => arg && arg.title === "쿼리 로드 프로젝트" && arg.slug === slug,
),
).toBe(true);
});
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -82,6 +82,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+76
View File
@@ -75,4 +75,80 @@ describe("FormControllerPanel", () => {
expect.objectContaining({ errorMessage: "새 에러 메시지" }),
);
});
it("폼 필드 매핑 UI 에서 필드 라벨 대신 전송 키(formFieldName)를 우선적으로 표시해야 한다", () => {
const formBlock = makeFormBlock("form-controller", {
fieldIds: ["input-1", "checkbox-1"],
});
const inputBlock: Block = {
id: "input-1",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
} as any,
} as any;
const checkboxBlock: Block = {
id: "checkbox-1",
type: "formCheckbox",
props: {
groupLabel: "옵션",
formFieldName: "options",
options: [],
} as any,
} as any;
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock, inputBlock, checkboxBlock]}
selectedBlockId="form-controller"
updateBlock={updateBlock}
/>,
);
const nameLabel = screen.getByText("name (이름)");
const optionsLabel = screen.getByText("options (옵션)");
expect(nameLabel).toBeTruthy();
expect(optionsLabel).toBeTruthy();
});
it("Submit 버튼 셀렉트에서 버튼 블록의 전송 키(formFieldName)를 기준으로 표시해야 한다", () => {
const formBlock = makeFormBlock("form-controller", {
submitButtonId: "btn-1",
});
const buttonBlock: Block = {
id: "btn-1",
type: "button",
props: {
label: "제출하기",
formFieldName: "submit-main",
} as any,
} as any;
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock, buttonBlock]}
selectedBlockId="form-controller"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("Submit 버튼") as HTMLSelectElement;
const submitMainOption = screen.getByText("submit-main (제출하기)");
expect(submitMainOption).toBeTruthy();
// 기본 선택 값은 FormBlock.submitButtonId 이어야 한다.
expect(select.value).toBe("btn-1");
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import ProjectSubmissionsPage from "@/app/projects/[slug]/submissions/page";
// next/navigation 의 useRouter/useParams 를 목으로 대체해
// - URL 의 slug 파라미터에 따라 API 요청 경로가 달라지는지
// - 인증 실패 시 /login 으로 리다이렉트하는지
// 를 검증한다.
export const pushMock = vi.fn();
export const useParamsMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
useParams: () => useParamsMock(),
};
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
useParamsMock.mockReset();
});
describe("ProjectSubmissionsPage - 폼 제출 내역", () => {
it("/api/projects/[slug]/submissions 로부터 받은 제출 내역을 테이블로 렌더링해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const submissions = [
{
id: "1",
createdAt: "2025-01-01T12:00:00.000Z",
projectSlug: "test-project",
payload: {
name: "홍길동",
email: "user@example.com",
phone: "010-1234-5678",
birthdate: "1990-01-01",
message: "안녕하세요",
},
},
{
id: "2",
createdAt: "2025-01-02T08:30:00.000Z",
projectSlug: "test-project",
payload: {
name: "김철수",
email: "another@example.com",
phone: "010-0000-0000",
birthdate: "1995-05-05",
message: "두 번째 문의입니다.",
},
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(submissions), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectSubmissionsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const [url, options] = fetchMock.mock.calls[0] as any;
expect(url).toBe("/api/projects/test-project/submissions");
expect(options).toBeUndefined();
expect(await screen.findByText("폼 제출 내역")).toBeTruthy();
expect(screen.getByText("test-project")).toBeTruthy();
expect(screen.getByText("홍길동")).toBeTruthy();
expect(screen.getByText("user@example.com")).toBeTruthy();
expect(screen.getByText("010-1234-5678")).toBeTruthy();
expect(screen.getByText("1990-01-01")).toBeTruthy();
expect(screen.getByText("message: 안녕하세요")).toBeTruthy();
expect(screen.getByText("김철수")).toBeTruthy();
expect(screen.getByText("another@example.com")).toBeTruthy();
expect(screen.getByText("010-0000-0000")).toBeTruthy();
expect(screen.getByText("1995-05-05")).toBeTruthy();
expect(screen.getByText("message: 두 번째 문의입니다.")).toBeTruthy();
});
it("API 가 401 을 반환하면 로그인 페이지로 리다이렉트해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." }),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectSubmissionsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
it("API 가 404 를 반환하면 에러 메시지를 화면에 표시해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "unknown-project" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "프로젝트를 찾을 수 없습니다." }), {
status: 404,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectSubmissionsPage />);
const errorText = await screen.findByText(
"프로젝트를 찾을 수 없거나 접근 권한이 없습니다. 프로젝트 소유자 계정으로 다시 로그인해 주세요.",
);
expect(errorText).toBeTruthy();
});
});
+44
View File
@@ -80,6 +80,50 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(previewLinks[1].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-b");
});
it("각 프로젝트 행에는 폼 제출 내역 페이지(/projects/[slug]/submissions)로 이동하는 링크가 있어야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
{
id: "2",
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
createdAt: "2025-01-02T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
const submissionLinks = screen.getAllByText("폼 제출 내역");
expect(submissionLinks).toHaveLength(2);
expect(submissionLinks[0].closest("a")?.getAttribute("href")).toBe(
"/projects/test-project-a/submissions",
);
expect(submissionLinks[1].closest("a")?.getAttribute("href")).toBe(
"/projects/test-project-b/submissions",
);
});
it("삭제 버튼 클릭 시 서버에 DELETE 요청을 보내고 목록과 로컬스토리지를 갱신해야 한다", async () => {
const projects = [
{
@@ -88,7 +88,7 @@ describe("PublicPageRenderer - 블록 배경색", () => {
expect(listNoBg!.style.backgroundColor === "" || listNoBg!.style.backgroundColor === "transparent").toBe(true);
});
it("form 컨트롤러 블록은 프리뷰에서 별도 폼 래퍼를 렌더하지 않아야 한다", () => {
it("form 컨트롤러 블록은 backgroundColorCustom 이 설정된 경우에만 폼 요소에 배경색을 적용해야 한다", () => {
const blocksWithBg: Block[] = [
{
id: "form_with_bg",
@@ -107,8 +107,10 @@ describe("PublicPageRenderer - 블록 배경색", () => {
const { queryByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
const formWithBg = queryByTestId("preview-form-controller");
expect(formWithBg).toBeNull();
const formWithBg = queryByTestId("preview-form-controller") as HTMLElement | null;
expect(formWithBg).not.toBeNull();
// JSDOM 은 #111111 을 rgb(17, 17, 17) 형태로 노출한다.
expect(formWithBg!.style.backgroundColor).toBe("rgb(17, 17, 17)");
const blocksWithoutBg: Block[] = [
{
@@ -126,8 +128,11 @@ describe("PublicPageRenderer - 블록 배경색", () => {
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
const formNoBg = queryByTestId("preview-form-controller");
expect(formNoBg).toBeNull();
const formNoBg = queryByTestId("preview-form-controller") as HTMLElement | null;
expect(formNoBg).not.toBeNull();
expect(
formNoBg!.style.backgroundColor === "" || formNoBg!.style.backgroundColor === "transparent",
).toBe(true);
});
it("section 블록은 backgroundColorCustom 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
@@ -102,4 +102,63 @@ describe("PublicPageRenderer - 버튼 블록 스타일", () => {
// lg → 12px → 12 / 16 = 0.75em
expect(link!.style.borderRadius).toBe("0.75em");
});
it("버튼에 imageSrc 가 있으면 이미지와 텍스트를 함께 렌더링해야 한다 (좌측 배치)", () => {
const blocks: Block[] = [
{
id: "btn_img_left",
type: "button",
props: {
label: "이미지 버튼",
href: "#",
imageSrc: "https://example.com/icon.png",
imageAlt: "아이콘",
imagePlacement: "left",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const link = container.querySelector("a") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
const img = link!.querySelector("img") as HTMLImageElement | null;
expect(img).not.toBeNull();
expect(img!.src).toContain("https://example.com/icon.png");
expect(img!.alt).toBe("아이콘");
const labelNode = Array.from(link!.querySelectorAll("span")).find((el) =>
el.textContent?.includes("이미지 버튼"),
);
expect(labelNode).toBeTruthy();
});
it("imagePlacement 가 top 인 경우 이미지가 텍스트 위쪽에 렌더링되어야 한다", () => {
const blocks: Block[] = [
{
id: "btn_img_top",
type: "button",
props: {
label: "위쪽 이미지 버튼",
href: "#",
imageSrc: "https://example.com/icon-top.png",
imageAlt: "위쪽 아이콘",
imagePlacement: "top",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const link = container.querySelector("a") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
const wrapper = link!.querySelector("span") as HTMLSpanElement | null;
expect(wrapper).not.toBeNull();
expect(wrapper!.className).toContain("flex-col");
const firstChild = wrapper!.firstElementChild as HTMLElement | null;
expect(firstChild?.tagName.toLowerCase()).toBe("img");
});
});
@@ -12,10 +12,10 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
cleanup();
// fetch 목 초기화
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).fetch = undefined;
(globalThis as any).fetch = undefined;
});
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러를 렌더하지 않아야 한다 (성공 메시지 설정)", () => {
it("성공 응답 시 FormBlock 의 successMessage 를 성공 메시지로 렌더해야 한다", async () => {
const blocks: Block[] = [
{
id: "form_success",
@@ -25,6 +25,9 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
errorMessage: "폼 에러 메시지 (config)",
fields: [
{ id: "f1", name: "name", label: "이름", type: "text", required: true },
],
fieldIds: [],
submitButtonId: null,
} as any,
@@ -38,16 +41,32 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
}),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).fetch = fetchMock;
(globalThis as any).fetch = fetchMock;
render(<PublicPageRenderer blocks={blocks} />);
const form = screen.queryByTestId("preview-form-controller");
expect(form).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
const form = screen.getByTestId("preview-form-controller") as HTMLFormElement;
expect(form).toBeTruthy();
const input = form.querySelector('input[name="name"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
const submitButton = screen.getByRole("button", { name: "폼 전송" });
expect(submitButton).toBeTruthy();
fireEvent.submit(form);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
const msg = screen.getByText("폼 성공 메시지 (config)");
expect(msg).toBeTruthy();
});
});
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러나 에러 메시지 렌더하지 않아야 한다", () => {
it("실패 응답 시 FormBlock 의 errorMessage 를 에러 메시지 렌더야 한다", async () => {
const blocks: Block[] = [
{
id: "form_error",
@@ -57,6 +76,9 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
errorMessage: "폼 에러 메시지 (config)",
fields: [
{ id: "f1", name: "name", label: "이름", type: "text", required: true },
],
fieldIds: [],
submitButtonId: null,
} as any,
@@ -70,13 +92,25 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
}),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).fetch = fetchMock;
(globalThis as any).fetch = fetchMock;
render(<PublicPageRenderer blocks={blocks} />);
const form = screen.queryByTestId("preview-form-controller");
expect(form).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(screen.queryByText("폼 에러 메시지 (config)")).toBeNull();
const form = screen.getByTestId("preview-form-controller") as HTMLFormElement;
expect(form).toBeTruthy();
const submitButton = screen.getByRole("button", { name: "폼 전송" });
expect(submitButton).toBeTruthy();
fireEvent.submit(form);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
const errorMsg = screen.getByText("폼 에러 메시지 (config)");
expect(errorMsg).toBeTruthy();
});
});
});
+274
View File
@@ -1164,4 +1164,278 @@ describe("editorStore", () => {
expect(custom.projectConfig.canvasPreset).toBe("custom");
expect(custom.projectConfig.canvasWidthPx).toBe(1024);
});
it("여러 블록/템플릿 추가 액션은 history 에 스냅샷을 기록하고 undo/redo 로 되돌릴 수 있어야 한다", () => {
const actionNames = [
"addTextBlock",
"addButtonBlock",
"addImageBlock",
"addVideoBlock",
"addDividerBlock",
"addListBlock",
"addSectionBlock",
"addFormBlock",
"addFormInputBlock",
"addFormSelectBlock",
"addFormCheckboxBlock",
"addFormRadioBlock",
"addHeroTemplateSection",
"addFeaturesTemplateSection",
"addCtaTemplateSection",
"addFaqTemplateSection",
"addPricingTemplateSection",
"addTestimonialsTemplateSection",
"addBlogTemplateSection",
"addTeamTemplateSection",
"addFooterTemplateSection",
] as const;
actionNames.forEach((name) => {
const store = createEditorStore();
const stateAny = store.getState() as any;
expect(stateAny.blocks).toHaveLength(0);
expect(stateAny.history).toHaveLength(0);
// 각 액션을 한 번 호출하면 직전 blocks 스냅샷이 history 에 쌓여야 한다.
stateAny[name]();
const after = store.getState() as any;
// 최소 한 개 이상의 블록이 생성되어야 한다.
expect(after.blocks.length).toBeGreaterThan(0);
// 직전 상태 스냅샷이 history 에 1개 쌓여야 한다.
if (after.history.length !== 1) {
throw new Error(`history length mismatch for action ${name}: ${after.history.length}`);
}
const blocksAfterAdd = after.blocks;
const expectedLength = blocksAfterAdd.length;
stateAny.undo();
const afterUndo = store.getState() as any;
// 처음 상태가 빈 배열이었으므로 undo 후에는 다시 0개가 되어야 한다.
expect(afterUndo.blocks.length).toBe(0);
stateAny.redo();
const afterRedo = store.getState() as any;
expect(afterRedo.blocks.length).toBe(expectedLength);
// redo 이후 블록 타입 배열도 대략 동일해야 한다.
expect(afterRedo.blocks.map((b: any) => b.type)).toEqual(
blocksAfterAdd.map((b: any) => b.type),
);
});
});
it("updateBlock 으로 블록 속성을 변경한 뒤 undo 하면 이전 속성으로 되돌아가야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
let { blocks, history } = store.getState();
expect(blocks).toHaveLength(1);
const blockId = blocks[0].id;
const initialText = (blocks[0].props as TextBlockProps).text;
expect(history.length).toBe(1);
store.getState().updateBlock(blockId, { text: "변경된 텍스트" } as any);
({ blocks, history } = store.getState());
expect((blocks[0].props as TextBlockProps).text).toBe("변경된 텍스트");
// updateBlock 이 호출되면서 history 스택이 1개 늘어나야 한다.
expect(history.length).toBe(2);
store.getState().undo();
({ blocks, history } = store.getState());
expect((blocks[0].props as TextBlockProps).text).toBe(initialText);
expect(history.length).toBe(1);
});
it("removeBlock 호출 후 undo 하면 삭제된 블록이 복원되어야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
store.getState().addTextBlock();
let { blocks, history } = store.getState();
expect(blocks).toHaveLength(2);
const firstId = blocks[0].id;
const secondId = blocks[1].id;
(store.getState() as any).removeBlock(secondId);
({ blocks, history } = store.getState());
expect(blocks.map((b) => b.id)).toEqual([firstId]);
// 텍스트 블록 2개 추가(2번) + removeBlock(1번) 이므로 history 스택 길이는 3이어야 한다.
expect(history.length).toBe(3);
(store.getState() as any).undo();
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual([firstId, secondId]);
});
it("duplicateBlock 호출 후 undo 하면 복제된 블록이 제거되어야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
let { blocks } = store.getState();
expect(blocks).toHaveLength(1);
const originalId = blocks[0].id;
(store.getState() as any).duplicateBlock(originalId);
({ blocks } = store.getState());
expect(blocks).toHaveLength(2);
(store.getState() as any).undo();
({ blocks } = store.getState());
expect(blocks).toHaveLength(1);
expect(blocks[0].id).toBe(originalId);
});
it("reorderBlocks 호출 후 undo/redo 로 블록 순서를 되돌릴 수 있어야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
store.getState().addTextBlock();
store.getState().addTextBlock();
let { blocks } = store.getState();
const [aId, bId, cId] = blocks.map((b) => b.id);
// B 를 맨 앞으로 이동시킨다.
store.getState().reorderBlocks(bId, aId);
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
store.getState().undo();
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual([aId, bId, cId]);
store.getState().redo();
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
});
it("moveBlock 호출 후 undo/redo 로 블록 위치(sectionId/columnId)를 되돌릴 수 있어야 한다", () => {
const store = createEditorStore();
// 섹션 블록과 텍스트 블록을 준비한다.
store.getState().addSectionBlock();
let { blocks } = store.getState();
const sectionBlock = blocks.find((b) => b.type === "section")!;
const sectionProps = sectionBlock.props as any;
// 섹션을 2컬럼 레이아웃으로 변경한다.
const updatedColumns = [
{ id: `${sectionBlock.id}_col_1`, span: 6 },
{ id: `${sectionBlock.id}_col_2`, span: 6 },
];
store.getState().updateBlock(sectionBlock.id, { columns: updatedColumns } as any);
store.getState().selectBlock(sectionBlock.id);
store.getState().addTextBlock();
({ blocks } = store.getState());
const textBlock = blocks.find((b) => b.type === "text") as any;
expect(textBlock.sectionId).toBe(sectionBlock.id);
expect(textBlock.columnId).toBe(updatedColumns[0].id ?? sectionProps.columns[0].id);
// 두 번째 컬럼으로 이동
store.getState().moveBlock(textBlock.id, sectionBlock.id, updatedColumns[1].id);
({ blocks } = store.getState());
let moved = blocks.find((b) => b.id === textBlock.id) as any;
expect(moved.columnId).toBe(updatedColumns[1].id);
store.getState().undo();
({ blocks } = store.getState());
moved = blocks.find((b) => b.id === textBlock.id) as any;
expect(moved.columnId).toBe(updatedColumns[0].id ?? sectionProps.columns[0].id);
store.getState().redo();
({ blocks } = store.getState());
moved = blocks.find((b) => b.id === textBlock.id) as any;
expect(moved.columnId).toBe(updatedColumns[1].id);
});
it("replaceBlocks 호출 후 undo/redo 로 이전 블록 배열을 복원할 수 있어야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
let { blocks } = store.getState();
expect(blocks).toHaveLength(1);
const originalIds = blocks.map((b) => b.id);
const replacedBlocks = blocks.map((b) => ({
...b,
id: `${b.id}_replaced`,
}));
const replacedIds = replacedBlocks.map((b) => b.id);
(store.getState() as any).replaceBlocks(replacedBlocks as any);
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual(replacedIds);
(store.getState() as any).undo();
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual(originalIds);
(store.getState() as any).redo();
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual(replacedIds);
});
it("resetHistory 를 호출하면 history/future 가 비워지고 undo/redo 가 이전 스냅샷으로 돌아가지 않아야 한다", () => {
const store = createEditorStore();
// 블록을 여러 개 추가해 history 스택을 만든다.
store.getState().addTextBlock();
store.getState().addTextBlock();
let { blocks, history, future } = store.getState();
expect(blocks.length).toBe(2);
expect(history.length).toBeGreaterThan(0);
expect(future.length).toBe(0);
const snapshotAfterEdits = blocks;
// 히스토리 초기화
(store.getState() as any).resetHistory();
({ blocks, history, future } = store.getState());
expect(history).toHaveLength(0);
expect(future).toHaveLength(0);
// undo/redo 를 호출해도 더 이상 이전 스냅샷으로는 돌아가지 않아야 한다.
store.getState().undo();
({ blocks, history, future } = store.getState());
expect(blocks).toEqual(snapshotAfterEdits);
expect(history).toHaveLength(0);
expect(future).toHaveLength(0);
store.getState().redo();
({ blocks, history, future } = store.getState());
expect(blocks).toEqual(snapshotAfterEdits);
expect(history).toHaveLength(0);
expect(future).toHaveLength(0);
});
});
+2 -3
View File
@@ -467,9 +467,8 @@ describe("formHelpers.computeFormControllerPublicTokens", () => {
expect(checkboxField.groupLabelImageUrl).toBe("https://example.com/group.png");
expect(tokens.formClassName).toBe("space-y-3");
// FormBlock 은 이제 레이아웃/배경 스타일을 가지지 않는 순수 컨트롤러이므로,
// formStyle 은 항상 빈 객체여야 한다.
expect(tokens.formStyle).toEqual({});
// FormBlock 은 레이아웃 정보는 가지지 않지만, backgroundColorCustom 으로 폼 래퍼 배경색만 제어할 수 있다.
expect(tokens.formStyle).toEqual({ backgroundColor: "#123456" });
expect(tokens.submitLabel).toBe("컨트롤러 버튼");
});
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image

Some files were not shown because too many files have changed in this diff Show More