Merge pull request 'CI: feature/project-public-modes' (#37) from feature/project-public-modes into main
This commit was merged in pull request #37.
This commit is contained in:
@@ -145,11 +145,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seoCanonicalRaw) {
|
// canonical URL 은 Export HTML 에서는 meta property="og:url" 로만 사용하고,
|
||||||
seoHeadParts.push(
|
// 별도의 <link rel="canonical"> 태그는 생성하지 않는다.
|
||||||
` <link rel="canonical" href="${escapeAttr(seoCanonicalRaw)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seoNoIndex) {
|
if (seoNoIndex) {
|
||||||
seoHeadParts.push(
|
seoHeadParts.push(
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ import { encryptJson } from "@/features/auth/authCrypto";
|
|||||||
|
|
||||||
const prisma = new PrismaClient() as any;
|
const prisma = new PrismaClient() as any;
|
||||||
|
|
||||||
|
function resolveProjectSlug(formData: FormData, config: FormBlockProps | null): string | null {
|
||||||
|
const projectSlugRaw = formData.get("__projectSlug");
|
||||||
|
let projectSlug: string | null = null;
|
||||||
|
|
||||||
|
if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") {
|
||||||
|
projectSlug = projectSlugRaw.trim();
|
||||||
|
} else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") {
|
||||||
|
const v = config.extraParams.projectSlug.trim();
|
||||||
|
if (v !== "") {
|
||||||
|
projectSlug = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectSlug;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set<string> {
|
function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set<string> {
|
||||||
const result = new Set<string>();
|
const result = new Set<string>();
|
||||||
const fields: FormFieldConfig[] = Array.isArray(config?.fields) ? (config!.fields as FormFieldConfig[]) : [];
|
const fields: FormFieldConfig[] = Array.isArray(config?.fields) ? (config!.fields as FormFieldConfig[]) : [];
|
||||||
@@ -55,17 +71,7 @@ function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set<string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function persistFormSubmission(formData: FormData, config: FormBlockProps | null) {
|
async function persistFormSubmission(formData: FormData, config: FormBlockProps | null) {
|
||||||
const projectSlugRaw = formData.get("__projectSlug");
|
const projectSlug = resolveProjectSlug(formData, config);
|
||||||
let projectSlug: string | null = null;
|
|
||||||
|
|
||||||
if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") {
|
|
||||||
projectSlug = projectSlugRaw.trim();
|
|
||||||
} else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") {
|
|
||||||
const v = config.extraParams.projectSlug.trim();
|
|
||||||
if (v !== "") {
|
|
||||||
projectSlug = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sensitiveNames = buildSensitiveFieldNameSet(config);
|
const sensitiveNames = buildSensitiveFieldNameSet(config);
|
||||||
|
|
||||||
@@ -167,6 +173,44 @@ export async function POST(req: Request) {
|
|||||||
const successMessage = config?.successMessage;
|
const successMessage = config?.successMessage;
|
||||||
const errorMessage = config?.errorMessage;
|
const errorMessage = config?.errorMessage;
|
||||||
|
|
||||||
|
const projectSlug = resolveProjectSlug(formData, config);
|
||||||
|
let projectStatus: string | null = null;
|
||||||
|
|
||||||
|
if (projectSlug) {
|
||||||
|
try {
|
||||||
|
const project = await prisma.project.findUnique({ where: { slug: projectSlug } });
|
||||||
|
if (project && typeof (project as any).status === "string") {
|
||||||
|
projectStatus = (project as any).status as string;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 상태 조회 실패는 제출 자체를 막지 않는다. (projectSlug 기반 저장은 계속 허용)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProjectClosed = (status: string | null): boolean => {
|
||||||
|
if (!status) return false;
|
||||||
|
if (status === "ARCHIVED") return true;
|
||||||
|
if (status === "DRAFT") return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isProjectClosed(projectStatus)) {
|
||||||
|
const message =
|
||||||
|
errorMessage ??
|
||||||
|
(projectStatus === "DRAFT"
|
||||||
|
? "아직 공개되지 않은 프로젝트입니다."
|
||||||
|
: "이 프로젝트는 현재 폼 제출이 중단된 상태입니다.");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "project_closed",
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (submitTarget === "internal") {
|
if (submitTarget === "internal") {
|
||||||
await persistFormSubmission(formData, config);
|
await persistFormSubmission(formData, config);
|
||||||
console.log("[forms/submit][internal]", { name, email, message });
|
console.log("[forms/submit][internal]", { name, email, message });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient, ProjectStatus } from "@prisma/client";
|
||||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||||
|
import { cachePublicProject, parseProjectContentJson } from "@/features/projects/publicCache";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -98,3 +99,70 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const authUser = await getAuthUserFromRequest(request);
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ message: "프로젝트를 수정하려면 로그인이 필요합니다." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await context.params;
|
||||||
|
|
||||||
|
let body: any = null;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ message: "잘못된 요청 본문입니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatus = body?.status as ProjectStatus | undefined;
|
||||||
|
const allowedStatuses: ProjectStatus[] = ["DRAFT", "PUBLISHED", "ARCHIVED"];
|
||||||
|
|
||||||
|
if (!nextStatus || !allowedStatuses.includes(nextStatus)) {
|
||||||
|
return NextResponse.json({ message: "유효하지 않은 프로젝트 상태입니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project || (project.userId && project.userId !== authUser.id)) {
|
||||||
|
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.project.update({
|
||||||
|
where: { slug },
|
||||||
|
data: { status: nextStatus },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 퍼블릭 캐시 스냅샷도 함께 갱신해 /p/[slug] 가 최신 상태를 사용할 수 있도록 한다.
|
||||||
|
try {
|
||||||
|
const { blocks, projectConfig } = parseProjectContentJson((updated as any).contentJson);
|
||||||
|
|
||||||
|
cachePublicProject({
|
||||||
|
slug: updated.slug,
|
||||||
|
title: updated.title,
|
||||||
|
status: (updated as any).status ?? nextStatus,
|
||||||
|
blocks,
|
||||||
|
projectConfig,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 캐시 갱신 실패는 퍼블릭 렌더링에만 영향을 주므로 무시한다.
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(updated, { status: 200 });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === "P2025") {
|
||||||
|
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "프로젝트 수정 중 오류가 발생했습니다.", error: error?.message },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||||
import type { Block } from "@/features/editor/state/editorStore";
|
import { cachePublicProject, parseProjectContentJson } from "@/features/projects/publicCache";
|
||||||
import { cachePublicProject } from "@/features/projects/publicCache";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -62,7 +61,80 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(projects, { status: 200 });
|
if (projects.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
projects.map((p) => ({
|
||||||
|
...p,
|
||||||
|
todaySubmissions: 0,
|
||||||
|
totalSubmissions: 0,
|
||||||
|
})),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectIds = projects.map((p) => p.id);
|
||||||
|
|
||||||
|
const submissions = await prisma.formSubmission.findMany({
|
||||||
|
where: {
|
||||||
|
userId: authUser.id,
|
||||||
|
projectId: { in: projectIds },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectId: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startOfToday = new Date(now);
|
||||||
|
startOfToday.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const statsByProjectId = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
todaySubmissions: number;
|
||||||
|
totalSubmissions: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
statsByProjectId.set(project.id, { todaySubmissions: 0, totalSubmissions: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const submission of submissions) {
|
||||||
|
const projectId = submission.projectId;
|
||||||
|
if (!projectId) continue;
|
||||||
|
|
||||||
|
const stat = statsByProjectId.get(projectId);
|
||||||
|
if (!stat) continue;
|
||||||
|
|
||||||
|
stat.totalSubmissions += 1;
|
||||||
|
|
||||||
|
const createdAt =
|
||||||
|
submission.createdAt instanceof Date
|
||||||
|
? submission.createdAt
|
||||||
|
: new Date((submission.createdAt as unknown as string) ?? "");
|
||||||
|
|
||||||
|
if (createdAt >= startOfToday) {
|
||||||
|
stat.todaySubmissions += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = projects.map((project) => {
|
||||||
|
const stat = statsByProjectId.get(project.id) ?? {
|
||||||
|
todaySubmissions: 0,
|
||||||
|
totalSubmissions: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
todaySubmissions: stat.todaySubmissions,
|
||||||
|
totalSubmissions: stat.totalSubmissions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result, { status: 200 });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "프로젝트 목록을 가져오는 중 오류가 발생했습니다.", error: error?.message },
|
{ message: "프로젝트 목록을 가져오는 중 오류가 발생했습니다.", error: error?.message },
|
||||||
@@ -112,17 +184,15 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// 퍼블릭 슬러그 페이지(/p/[slug])가 DB 조회 없이도 최신 스냅샷을 바로 렌더링할 수 있도록
|
// 퍼블릭 슬러그 페이지(/p/[slug])가 DB 조회 없이도 최신 스냅샷을 바로 렌더링할 수 있도록
|
||||||
// 메모리 기반 캐시에 프로젝트 스냅샷을 저장해 둔다.
|
// 메모리 기반 캐시에 프로젝트 스냅샷을 저장해 둔다.
|
||||||
const rawContent = project.contentJson;
|
const { blocks, projectConfig } = parseProjectContentJson((project as any).contentJson);
|
||||||
let contentBlocks: Block[] = [];
|
|
||||||
if (Array.isArray(rawContent)) {
|
|
||||||
contentBlocks = rawContent as unknown as Block[];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
cachePublicProject({
|
cachePublicProject({
|
||||||
slug: project.slug,
|
slug: project.slug,
|
||||||
title: project.title,
|
title: project.title,
|
||||||
contentJson: contentBlocks,
|
status: (project as any).status ?? "DRAFT",
|
||||||
|
blocks,
|
||||||
|
projectConfig,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// 캐시 저장 실패는 퍼블릭 렌더링에만 영향을 주므로, API 응답 자체는 정상적으로 계속 진행한다.
|
// 캐시 저장 실패는 퍼블릭 렌더링에만 영향을 주므로, API 응답 자체는 정상적으로 계속 진행한다.
|
||||||
|
|||||||
+79
-12
@@ -282,9 +282,27 @@ function EditorPageInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data && Array.isArray(data.contentJson)) {
|
const rawContent = (data as any)?.contentJson;
|
||||||
replaceBlocks(data.contentJson as any);
|
let nextBlocks: Block[] | null = null;
|
||||||
if (data.title && typeof data.title === "string") {
|
let nextProjectConfig: ProjectConfig | null = null;
|
||||||
|
|
||||||
|
if (Array.isArray(rawContent)) {
|
||||||
|
nextBlocks = rawContent as Block[];
|
||||||
|
} else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) {
|
||||||
|
nextBlocks = rawContent.blocks as Block[];
|
||||||
|
if (rawContent.projectConfig && typeof rawContent.projectConfig === "object") {
|
||||||
|
nextProjectConfig = rawContent.projectConfig as ProjectConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBlocks) {
|
||||||
|
replaceBlocks(nextBlocks as any);
|
||||||
|
if (nextProjectConfig) {
|
||||||
|
updateProjectConfig({
|
||||||
|
...(nextProjectConfig as ProjectConfig),
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
} else if (data.title && typeof data.title === "string") {
|
||||||
updateProjectConfig({ title: data.title, slug });
|
updateProjectConfig({ title: data.title, slug });
|
||||||
} else {
|
} else {
|
||||||
updateProjectConfig({ slug });
|
updateProjectConfig({ slug });
|
||||||
@@ -483,9 +501,13 @@ function EditorPageInner() {
|
|||||||
// 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준)
|
// 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const localKey = `pb:project:${slug}`;
|
const localKey = `pb:project:${slug}`;
|
||||||
|
const snapshot = {
|
||||||
|
blocks,
|
||||||
|
projectConfig,
|
||||||
|
};
|
||||||
const payload = {
|
const payload = {
|
||||||
title,
|
title,
|
||||||
contentJson: blocks,
|
contentJson: snapshot,
|
||||||
};
|
};
|
||||||
window.localStorage.setItem(localKey, JSON.stringify(payload));
|
window.localStorage.setItem(localKey, JSON.stringify(payload));
|
||||||
|
|
||||||
@@ -505,6 +527,10 @@ function EditorPageInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2) 서버에도 저장 (백업/공유용)
|
// 2) 서버에도 저장 (백업/공유용)
|
||||||
|
const snapshotForServer = {
|
||||||
|
blocks,
|
||||||
|
projectConfig,
|
||||||
|
};
|
||||||
const response = await fetch("/api/projects", {
|
const response = await fetch("/api/projects", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -513,7 +539,7 @@ function EditorPageInner() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
contentJson: blocks,
|
contentJson: snapshotForServer,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -559,10 +585,33 @@ function EditorPageInner() {
|
|||||||
const raw = window.localStorage.getItem(localKey);
|
const raw = window.localStorage.getItem(localKey);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw) as {
|
||||||
if (parsed && Array.isArray(parsed.contentJson)) {
|
title?: string;
|
||||||
replaceBlocks(parsed.contentJson as any);
|
contentJson?: any;
|
||||||
if (parsed.title && typeof parsed.title === "string") {
|
};
|
||||||
|
|
||||||
|
const rawContent = (parsed as any)?.contentJson;
|
||||||
|
let nextBlocks: Block[] | null = null;
|
||||||
|
let nextProjectConfig: ProjectConfig | null = null;
|
||||||
|
|
||||||
|
if (Array.isArray(rawContent)) {
|
||||||
|
nextBlocks = rawContent as Block[];
|
||||||
|
} else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) {
|
||||||
|
nextBlocks = rawContent.blocks as Block[];
|
||||||
|
if (rawContent.projectConfig && typeof rawContent.projectConfig === "object") {
|
||||||
|
nextProjectConfig = rawContent.projectConfig as ProjectConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBlocks) {
|
||||||
|
replaceBlocks(nextBlocks as any);
|
||||||
|
|
||||||
|
if (nextProjectConfig) {
|
||||||
|
updateProjectConfig({
|
||||||
|
...(nextProjectConfig as ProjectConfig),
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
} else if (parsed.title && typeof parsed.title === "string") {
|
||||||
updateProjectConfig({ title: parsed.title, slug });
|
updateProjectConfig({ title: parsed.title, slug });
|
||||||
} else {
|
} else {
|
||||||
updateProjectConfig({ slug });
|
updateProjectConfig({ slug });
|
||||||
@@ -588,9 +637,27 @@ function EditorPageInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data && Array.isArray(data.contentJson)) {
|
const rawContent = (data as any)?.contentJson;
|
||||||
replaceBlocks(data.contentJson as any);
|
let nextBlocks: Block[] | null = null;
|
||||||
if (data.title && typeof data.title === "string") {
|
let nextProjectConfig: ProjectConfig | null = null;
|
||||||
|
|
||||||
|
if (Array.isArray(rawContent)) {
|
||||||
|
nextBlocks = rawContent as Block[];
|
||||||
|
} else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) {
|
||||||
|
nextBlocks = rawContent.blocks as Block[];
|
||||||
|
if (rawContent.projectConfig && typeof rawContent.projectConfig === "object") {
|
||||||
|
nextProjectConfig = rawContent.projectConfig as ProjectConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBlocks) {
|
||||||
|
replaceBlocks(nextBlocks as any);
|
||||||
|
if (nextProjectConfig) {
|
||||||
|
updateProjectConfig({
|
||||||
|
...(nextProjectConfig as ProjectConfig),
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
} else if (data.title && typeof data.title === "string") {
|
||||||
updateProjectConfig({ title: data.title, slug });
|
updateProjectConfig({ title: data.title, slug });
|
||||||
} else {
|
} else {
|
||||||
updateProjectConfig({ slug });
|
updateProjectConfig({ slug });
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { getPublicPageRendererMessages } from "@/features/i18n/messages/publicPa
|
|||||||
|
|
||||||
interface PublicProjectPageClientProps {
|
interface PublicProjectPageClientProps {
|
||||||
html: string;
|
html: string;
|
||||||
|
canSubmit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PublicProjectPageClient({ html }: PublicProjectPageClientProps) {
|
export default function PublicProjectPageClient({ html, canSubmit = true }: PublicProjectPageClientProps) {
|
||||||
const m = getPublicPageRendererMessages(null);
|
const m = getPublicPageRendererMessages(null);
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -52,6 +53,12 @@ export default function PublicProjectPageClient({ html }: PublicProjectPageClien
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canSubmit) {
|
||||||
|
const errorMsg = (config && config.errorMessage) || m.submitErrorDefault;
|
||||||
|
showToast(errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const action = form.getAttribute("action") || "/api/forms/submit";
|
const action = form.getAttribute("action") || "/api/forms/submit";
|
||||||
|
|
||||||
fetch(action, { method: "POST", body: formData })
|
fetch(action, { method: "POST", body: formData })
|
||||||
|
|||||||
+161
-13
@@ -1,9 +1,10 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
import { buildStaticHtml } from "@/features/export/buildStaticHtml";
|
import { buildStaticHtml } from "@/features/export/buildStaticHtml";
|
||||||
import { getCachedPublicProject } from "@/features/projects/publicCache";
|
import { getCachedPublicProject, parseProjectContentJson } from "@/features/projects/publicCache";
|
||||||
import PublicProjectPageClient from "./PublicProjectPageClient";
|
import PublicProjectPageClient from "./PublicProjectPageClient";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -14,6 +15,113 @@ interface PageParams {
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveSlugParam(rawParams: { slug: string } | Promise<{ slug: string }>): Promise<string> {
|
||||||
|
const resolved = await rawParams;
|
||||||
|
const slugRaw = resolved?.slug ?? "";
|
||||||
|
return slugRaw.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(
|
||||||
|
props:
|
||||||
|
| { params: { slug: string } }
|
||||||
|
| {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
},
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const slug = await resolveSlugParam((props as any).params);
|
||||||
|
if (!slug) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = getCachedPublicProject(slug);
|
||||||
|
|
||||||
|
let status: string | null = null;
|
||||||
|
let projectConfigFromContent: ProjectConfig | null = null;
|
||||||
|
let fallbackTitle: string | null = null;
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
status = (cached as any).status ?? null;
|
||||||
|
projectConfigFromContent = (cached as any).projectConfig ?? null;
|
||||||
|
fallbackTitle = cached.title ?? null;
|
||||||
|
} else {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
contentJson: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
status = (project as any).status ?? null;
|
||||||
|
const { projectConfig } = parseProjectContentJson((project as any).contentJson);
|
||||||
|
projectConfigFromContent = projectConfig;
|
||||||
|
fallbackTitle = project.title ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "DRAFT") {
|
||||||
|
// DRAFT 프로젝트는 404 처리되므로 별도의 메타데이터를 노출하지 않는다.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseConfig: ProjectConfig = {
|
||||||
|
...(projectConfigFromContent ?? {}),
|
||||||
|
title: (projectConfigFromContent?.title ?? fallbackTitle ?? slug) as any,
|
||||||
|
slug,
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
const baseTitleRaw = (baseConfig.title ?? "").trim() || "Page Builder";
|
||||||
|
const seoTitleRaw = (baseConfig as any).seoTitle
|
||||||
|
? String((baseConfig as any).seoTitle).trim()
|
||||||
|
: "";
|
||||||
|
const pageTitle = seoTitleRaw || baseTitleRaw;
|
||||||
|
|
||||||
|
const seoDescriptionRaw = (baseConfig as any).seoDescription
|
||||||
|
? String((baseConfig as any).seoDescription).trim()
|
||||||
|
: "";
|
||||||
|
const seoCanonicalRaw = (baseConfig as any).seoCanonicalUrl
|
||||||
|
? String((baseConfig as any).seoCanonicalUrl).trim()
|
||||||
|
: "";
|
||||||
|
const seoOgImageRaw = (baseConfig as any).seoOgImageUrl
|
||||||
|
? String((baseConfig as any).seoOgImageUrl).trim()
|
||||||
|
: "";
|
||||||
|
const seoNoIndex = Boolean((baseConfig as any).seoNoIndex);
|
||||||
|
|
||||||
|
const description = seoDescriptionRaw || undefined;
|
||||||
|
const ogUrl = seoCanonicalRaw || undefined;
|
||||||
|
const ogImage = seoOgImageRaw || undefined;
|
||||||
|
|
||||||
|
const metadata: Metadata = {
|
||||||
|
title: pageTitle,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title: pageTitle,
|
||||||
|
description,
|
||||||
|
type: "website",
|
||||||
|
url: ogUrl,
|
||||||
|
images: ogImage ? [ogImage] : undefined,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: pageTitle,
|
||||||
|
description,
|
||||||
|
images: ogImage ? [ogImage] : undefined,
|
||||||
|
},
|
||||||
|
robots: seoNoIndex
|
||||||
|
? {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
// /p/[slug] 공개 페이지
|
// /p/[slug] 공개 페이지
|
||||||
// - slug 에 해당하는 Project 의 contentJson 을 불러와 buildStaticHtml 로 정적 HTML 을 생성한다.
|
// - slug 에 해당하는 Project 의 contentJson 을 불러와 buildStaticHtml 로 정적 HTML 을 생성한다.
|
||||||
// - Export ZIP 에 포함되는 index.html 과 동일한 app-root DOM 구조를 사용해 렌더링한다.
|
// - Export ZIP 에 포함되는 index.html 과 동일한 app-root DOM 구조를 사용해 렌더링한다.
|
||||||
@@ -33,16 +141,21 @@ export default async function PublicProjectPage({ params }: PageParams) {
|
|||||||
|
|
||||||
let blocks: Block[];
|
let blocks: Block[];
|
||||||
let title: string;
|
let title: string;
|
||||||
|
let status: string | null = null;
|
||||||
|
let projectConfigFromContent: ProjectConfig | null = null;
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
blocks = cached.contentJson ?? [];
|
blocks = cached.blocks ?? [];
|
||||||
title = cached.title ?? "";
|
title = cached.title ?? "";
|
||||||
|
status = (cached as any).status ?? null;
|
||||||
|
projectConfigFromContent = (cached as any).projectConfig ?? null;
|
||||||
} else {
|
} else {
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
select: {
|
select: {
|
||||||
title: true,
|
title: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
|
status: true,
|
||||||
contentJson: true,
|
contentJson: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -51,24 +164,40 @@ export default async function PublicProjectPage({ params }: PageParams) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawContent = project.contentJson;
|
const { blocks: parsedBlocks, projectConfig } = parseProjectContentJson(
|
||||||
let contentBlocks: Block[] = [];
|
(project as any).contentJson,
|
||||||
if (Array.isArray(rawContent)) {
|
);
|
||||||
contentBlocks = rawContent as unknown as Block[];
|
blocks = parsedBlocks;
|
||||||
}
|
|
||||||
blocks = contentBlocks;
|
|
||||||
title = project.title;
|
title = project.title;
|
||||||
|
status = (project as any).status ?? null;
|
||||||
|
projectConfigFromContent = projectConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "DRAFT") {
|
||||||
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export index.html 과 동일한 규칙으로 ProjectConfig 를 구성한다.
|
// Export index.html 과 동일한 규칙으로 ProjectConfig 를 구성한다.
|
||||||
const projectConfig: ProjectConfig = {
|
const baseConfig: ProjectConfig = {
|
||||||
|
...(projectConfigFromContent ?? {}),
|
||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
canvasPreset: "full",
|
|
||||||
canvasBgColorHex: "#020617",
|
|
||||||
bodyBgColorHex: "#020617",
|
|
||||||
} as ProjectConfig;
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
if (!baseConfig.canvasPreset) {
|
||||||
|
baseConfig.canvasPreset = "full" as ProjectConfig["canvasPreset"];
|
||||||
|
}
|
||||||
|
if (!baseConfig.canvasBgColorHex) {
|
||||||
|
baseConfig.canvasBgColorHex = "#020617";
|
||||||
|
}
|
||||||
|
if (!baseConfig.bodyBgColorHex) {
|
||||||
|
baseConfig.bodyBgColorHex = "#020617";
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectConfig: ProjectConfig = baseConfig;
|
||||||
|
|
||||||
|
const canSubmit = status === "ARCHIVED" ? false : true;
|
||||||
|
|
||||||
const fullHtml = buildStaticHtml(blocks, projectConfig);
|
const fullHtml = buildStaticHtml(blocks, projectConfig);
|
||||||
|
|
||||||
// buildStaticHtml 이 생성한 index.html 에서 <main> ... </main> 블록만 추출해 렌더링한다.
|
// buildStaticHtml 이 생성한 index.html 에서 <main> ... </main> 블록만 추출해 렌더링한다.
|
||||||
@@ -76,5 +205,24 @@ export default async function PublicProjectPage({ params }: PageParams) {
|
|||||||
const mainMatch = fullHtml.match(/<main[\s\S]*?<\/main>/);
|
const mainMatch = fullHtml.match(/<main[\s\S]*?<\/main>/);
|
||||||
const mainHtml = mainMatch ? mainMatch[0] : "";
|
const mainHtml = mainMatch ? mainMatch[0] : "";
|
||||||
|
|
||||||
return <PublicProjectPageClient html={mainHtml} />;
|
return (
|
||||||
|
<>
|
||||||
|
{(projectConfig as any).headHtml ? (
|
||||||
|
<div
|
||||||
|
// headHtml 은 meta/script 태그 등을 포함하는 임의의 HTML 조각으로 가정하고,
|
||||||
|
// SSR 시 그대로 문서에 포함되도록 body 안에 삽입한다.
|
||||||
|
dangerouslySetInnerHTML={{ __html: String((projectConfig as any).headHtml) }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{(projectConfig as any).trackingScript ? (
|
||||||
|
<div
|
||||||
|
// trackingScript 는 HTML 문자열(일반적으로 <script>...</script>) 이므로 그대로 삽입한다.
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: String((projectConfig as any).trackingScript),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<PublicProjectPageClient html={mainHtml} canSubmit={canSubmit} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-18
@@ -15,6 +15,8 @@ import {
|
|||||||
FolderKanban,
|
FolderKanban,
|
||||||
SunMoon,
|
SunMoon,
|
||||||
BarChart2,
|
BarChart2,
|
||||||
|
HelpCircle,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
||||||
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
|
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
|
||||||
@@ -27,6 +29,8 @@ interface ProjectListItem {
|
|||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
todaySubmissions?: number;
|
||||||
|
totalSubmissions?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
@@ -41,6 +45,7 @@ export default function ProjectsPage() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [emailVerified, setEmailVerified] = useState<boolean | null>(null);
|
const [emailVerified, setEmailVerified] = useState<boolean | null>(null);
|
||||||
|
const [isStatusHelpOpen, setIsStatusHelpOpen] = useState(false);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,6 +122,50 @@ export default function ProjectsPage() {
|
|||||||
setCurrentPage((prev) => Math.min(prev, totalPages));
|
setCurrentPage((prev) => Math.min(prev, totalPages));
|
||||||
}, [projects.length, pageSize]);
|
}, [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) => {
|
const handleDelete = async (slug: string) => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const confirmed = window.confirm(p.confirmDeleteSingle);
|
const confirmed = window.confirm(p.confirmDeleteSingle);
|
||||||
@@ -391,7 +440,30 @@ export default function ProjectsPage() {
|
|||||||
</th>
|
</th>
|
||||||
<th className="py-2 pr-4">{p.tableHeaderTitle}</th>
|
<th className="py-2 pr-4">{p.tableHeaderTitle}</th>
|
||||||
<th className="py-2 pr-4">{p.tableHeaderSlug}</th>
|
<th className="py-2 pr-4">{p.tableHeaderSlug}</th>
|
||||||
<th className="py-2 pr-4">{p.tableHeaderStatus}</th>
|
<th className="py-2 pr-4 relative">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span>{p.tableHeaderStatus}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={p.tableHeaderStatusHelpLabel}
|
||||||
|
aria-pressed={isStatusHelpOpen}
|
||||||
|
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-slate-300 text-slate-500 bg-white hover:bg-slate-100 dark:border-slate-600 dark:text-slate-300 dark:bg-slate-900 dark:hover:bg-slate-800"
|
||||||
|
onClick={() => {
|
||||||
|
setIsStatusHelpOpen((prev) => !prev);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-3 h-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{isStatusHelpOpen && (
|
||||||
|
<div className="absolute z-20 mt-1 w-72 rounded-md border border-slate-200 bg-white p-2 text-[10px] text-slate-700 shadow-md dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200">
|
||||||
|
<pre className="whitespace-pre-wrap font-sans text-[10px] leading-snug">
|
||||||
|
{p.tableHeaderStatusHelpTooltip}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
<th className="py-2 pr-4">{p.tableHeaderSubmissions}</th>
|
||||||
<th className="py-2 pr-4">{p.tableHeaderCreatedAt}</th>
|
<th className="py-2 pr-4">{p.tableHeaderCreatedAt}</th>
|
||||||
<th className="py-2 pr-4">{p.tableHeaderUpdatedAt}</th>
|
<th className="py-2 pr-4">{p.tableHeaderUpdatedAt}</th>
|
||||||
<th className="py-2 pr-4 text-right">{p.tableHeaderActions}</th>
|
<th className="py-2 pr-4 text-right">{p.tableHeaderActions}</th>
|
||||||
@@ -424,7 +496,36 @@ export default function ProjectsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{project.title}</td>
|
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{project.title}</td>
|
||||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 font-mono text-[11px]">{project.slug}</td>
|
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 font-mono text-[11px]">{project.slug}</td>
|
||||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{project.status}</td>
|
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">
|
||||||
|
<select
|
||||||
|
value={project.status}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value as "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||||
|
if (value !== project.status) {
|
||||||
|
void handleStatusChange(project.slug, value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="border border-slate-300 rounded px-1.5 py-1 text-[11px] bg-white text-slate-700 dark:bg-slate-900 dark:border-slate-700 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">{p.statusDraftLabel}</option>
|
||||||
|
<option value="PUBLISHED">{p.statusPublishedLabel}</option>
|
||||||
|
<option value="ARCHIVED">{p.statusArchivedLabel}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 text-[11px]">
|
||||||
|
<Link
|
||||||
|
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
|
||||||
|
className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
|
||||||
|
>
|
||||||
|
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
{(project.todaySubmissions ?? 0).toString()}/{
|
||||||
|
(project.totalSubmissions ?? 0).toString()
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">{p.actionViewSubmissions}</span>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
<td className="py-2 pr-4 text-slate-500 dark:text-slate-400 text-[11px]">
|
<td className="py-2 pr-4 text-slate-500 dark:text-slate-400 text-[11px]">
|
||||||
{new Date(project.createdAt).toLocaleString()}
|
{new Date(project.createdAt).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
@@ -432,37 +533,32 @@ export default function ProjectsPage() {
|
|||||||
{new Date(project.updatedAt).toLocaleString()}
|
{new Date(project.updatedAt).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pl-4 pr-0 text-right text-[11px]">
|
<td className="py-2 pl-4 pr-0 text-right text-[11px]">
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/editor?slug=${encodeURIComponent(project.slug)}`}
|
href={`/editor?slug=${encodeURIComponent(project.slug)}`}
|
||||||
className="inline-flex items-center gap-1 text-sky-600 hover:text-sky-700 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
|
className="inline-flex items-center gap-1 text-sky-600 hover:text-sky-700 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3 h-3" aria-hidden="true" />
|
<Pencil className="w-4 h-4" aria-hidden="true" />
|
||||||
<span>{p.actionEdit}</span>
|
<span className="sr-only">{p.actionEdit}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/preview?slug=${encodeURIComponent(project.slug)}`}
|
href={`/preview?slug=${encodeURIComponent(project.slug)}`}
|
||||||
className="inline-flex items-center gap-1 text-slate-600 hover:text-slate-800 underline-offset-2 hover:underline dark:text-slate-300 dark:hover:text-slate-100"
|
className="inline-flex items-center gap-1 text-slate-600 hover:text-slate-800 underline-offset-2 hover:underline dark:text-slate-300 dark:hover:text-slate-100"
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
<Eye className="w-4 h-4" aria-hidden="true" />
|
||||||
<span>{p.actionPreview}</span>
|
<span className="sr-only">{p.actionPreview}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{emailVerified !== false && (
|
{emailVerified !== false && (
|
||||||
<Link
|
<Link
|
||||||
href={`/p/${encodeURIComponent(project.slug)}`}
|
href={`/p/${encodeURIComponent(project.slug)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-sky-700 hover:text-sky-900 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
|
className="inline-flex items-center gap-1 text-sky-700 hover:text-sky-900 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
<ExternalLink className="w-4 h-4" aria-hidden="true" />
|
||||||
<span>{t.projectOpenPublicPageLink}</span>
|
<span className="sr-only">{t.projectOpenPublicPageLink}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link
|
|
||||||
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
|
|
||||||
className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
|
|
||||||
>
|
|
||||||
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
|
||||||
<span>{p.actionViewSubmissions}</span>
|
|
||||||
</Link>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 underline-offset-2 hover:underline dark:text-red-300 dark:hover:text-red-200"
|
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 underline-offset-2 hover:underline dark:text-red-300 dark:hover:text-red-200"
|
||||||
@@ -470,8 +566,8 @@ export default function ProjectsPage() {
|
|||||||
void handleDelete(project.slug);
|
void handleDelete(project.slug);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||||
<span>{p.actionDelete}</span>
|
<span className="sr-only">{p.actionDelete}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -92,8 +92,9 @@ const EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES: Record<AppLocale, EditorProjectP
|
|||||||
seoOgImageUrlAria: "OG/Twitter image URL",
|
seoOgImageUrlAria: "OG/Twitter image URL",
|
||||||
seoOgImageUrlPlaceholder: "e.g. https://example.com/og-image.png",
|
seoOgImageUrlPlaceholder: "e.g. https://example.com/og-image.png",
|
||||||
|
|
||||||
seoCanonicalUrlLabel: "Canonical URL",
|
// Canonical 은 더 이상 <link rel=\"canonical\"> 로 쓰지 않고, 공유 URL(og:url) 설정 용도로만 사용한다.
|
||||||
seoCanonicalUrlAria: "Canonical URL",
|
seoCanonicalUrlLabel: "Share URL (og:url)",
|
||||||
|
seoCanonicalUrlAria: "Share URL (og:url)",
|
||||||
seoCanonicalUrlPlaceholder: "e.g. https://example.com/landing",
|
seoCanonicalUrlPlaceholder: "e.g. https://example.com/landing",
|
||||||
|
|
||||||
seoNoIndexAria: "Hide from search engines (noindex)",
|
seoNoIndexAria: "Hide from search engines (noindex)",
|
||||||
@@ -145,8 +146,9 @@ const EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES: Record<AppLocale, EditorProjectP
|
|||||||
seoOgImageUrlAria: "OG/Twitter 이미지 URL",
|
seoOgImageUrlAria: "OG/Twitter 이미지 URL",
|
||||||
seoOgImageUrlPlaceholder: "예: https://example.com/og-image.png",
|
seoOgImageUrlPlaceholder: "예: https://example.com/og-image.png",
|
||||||
|
|
||||||
seoCanonicalUrlLabel: "Canonical URL",
|
// Canonical 은 더 이상 <link rel=\"canonical\"> 로 쓰지 않고, 공유 URL(og:url) 설정 용도로만 사용한다.
|
||||||
seoCanonicalUrlAria: "Canonical URL",
|
seoCanonicalUrlLabel: "공유 URL (og:url)",
|
||||||
|
seoCanonicalUrlAria: "공유 URL (og:url)",
|
||||||
seoCanonicalUrlPlaceholder: "예: https://example.com/landing",
|
seoCanonicalUrlPlaceholder: "예: https://example.com/landing",
|
||||||
|
|
||||||
seoNoIndexAria: "검색 엔진에 노출하지 않기 (noindex)",
|
seoNoIndexAria: "검색 엔진에 노출하지 않기 (noindex)",
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ export type ProjectsMessages = {
|
|||||||
tableHeaderTitle: string;
|
tableHeaderTitle: string;
|
||||||
tableHeaderSlug: string;
|
tableHeaderSlug: string;
|
||||||
tableHeaderStatus: string;
|
tableHeaderStatus: string;
|
||||||
|
tableHeaderStatusHelpLabel: string;
|
||||||
|
tableHeaderStatusHelpTooltip: string;
|
||||||
|
statusDraftLabel: string;
|
||||||
|
statusPublishedLabel: string;
|
||||||
|
statusArchivedLabel: string;
|
||||||
|
tableHeaderSubmissions: string;
|
||||||
|
tableHeaderSubmissionsHelpLabel: string;
|
||||||
|
tableHeaderSubmissionsHelpTooltip: string;
|
||||||
tableHeaderCreatedAt: string;
|
tableHeaderCreatedAt: string;
|
||||||
tableHeaderUpdatedAt: string;
|
tableHeaderUpdatedAt: string;
|
||||||
tableHeaderActions: string;
|
tableHeaderActions: string;
|
||||||
@@ -60,6 +68,18 @@ const PROJECTS_MESSAGES: Record<AppLocale, ProjectsMessages> = {
|
|||||||
tableHeaderTitle: "Title",
|
tableHeaderTitle: "Title",
|
||||||
tableHeaderSlug: "Address (slug)",
|
tableHeaderSlug: "Address (slug)",
|
||||||
tableHeaderStatus: "Status",
|
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",
|
tableHeaderCreatedAt: "Created at",
|
||||||
tableHeaderUpdatedAt: "Updated at",
|
tableHeaderUpdatedAt: "Updated at",
|
||||||
tableHeaderActions: "Actions",
|
tableHeaderActions: "Actions",
|
||||||
@@ -100,6 +120,18 @@ const PROJECTS_MESSAGES: Record<AppLocale, ProjectsMessages> = {
|
|||||||
tableHeaderTitle: "제목",
|
tableHeaderTitle: "제목",
|
||||||
tableHeaderSlug: "주소(slug)",
|
tableHeaderSlug: "주소(slug)",
|
||||||
tableHeaderStatus: "상태",
|
tableHeaderStatus: "상태",
|
||||||
|
tableHeaderStatusHelpLabel: "프로젝트 상태 설명",
|
||||||
|
tableHeaderStatusHelpTooltip:
|
||||||
|
"차단: 퍼블릭 페이지가 404로 차단되고 폼 제출을 받지 않습니다.\n" +
|
||||||
|
"운영 중: 퍼블릭 페이지에 노출되고 폼 제출을 정상적으로 받습니다.\n" +
|
||||||
|
"제출 중단(오픈): 퍼블릭 페이지는 열리지만 새 폼 제출은 차단됩니다.",
|
||||||
|
statusDraftLabel: "차단",
|
||||||
|
statusPublishedLabel: "운영 중",
|
||||||
|
statusArchivedLabel: "제출 중단(오픈)",
|
||||||
|
tableHeaderSubmissions: "제출 내역 (오늘/전체)",
|
||||||
|
tableHeaderSubmissionsHelpLabel: "제출 내역 수치 설명",
|
||||||
|
tableHeaderSubmissionsHelpTooltip:
|
||||||
|
"아이콘 옆 숫자는 '오늘 제출 수 / 전체 누적 제출 수'를 의미합니다.",
|
||||||
tableHeaderCreatedAt: "생성일",
|
tableHeaderCreatedAt: "생성일",
|
||||||
tableHeaderUpdatedAt: "수정일",
|
tableHeaderUpdatedAt: "수정일",
|
||||||
tableHeaderActions: "액션",
|
tableHeaderActions: "액션",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Block } from "@/features/editor/state/editorStore";
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
interface PublicProjectSnapshot {
|
interface PublicProjectSnapshot {
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
contentJson: Block[];
|
status: string;
|
||||||
|
blocks: Block[];
|
||||||
|
projectConfig: ProjectConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStore(): Map<string, PublicProjectSnapshot> {
|
function getStore(): Map<string, PublicProjectSnapshot> {
|
||||||
@@ -23,3 +25,26 @@ export function getCachedPublicProject(slug: string): PublicProjectSnapshot | nu
|
|||||||
const store = getStore();
|
const store = getStore();
|
||||||
return store.get(slug) ?? null;
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 blocks: Block[] = [];
|
||||||
|
|
||||||
const projectConfig: ProjectConfig = {
|
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:description" content="SEO 설명"');
|
||||||
expect(html).toContain('meta property="og:type" content="website"');
|
expect(html).toContain('meta property="og:type" content="website"');
|
||||||
expect(html).toContain('meta property="og:image" content="https://example.com/og.png"');
|
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 property="og:url" content="https://example.com/landing"');
|
||||||
|
|
||||||
expect(html).toContain('meta name="twitter:card" content="summary_large_image"');
|
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:description" content="SEO 설명"');
|
||||||
expect(html).toContain('meta name="twitter:image" content="https://example.com/og.png"');
|
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" />');
|
expect(html).toContain('<meta name="robots" content="noindex, nofollow" />');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -549,4 +549,165 @@ describe("/api/forms/submit", () => {
|
|||||||
expect(sensitive.birthdate).toBe("1990-01-01");
|
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
@@ -5,6 +5,7 @@ import { signAccessToken } from "@/features/auth/authCrypto";
|
|||||||
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
|
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
|
||||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
|
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
|
||||||
const inMemoryProjects: any[] = [];
|
const inMemoryProjects: any[] = [];
|
||||||
|
const inMemoryFormSubmissions: any[] = [];
|
||||||
|
|
||||||
vi.mock("@prisma/client", () => {
|
vi.mock("@prisma/client", () => {
|
||||||
class PrismaClientMock {
|
class PrismaClientMock {
|
||||||
@@ -24,6 +25,23 @@ vi.mock("@prisma/client", () => {
|
|||||||
findUnique: async ({ where: { slug } }: any) => {
|
findUnique: async ({ where: { slug } }: any) => {
|
||||||
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
|
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) => {
|
upsert: async ({ where: { slug }, update, create }: any) => {
|
||||||
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
|
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -89,6 +107,35 @@ vi.mock("@prisma/client", () => {
|
|||||||
return items;
|
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 };
|
return { PrismaClient: PrismaClientMock };
|
||||||
@@ -111,6 +158,7 @@ async function buildAuthHeaders() {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-projects";
|
process.env.AUTH_JWT_SECRET = "test-jwt-secret-projects";
|
||||||
inMemoryProjects.length = 0;
|
inMemoryProjects.length = 0;
|
||||||
|
inMemoryFormSubmissions.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/api/projects", () => {
|
describe("/api/projects", () => {
|
||||||
@@ -161,7 +209,15 @@ describe("/api/projects", () => {
|
|||||||
expect(getResponse.status).toBe(200);
|
expect(getResponse.status).toBe(200);
|
||||||
const fetched = (await getResponse.json()) as any;
|
const fetched = (await getResponse.json()) as any;
|
||||||
expect(fetched.slug).toBe(payload.slug);
|
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 () => {
|
it("GET /api/projects 는 최근 생성 순으로 프로젝트 목록을 반환해야 한다", async () => {
|
||||||
@@ -365,6 +421,126 @@ describe("/api/projects", () => {
|
|||||||
expect(slugsB).not.toContain(slugA);
|
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 () => {
|
it("다른 유저가 이미 소유한 slug 로 POST 하면 409 를 반환해야 한다", async () => {
|
||||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||||
|
|
||||||
@@ -405,6 +581,46 @@ describe("/api/projects", () => {
|
|||||||
expect(secondRes.status).toBe(409);
|
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 () => {
|
it("GET /api/projects/[slug] 는 소유자가 아닌 유저에게 404 를 반환해야 한다", async () => {
|
||||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||||
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -166,8 +166,13 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
|
|
||||||
const parsed = JSON.parse(raw as string);
|
const parsed = JSON.parse(raw as string);
|
||||||
expect(parsed.title).toBe("저장 테스트 프로젝트");
|
expect(parsed.title).toBe("저장 테스트 프로젝트");
|
||||||
expect(Array.isArray(parsed.contentJson)).toBe(true);
|
// contentJson 은 이제 { blocks, projectConfig } 스냅샷 객체로 저장된다.
|
||||||
expect(parsed.contentJson[0].id).toBe("blk_1");
|
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(
|
const projectCall = fetchMock.mock.calls.find(
|
||||||
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
|
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
|
||||||
@@ -181,8 +186,13 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
const body = JSON.parse(options.body);
|
const body = JSON.parse(options.body);
|
||||||
expect(body.slug).toBe("save-test-slug");
|
expect(body.slug).toBe("save-test-slug");
|
||||||
expect(body.title).toBe("저장 테스트 프로젝트");
|
expect(body.title).toBe("저장 테스트 프로젝트");
|
||||||
expect(Array.isArray(body.contentJson)).toBe(true);
|
// 서버로 전송되는 contentJson 도 { blocks, projectConfig } 스냅샷이어야 한다.
|
||||||
expect(body.contentJson[0].id).toBe("blk_1");
|
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");
|
const message = await screen.findByText("Project saved: save-test-slug");
|
||||||
expect(message).toBeTruthy();
|
expect(message).toBeTruthy();
|
||||||
@@ -257,7 +267,15 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
localKey,
|
localKey,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
title: "로컬 프로젝트",
|
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({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
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);
|
} as any);
|
||||||
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
@@ -366,7 +396,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
expect(message).toBeTruthy();
|
expect(message).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("서버에서 복잡한 프로젝트 contentJson 을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => {
|
it("서버에서 복잡한 프로젝트 contentJson 스냅샷을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => {
|
||||||
const slug = "roundtrip-slug";
|
const slug = "roundtrip-slug";
|
||||||
|
|
||||||
const complexBlocks: Block[] = [
|
const complexBlocks: Block[] = [
|
||||||
@@ -454,7 +484,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
if (url === `/api/projects/${slug}` && method === "GET") {
|
if (url === `/api/projects/${slug}` && method === "GET") {
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({ slug, title: "복잡한 프로젝트", contentJson: complexBlocks }),
|
JSON.stringify({
|
||||||
|
slug,
|
||||||
|
title: "복잡한 프로젝트",
|
||||||
|
contentJson: {
|
||||||
|
blocks: complexBlocks,
|
||||||
|
projectConfig: {
|
||||||
|
title: "복잡한 프로젝트",
|
||||||
|
slug,
|
||||||
|
canvasPreset: "full",
|
||||||
|
} as ProjectConfig,
|
||||||
|
},
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -576,7 +617,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
if (url === `/api/projects/${slug}` && method === "GET") {
|
if (url === `/api/projects/${slug}` && method === "GET") {
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({ slug, title: "쿼리 로드 프로젝트", contentJson: serverBlocks }),
|
JSON.stringify({
|
||||||
|
slug,
|
||||||
|
title: "쿼리 로드 프로젝트",
|
||||||
|
contentJson: {
|
||||||
|
blocks: serverBlocks,
|
||||||
|
projectConfig: {
|
||||||
|
title: "쿼리 로드 프로젝트",
|
||||||
|
slug,
|
||||||
|
canvasPreset: "full",
|
||||||
|
} as ProjectConfig,
|
||||||
|
},
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
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 />);
|
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" } });
|
fireEvent.change(input, { target: { value: "https://example.com/landing" } });
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
render(<ProjectsPage />);
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||||
@@ -104,7 +104,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
render(<ProjectsPage />);
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const link = screen.getByText("All submissions");
|
const link = screen.getByText("All submissions");
|
||||||
@@ -136,7 +136,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
render(<ProjectsPage />);
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const link = screen.getByText("Dashboard");
|
const link = screen.getByText("Dashboard");
|
||||||
@@ -168,7 +168,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
render(<ProjectsPage />);
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const titleCellNode = await screen.findByText("테스트 프로젝트 A");
|
const titleCellNode = await screen.findByText("테스트 프로젝트 A");
|
||||||
@@ -212,7 +212,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
render(<ProjectsPage />);
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolbar = await screen.findByTestId("projects-toolbar");
|
const toolbar = await screen.findByTestId("projects-toolbar");
|
||||||
@@ -276,7 +276,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
render(<ProjectsPage />);
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const dashboardLink = await screen.findByRole("link", { name: "Dashboard" });
|
const dashboardLink = await screen.findByRole("link", { name: "Dashboard" });
|
||||||
@@ -314,7 +314,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
render(<ProjectsPage />);
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const html = document.documentElement;
|
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 () => {
|
it("삭제 버튼 클릭 시 서버에 DELETE 요청을 보내고 목록과 로컬스토리지를 갱신해야 한다", async () => {
|
||||||
const projects = [
|
const projects = [
|
||||||
{
|
{
|
||||||
@@ -594,7 +648,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
render(<ProjectsPage />);
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -671,7 +725,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
render(<ProjectsPage />);
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -817,4 +871,247 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
|
|
||||||
confirmSpy.mockRestore();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user