CI: feature/forms-submissions #23
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function POST(request: Request) {
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return res;
|
||||
|
||||
@@ -58,6 +58,7 @@ export async function POST(request: Request) {
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return res;
|
||||
|
||||
@@ -365,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("");
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -39,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}`;
|
||||
@@ -163,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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
@@ -594,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) => (
|
||||
|
||||
@@ -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(" "),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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 },
|
||||
});
|
||||
|
||||
expect(signupRes.status()).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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 @@
|
||||
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 @@
|
||||
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-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-section-video
|
||||
@@ -0,0 +1 @@
|
||||
dummy-section-video
|
||||
@@ -0,0 +1 @@
|
||||
dummy-video
|
||||
@@ -0,0 +1 @@
|
||||
dummy-video
|
||||
@@ -0,0 +1 @@
|
||||
dummy-poster
|
||||
@@ -0,0 +1 @@
|
||||
dummy-poster
|
||||
Reference in New Issue
Block a user