558 lines
20 KiB
TypeScript
558 lines
20 KiB
TypeScript
import "dotenv/config";
|
|
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 로 전달해야 한다.
|
|
|
|
describe("/api/forms/submit", () => {
|
|
it("internal 모드에서는 v2 필드 이름(email_address 등)을 포함한 FormData 를 받아도 ok:true 를 반환해야 한다", async () => {
|
|
const config: FormBlockProps = {
|
|
kind: "contact",
|
|
submitTarget: "internal",
|
|
successMessage: "성공적으로 전송되었습니다.",
|
|
errorMessage: "전송 중 오류가 발생했습니다.",
|
|
fieldIds: [],
|
|
submitButtonId: null,
|
|
};
|
|
|
|
const formData = new FormData();
|
|
formData.append("email_address", "test@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);
|
|
});
|
|
|
|
it("webhook 모드에서는 extraParams 와 FormData 필드들이 x-www-form-urlencoded payload 로 전달되어야 한다", async () => {
|
|
const destinationUrl = `${BASE_URL}/webhook-test`;
|
|
|
|
const config: FormBlockProps = {
|
|
kind: "contact",
|
|
submitTarget: "webhook",
|
|
destinationUrl,
|
|
method: "POST",
|
|
headers: { Authorization: "Bearer test-token" },
|
|
extraParams: { source: "builder", formId: "contact-hero" },
|
|
successMessage: "성공적으로 전송되었습니다.",
|
|
errorMessage: "전송 중 오류가 발생했습니다.",
|
|
fieldIds: [],
|
|
submitButtonId: null,
|
|
};
|
|
|
|
// fetch 를 목킹하여 payload 와 헤더가 기대대로 전달되는지 검증한다.
|
|
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
|
// @ts-expect-error - 글로벌 fetch 재정의
|
|
global.fetch = fetchMock;
|
|
|
|
const formData = new FormData();
|
|
formData.append("email_address", "test@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(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toBe(destinationUrl);
|
|
expect(options.method).toBe("POST");
|
|
expect(options.headers).toMatchObject({
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Authorization: "Bearer test-token",
|
|
});
|
|
|
|
const body = options.body as string;
|
|
// payload 안에 폼 필드와 extraParams 가 모두 포함되어야 한다.
|
|
expect(body).toContain("email_address=test%40example.com");
|
|
expect(body).toContain("source=builder");
|
|
expect(body).toContain("formId=contact-hero");
|
|
});
|
|
|
|
it("webhook 모드에서 payloadFormat 이 json 인 경우 application/json 으로 JSON body 를 전송해야 한다", async () => {
|
|
const destinationUrl = `${BASE_URL}/webhook-json-test`;
|
|
|
|
const config: FormBlockProps = {
|
|
kind: "contact",
|
|
submitTarget: "webhook",
|
|
destinationUrl,
|
|
method: "POST",
|
|
headers: { Authorization: "Bearer json-token" },
|
|
extraParams: { source: "builder", formId: "contact-json" },
|
|
successMessage: "성공적으로 전송되었습니다.",
|
|
errorMessage: "전송 중 오류가 발생했습니다.",
|
|
fieldIds: [],
|
|
submitButtonId: null,
|
|
payloadFormat: "json",
|
|
};
|
|
|
|
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
|
// @ts-expect-error - 글로벌 fetch 재정의
|
|
global.fetch = fetchMock;
|
|
|
|
const formData = new FormData();
|
|
formData.append("email_address", "json@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 unknown as [string, RequestInit];
|
|
expect(url).toBe(destinationUrl);
|
|
expect(options.method).toBe("POST");
|
|
expect(options.headers).toMatchObject({
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer json-token",
|
|
});
|
|
|
|
const body = options.body as string;
|
|
const parsed = JSON.parse(body) as Record<string, string>;
|
|
expect(parsed.email_address).toBe("json@example.com");
|
|
expect(parsed.source).toBe("builder");
|
|
expect(parsed.formId).toBe("contact-json");
|
|
});
|
|
|
|
it("webhook 모드 성공 시에도 successMessage 가 응답 message 필드에 포함되어야 한다", async () => {
|
|
const destinationUrl = `${BASE_URL}/webhook-success-message`;
|
|
|
|
const config: FormBlockProps = {
|
|
kind: "contact",
|
|
submitTarget: "webhook",
|
|
destinationUrl,
|
|
method: "POST",
|
|
headers: { Authorization: "Bearer success-token" },
|
|
extraParams: { source: "builder" },
|
|
successMessage: "웹훅으로 전송되었습니다.",
|
|
errorMessage: "웹훅 전송 실패",
|
|
fieldIds: [],
|
|
submitButtonId: null,
|
|
};
|
|
|
|
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
|
// @ts-expect-error - 글로벌 fetch 재정의
|
|
global.fetch = fetchMock;
|
|
|
|
const formData = new FormData();
|
|
formData.append("email_address", "success-webhook@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);
|
|
});
|
|
|
|
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",
|
|
submitTarget: "webhook",
|
|
// destinationUrl 누락
|
|
successMessage: "성공 메시지",
|
|
errorMessage: "Webhook URL 이 설정되지 않았습니다.",
|
|
fieldIds: [],
|
|
submitButtonId: null,
|
|
} as any;
|
|
|
|
const formData = new FormData();
|
|
formData.append("email_address", "missing-destination@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(400);
|
|
const json = (await res.json()) as any;
|
|
expect(json.ok).toBe(false);
|
|
expect(json.error).toBe("destinationUrl_missing");
|
|
expect(json.message).toBe(config.errorMessage);
|
|
});
|
|
|
|
it("webhook 모드에서 원격 응답이 실패하면 502 와 webhook_failed, errorMessage 가 포함되어야 한다", async () => {
|
|
const destinationUrl = `${BASE_URL}/webhook-fail`;
|
|
|
|
const config: FormBlockProps = {
|
|
kind: "contact",
|
|
submitTarget: "webhook",
|
|
destinationUrl,
|
|
method: "POST",
|
|
headers: {},
|
|
extraParams: {},
|
|
successMessage: "성공 메시지",
|
|
errorMessage: "웹훅 호출이 실패했습니다.",
|
|
fieldIds: [],
|
|
submitButtonId: null,
|
|
};
|
|
|
|
const fetchMock = vi.fn(async () => new Response("remote error", { status: 503 }));
|
|
// @ts-expect-error - 글로벌 fetch 재정의
|
|
global.fetch = fetchMock;
|
|
|
|
const formData = new FormData();
|
|
formData.append("email_address", "fail-remote@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(502);
|
|
const json = (await res.json()) as any;
|
|
expect(json.ok).toBe(false);
|
|
expect(json.error).toBe("webhook_failed");
|
|
expect(json.message).toBe(config.errorMessage);
|
|
});
|
|
|
|
it("webhook 모드에서 fetch 예외가 발생하면 500 과 webhook_exception, errorMessage 가 포함되어야 한다", async () => {
|
|
const destinationUrl = `${BASE_URL}/webhook-exception`;
|
|
|
|
const config: FormBlockProps = {
|
|
kind: "contact",
|
|
submitTarget: "webhook",
|
|
destinationUrl,
|
|
method: "POST",
|
|
headers: {},
|
|
extraParams: {},
|
|
successMessage: "성공 메시지",
|
|
errorMessage: "웹훅 호출 중 예외가 발생했습니다.",
|
|
fieldIds: [],
|
|
submitButtonId: null,
|
|
};
|
|
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("network error");
|
|
});
|
|
// @ts-expect-error - 글로벌 fetch 재정의
|
|
global.fetch = fetchMock;
|
|
|
|
const formData = new FormData();
|
|
formData.append("email_address", "exception@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(500);
|
|
const json = (await res.json()) as any;
|
|
expect(json.ok).toBe(false);
|
|
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");
|
|
});
|
|
});
|
|
});
|