...) 이므로 그대로 삽입한다.
+ dangerouslySetInnerHTML={{
+ __html: String((projectConfig as any).trackingScript),
+ }}
+ />
+ ) : null}
+
+ >
+ );
}
diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx
index de3ab88..7a35574 100644
--- a/src/app/projects/page.tsx
+++ b/src/app/projects/page.tsx
@@ -15,6 +15,8 @@ import {
FolderKanban,
SunMoon,
BarChart2,
+ HelpCircle,
+ ExternalLink,
} from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
@@ -27,6 +29,8 @@ interface ProjectListItem {
status: string;
createdAt: string;
updatedAt: string;
+ todaySubmissions?: number;
+ totalSubmissions?: number;
}
export default function ProjectsPage() {
@@ -41,6 +45,7 @@ export default function ProjectsPage() {
const [currentPage, setCurrentPage] = useState(1);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [emailVerified, setEmailVerified] = useState
(null);
+ const [isStatusHelpOpen, setIsStatusHelpOpen] = useState(false);
const pageSize = 10;
useEffect(() => {
@@ -117,6 +122,50 @@ export default function ProjectsPage() {
setCurrentPage((prev) => Math.min(prev, totalPages));
}, [projects.length, pageSize]);
+ const handleStatusChange = async (
+ slug: string,
+ nextStatus: "DRAFT" | "PUBLISHED" | "ARCHIVED",
+ ) => {
+ try {
+ const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ status: nextStatus }),
+ });
+
+ if (!res.ok) {
+ setStatus("error");
+ setErrorMessage(p.errorFetchGeneric);
+ return;
+ }
+
+ const updated = (await res.json().catch(() => null)) as
+ | (ProjectListItem & { status?: string })
+ | null;
+
+ setProjects((prev) =>
+ prev.map((project) => {
+ if (project.slug !== slug) return project;
+ const updatedStatus = updated?.status ?? nextStatus;
+ const updatedAt = (updated as any)?.updatedAt ?? project.updatedAt;
+ return {
+ ...project,
+ status: updatedStatus,
+ updatedAt,
+ };
+ }),
+ );
+
+ setStatus("idle");
+ setErrorMessage(null);
+ } catch {
+ setStatus("error");
+ setErrorMessage(p.errorFetchGeneric);
+ }
+ };
+
const handleDelete = async (slug: string) => {
if (typeof window !== "undefined") {
const confirmed = window.confirm(p.confirmDeleteSingle);
@@ -391,7 +440,30 @@ export default function ProjectsPage() {
{p.tableHeaderTitle} |
{p.tableHeaderSlug} |
- {p.tableHeaderStatus} |
+
+
+ {p.tableHeaderStatus}
+
+
+ {isStatusHelpOpen && (
+
+
+ {p.tableHeaderStatusHelpTooltip}
+
+
+ )}
+ |
+ {p.tableHeaderSubmissions} |
{p.tableHeaderCreatedAt} |
{p.tableHeaderUpdatedAt} |
{p.tableHeaderActions} |
@@ -424,7 +496,36 @@ export default function ProjectsPage() {
{project.title} |
{project.slug} |
- {project.status} |
+
+
+ |
+
+
+
+
+ {(project.todaySubmissions ?? 0).toString()}/{
+ (project.totalSubmissions ?? 0).toString()
+ }
+
+ {p.actionViewSubmissions}
+
+ |
{new Date(project.createdAt).toLocaleString()}
|
@@ -432,37 +533,32 @@ export default function ProjectsPage() {
{new Date(project.updatedAt).toLocaleString()}
-
+
-
- {p.actionEdit}
+
+ {p.actionEdit}
-
- {p.actionPreview}
+
+ {p.actionPreview}
{emailVerified !== false && (
-
- {t.projectOpenPublicPageLink}
+
+ {t.projectOpenPublicPageLink}
)}
-
-
- {p.actionViewSubmissions}
-
|
diff --git a/src/features/i18n/messages/editorProjectPropertiesPanel.ts b/src/features/i18n/messages/editorProjectPropertiesPanel.ts
index cdf00fa..69e5242 100644
--- a/src/features/i18n/messages/editorProjectPropertiesPanel.ts
+++ b/src/features/i18n/messages/editorProjectPropertiesPanel.ts
@@ -92,8 +92,9 @@ const EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES: Record 로 쓰지 않고, 공유 URL(og:url) 설정 용도로만 사용한다.
+ seoCanonicalUrlLabel: "Share URL (og:url)",
+ seoCanonicalUrlAria: "Share URL (og:url)",
seoCanonicalUrlPlaceholder: "e.g. https://example.com/landing",
seoNoIndexAria: "Hide from search engines (noindex)",
@@ -145,8 +146,9 @@ const EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES: Record 로 쓰지 않고, 공유 URL(og:url) 설정 용도로만 사용한다.
+ seoCanonicalUrlLabel: "공유 URL (og:url)",
+ seoCanonicalUrlAria: "공유 URL (og:url)",
seoCanonicalUrlPlaceholder: "예: https://example.com/landing",
seoNoIndexAria: "검색 엔진에 노출하지 않기 (noindex)",
diff --git a/src/features/i18n/messages/projects.ts b/src/features/i18n/messages/projects.ts
index 5697ca4..af61304 100644
--- a/src/features/i18n/messages/projects.ts
+++ b/src/features/i18n/messages/projects.ts
@@ -19,6 +19,14 @@ export type ProjectsMessages = {
tableHeaderTitle: string;
tableHeaderSlug: string;
tableHeaderStatus: string;
+ tableHeaderStatusHelpLabel: string;
+ tableHeaderStatusHelpTooltip: string;
+ statusDraftLabel: string;
+ statusPublishedLabel: string;
+ statusArchivedLabel: string;
+ tableHeaderSubmissions: string;
+ tableHeaderSubmissionsHelpLabel: string;
+ tableHeaderSubmissionsHelpTooltip: string;
tableHeaderCreatedAt: string;
tableHeaderUpdatedAt: string;
tableHeaderActions: string;
@@ -60,6 +68,18 @@ const PROJECTS_MESSAGES: Record = {
tableHeaderTitle: "Title",
tableHeaderSlug: "Address (slug)",
tableHeaderStatus: "Status",
+ tableHeaderStatusHelpLabel: "Project status help",
+ tableHeaderStatusHelpTooltip:
+ "Draft: Public page is blocked (404) and form submissions are not accepted.\n" +
+ "Published: Public page is live and form submissions are accepted.\n" +
+ "Archived: Public page is live but new form submissions are blocked.",
+ statusDraftLabel: "Draft (blocked)",
+ statusPublishedLabel: "Published (live)",
+ statusArchivedLabel: "Archived (read-only)",
+ tableHeaderSubmissions: "Submissions (today/total)",
+ tableHeaderSubmissionsHelpLabel: "Submission counts help",
+ tableHeaderSubmissionsHelpTooltip:
+ "Shows form submission counts as [icon] today/total (today's submissions / all submissions).",
tableHeaderCreatedAt: "Created at",
tableHeaderUpdatedAt: "Updated at",
tableHeaderActions: "Actions",
@@ -100,6 +120,18 @@ const PROJECTS_MESSAGES: Record = {
tableHeaderTitle: "제목",
tableHeaderSlug: "주소(slug)",
tableHeaderStatus: "상태",
+ tableHeaderStatusHelpLabel: "프로젝트 상태 설명",
+ tableHeaderStatusHelpTooltip:
+ "차단: 퍼블릭 페이지가 404로 차단되고 폼 제출을 받지 않습니다.\n" +
+ "운영 중: 퍼블릭 페이지에 노출되고 폼 제출을 정상적으로 받습니다.\n" +
+ "제출 중단(오픈): 퍼블릭 페이지는 열리지만 새 폼 제출은 차단됩니다.",
+ statusDraftLabel: "차단",
+ statusPublishedLabel: "운영 중",
+ statusArchivedLabel: "제출 중단(오픈)",
+ tableHeaderSubmissions: "제출 내역 (오늘/전체)",
+ tableHeaderSubmissionsHelpLabel: "제출 내역 수치 설명",
+ tableHeaderSubmissionsHelpTooltip:
+ "아이콘 옆 숫자는 '오늘 제출 수 / 전체 누적 제출 수'를 의미합니다.",
tableHeaderCreatedAt: "생성일",
tableHeaderUpdatedAt: "수정일",
tableHeaderActions: "액션",
diff --git a/src/features/projects/publicCache.ts b/src/features/projects/publicCache.ts
index 96a7133..ed3c21c 100644
--- a/src/features/projects/publicCache.ts
+++ b/src/features/projects/publicCache.ts
@@ -1,9 +1,11 @@
-import type { Block } from "@/features/editor/state/editorStore";
+import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
interface PublicProjectSnapshot {
slug: string;
title: string;
- contentJson: Block[];
+ status: string;
+ blocks: Block[];
+ projectConfig: ProjectConfig | null;
}
function getStore(): Map {
@@ -23,3 +25,26 @@ export function getCachedPublicProject(slug: string): PublicProjectSnapshot | nu
const store = getStore();
return store.get(slug) ?? null;
}
+
+export function parseProjectContentJson(rawContent: any): {
+ blocks: Block[];
+ projectConfig: ProjectConfig | null;
+} {
+ let blocks: Block[] = [];
+ let projectConfig: ProjectConfig | null = null;
+
+ if (Array.isArray(rawContent)) {
+ blocks = rawContent as Block[];
+ } else if (rawContent && typeof rawContent === "object") {
+ const maybeBlocks = (rawContent as any).blocks;
+ if (Array.isArray(maybeBlocks)) {
+ blocks = maybeBlocks as Block[];
+ const maybeConfig = (rawContent as any).projectConfig;
+ if (maybeConfig && typeof maybeConfig === "object") {
+ projectConfig = maybeConfig as ProjectConfig;
+ }
+ }
+ }
+
+ return { blocks, projectConfig };
+}
diff --git a/tests/api/export.spec.ts b/tests/api/export.spec.ts
index 361e2ec..6b631b4 100644
--- a/tests/api/export.spec.ts
+++ b/tests/api/export.spec.ts
@@ -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" 로만 사용하고, 태그는 생성하지 않는다.
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('');
+ // canonical 링크 태그는 생성되지 않아야 한다.
+ expect(html).not.toContain('');
expect(html).toContain('');
});
diff --git a/tests/api/formsSubmit.spec.ts b/tests/api/formsSubmit.spec.ts
index 835fa98..03737a3 100644
--- a/tests/api/formsSubmit.spec.ts
+++ b/tests/api/formsSubmit.spec.ts
@@ -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();
+ });
+});
+
});
diff --git a/tests/api/projects.spec.ts b/tests/api/projects.spec.ts
index 3d870b1..5585aa5 100644
--- a/tests/api/projects.spec.ts
+++ b/tests/api/projects.spec.ts
@@ -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");
diff --git a/tests/e2e/project-status-public-page.spec.ts b/tests/e2e/project-status-public-page.spec.ts
new file mode 100644
index 0000000..e3c9856
--- /dev/null
+++ b/tests/e2e/project-status-public-page.spec.ts
@@ -0,0 +1,243 @@
+import { test, expect } from "@playwright/test";
+
+async function signupAndGetAccessToken(
+ request: import("@playwright/test").APIRequestContext,
+ email: string,
+ password: string,
+): Promise {
+ 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);
+ });
+});
diff --git a/tests/e2e/public-seo.spec.ts b/tests/e2e/public-seo.spec.ts
new file mode 100644
index 0000000..efd7864
--- /dev/null
+++ b/tests/e2e/public-seo.spec.ts
@@ -0,0 +1,231 @@
+import { test, expect } from "@playwright/test";
+
+async function signupAndGetAccessToken(
+ request: import("@playwright/test").APIRequestContext,
+ email: string,
+ password: string,
+): Promise {
+ 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 설정이 실제 /메타 태그/스크립트에 반영되는지 검증하는 E2E.
+// - seoTitle/seoDescription/seoCanonicalUrl/seoOgImageUrl → title 및 OG/Twitter 메타
+// - seoNoIndex=true → 존재
+// - seoCanonicalUrl 은 canonical 가 아니라 og:url 로만 노출되어야 한다.
+// - headHtml 은 에 그대로 삽입되고, trackingScript 는 에 삽입/실행되어야 한다.
+
+test.describe("Public SEO metadata", () => {
+ test("PUBLISHED 프로젝트의 SEO 설정이 및 메타 태그에 반영되어야 한다", 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: '',
+ trackingScript: "",
+ };
+
+ 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)
+ 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 는 생성되지 않아야 한다.
+ const canonicalLink = page.locator('link[rel="canonical"]');
+ await expect(canonicalLink).toHaveCount(0);
+
+ // 5) headHtml 스니펫이 에 삽입되어야 한다.
+ const customHeadMeta = page.locator('meta[name="x-public-head"]');
+ await expect(customHeadMeta).toHaveAttribute("content", "public-seo-test");
+
+ // 6) trackingScript 가 에 삽입 및 실행되어 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/);
+ });
+});
diff --git a/tests/unit/EditorProjectSaveLoad.spec.tsx b/tests/unit/EditorProjectSaveLoad.spec.tsx
index 89b4c26..1d91568 100644
--- a/tests/unit/EditorProjectSaveLoad.spec.tsx
+++ b/tests/unit/EditorProjectSaveLoad.spec.tsx
@@ -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" },
diff --git a/tests/unit/ProjectPropertiesPanel.seo.spec.tsx b/tests/unit/ProjectPropertiesPanel.seo.spec.tsx
index 1d7457c..45d2c4d 100644
--- a/tests/unit/ProjectPropertiesPanel.seo.spec.tsx
+++ b/tests/unit/ProjectPropertiesPanel.seo.spec.tsx
@@ -72,10 +72,10 @@ describe("ProjectPropertiesPanel SEO 메타", () => {
});
});
- it("Canonical URL 입력을 변경하면 seoCanonicalUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
+ it("공유 URL(og:url) 입력을 변경하면 seoCanonicalUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
render();
- 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" } });
diff --git a/tests/unit/ProjectsPage.spec.tsx b/tests/unit/ProjectsPage.spec.tsx
index 1725f68..aaa242a 100644
--- a/tests/unit/ProjectsPage.spec.tsx
+++ b/tests/unit/ProjectsPage.spec.tsx
@@ -56,7 +56,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render();
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();
await waitFor(() => {
- expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalled();
});
const link = screen.getByText("All submissions");
@@ -136,7 +136,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render();
await waitFor(() => {
- expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalled();
});
const link = screen.getByText("Dashboard");
@@ -168,7 +168,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render();
await waitFor(() => {
- expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalled();
});
const titleCellNode = await screen.findByText("테스트 프로젝트 A");
@@ -212,7 +212,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render();
await waitFor(() => {
- expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalled();
});
const toolbar = await screen.findByTestId("projects-toolbar");
@@ -276,7 +276,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render();
await waitFor(() => {
- expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalled();
});
const dashboardLink = await screen.findByRole("link", { name: "Dashboard" });
@@ -314,7 +314,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render();
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();
+
+ 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();
await waitFor(() => {
- expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalled();
});
await waitFor(() => {
@@ -671,7 +725,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
render();
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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 헤더 텍스트 자체에 today/total 의미가 드러나야 한다.
+ const header = await screen.findByText("Submissions (today/total)");
+ expect(header).toBeTruthy();
+
+ // 별도의 제출 내역 툴팁 아이콘은 존재하지 않아야 한다.
+ expect(screen.queryByLabelText("Submission counts help")).toBeNull();
+ });
});