form export 수정 및 통합테스트
CI / test (push) Successful in 49m26s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-28 14:25:30 +09:00
parent c7b6097bd9
commit c9a62d479c
16 changed files with 234 additions and 5 deletions
+7
View File
@@ -65,6 +65,11 @@ describe("/api/export", () => {
expect(cssEntry).toBeTruthy();
expect(jsEntry).toBeTruthy();
const mainJs = await jsEntry!.async("string");
expect(mainJs).toContain("pbInitFormControllers");
expect(mainJs).toContain('querySelectorAll("form.pb-form-controller")');
expect(mainJs).toContain("fetch(");
const html = await indexEntry!.async("string");
expect(html).toContain("<title>내보내기 테스트 페이지</title>");
expect(html).toContain("ZIP 내보내기 테스트");
@@ -475,6 +480,7 @@ describe("/api/export", () => {
// - <form id="form_form_1" ...>
const formId = "form_form_1";
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 속성을 가져야 한다.
// name="name" input 은 required + form="form_form_1" 이어야 한다.
@@ -489,6 +495,7 @@ describe("/api/export", () => {
const formHtml = html.slice(formStart, formEnd);
expect(formHtml).not.toContain('name="name"');
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);
});
});
+43
View File
@@ -185,6 +185,49 @@ describe("/api/forms/submit", () => {
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 () => {
const config: FormBlockProps = {
kind: "contact",