import "dotenv/config"; import { describe, it, expect, vi, beforeEach } from "vitest"; 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 { project = { create: async ({ data }: any) => { const now = new Date(); const project = { id: String(inMemoryProjects.length + 1), status: "DRAFT", createdAt: now, updatedAt: now, ...data, }; inMemoryProjects.push(project); return project; }, 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) { const existing = inMemoryProjects[index]; const updated = { ...existing, ...update, updatedAt: new Date(), }; inMemoryProjects[index] = updated; return updated; } const now = new Date(); const project = { id: String(inMemoryProjects.length + 1), status: "DRAFT", createdAt: now, updatedAt: now, ...create, }; inMemoryProjects.push(project); return project; }, delete: async ({ where: { slug } }: 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 [removed] = inMemoryProjects.splice(index, 1); return removed; }, findMany: async ({ where, orderBy, take, select }: any = {}) => { let items = [...inMemoryProjects]; if (where?.userId) { items = items.filter((p) => p.userId === where.userId); } if (orderBy?.createdAt === "desc") { items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); } if (typeof take === "number") { items = items.slice(0, take); } if (select) { return items.map((p) => { const picked: any = {}; for (const key of Object.keys(select)) { if (select[key]) { picked[key] = p[key]; } } return picked; }); } 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 }; }); const BASE_URL = "http://localhost"; const TEST_USER = { id: "user-1", email: "projects@example.com", tokenVersion: 1 }; const OTHER_USER = { id: "user-2", email: "projects2@example.com", tokenVersion: 1 }; async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) { const token = await signAccessToken(user); return { cookie: `pb_access=${token}` }; } async function buildAuthHeaders() { return buildAuthHeadersFor(TEST_USER); } beforeEach(() => { process.env.AUTH_JWT_SECRET = "test-jwt-secret-projects"; inMemoryProjects.length = 0; inMemoryFormSubmissions.length = 0; }); describe("/api/projects", () => { it("POST /api/projects 로 프로젝트를 생성하고 GET /api/projects/[slug] 로 조회할 수 있어야 한다", async () => { const payload = { title: "테스트 프로젝트", slug: `test-slug-${Date.now()}`, contentJson: [ { id: "blk_test", type: "text", props: { text: "JSON에서 온 텍스트", align: "left", size: "base" }, }, ], }; const { POST: createProject } = await import("@/app/api/projects/route"); const headers = await buildAuthHeaders(); const createResponse = await createProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(payload), }), ); if (createResponse.status !== 201) { // 디버깅을 위해 에러 응답을 한번 출력해 둔다. // eslint-disable-next-line no-console console.error("createResponse status", createResponse.status, await createResponse.text()); } expect(createResponse.status).toBe(201); const created = (await createResponse.json()) as any; expect(created.title).toBe(payload.title); expect(created.slug).toBe(payload.slug); const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route"); const getHeaders = await buildAuthHeaders(); const getResponse = await getProjectBySlug( new Request(`${BASE_URL}/api/projects/${payload.slug}`, { headers: getHeaders }), { params: { slug: payload.slug } } as any, ); expect(getResponse.status).toBe(200); const fetched = (await getResponse.json()) as any; expect(fetched.slug).toBe(payload.slug); // 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 () => { const { POST: createProject } = await import("@/app/api/projects/route"); const firstPayload = { title: "첫 번째 프로젝트", slug: "first-project", contentJson: [], }; const secondPayload = { title: "두 번째 프로젝트", slug: "second-project", contentJson: [], }; const headers = await buildAuthHeaders(); await createProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(firstPayload), }), ); await new Promise((resolve) => setTimeout(resolve, 5)); await createProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(secondPayload), }), ); const { GET: listProjects } = await import("@/app/api/projects/route"); const listHeaders = await buildAuthHeaders(); const res = await listProjects(new Request(`${BASE_URL}/api/projects`, { headers: listHeaders })); expect(res.status).toBe(200); const list = (await res.json()) as any[]; expect(list.length).toBeGreaterThanOrEqual(2); expect(list[0].slug).toBe("second-project"); expect(list[1].slug).toBe("first-project"); }); it("DELETE /api/projects/[slug] 로 프로젝트를 삭제할 수 있어야 한다", async () => { const { POST: createProject } = await import("@/app/api/projects/route"); const slug = `delete-slug-${Date.now()}`; const payload = { title: "삭제 테스트 프로젝트", slug, contentJson: [], }; const headers = await buildAuthHeaders(); await createProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(payload), }), ); const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route"); const deleteHeaders = await buildAuthHeaders(); const deleteResponse = await deleteProject( new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE", headers: deleteHeaders }), { params: { slug } } as any, ); expect(deleteResponse.status).toBe(200); const getAfterDelete = await getProjectBySlug( new Request(`${BASE_URL}/api/projects/${slug}`, { headers: deleteHeaders }), { params: { slug } } as any, ); expect(getAfterDelete.status).toBe(404); }); it("동일 slug 로 두 번 POST 하면 두 번째 요청은 기존 프로젝트를 업데이트해야 한다", async () => { const { POST: handleProject } = await import("@/app/api/projects/route"); const slug = "duplicate-slug"; const firstPayload = { title: "첫 번째 제목", slug, contentJson: [ { id: "blk_first", type: "text", props: { text: "첫 번째 내용", align: "left", size: "base" }, }, ], }; const secondPayload = { title: "두 번째 제목", slug, contentJson: [ { id: "blk_second", type: "text", props: { text: "두 번째 내용", align: "left", size: "base" }, }, ], }; const headers = await buildAuthHeaders(); const firstRes = await handleProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(firstPayload), }), ); expect(firstRes.status).toBe(201); const secondRes = await handleProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(secondPayload), }), ); expect([200, 201]).toContain(secondRes.status); const updated = (await secondRes.json()) as any; expect(updated.slug).toBe(slug); expect(updated.title).toBe("두 번째 제목"); expect(updated.contentJson).toEqual(secondPayload.contentJson); }); it("GET /api/projects 는 현재 로그인 유저의 프로젝트만 반환해야 한다", async () => { const { POST: createProject, GET: listProjects } = await import("@/app/api/projects/route"); const slugA = "user-a-project"; const slugB = "user-b-project"; const headersA = await buildAuthHeadersFor(TEST_USER); const headersB = await buildAuthHeadersFor(OTHER_USER); const payloadA = { title: "유저 A 프로젝트", slug: slugA, contentJson: [], }; const payloadB = { title: "유저 B 프로젝트", slug: slugB, contentJson: [], }; await createProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...headersA }, body: JSON.stringify(payloadA), }), ); await createProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...headersB }, body: JSON.stringify(payloadB), }), ); const resA = await listProjects( new Request(`${BASE_URL}/api/projects`, { headers: headersA }), ); expect(resA.status).toBe(200); const listA = (await resA.json()) as any[]; const slugsA = listA.map((p) => p.slug); expect(slugsA).toContain(slugA); expect(slugsA).not.toContain(slugB); const resB = await listProjects( new Request(`${BASE_URL}/api/projects`, { headers: headersB }), ); expect(resB.status).toBe(200); const listB = (await resB.json()) as any[]; const slugsB = listB.map((p) => p.slug); expect(slugsB).toContain(slugB); 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"); const slug = "shared-slug"; const ownerPayload = { title: "소유자 프로젝트", slug, contentJson: [], }; const otherPayload = { title: "다른 유저 프로젝트", slug, contentJson: [], }; const ownerHeaders = await buildAuthHeadersFor(TEST_USER); const otherHeaders = await buildAuthHeadersFor(OTHER_USER); const firstRes = await handleProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...ownerHeaders }, body: JSON.stringify(ownerPayload), }), ); expect(firstRes.status).toBe(201); const secondRes = await handleProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...otherHeaders }, body: JSON.stringify(otherPayload), }), ); 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"); const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route"); const slug = "owner-only-slug"; const ownerHeaders = await buildAuthHeadersFor(TEST_USER); const otherHeaders = await buildAuthHeadersFor(OTHER_USER); const payload = { title: "소유자 프로젝트", slug, contentJson: [], }; const createRes = await handleProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...ownerHeaders }, body: JSON.stringify(payload), }), ); expect(createRes.status).toBe(201); const resOther = await getProjectBySlug( new Request(`${BASE_URL}/api/projects/${slug}`, { headers: otherHeaders }), { params: { slug } } as any, ); expect(resOther.status).toBe(404); const resOwner = await getProjectBySlug( new Request(`${BASE_URL}/api/projects/${slug}`, { headers: ownerHeaders }), { params: { slug } } as any, ); expect(resOwner.status).toBe(200); }); it("DELETE /api/projects/[slug] 는 소유자가 아닌 유저가 호출하면 404 를 반환하고 실제 데이터는 삭제되지 않아야 한다", async () => { const { POST: handleProject } = await import("@/app/api/projects/route"); const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route"); const slug = "owner-delete-slug"; const ownerHeaders = await buildAuthHeadersFor(TEST_USER); const otherHeaders = await buildAuthHeadersFor(OTHER_USER); const payload = { title: "삭제 소유자 프로젝트", slug, contentJson: [], }; const createRes = await handleProject( new Request(`${BASE_URL}/api/projects`, { method: "POST", headers: { "Content-Type": "application/json", ...ownerHeaders }, body: JSON.stringify(payload), }), ); expect(createRes.status).toBe(201); const deleteResOther = await deleteProject( new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE", headers: otherHeaders }), { params: { slug } } as any, ); expect(deleteResOther.status).toBe(404); const resOwner = await getProjectBySlug( new Request(`${BASE_URL}/api/projects/${slug}`, { headers: ownerHeaders }), { params: { slug } } as any, ); expect(resOwner.status).toBe(200); }); });