사용자 로그인, 가입 처리
CI / test (push) Successful in 3m40s
CI / pr_and_merge (push) Successful in 1m8s

This commit is contained in:
2025-11-30 23:08:38 +09:00
parent 25162e4c12
commit 64e4a59244
82 changed files with 2817 additions and 28 deletions
+218 -12
View File
@@ -1,5 +1,6 @@
import "dotenv/config";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { signAccessToken } from "@/features/auth/authCrypto";
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
@@ -58,9 +59,13 @@ vi.mock("@/generated/prisma/client", () => {
const [removed] = inMemoryProjects.splice(index, 1);
return removed;
},
findMany: async ({ orderBy, take, select }: any = {}) => {
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());
}
@@ -91,6 +96,23 @@ vi.mock("@/generated/prisma/client", () => {
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 = {
@@ -107,10 +129,12 @@ describe("/api/projects", () => {
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: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(payload),
}),
);
@@ -127,8 +151,10 @@ describe("/api/projects", () => {
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}`),
new Request(`${BASE_URL}/api/projects/${payload.slug}`, { headers: getHeaders }),
{ params: { slug: payload.slug } } as any,
);
@@ -153,10 +179,12 @@ describe("/api/projects", () => {
contentJson: [],
};
const headers = await buildAuthHeaders();
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(firstPayload),
}),
);
@@ -166,14 +194,16 @@ describe("/api/projects", () => {
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(secondPayload),
}),
);
const { GET: listProjects } = await import("@/app/api/projects/route");
const res = await listProjects(new Request(`${BASE_URL}/api/projects`));
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[];
@@ -193,25 +223,29 @@ describe("/api/projects", () => {
contentJson: [],
};
const headers = await buildAuthHeaders();
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
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" }),
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}`),
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: deleteHeaders }),
{ params: { slug } } as any,
);
@@ -247,10 +281,12 @@ describe("/api/projects", () => {
],
};
const headers = await buildAuthHeaders();
const firstRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(firstPayload),
}),
);
@@ -260,7 +296,7 @@ describe("/api/projects", () => {
const secondRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(secondPayload),
}),
);
@@ -272,4 +308,174 @@ describe("/api/projects", () => {
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);
});
});