form export 수정 및 통합테스트
This commit is contained in:
@@ -343,13 +343,24 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다.
|
// 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다.
|
||||||
// - Export 에서는 레이아웃/스타일을 가지지 않고, id/메서드만 가지는 최소한의 <form> 요소만 만든다.
|
// - Export 에서는 레이아웃/스타일 대신 id/메서드/액션만 가지는 최소한의 <form> 요소만 만든다.
|
||||||
|
// - action 은 퍼블릭 페이지에서 우리 서버의 forms/submit 엔드포인트로 전송하기 위한 고정 경로다.
|
||||||
const formId = `form_${block.id}`;
|
const formId = `form_${block.id}`;
|
||||||
const method = props.method ?? "POST";
|
const method = props.method ?? "POST";
|
||||||
const methodAttr = ` method="${method.toLowerCase()}"`;
|
const methodAttr = ` method="${method.toLowerCase()}"`;
|
||||||
|
const actionAttr = ` action="/api/forms/submit"`;
|
||||||
|
|
||||||
|
// 정적 Export 에서도 FormBlock 설정(전송 대상/웹훅 설정 등)을 함께 전달할 수 있도록
|
||||||
|
// preview 렌더러와 동일하게 __config hidden 필드에 FormBlockProps 전체를 JSON 으로 담아둔다.
|
||||||
|
const configJson = JSON.stringify(props ?? {});
|
||||||
|
const escapedConfig = escapeAttr(configJson);
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
parts.push(`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}></form>`);
|
parts.push(
|
||||||
|
`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}${actionAttr}>`,
|
||||||
|
);
|
||||||
|
parts.push(`<input type="hidden" name="__config" value="${escapedConfig}" />`);
|
||||||
|
parts.push("</form>");
|
||||||
return parts.join("");
|
return parts.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,7 +629,53 @@ export async function POST(request: Request) {
|
|||||||
zip.file("builder.css", "/* builder.css 를 찾을 수 없습니다. */\n");
|
zip.file("builder.css", "/* builder.css 를 찾을 수 없습니다. */\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainJs = `console.log("Page Builder static export loaded");`;
|
const mainJs = [
|
||||||
|
"(function(){",
|
||||||
|
" function pbInitFormControllers(){",
|
||||||
|
" var forms = document.querySelectorAll(\"form.pb-form-controller\");",
|
||||||
|
" if (!forms || !forms.length) { return; }",
|
||||||
|
" forms.forEach(function(form){",
|
||||||
|
" form.addEventListener(\"submit\", function(event){",
|
||||||
|
" if (!form) { return; }",
|
||||||
|
" if (event && typeof event.preventDefault === 'function') { event.preventDefault(); }",
|
||||||
|
" var formData = new FormData(form);",
|
||||||
|
" var configInput = form.querySelector('input[name=\"__config\"]');",
|
||||||
|
" var config = null;",
|
||||||
|
" if (configInput && configInput.value) {",
|
||||||
|
" try { config = JSON.parse(configInput.value); } catch (e) { config = null; }",
|
||||||
|
" }",
|
||||||
|
" var action = form.getAttribute('action') || '/api/forms/submit';",
|
||||||
|
" fetch(action, { method: 'POST', body: formData })",
|
||||||
|
" .then(function(res){ return res.json().catch(function(){ return {}; }); })",
|
||||||
|
" .then(function(data){",
|
||||||
|
" var ok = data && data.ok;",
|
||||||
|
" var message = data && data.message;",
|
||||||
|
" if (ok) {",
|
||||||
|
" var success = message || (config && config.successMessage) || '성공적으로 전송되었습니다.';",
|
||||||
|
" if (typeof alert === 'function') { alert(success); }",
|
||||||
|
" try { form.reset(); } catch (e) {}",
|
||||||
|
" } else {",
|
||||||
|
" var errorMsg = message || (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';",
|
||||||
|
" if (typeof alert === 'function') { alert(errorMsg); }",
|
||||||
|
" }",
|
||||||
|
" })",
|
||||||
|
" .catch(function(){",
|
||||||
|
" var errorMsg = (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';",
|
||||||
|
" if (typeof alert === 'function') { alert(errorMsg); }",
|
||||||
|
" });",
|
||||||
|
" });",
|
||||||
|
" });",
|
||||||
|
" }",
|
||||||
|
" if (typeof document !== 'undefined') {",
|
||||||
|
" if (document.readyState === 'loading') {",
|
||||||
|
" document.addEventListener('DOMContentLoaded', pbInitFormControllers);",
|
||||||
|
" } else {",
|
||||||
|
" pbInitFormControllers();",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
" console.log('Page Builder static export loaded');",
|
||||||
|
"})();",
|
||||||
|
].join("\n");
|
||||||
zip.file("main.js", mainJs);
|
zip.file("main.js", mainJs);
|
||||||
|
|
||||||
const zipArrayBuffer = await zip.generateAsync({ type: "arraybuffer" });
|
const zipArrayBuffer = await zip.generateAsync({ type: "arraybuffer" });
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ ok: true, message: successMessage });
|
return NextResponse.json({ ok: true, message: successMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (submitTarget === "both") {
|
||||||
|
console.log("[forms/submit][internal]", { name, email, message });
|
||||||
|
}
|
||||||
|
|
||||||
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
|
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
|
||||||
if (submitTarget === "webhook") {
|
if (submitTarget === "webhook" || submitTarget === "both") {
|
||||||
const destinationUrl = config?.destinationUrl;
|
const destinationUrl = config?.destinationUrl;
|
||||||
if (!destinationUrl) {
|
if (!destinationUrl) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -643,7 +643,7 @@ export interface FormFieldConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 폼 블록 속성
|
// 폼 블록 속성
|
||||||
export type FormSubmitTarget = "internal" | "webhook";
|
export type FormSubmitTarget = "internal" | "webhook" | "both";
|
||||||
export type FormPayloadFormat = "form" | "json";
|
export type FormPayloadFormat = "form" | "json";
|
||||||
|
|
||||||
export interface FormBlockProps {
|
export interface FormBlockProps {
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ describe("/api/export", () => {
|
|||||||
expect(cssEntry).toBeTruthy();
|
expect(cssEntry).toBeTruthy();
|
||||||
expect(jsEntry).toBeTruthy();
|
expect(jsEntry).toBeTruthy();
|
||||||
|
|
||||||
|
const mainJs = await jsEntry!.async("string");
|
||||||
|
expect(mainJs).toContain("pbInitFormControllers");
|
||||||
|
expect(mainJs).toContain('querySelectorAll("form.pb-form-controller")');
|
||||||
|
expect(mainJs).toContain("fetch(");
|
||||||
|
|
||||||
const html = await indexEntry!.async("string");
|
const html = await indexEntry!.async("string");
|
||||||
expect(html).toContain("<title>내보내기 테스트 페이지</title>");
|
expect(html).toContain("<title>내보내기 테스트 페이지</title>");
|
||||||
expect(html).toContain("ZIP 내보내기 테스트");
|
expect(html).toContain("ZIP 내보내기 테스트");
|
||||||
@@ -475,6 +480,7 @@ describe("/api/export", () => {
|
|||||||
// - <form id="form_form_1" ...>
|
// - <form id="form_form_1" ...>
|
||||||
const formId = "form_form_1";
|
const formId = "form_form_1";
|
||||||
expect(html).toMatch(new RegExp(`<form[^>]*id=\\"${formId}\\"`));
|
expect(html).toMatch(new RegExp(`<form[^>]*id=\\"${formId}\\"`));
|
||||||
|
expect(html).toMatch(new RegExp(`<form[^>]*id=\\"${formId}\\"[^>]*action=\\"/api/forms/submit\\"`));
|
||||||
|
|
||||||
// 컨트롤러 필드 블록(formInput/formSelect) 이 렌더한 input/select 는 각각 name 과 form 속성을 가져야 한다.
|
// 컨트롤러 필드 블록(formInput/formSelect) 이 렌더한 input/select 는 각각 name 과 form 속성을 가져야 한다.
|
||||||
// name="name" input 은 required + form="form_form_1" 이어야 한다.
|
// name="name" input 은 required + form="form_form_1" 이어야 한다.
|
||||||
@@ -489,6 +495,7 @@ describe("/api/export", () => {
|
|||||||
const formHtml = html.slice(formStart, formEnd);
|
const formHtml = html.slice(formStart, formEnd);
|
||||||
expect(formHtml).not.toContain('name="name"');
|
expect(formHtml).not.toContain('name="name"');
|
||||||
expect(formHtml).not.toContain('name="plan"');
|
expect(formHtml).not.toContain('name="plan"');
|
||||||
|
expect(formHtml).toContain('name="__config"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
const BASE_URL = "http://localhost";
|
||||||
|
|
||||||
|
// 정적 Export + main.js + /api/forms/submit 를 묶어서 검증하는 통합 테스트.
|
||||||
|
// - /api/export 로 ZIP 을 생성해서 index.html / main.js 를 추출한다.
|
||||||
|
// - jsdom DOM 에 index.html 을 주입하고, main.js 스크립트를 실제로 실행한다.
|
||||||
|
// - form.pb-form-controller 에서 submit 이벤트를 발생시키면
|
||||||
|
// main.js 가 submit 을 가로채고 /api/forms/submit 로 fetch 를 호출해야 한다.
|
||||||
|
|
||||||
|
describe("정적 Export 폼 컨트롤러 통합", () => {
|
||||||
|
it("Export된 index.html + main.js 에서 submit 시 /api/forms/submit 로 fetch 를 호출해야 한다", async () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_1",
|
||||||
|
type: "form",
|
||||||
|
props: {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "internal",
|
||||||
|
fieldIds: ["input_name"],
|
||||||
|
submitButtonId: null,
|
||||||
|
formWidthMode: "full",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "input_name",
|
||||||
|
type: "formInput",
|
||||||
|
props: {
|
||||||
|
label: "이름",
|
||||||
|
formFieldName: "name",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const projectConfig: ProjectConfig = {
|
||||||
|
title: "폼 통합 테스트",
|
||||||
|
slug: "form-integration-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = { blocks, projectConfig };
|
||||||
|
|
||||||
|
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||||
|
|
||||||
|
const res = await handleExport(
|
||||||
|
new Request(`${BASE_URL}/api/export`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||||
|
|
||||||
|
const indexEntry = zip.file("index.html");
|
||||||
|
const jsEntry = zip.file("main.js");
|
||||||
|
|
||||||
|
expect(indexEntry).toBeTruthy();
|
||||||
|
expect(jsEntry).toBeTruthy();
|
||||||
|
|
||||||
|
const html = await indexEntry!.async("string");
|
||||||
|
const mainJs = await jsEntry!.async("string");
|
||||||
|
|
||||||
|
// jsdom DOM 구성: export 된 HTML 을 그대로 주입한다.
|
||||||
|
document.documentElement.innerHTML = html;
|
||||||
|
|
||||||
|
const form = document.querySelector("form.pb-form-controller") as HTMLFormElement | null;
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
|
||||||
|
// name 필드에 값 채우기 (FormData 에 실제 값이 들어가도록)
|
||||||
|
const nameInput = document.querySelector('input[name="name"]') as HTMLInputElement | null;
|
||||||
|
if (nameInput) {
|
||||||
|
nameInput.value = "Alice";
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch 를 목킹하여 main.js 의 submit 가로채기 동작을 검증한다.
|
||||||
|
const fetchMock = vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ ok: true, message: "ok" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
(globalThis as any).fetch = fetchMock;
|
||||||
|
|
||||||
|
// main.js 실행 (IIFE 코드이므로 평가만 하면 즉시 pbInitFormControllers 가 동작한다.)
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
new Function(mainJs)();
|
||||||
|
|
||||||
|
// submit 이벤트 발생: main.js 가 이를 가로채고 fetch 를 호출해야 한다.
|
||||||
|
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
|
||||||
|
form!.dispatchEvent(submitEvent);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, options] = fetchMock.mock.calls[0] as [any, RequestInit];
|
||||||
|
|
||||||
|
expect(url).toBe("/api/forms/submit");
|
||||||
|
expect(options.method).toBe("POST");
|
||||||
|
expect(options.body).toBeInstanceOf(FormData);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -185,6 +185,49 @@ describe("/api/forms/submit", () => {
|
|||||||
expect(json.message).toBe(config.successMessage);
|
expect(json.message).toBe(config.successMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("both 모드에서는 internal 처리 후 webhook 으로도 전송되어야 한다", async () => {
|
||||||
|
const destinationUrl = `${BASE_URL}/webhook-both`;
|
||||||
|
|
||||||
|
const config: FormBlockProps = {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "both",
|
||||||
|
destinationUrl,
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: "Bearer both-token" },
|
||||||
|
extraParams: { source: "builder-both" },
|
||||||
|
successMessage: "양쪽 모두 전송되었습니다.",
|
||||||
|
errorMessage: "both 모드 전송 실패",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||||
|
(globalThis as any).fetch = fetchMock;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("email_address", "both@example.com");
|
||||||
|
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(json.message).toBe(config.successMessage);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe(destinationUrl);
|
||||||
|
expect(options.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
it("webhook 모드에서 destinationUrl 이 없으면 400 과 destinationUrl_missing 및 errorMessage 가 포함되어야 한다", async () => {
|
it("webhook 모드에서 destinationUrl 이 없으면 400 과 destinationUrl_missing 및 errorMessage 가 포함되어야 한다", async () => {
|
||||||
const config: FormBlockProps = {
|
const config: FormBlockProps = {
|
||||||
kind: "contact",
|
kind: "contact",
|
||||||
|
|||||||
@@ -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