리팩터링
This commit is contained in:
@@ -378,7 +378,7 @@ describe("/api/export", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seoTitle/seoDescription/seoCanonicalUrl/seoOgImageUrl/seoNoIndex 가 설정된 경우 head 에 SEO 메타 태그들이 생성되어야 한다", () => {
|
||||
it("seoTitle/seoDescription/seoCanonicalUrl/seoOgImageUrl/seoNoIndex 가 설정된 경우 head 에 SEO 메타 태그들이 생성되지만 canonical 링크 태그는 생성되지 않아야 한다", () => {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
@@ -400,6 +400,7 @@ describe("/api/export", () => {
|
||||
expect(html).toContain('meta property="og:description" content="SEO 설명"');
|
||||
expect(html).toContain('meta property="og:type" content="website"');
|
||||
expect(html).toContain('meta property="og:image" content="https://example.com/og.png"');
|
||||
// canonical URL 은 meta property="og:url" 로만 사용하고, <link rel="canonical"> 태그는 생성하지 않는다.
|
||||
expect(html).toContain('meta property="og:url" content="https://example.com/landing"');
|
||||
|
||||
expect(html).toContain('meta name="twitter:card" content="summary_large_image"');
|
||||
@@ -407,7 +408,8 @@ describe("/api/export", () => {
|
||||
expect(html).toContain('meta name="twitter:description" content="SEO 설명"');
|
||||
expect(html).toContain('meta name="twitter:image" content="https://example.com/og.png"');
|
||||
|
||||
expect(html).toContain('<link rel="canonical" href="https://example.com/landing" />');
|
||||
// canonical 링크 태그는 생성되지 않아야 한다.
|
||||
expect(html).not.toContain('<link rel="canonical" href="https://example.com/landing" />');
|
||||
expect(html).toContain('<meta name="robots" content="noindex, nofollow" />');
|
||||
});
|
||||
|
||||
|
||||
@@ -549,4 +549,165 @@ describe("/api/forms/submit", () => {
|
||||
expect(sensitive.birthdate).toBe("1990-01-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("프로젝트 status 에 따른 제출 허용/차단", () => {
|
||||
it("PUBLISHED 상태 프로젝트는 internal 모드 제출을 허용해야 한다", async () => {
|
||||
inMemoryProjects.push({
|
||||
id: "proj-published",
|
||||
slug: "project-published",
|
||||
userId: "owner-published",
|
||||
status: "PUBLISHED",
|
||||
});
|
||||
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
successMessage: "ok",
|
||||
errorMessage: "err",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email", "published@example.com");
|
||||
formData.append("message", "message");
|
||||
formData.append("__projectSlug", "project-published");
|
||||
formData.append("__config", JSON.stringify(config));
|
||||
|
||||
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||
|
||||
const res = await handleSubmit(
|
||||
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(true);
|
||||
|
||||
expect(inMemoryFormSubmissions.length).toBe(1);
|
||||
const saved = inMemoryFormSubmissions[0] as any;
|
||||
expect(saved.projectSlug).toBe("project-published");
|
||||
expect(saved.projectId).toBe("proj-published");
|
||||
expect(saved.userId).toBe("owner-published");
|
||||
});
|
||||
|
||||
it("ARCHIVED 상태 프로젝트는 project_closed 로 제출을 차단해야 한다", async () => {
|
||||
inMemoryProjects.push({
|
||||
id: "proj-archived",
|
||||
slug: "project-archived",
|
||||
userId: "owner-archived",
|
||||
status: "ARCHIVED",
|
||||
});
|
||||
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
successMessage: "ok",
|
||||
errorMessage: "폼 제출이 중단된 프로젝트입니다.",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email", "archived@example.com");
|
||||
formData.append("__projectSlug", "project-archived");
|
||||
formData.append("__config", JSON.stringify(config));
|
||||
|
||||
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||
|
||||
const res = await handleSubmit(
|
||||
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(false);
|
||||
expect(json.error).toBe("project_closed");
|
||||
expect(json.message).toBe(config.errorMessage);
|
||||
|
||||
expect(inMemoryFormSubmissions.length).toBe(0);
|
||||
});
|
||||
|
||||
it("DRAFT 상태 프로젝트도 project_closed 로 제출을 차단해야 한다", async () => {
|
||||
inMemoryProjects.push({
|
||||
id: "proj-draft",
|
||||
slug: "project-draft",
|
||||
userId: "owner-draft",
|
||||
status: "DRAFT",
|
||||
});
|
||||
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
successMessage: "ok",
|
||||
errorMessage: "아직 공개되지 않은 프로젝트입니다.",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email", "draft@example.com");
|
||||
formData.append("__projectSlug", "project-draft");
|
||||
formData.append("__config", JSON.stringify(config));
|
||||
|
||||
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||
|
||||
const res = await handleSubmit(
|
||||
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(false);
|
||||
expect(json.error).toBe("project_closed");
|
||||
expect(json.message).toBe(config.errorMessage);
|
||||
|
||||
expect(inMemoryFormSubmissions.length).toBe(0);
|
||||
});
|
||||
|
||||
it("프로젝트를 찾지 못한 경우에는 기존처럼 projectSlug 만으로 제출을 허용해야 한다", async () => {
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
successMessage: "ok",
|
||||
errorMessage: "err",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email", "unknown@example.com");
|
||||
formData.append("__projectSlug", "missing-project");
|
||||
formData.append("__config", JSON.stringify(config));
|
||||
|
||||
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||
|
||||
const res = await handleSubmit(
|
||||
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(true);
|
||||
|
||||
expect(inMemoryFormSubmissions.length).toBe(1);
|
||||
const saved = inMemoryFormSubmissions[0] as any;
|
||||
expect(saved.projectSlug).toBe("missing-project");
|
||||
expect(saved.projectId ?? null).toBeNull();
|
||||
expect(saved.userId ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+217
-1
@@ -5,6 +5,7 @@ import { signAccessToken } from "@/features/auth/authCrypto";
|
||||
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
|
||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
|
||||
const inMemoryProjects: any[] = [];
|
||||
const inMemoryFormSubmissions: any[] = [];
|
||||
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
@@ -24,6 +25,23 @@ vi.mock("@prisma/client", () => {
|
||||
findUnique: async ({ where: { slug } }: any) => {
|
||||
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
|
||||
},
|
||||
update: async ({ where: { slug }, data }: any) => {
|
||||
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
|
||||
if (index === -1) {
|
||||
const error: any = new Error("Project not found");
|
||||
error.code = "P2025";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existing = inMemoryProjects[index];
|
||||
const updated = {
|
||||
...existing,
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
inMemoryProjects[index] = updated;
|
||||
return updated;
|
||||
},
|
||||
upsert: async ({ where: { slug }, update, create }: any) => {
|
||||
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
|
||||
if (index >= 0) {
|
||||
@@ -89,6 +107,35 @@ vi.mock("@prisma/client", () => {
|
||||
return items;
|
||||
},
|
||||
};
|
||||
|
||||
formSubmission = {
|
||||
findMany: async ({ where, select }: any = {}) => {
|
||||
let items = [...inMemoryFormSubmissions];
|
||||
|
||||
if (where?.userId) {
|
||||
items = items.filter((s) => s.userId === where.userId);
|
||||
}
|
||||
|
||||
if (where?.projectId?.in && Array.isArray(where.projectId.in)) {
|
||||
const ids: string[] = where.projectId.in;
|
||||
items = items.filter((s) => s.projectId && ids.includes(s.projectId));
|
||||
}
|
||||
|
||||
if (select) {
|
||||
return items.map((s) => {
|
||||
const picked: any = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (select[key]) {
|
||||
picked[key] = s[key];
|
||||
}
|
||||
}
|
||||
return picked;
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient: PrismaClientMock };
|
||||
@@ -111,6 +158,7 @@ async function buildAuthHeaders() {
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-projects";
|
||||
inMemoryProjects.length = 0;
|
||||
inMemoryFormSubmissions.length = 0;
|
||||
});
|
||||
|
||||
describe("/api/projects", () => {
|
||||
@@ -161,7 +209,15 @@ describe("/api/projects", () => {
|
||||
expect(getResponse.status).toBe(200);
|
||||
const fetched = (await getResponse.json()) as any;
|
||||
expect(fetched.slug).toBe(payload.slug);
|
||||
expect(fetched.contentJson).toEqual(payload.contentJson);
|
||||
// contentJson 은 배열 또는 { blocks, projectConfig } 스냅샷으로 저장될 수 있다.
|
||||
const rawContent = fetched.contentJson;
|
||||
if (Array.isArray(rawContent)) {
|
||||
expect(rawContent).toEqual(payload.contentJson);
|
||||
} else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) {
|
||||
expect(rawContent.blocks).toEqual(payload.contentJson);
|
||||
} else {
|
||||
throw new Error("unexpected contentJson shape from /api/projects/[slug]");
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /api/projects 는 최근 생성 순으로 프로젝트 목록을 반환해야 한다", async () => {
|
||||
@@ -365,6 +421,126 @@ describe("/api/projects", () => {
|
||||
expect(slugsB).not.toContain(slugA);
|
||||
});
|
||||
|
||||
it("GET /api/projects 는 각 프로젝트별 오늘/전체 제출 수를 함께 반환해야 한다", async () => {
|
||||
const { GET: listProjects } = await import("@/app/api/projects/route");
|
||||
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const projectA = {
|
||||
id: "proj-a",
|
||||
userId: TEST_USER.id,
|
||||
title: "프로젝트 A",
|
||||
slug: "project-a",
|
||||
status: "PUBLISHED",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const projectB = {
|
||||
id: "proj-b",
|
||||
userId: TEST_USER.id,
|
||||
title: "프로젝트 B",
|
||||
slug: "project-b",
|
||||
status: "PUBLISHED",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const projectOther = {
|
||||
id: "proj-other",
|
||||
userId: OTHER_USER.id,
|
||||
title: "다른 유저 프로젝트",
|
||||
slug: "other-project",
|
||||
status: "PUBLISHED",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
inMemoryProjects.push(projectA, projectB, projectOther);
|
||||
|
||||
inMemoryFormSubmissions.push(
|
||||
// 프로젝트 A: 오늘 1건, 어제 1건 (총 2건)
|
||||
{
|
||||
id: "sub-1",
|
||||
projectId: projectA.id,
|
||||
projectSlug: projectA.slug,
|
||||
userId: TEST_USER.id,
|
||||
createdAt: now,
|
||||
payloadJson: {},
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
{
|
||||
id: "sub-2",
|
||||
projectId: projectA.id,
|
||||
projectSlug: projectA.slug,
|
||||
userId: TEST_USER.id,
|
||||
createdAt: yesterday,
|
||||
payloadJson: {},
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
// 프로젝트 B: 오늘 2건
|
||||
{
|
||||
id: "sub-3",
|
||||
projectId: projectB.id,
|
||||
projectSlug: projectB.slug,
|
||||
userId: TEST_USER.id,
|
||||
createdAt: now,
|
||||
payloadJson: {},
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
{
|
||||
id: "sub-4",
|
||||
projectId: projectB.id,
|
||||
projectSlug: projectB.slug,
|
||||
userId: TEST_USER.id,
|
||||
createdAt: now,
|
||||
payloadJson: {},
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
// 다른 유저 프로젝트 제출: 집계에 포함되면 안 된다.
|
||||
{
|
||||
id: "sub-5",
|
||||
projectId: projectOther.id,
|
||||
projectSlug: projectOther.slug,
|
||||
userId: OTHER_USER.id,
|
||||
createdAt: now,
|
||||
payloadJson: {},
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
);
|
||||
|
||||
const headers = await buildAuthHeadersFor(TEST_USER);
|
||||
|
||||
const res = await listProjects(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const list = (await res.json()) as any[];
|
||||
|
||||
const a = list.find((p) => p.slug === projectA.slug);
|
||||
const b = list.find((p) => p.slug === projectB.slug);
|
||||
const other = list.find((p) => p.slug === projectOther.slug);
|
||||
|
||||
expect(a).toBeTruthy();
|
||||
expect(b).toBeTruthy();
|
||||
expect(other).toBeUndefined();
|
||||
|
||||
expect(a.todaySubmissions).toBe(1);
|
||||
expect(a.totalSubmissions).toBe(2);
|
||||
|
||||
expect(b.todaySubmissions).toBe(2);
|
||||
expect(b.totalSubmissions).toBe(2);
|
||||
});
|
||||
|
||||
it("다른 유저가 이미 소유한 slug 로 POST 하면 409 를 반환해야 한다", async () => {
|
||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||
|
||||
@@ -404,6 +580,46 @@ describe("/api/projects", () => {
|
||||
|
||||
expect(secondRes.status).toBe(409);
|
||||
});
|
||||
|
||||
it("PATCH /api/projects/[slug] 로 프로젝트 status 를 변경할 수 있어야 한다", async () => {
|
||||
const { POST: createProject } = await import("@/app/api/projects/route");
|
||||
const { PATCH: updateProjectStatus } = await import("@/app/api/projects/[slug]/route");
|
||||
|
||||
const slug = "status-change-slug";
|
||||
|
||||
const payload = {
|
||||
title: "상태 변경 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
const createRes = await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const patchBody = { status: "PUBLISHED" };
|
||||
|
||||
const patchRes = await updateProjectStatus(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(patchBody),
|
||||
}),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
const updated = (await patchRes.json()) as any;
|
||||
expect(updated.slug).toBe(slug);
|
||||
expect(updated.status).toBe("PUBLISHED");
|
||||
});
|
||||
|
||||
it("GET /api/projects/[slug] 는 소유자가 아닌 유저에게 404 를 반환해야 한다", async () => {
|
||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||
|
||||
Reference in New Issue
Block a user