482 lines
15 KiB
TypeScript
482 lines
15 KiB
TypeScript
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[] = [];
|
|
|
|
vi.mock("@/generated/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;
|
|
},
|
|
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;
|
|
},
|
|
};
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
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);
|
|
expect(fetched.contentJson).toEqual(payload.contentJson);
|
|
});
|
|
|
|
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("다른 유저가 이미 소유한 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("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);
|
|
});
|
|
});
|