리팩터링
CI / test (push) Successful in 5m38s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m12s

This commit is contained in:
2025-12-16 17:49:25 +09:00
parent 87cdc1868b
commit 7491dacba2
19 changed files with 1857 additions and 99 deletions
+4 -2
View File
@@ -378,7 +378,7 @@ describe("/api/export", () => {
);
});
it("seoTitle/seoDescription/seoCanonicalUrl/seoOgImageUrl/seoNoIndex 가 설정된 경우 head 에 SEO 메타 태그들이 생성되야 한다", () => {
it("seoTitle/seoDescription/seoCanonicalUrl/seoOgImageUrl/seoNoIndex 가 설정된 경우 head 에 SEO 메타 태그들이 생성되지만 canonical 링크 태그는 생성되지 않아야 한다", () => {
const blocks: Block[] = [];
const projectConfig: ProjectConfig = {
@@ -400,6 +400,7 @@ describe("/api/export", () => {
expect(html).toContain('meta property="og:description" content="SEO 설명"');
expect(html).toContain('meta property="og:type" content="website"');
expect(html).toContain('meta property="og:image" content="https://example.com/og.png"');
// canonical URL 은 meta property="og:url" 로만 사용하고, <link rel="canonical"> 태그는 생성하지 않는다.
expect(html).toContain('meta property="og:url" content="https://example.com/landing"');
expect(html).toContain('meta name="twitter:card" content="summary_large_image"');
@@ -407,7 +408,8 @@ describe("/api/export", () => {
expect(html).toContain('meta name="twitter:description" content="SEO 설명"');
expect(html).toContain('meta name="twitter:image" content="https://example.com/og.png"');
expect(html).toContain('<link rel="canonical" href="https://example.com/landing" />');
// canonical 링크 태그는 생성되지 않아야 한다.
expect(html).not.toContain('<link rel="canonical" href="https://example.com/landing" />');
expect(html).toContain('<meta name="robots" content="noindex, nofollow" />');
});
+161
View File
@@ -549,4 +549,165 @@ describe("/api/forms/submit", () => {
expect(sensitive.birthdate).toBe("1990-01-01");
});
});
describe("프로젝트 status 에 따른 제출 허용/차단", () => {
it("PUBLISHED 상태 프로젝트는 internal 모드 제출을 허용해야 한다", async () => {
inMemoryProjects.push({
id: "proj-published",
slug: "project-published",
userId: "owner-published",
status: "PUBLISHED",
});
const config: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
successMessage: "ok",
errorMessage: "err",
fieldIds: [],
submitButtonId: null,
};
const formData = new FormData();
formData.append("email", "published@example.com");
formData.append("message", "message");
formData.append("__projectSlug", "project-published");
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-published");
expect(saved.projectId).toBe("proj-published");
expect(saved.userId).toBe("owner-published");
});
it("ARCHIVED 상태 프로젝트는 project_closed 로 제출을 차단해야 한다", async () => {
inMemoryProjects.push({
id: "proj-archived",
slug: "project-archived",
userId: "owner-archived",
status: "ARCHIVED",
});
const config: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
successMessage: "ok",
errorMessage: "폼 제출이 중단된 프로젝트입니다.",
fieldIds: [],
submitButtonId: null,
};
const formData = new FormData();
formData.append("email", "archived@example.com");
formData.append("__projectSlug", "project-archived");
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(403);
const json = (await res.json()) as any;
expect(json.ok).toBe(false);
expect(json.error).toBe("project_closed");
expect(json.message).toBe(config.errorMessage);
expect(inMemoryFormSubmissions.length).toBe(0);
});
it("DRAFT 상태 프로젝트도 project_closed 로 제출을 차단해야 한다", async () => {
inMemoryProjects.push({
id: "proj-draft",
slug: "project-draft",
userId: "owner-draft",
status: "DRAFT",
});
const config: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
successMessage: "ok",
errorMessage: "아직 공개되지 않은 프로젝트입니다.",
fieldIds: [],
submitButtonId: null,
};
const formData = new FormData();
formData.append("email", "draft@example.com");
formData.append("__projectSlug", "project-draft");
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(403);
const json = (await res.json()) as any;
expect(json.ok).toBe(false);
expect(json.error).toBe("project_closed");
expect(json.message).toBe(config.errorMessage);
expect(inMemoryFormSubmissions.length).toBe(0);
});
it("프로젝트를 찾지 못한 경우에는 기존처럼 projectSlug 만으로 제출을 허용해야 한다", async () => {
const config: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
successMessage: "ok",
errorMessage: "err",
fieldIds: [],
submitButtonId: null,
};
const formData = new FormData();
formData.append("email", "unknown@example.com");
formData.append("__projectSlug", "missing-project");
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("missing-project");
expect(saved.projectId ?? null).toBeNull();
expect(saved.userId ?? null).toBeNull();
});
});
});
+217 -1
View File
@@ -5,6 +5,7 @@ import { signAccessToken } from "@/features/auth/authCrypto";
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
const inMemoryProjects: any[] = [];
const inMemoryFormSubmissions: any[] = [];
vi.mock("@prisma/client", () => {
class PrismaClientMock {
@@ -24,6 +25,23 @@ vi.mock("@prisma/client", () => {
findUnique: async ({ where: { slug } }: any) => {
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
},
update: async ({ where: { slug }, data }: any) => {
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
if (index === -1) {
const error: any = new Error("Project not found");
error.code = "P2025";
throw error;
}
const existing = inMemoryProjects[index];
const updated = {
...existing,
...data,
updatedAt: new Date(),
};
inMemoryProjects[index] = updated;
return updated;
},
upsert: async ({ where: { slug }, update, create }: any) => {
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
if (index >= 0) {
@@ -89,6 +107,35 @@ vi.mock("@prisma/client", () => {
return items;
},
};
formSubmission = {
findMany: async ({ where, select }: any = {}) => {
let items = [...inMemoryFormSubmissions];
if (where?.userId) {
items = items.filter((s) => s.userId === where.userId);
}
if (where?.projectId?.in && Array.isArray(where.projectId.in)) {
const ids: string[] = where.projectId.in;
items = items.filter((s) => s.projectId && ids.includes(s.projectId));
}
if (select) {
return items.map((s) => {
const picked: any = {};
for (const key of Object.keys(select)) {
if (select[key]) {
picked[key] = s[key];
}
}
return picked;
});
}
return items;
},
};
}
return { PrismaClient: PrismaClientMock };
@@ -111,6 +158,7 @@ async function buildAuthHeaders() {
beforeEach(() => {
process.env.AUTH_JWT_SECRET = "test-jwt-secret-projects";
inMemoryProjects.length = 0;
inMemoryFormSubmissions.length = 0;
});
describe("/api/projects", () => {
@@ -161,7 +209,15 @@ describe("/api/projects", () => {
expect(getResponse.status).toBe(200);
const fetched = (await getResponse.json()) as any;
expect(fetched.slug).toBe(payload.slug);
expect(fetched.contentJson).toEqual(payload.contentJson);
// contentJson 은 배열 또는 { blocks, projectConfig } 스냅샷으로 저장될 수 있다.
const rawContent = fetched.contentJson;
if (Array.isArray(rawContent)) {
expect(rawContent).toEqual(payload.contentJson);
} else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) {
expect(rawContent.blocks).toEqual(payload.contentJson);
} else {
throw new Error("unexpected contentJson shape from /api/projects/[slug]");
}
});
it("GET /api/projects 는 최근 생성 순으로 프로젝트 목록을 반환해야 한다", async () => {
@@ -365,6 +421,126 @@ describe("/api/projects", () => {
expect(slugsB).not.toContain(slugA);
});
it("GET /api/projects 는 각 프로젝트별 오늘/전체 제출 수를 함께 반환해야 한다", async () => {
const { GET: listProjects } = await import("@/app/api/projects/route");
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const projectA = {
id: "proj-a",
userId: TEST_USER.id,
title: "프로젝트 A",
slug: "project-a",
status: "PUBLISHED",
createdAt: now,
updatedAt: now,
};
const projectB = {
id: "proj-b",
userId: TEST_USER.id,
title: "프로젝트 B",
slug: "project-b",
status: "PUBLISHED",
createdAt: now,
updatedAt: now,
};
const projectOther = {
id: "proj-other",
userId: OTHER_USER.id,
title: "다른 유저 프로젝트",
slug: "other-project",
status: "PUBLISHED",
createdAt: now,
updatedAt: now,
};
inMemoryProjects.push(projectA, projectB, projectOther);
inMemoryFormSubmissions.push(
// 프로젝트 A: 오늘 1건, 어제 1건 (총 2건)
{
id: "sub-1",
projectId: projectA.id,
projectSlug: projectA.slug,
userId: TEST_USER.id,
createdAt: now,
payloadJson: {},
sensitiveEnc: undefined,
metaJson: null,
},
{
id: "sub-2",
projectId: projectA.id,
projectSlug: projectA.slug,
userId: TEST_USER.id,
createdAt: yesterday,
payloadJson: {},
sensitiveEnc: undefined,
metaJson: null,
},
// 프로젝트 B: 오늘 2건
{
id: "sub-3",
projectId: projectB.id,
projectSlug: projectB.slug,
userId: TEST_USER.id,
createdAt: now,
payloadJson: {},
sensitiveEnc: undefined,
metaJson: null,
},
{
id: "sub-4",
projectId: projectB.id,
projectSlug: projectB.slug,
userId: TEST_USER.id,
createdAt: now,
payloadJson: {},
sensitiveEnc: undefined,
metaJson: null,
},
// 다른 유저 프로젝트 제출: 집계에 포함되면 안 된다.
{
id: "sub-5",
projectId: projectOther.id,
projectSlug: projectOther.slug,
userId: OTHER_USER.id,
createdAt: now,
payloadJson: {},
sensitiveEnc: undefined,
metaJson: null,
},
);
const headers = await buildAuthHeadersFor(TEST_USER);
const res = await listProjects(
new Request(`${BASE_URL}/api/projects`, {
headers,
}),
);
expect(res.status).toBe(200);
const list = (await res.json()) as any[];
const a = list.find((p) => p.slug === projectA.slug);
const b = list.find((p) => p.slug === projectB.slug);
const other = list.find((p) => p.slug === projectOther.slug);
expect(a).toBeTruthy();
expect(b).toBeTruthy();
expect(other).toBeUndefined();
expect(a.todaySubmissions).toBe(1);
expect(a.totalSubmissions).toBe(2);
expect(b.todaySubmissions).toBe(2);
expect(b.totalSubmissions).toBe(2);
});
it("다른 유저가 이미 소유한 slug 로 POST 하면 409 를 반환해야 한다", async () => {
const { POST: handleProject } = await import("@/app/api/projects/route");
@@ -404,6 +580,46 @@ describe("/api/projects", () => {
expect(secondRes.status).toBe(409);
});
it("PATCH /api/projects/[slug] 로 프로젝트 status 를 변경할 수 있어야 한다", async () => {
const { POST: createProject } = await import("@/app/api/projects/route");
const { PATCH: updateProjectStatus } = await import("@/app/api/projects/[slug]/route");
const slug = "status-change-slug";
const payload = {
title: "상태 변경 프로젝트",
slug,
contentJson: [],
};
const headers = await buildAuthHeaders();
const createRes = await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(payload),
}),
);
expect(createRes.status).toBe(201);
const patchBody = { status: "PUBLISHED" };
const patchRes = await updateProjectStatus(
new Request(`${BASE_URL}/api/projects/${slug}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(patchBody),
}),
{ params: { slug } } as any,
);
expect(patchRes.status).toBe(200);
const updated = (await patchRes.json()) as any;
expect(updated.slug).toBe(slug);
expect(updated.status).toBe("PUBLISHED");
});
it("GET /api/projects/[slug] 는 소유자가 아닌 유저에게 404 를 반환해야 한다", async () => {
const { POST: handleProject } = await import("@/app/api/projects/route");
@@ -0,0 +1,243 @@
import { test, expect } from "@playwright/test";
async function signupAndGetAccessToken(
request: import("@playwright/test").APIRequestContext,
email: string,
password: string,
): Promise<string> {
const signupRes = await request.post("/api/auth/signup", {
headers: { "Content-Type": "application/json" },
data: { email, password },
});
const signupStatus = signupRes.status();
if (signupStatus !== 201) {
// 디버깅용: CI 등에서 500 이 떨어질 때 응답 바디를 함께 로그로 출력해 원인을 파악할 수 있게 한다.
console.log("[project-status-public-page] signup error status=", signupStatus);
try {
const bodyText = await signupRes.text();
console.log("[project-status-public-page] signup error body=", bodyText);
} catch (e) {
console.log("[project-status-public-page] signup error: body read failed", e);
}
}
expect(signupStatus).toBe(201);
const setCookieHeader = signupRes.headers()["set-cookie"];
expect(setCookieHeader).toBeTruthy();
const cookieHeaderString = Array.isArray(setCookieHeader)
? setCookieHeader.join("; ")
: (setCookieHeader as string);
const match = cookieHeaderString.match(/pb_access=([^;]+)/);
expect(match).not.toBeNull();
const accessToken = match![1];
return accessToken;
}
async function createProject(
request: import("@playwright/test").APIRequestContext,
accessToken: string,
params: { slug: string; title: string; contentJson?: any },
) {
const { slug, title, contentJson = [] } = params;
const res = await request.post("/api/projects", {
headers: {
"Content-Type": "application/json",
Cookie: `pb_access=${accessToken}`,
},
data: {
title,
slug,
contentJson,
},
});
const status = res.status();
if (status !== 201) {
console.log("[project-status-public-page] createProject error status=", status);
try {
const bodyText = await res.text();
console.log("[project-status-public-page] createProject error body=", bodyText);
} catch (e) {
console.log("[project-status-public-page] createProject error: body read failed", e);
}
}
expect(status).toBe(201);
}
async function patchProjectStatus(
request: import("@playwright/test").APIRequestContext,
accessToken: string,
slug: string,
status: "DRAFT" | "PUBLISHED" | "ARCHIVED",
) {
const res = await request.patch(`/api/projects/${slug}`, {
headers: {
"Content-Type": "application/json",
Cookie: `pb_access=${accessToken}`,
},
data: { status },
});
const code = res.status();
if (code !== 200) {
console.log("[project-status-public-page] patchProjectStatus error status=", code);
try {
const bodyText = await res.text();
console.log("[project-status-public-page] patchProjectStatus error body=", bodyText);
} catch (e) {
console.log("[project-status-public-page] patchProjectStatus error: body read failed", e);
}
}
expect(code).toBe(200);
}
// 프로젝트 status 에 따른 퍼블릭 페이지(/p/[slug]) 기본 동작을 검증하는 E2E.
// - DRAFT: 404
// - PUBLISHED: 200
test.describe("Public project status behaviour", () => {
test("DRAFT 프로젝트는 퍼블릭 페이지에서 404 를 반환해야 한다", async ({ page, request }) => {
const now = Date.now();
const email = `status-draft-${now}@example.com`;
const password = "status-draft-password";
const slug = `status-draft-${now}`;
const accessToken = await signupAndGetAccessToken(request, email, password);
await createProject(request, accessToken, {
slug,
title: "Status DRAFT project",
contentJson: [],
});
const response = await page.goto(`/p/${slug}`);
expect(response?.status()).toBe(404);
});
test("PUBLISHED 프로젝트는 퍼블릭 페이지에서 200 으로 열려야 한다", async ({ page, request }) => {
const now = Date.now();
const email = `status-published-${now}@example.com`;
const password = "status-published-password";
const slug = `status-published-${now}`;
const accessToken = await signupAndGetAccessToken(request, email, password);
await createProject(request, accessToken, {
slug,
title: "Status PUBLISHED project",
contentJson: [],
});
await patchProjectStatus(request, accessToken, slug, "PUBLISHED");
const response = await page.goto(`/p/${slug}`);
expect(response?.status()).toBe(200);
});
test("ARCHIVED 프로젝트는 퍼블릭 페이지에서 폼은 보이지만 제출이 차단되어야 한다", async ({ page, request }) => {
const now = Date.now();
const email = `status-archived-${now}@example.com`;
const password = "status-archived-password";
const slug = `status-archived-${now}`;
const accessToken = await signupAndGetAccessToken(request, email, password);
const inputId = "field_name";
const buttonId = "submit_btn";
const formId = "form_controller_1";
const disabledMessage = "Submission is disabled for this project.";
const contentJson = [
{
id: inputId,
type: "formInput",
props: {
formFieldName: "name",
label: "Name",
inputType: "text",
labelDisplay: "visible",
align: "left",
widthMode: "full",
paddingX: 0,
paddingY: 0,
borderRadius: "md",
},
sectionId: null,
columnId: null,
},
{
id: buttonId,
type: "button",
props: {
label: "Submit",
href: "#",
align: "left",
size: "md",
variant: "solid",
colorPalette: "primary",
borderRadius: "md",
},
sectionId: null,
columnId: null,
},
{
id: formId,
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "Submitted successfully.",
errorMessage: disabledMessage,
payloadFormat: "form",
fieldIds: [inputId],
requiredFieldIds: [inputId],
submitButtonId: buttonId,
formWidthMode: "auto",
},
sectionId: null,
columnId: null,
},
];
await createProject(request, accessToken, {
slug,
title: "Status ARCHIVED project",
contentJson,
});
await patchProjectStatus(request, accessToken, slug, "ARCHIVED");
const submitRequests: string[] = [];
page.on("request", (req) => {
const url = req.url();
if (url.includes("/api/forms/submit")) {
submitRequests.push(url);
}
});
const response = await page.goto(`/p/${slug}`);
expect(response?.status()).toBe(200);
const input = page.locator("input.pb-input").first();
await input.waitFor();
// 퍼블릭 페이지 클라이언트 스크립트가 폼 submit 이벤트를 후킹해 data-pb-initialized 속성을 설정할 때까지 기다린다.
// 이 폼은 hidden 일 수 있으므로, DOM 에만 붙어 있으면 되도록 state 는 attached 로 제한한다.
await page.waitForSelector('form[data-pb-initialized="1"]', { state: "attached" });
await input.fill("Archived user");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText(disabledMessage)).toBeVisible();
expect(submitRequests.length).toBe(0);
});
});
+231
View File
@@ -0,0 +1,231 @@
import { test, expect } from "@playwright/test";
async function signupAndGetAccessToken(
request: import("@playwright/test").APIRequestContext,
email: string,
password: string,
): Promise<string> {
const signupRes = await request.post("/api/auth/signup", {
headers: { "Content-Type": "application/json" },
data: { email, password },
});
const signupStatus = signupRes.status();
if (signupStatus !== 201) {
// 디버깅용: CI 등에서 500 이 떨어질 때 응답 바디를 함께 로그로 출력해 원인을 파악할 수 있게 한다.
console.log("[public-seo] signup error status=", signupStatus);
try {
const bodyText = await signupRes.text();
console.log("[public-seo] signup error body=", bodyText);
} catch (e) {
console.log("[public-seo] signup error: body read failed", e);
}
}
expect(signupStatus).toBe(201);
const setCookieHeader = signupRes.headers()["set-cookie"];
expect(setCookieHeader).toBeTruthy();
const cookieHeaderString = Array.isArray(setCookieHeader)
? setCookieHeader.join("; ")
: (setCookieHeader as string);
const match = cookieHeaderString.match(/pb_access=([^;]+)/);
expect(match).not.toBeNull();
const accessToken = match![1];
return accessToken;
}
async function createProjectWithSnapshot(
request: import("@playwright/test").APIRequestContext,
accessToken: string,
params: { slug: string; title: string; projectConfig: any },
) {
const { slug, title, projectConfig } = params;
const res = await request.post("/api/projects", {
headers: {
"Content-Type": "application/json",
Cookie: `pb_access=${accessToken}`,
},
data: {
title,
slug,
// 새 스펙: contentJson 은 blocks + projectConfig 스냅샷으로 보낸다.
contentJson: {
blocks: [],
projectConfig,
},
},
});
const status = res.status();
if (status !== 201) {
console.log("[public-seo] createProject error status=", status);
try {
const bodyText = await res.text();
console.log("[public-seo] createProject error body=", bodyText);
} catch (e) {
console.log("[public-seo] createProject error: body read failed", e);
}
}
expect(status).toBe(201);
}
async function patchProjectStatus(
request: import("@playwright/test").APIRequestContext,
accessToken: string,
slug: string,
status: "DRAFT" | "PUBLISHED" | "ARCHIVED",
) {
const res = await request.patch(`/api/projects/${slug}`, {
headers: {
"Content-Type": "application/json",
Cookie: `pb_access=${accessToken}`,
},
data: { status },
});
const code = res.status();
if (code !== 200) {
console.log("[public-seo] patchProjectStatus error status=", code);
try {
const bodyText = await res.text();
console.log("[public-seo] patchProjectStatus error body=", bodyText);
} catch (e) {
console.log("[public-seo] patchProjectStatus error: body read failed", e);
}
}
expect(code).toBe(200);
}
// 퍼블릭 페이지(/p/[slug])에서 프로젝트 SEO 설정이 실제 <title>/메타 태그/스크립트에 반영되는지 검증하는 E2E.
// - seoTitle/seoDescription/seoCanonicalUrl/seoOgImageUrl → title 및 OG/Twitter 메타
// - seoNoIndex=true → <meta name="robots" content="noindex, nofollow"> 존재
// - seoCanonicalUrl 은 canonical <link> 가 아니라 og:url 로만 노출되어야 한다.
// - headHtml 은 <head> 에 그대로 삽입되고, trackingScript 는 <body> 에 삽입/실행되어야 한다.
test.describe("Public SEO metadata", () => {
test("PUBLISHED 프로젝트의 SEO 설정이 <title> 및 메타 태그에 반영되어야 한다", async ({ page, request }) => {
const now = Date.now();
const email = `public-seo-${now}@example.com`;
const password = "public-seo-password";
const slug = `public-seo-${now}`;
const accessToken = await signupAndGetAccessToken(request, email, password);
const seoTitle = "SEO 타이틀 - My landing";
const seoDescription = "SEO 설명입니다.";
const seoUrl = `https://example.com/${slug}`;
const seoImage = "https://example.com/og-image.png";
const projectConfig = {
title: "에디터 타이틀",
slug,
canvasPreset: "full",
canvasBgColorHex: "#020617",
bodyBgColorHex: "#020617",
seoTitle,
seoDescription,
seoCanonicalUrl: seoUrl,
seoOgImageUrl: seoImage,
seoNoIndex: false,
headHtml: '<meta name="x-public-head" content="public-seo-test" />',
trackingScript: "<script>window.__pbPublicSeoTracking = 'ok';</script>",
};
await createProjectWithSnapshot(request, accessToken, {
slug,
title: projectConfig.title,
projectConfig,
});
await patchProjectStatus(request, accessToken, slug, "PUBLISHED");
const response = await page.goto(`/p/${slug}`);
expect(response?.status()).toBe(200);
// 1) <title>
await expect(page).toHaveTitle(seoTitle);
// 2) 기본/OG/Twitter 메타 태그
const metaDesc = page.locator('meta[name="description"]');
await expect(metaDesc).toHaveAttribute("content", seoDescription);
const ogTitle = page.locator('meta[property="og:title"]');
await expect(ogTitle).toHaveAttribute("content", seoTitle);
const ogDesc = page.locator('meta[property="og:description"]');
await expect(ogDesc).toHaveAttribute("content", seoDescription);
const ogUrl = page.locator('meta[property="og:url"]');
await expect(ogUrl).toHaveAttribute("content", seoUrl);
const ogImage = page.locator('meta[property="og:image"]');
await expect(ogImage).toHaveAttribute("content", seoImage);
const twitterTitle = page.locator('meta[name="twitter:title"]');
await expect(twitterTitle).toHaveAttribute("content", seoTitle);
// 3) seoNoIndex=false 인 경우 robots 메타는 없어야 한다.
const robots = page.locator('meta[name="robots"]');
await expect(robots).toHaveCount(0);
// 4) canonical <link> 는 생성되지 않아야 한다.
const canonicalLink = page.locator('link[rel="canonical"]');
await expect(canonicalLink).toHaveCount(0);
// 5) headHtml 스니펫이 <head> 에 삽입되어야 한다.
const customHeadMeta = page.locator('meta[name="x-public-head"]');
await expect(customHeadMeta).toHaveAttribute("content", "public-seo-test");
// 6) trackingScript 가 <body> 에 삽입 및 실행되어 window 플래그를 남겨야 한다.
const trackingValue = await page.evaluate(() => (window as any).__pbPublicSeoTracking ?? null);
expect(trackingValue).toBe("ok");
});
test("seoNoIndex=true 인 PUBLISHED 프로젝트는 robots noindex 메타 태그를 포함해야 한다", async ({ page, request }) => {
const now = Date.now();
const email = `public-seo-noindex-${now}@example.com`;
const password = "public-seo-noindex-password";
const slug = `public-seo-noindex-${now}`;
const accessToken = await signupAndGetAccessToken(request, email, password);
const seoTitle = "Noindex SEO 타이틀";
const seoDescription = "Noindex SEO 설명입니다.";
const projectConfig = {
title: "Noindex 프로젝트",
slug,
canvasPreset: "full",
canvasBgColorHex: "#020617",
bodyBgColorHex: "#020617",
seoTitle,
seoDescription,
seoCanonicalUrl: "",
seoOgImageUrl: "",
seoNoIndex: true,
};
await createProjectWithSnapshot(request, accessToken, {
slug,
title: projectConfig.title,
projectConfig,
});
await patchProjectStatus(request, accessToken, slug, "PUBLISHED");
const response = await page.goto(`/p/${slug}`);
expect(response?.status()).toBe(200);
await expect(page).toHaveTitle(seoTitle);
const robots = page.locator('meta[name="robots"]');
await expect(robots).toHaveAttribute("content", /noindex/);
});
});
+61 -9
View File
@@ -166,8 +166,13 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
const parsed = JSON.parse(raw as string);
expect(parsed.title).toBe("저장 테스트 프로젝트");
expect(Array.isArray(parsed.contentJson)).toBe(true);
expect(parsed.contentJson[0].id).toBe("blk_1");
// contentJson 은 이제 { blocks, projectConfig } 스냅샷 객체로 저장된다.
expect(parsed.contentJson).toBeTruthy();
expect(Array.isArray(parsed.contentJson.blocks)).toBe(true);
expect(parsed.contentJson.blocks[0].id).toBe("blk_1");
expect(parsed.contentJson.projectConfig).toBeTruthy();
expect(parsed.contentJson.projectConfig.title).toBe("저장 테스트 프로젝트");
expect(parsed.contentJson.projectConfig.slug).toBe("save-test-slug");
const projectCall = fetchMock.mock.calls.find(
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
@@ -181,8 +186,13 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
const body = JSON.parse(options.body);
expect(body.slug).toBe("save-test-slug");
expect(body.title).toBe("저장 테스트 프로젝트");
expect(Array.isArray(body.contentJson)).toBe(true);
expect(body.contentJson[0].id).toBe("blk_1");
// 서버로 전송되는 contentJson 도 { blocks, projectConfig } 스냅샷이어야 한다.
expect(body.contentJson).toBeTruthy();
expect(Array.isArray(body.contentJson.blocks)).toBe(true);
expect(body.contentJson.blocks[0].id).toBe("blk_1");
expect(body.contentJson.projectConfig).toBeTruthy();
expect(body.contentJson.projectConfig.title).toBe("저장 테스트 프로젝트");
expect(body.contentJson.projectConfig.slug).toBe("save-test-slug");
const message = await screen.findByText("Project saved: save-test-slug");
expect(message).toBeTruthy();
@@ -257,7 +267,15 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
localKey,
JSON.stringify({
title: "로컬 프로젝트",
contentJson: savedBlocks,
// 새 스펙: contentJson 은 blocks 와 projectConfig 를 함께 가지는 스냅샷 객체다.
contentJson: {
blocks: savedBlocks,
projectConfig: {
title: "로컬 프로젝트",
slug,
canvasPreset: "full",
} as ProjectConfig,
},
}),
);
@@ -319,7 +337,19 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ slug, title: "서버 프로젝트", contentJson: serverBlocks }),
// 서버에서도 contentJson 은 { blocks, projectConfig } 형태로 반환된다고 가정한다.
json: async () => ({
slug,
title: "서버 프로젝트",
contentJson: {
blocks: serverBlocks,
projectConfig: {
title: "서버 프로젝트",
slug,
canvasPreset: "full",
} as ProjectConfig,
},
}),
} as any);
vi.stubGlobal("fetch", fetchMock);
@@ -366,7 +396,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
expect(message).toBeTruthy();
});
it("서버에서 복잡한 프로젝트 contentJson 을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => {
it("서버에서 복잡한 프로젝트 contentJson 스냅샷을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => {
const slug = "roundtrip-slug";
const complexBlocks: Block[] = [
@@ -454,7 +484,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
if (url === `/api/projects/${slug}` && method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({ slug, title: "복잡한 프로젝트", contentJson: complexBlocks }),
JSON.stringify({
slug,
title: "복잡한 프로젝트",
contentJson: {
blocks: complexBlocks,
projectConfig: {
title: "복잡한 프로젝트",
slug,
canvasPreset: "full",
} as ProjectConfig,
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
@@ -576,7 +617,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
if (url === `/api/projects/${slug}` && method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({ slug, title: "쿼리 로드 프로젝트", contentJson: serverBlocks }),
JSON.stringify({
slug,
title: "쿼리 로드 프로젝트",
contentJson: {
blocks: serverBlocks,
projectConfig: {
title: "쿼리 로드 프로젝트",
slug,
canvasPreset: "full",
} as ProjectConfig,
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
@@ -72,10 +72,10 @@ describe("ProjectPropertiesPanel SEO 메타", () => {
});
});
it("Canonical URL 입력을 변경하면 seoCanonicalUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
it("공유 URL(og:url) 입력을 변경하면 seoCanonicalUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("Canonical URL") as HTMLInputElement;
const input = screen.getByLabelText("Share URL (og:url)") as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://example.com/landing" } });
+306 -9
View File
@@ -56,7 +56,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
});
const [url, options] = fetchMock.mock.calls[0] as any;
@@ -104,7 +104,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
});
const link = screen.getByText("All submissions");
@@ -136,7 +136,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
});
const link = screen.getByText("Dashboard");
@@ -168,7 +168,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
});
const titleCellNode = await screen.findByText("테스트 프로젝트 A");
@@ -212,7 +212,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
});
const toolbar = await screen.findByTestId("projects-toolbar");
@@ -276,7 +276,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
});
const dashboardLink = await screen.findByRole("link", { name: "Dashboard" });
@@ -314,7 +314,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
});
const html = document.documentElement;
@@ -377,6 +377,60 @@ describe("ProjectsPage - 프로젝트 목록", () => {
);
});
it("프로젝트 행의 퍼블릭 페이지 링크는 새 탭에서 열려야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "PUBLISHED",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/projects" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ emailVerified: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response("not found", { status: 404 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectsPage />);
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
const publicLinkText = await screen.findByText("Open public page");
const anchor = publicLinkText.closest("a") as HTMLAnchorElement | null;
expect(anchor).not.toBeNull();
expect(anchor!.getAttribute("href")).toBe("/p/test-project-a");
expect(anchor!.getAttribute("target")).toBe("_blank");
const rel = anchor!.getAttribute("rel") ?? "";
expect(rel).toContain("noopener");
expect(rel).toContain("noreferrer");
});
it("삭제 버튼 클릭 시 서버에 DELETE 요청을 보내고 목록과 로컬스토리지를 갱신해야 한다", async () => {
const projects = [
{
@@ -594,7 +648,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
});
await waitFor(() => {
@@ -671,7 +725,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
});
expect(
@@ -817,4 +871,247 @@ describe("ProjectsPage - 프로젝트 목록", () => {
confirmSpy.mockRestore();
});
it("상태 드롭다운에서 값을 변경하면 PATCH /api/projects/[slug] 가 호출되고 UI 가 갱신되어야 한다", 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",
},
];
const updatedProject = {
...projects[0],
status: "PUBLISHED",
updatedAt: "2025-01-01T01:00:00.000Z",
};
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/projects" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ emailVerified: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/projects/test-project-a" && method === "PATCH") {
return Promise.resolve(
new Response(JSON.stringify(updatedProject), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response("not found", { status: 404 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectsPage />);
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
const statusSelect = screen.getByRole("combobox") as HTMLSelectElement;
expect(statusSelect.value).toBe("DRAFT");
fireEvent.change(statusSelect, { target: { value: "PUBLISHED" } });
await waitFor(() => {
const patchCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/projects/test-project-a" && options?.method === "PATCH";
});
expect(patchCall).toBeDefined();
});
await waitFor(() => {
const updatedSelect = screen.getByRole("combobox") as HTMLSelectElement;
expect(updatedSelect.value).toBe("PUBLISHED");
});
});
it("상태 컬럼 헤더에는 상태 설명을 보여주는 ? 아이콘이 i18n 텍스트로 렌더링되어야 한다", 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",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
const statusHeader = await screen.findByText("Status");
expect(statusHeader).toBeTruthy();
const helpButton = screen.getByLabelText("Project status help");
expect(helpButton).toBeTruthy();
// 초기에는 상태 설명 텍스트가 화면에 보이지 않아야 한다.
expect(
screen.queryByText(/Draft: Public page is blocked \(404\) and form submissions are not accepted\./),
).toBeNull();
// ? 아이콘을 클릭하면 상태 설명 팝오버가 열려야 한다.
helpButton.click();
expect(
await screen.findByText(
/Draft: Public page is blocked \(404\) and form submissions are not accepted\./,
),
).toBeTruthy();
expect(
screen.getByText(/Published: Public page is live and form submissions are accepted\./),
).toBeTruthy();
expect(
screen.getByText(/Archived: Public page is live but new form submissions are blocked\./),
).toBeTruthy();
});
it("상태 드롭다운 옵션 텍스트는 i18n 상태 라벨을 사용해야 한다", 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",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
const select = screen.getByRole("combobox") as HTMLSelectElement;
const optionTexts = Array.from(select.options).map((opt) => opt.textContent || "");
expect(optionTexts).toContain("Draft (blocked)");
expect(optionTexts).toContain("Published (live)");
expect(optionTexts).toContain("Archived (read-only)");
});
it("프로젝트 목록 테이블에서 제출 내역 컬럼에 오늘/전체 제출 수를 [아이콘] today/total 형식으로 표시해야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "PUBLISHED",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
todaySubmissions: 1,
totalSubmissions: 2,
},
{
id: "2",
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
createdAt: "2025-01-02T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
todaySubmissions: 0,
totalSubmissions: 5,
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectsPage />);
const rowA = (await screen.findByText("테스트 프로젝트 A")).closest("tr") as HTMLElement | null;
const rowB = (await screen.findByText("테스트 프로젝트 B")).closest("tr") as HTMLElement | null;
expect(rowA).not.toBeNull();
expect(rowB).not.toBeNull();
expect(rowA!.textContent || "").toContain("1/2");
expect(rowB!.textContent || "").toContain("0/5");
});
it("제출 내역 컬럼 헤더 텍스트만으로 today/total 의미를 전달하고 별도 툴팁 아이콘은 없어야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "PUBLISHED",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
todaySubmissions: 1,
totalSubmissions: 2,
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectsPage />);
// 헤더 텍스트 자체에 today/total 의미가 드러나야 한다.
const header = await screen.findByText("Submissions (today/total)");
expect(header).toBeTruthy();
// 별도의 제출 내역 툴팁 아이콘은 존재하지 않아야 한다.
expect(screen.queryByLabelText("Submission counts help")).toBeNull();
});
});