사용자 로그인, 가입 처리
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
+266
View File
@@ -0,0 +1,266 @@
import "dotenv/config";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { hashPassword, signAccessToken } from "@/features/auth/authCrypto";
const BASE_URL = "http://localhost";
// PrismaClient 를 실제 DB 대신 메모리 기반 user 저장소를 사용하는 목으로 대체한다.
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 Auth API 테스트를 실행할 수 있다.
const inMemoryUsers: any[] = [];
vi.mock("@/generated/prisma/client", () => {
class PrismaClientMock {
user = {
findUnique: async ({ where: { email } }: any) => {
return inMemoryUsers.find((u) => u.email === email) ?? null;
},
create: async ({ data }: any) => {
const now = new Date();
const user = {
id: String(inMemoryUsers.length + 1),
createdAt: now,
updatedAt: now,
...data,
};
inMemoryUsers.push(user);
return user;
},
};
}
return { PrismaClient: PrismaClientMock };
});
beforeEach(() => {
// 각 테스트 전에 메모리 유저 저장소를 초기화한다.
inMemoryUsers.length = 0;
process.env.AUTH_JWT_SECRET = "test-jwt-secret-api";
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
});
describe("/api/auth", () => {
describe("POST /api/auth/signup", () => {
it("새 이메일과 8자 이상 비밀번호로 회원가입하면 User 가 생성되고 JWT 쿠키가 설정되어야 한다", async () => {
const { POST: signup } = await import("@/app/api/auth/signup/route");
const payload = { email: "user@example.com", password: "securePass1" };
const res = await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(201);
const json = (await res.json()) as any;
expect(json.email).toBe(payload.email);
expect(json.id).toBeDefined();
// 응답에는 passwordHash 가 포함되면 안 된다.
expect(json.passwordHash).toBeUndefined();
// 메모리 저장소에도 유저가 1명 생성되어야 한다.
expect(inMemoryUsers.length).toBe(1);
expect(inMemoryUsers[0].email).toBe(payload.email);
expect(typeof inMemoryUsers[0].passwordHash).toBe("string");
// JWT 세션 쿠키가 설정되어야 한다.
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toBeTruthy();
expect(setCookie).toContain("pb_access=");
});
it("비밀번호가 8자 미만이면 400 과 에러 메시지를 반환해야 한다", async () => {
const { POST: signup } = await import("@/app/api/auth/signup/route");
const res = await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "short@example.com", password: "short" }),
}),
);
expect(res.status).toBe(400);
const json = (await res.json()) as any;
expect(json.message).toBeDefined();
});
it("이미 존재하는 이메일로 회원가입하면 409 를 반환해야 한다", async () => {
const { POST: signup } = await import("@/app/api/auth/signup/route");
// 먼저 한 번 성공적으로 가입
await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "dup@example.com", password: "securePass1" }),
}),
);
// 같은 이메일로 다시 요청
const res = await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "dup@example.com", password: "anotherPass1" }),
}),
);
expect(res.status).toBe(409);
const json = (await res.json()) as any;
expect(json.message).toBeDefined();
});
});
describe("POST /api/auth/login", () => {
it("올바른 이메일/비밀번호로 로그인하면 200 과 JWT 쿠키를 반환해야 한다", async () => {
const { POST: signup } = await import("@/app/api/auth/signup/route");
const { POST: login } = await import("@/app/api/auth/login/route");
const email = "login@example.com";
const password = "securePass1";
// 사전 회원가입
await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}),
);
const res = await login(
new Request(`${BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.email).toBe(email);
expect(json.id).toBeDefined();
expect(json.passwordHash).toBeUndefined();
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toBeTruthy();
expect(setCookie).toContain("pb_access=");
});
it("잘못된 이메일 또는 비밀번호로 로그인하면 401 을 반환해야 한다", async () => {
const { POST: signup } = await import("@/app/api/auth/signup/route");
const { POST: login } = await import("@/app/api/auth/login/route");
const email = "wrong@example.com";
const password = "securePass1";
// 사전 회원가입
await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}),
);
// 존재하지 않는 이메일
const res1 = await login(
new Request(`${BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "nope@example.com", password }),
}),
);
expect(res1.status).toBe(401);
// 비밀번호 불일치
const res2 = await login(
new Request(`${BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: "wrongPass1" }),
}),
);
expect(res2.status).toBe(401);
});
});
describe("POST /api/auth/logout", () => {
it("로그아웃 시 pb_access 쿠키를 제거하는 Set-Cookie 헤더를 반환해야 한다", async () => {
const { POST: logout } = await import("@/app/api/auth/logout/route");
const res = await logout(
new Request(`${BASE_URL}/api/auth/logout`, {
method: "POST",
headers: {
cookie: "pb_access=dummy.token.value",
},
}),
);
expect(res.status).toBe(200);
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toBeTruthy();
expect(setCookie).toContain("pb_access=");
expect(setCookie?.toLowerCase()).toContain("max-age=0");
});
it("쿠키가 없어도 200 과 쿠키 제거 헤더를 반환해야 한다", async () => {
const { POST: logout } = await import("@/app/api/auth/logout/route");
const res = await logout(
new Request(`${BASE_URL}/api/auth/logout`, {
method: "POST",
}),
);
expect(res.status).toBe(200);
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toBeTruthy();
expect(setCookie).toContain("pb_access=");
expect(setCookie?.toLowerCase()).toContain("max-age=0");
});
});
describe("GET /api/auth/me", () => {
it("유효한 JWT 쿠키가 있을 때 현재 로그인 유저 정보를 반환해야 한다", async () => {
const { GET: me } = await import("@/app/api/auth/me/route");
const user = { id: "user-1", email: "me@example.com", tokenVersion: 1 };
const token = await signAccessToken(user);
const res = await me(
new Request(`${BASE_URL}/api/auth/me`, {
headers: {
cookie: `pb_access=${token}`,
},
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.id).toBe(user.id);
expect(json.email).toBe(user.email);
expect(json.passwordHash).toBeUndefined();
});
it("JWT 쿠키가 없거나 잘못된 경우 401 을 반환해야 한다", async () => {
const { GET: me } = await import("@/app/api/auth/me/route");
const res1 = await me(new Request(`${BASE_URL}/api/auth/me`));
expect(res1.status).toBe(401);
const res2 = await me(
new Request(`${BASE_URL}/api/auth/me`, {
headers: { cookie: "pb_access=invalid.token.here" },
}),
);
expect(res2.status).toBe(401);
});
});
});
+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);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { test, expect } from "@playwright/test";
// 인증 라우트 가드 E2E
// - 비로그인 사용자가 보호된 페이지에 접근하면 /login 으로 리다이렉트되어야 한다.
test("비로그인 사용자가 /projects 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
// /api/projects 응답을 401 으로 목 처리해 비로그인 상태를 시뮬레이션한다.
await page.route("**/api/projects", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "로그인이 필요합니다." }),
});
});
await page.goto("/projects");
// 최종적으로 로그인 페이지의 헤더가 보여야 한다.
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
test("비로그인 사용자가 /editor 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
// /api/auth/me 를 401 으로 응답하도록 목 처리해 비로그인 상태를 시뮬레이션한다.
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "인증이 필요합니다." }),
});
});
await page.goto("/editor");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "인증이 필요합니다." }),
});
});
await page.goto("/preview");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
const email = `e2e+${Date.now()}@example.com`;
const password = "securePass1";
// 백엔드/DB 에 의존하지 않고 프론트 동작만 검증하기 위해 관련 API 를 모두 목 처리한다.
// isLoggedIn 플래그를 두고, 회원가입 전에는 /api/auth/me 가 401 을, 회원가입 후에는 200 을 반환하도록 시뮬레이션한다.
let isLoggedIn = false;
await page.route("**/api/auth/signup", async (route) => {
isLoggedIn = true;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ id: "user-1", email }),
});
});
await page.route("**/api/auth/me", async (route) => {
if (!isLoggedIn) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "인증이 필요합니다." }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "user-1", email, tokenVersion: 1 }),
});
});
await page.route("**/api/projects", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
// 1) 회원가입
await page.goto("/signup");
await page.getByLabel("이메일").fill(email);
await page.getByLabel("비밀번호").fill(password);
await page.getByRole("button", { name: "회원가입" }).click();
// /projects 로 이동했는지, 헤더가 보이는지 확인
await expect(page).toHaveURL(/\/projects/);
await expect(page.getByRole("heading", { name: "프로젝트 목록" })).toBeVisible();
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
await page.goto("/editor");
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
// 3) /preview 접근 확인
await page.goto("/preview");
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
});
+141
View File
@@ -0,0 +1,141 @@
import { test, expect } from "@playwright/test";
test("서로 다른 유저는 /projects 에서 자신의 프로젝트만 볼 수 있어야 한다", async ({ page }) => {
type Project = {
id: string;
title: string;
slug: string;
status: string;
createdAt: string;
updatedAt: string;
};
let currentUser: "A" | "B" = "A";
const projectsA: Project[] = [];
const projectsB: Project[] = [];
let nextId = 1;
const nowIso = () => new Date().toISOString();
await page.route("**/api/auth/me", async (route) => {
const email = currentUser === "A" ? "user-a@example.com" : "user-b@example.com";
const id = currentUser === "A" ? "user-a" : "user-b";
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id, email, tokenVersion: 1 }),
});
});
await page.route("**/api/projects*", async (route) => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
const ownerProjects = currentUser === "A" ? projectsA : projectsB;
const otherProjects = currentUser === "A" ? projectsB : projectsA;
if (url.pathname === "/api/projects" && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(ownerProjects),
});
return;
}
if (url.pathname === "/api/projects" && method === "POST") {
const bodyText = request.postData() ?? "{}";
const body = JSON.parse(bodyText) as Partial<Project> & { title?: string; slug?: string };
const slug = body.slug ?? "";
const title = body.title ?? "";
const conflict = otherProjects.find((p) => p.slug === slug);
if (conflict) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }),
});
return;
}
const existingIndex = ownerProjects.findIndex((p) => p.slug === slug);
const now = nowIso();
let project: Project;
if (existingIndex >= 0) {
const existing = ownerProjects[existingIndex];
project = { ...existing, title: title || existing.title, updatedAt: now };
ownerProjects[existingIndex] = project;
} else {
project = {
id: String(nextId++),
title: title || "제목 없음",
slug,
status: "DRAFT",
createdAt: now,
updatedAt: now,
};
ownerProjects.push(project);
}
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(project),
});
return;
}
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
});
});
// 1) 유저 A: 에디터에서 프로젝트를 저장하고, /projects 에서 자신의 프로젝트만 보여야 한다.
await page.goto("/editor");
const propertiesSidebarA = page.getByTestId("properties-sidebar");
await propertiesSidebarA.getByLabel("프로젝트 제목").fill("유저 A 프로젝트");
await propertiesSidebarA.getByLabel("프로젝트 주소 (slug)").fill("user-a-project");
await page.getByRole("button", { name: "메뉴 ▼" }).click();
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
await expect(page).toHaveURL(/\/_?projects/);
await expect(page.getByText("유저 A 프로젝트")).toBeVisible();
await expect(page.getByText("user-a-project")).toBeVisible();
await expect(page.getByText("user-b-project")).toHaveCount(0);
// 2) 유저 B: currentUser 전환 후 별도 프로젝트를 저장하고, /projects 에서 자신의 것만 보여야 한다.
currentUser = "B";
await page.goto("/editor");
const propertiesSidebarB = page.getByTestId("properties-sidebar");
await propertiesSidebarB.getByLabel("프로젝트 제목").fill("유저 B 프로젝트");
await propertiesSidebarB.getByLabel("프로젝트 주소 (slug)").fill("user-b-project");
await page.getByRole("button", { name: "메뉴 ▼" }).click();
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
await expect(page).toHaveURL(/\/_?projects/);
await expect(page.getByText("유저 B 프로젝트")).toBeVisible();
await expect(page.getByText("user-b-project")).toBeVisible();
await expect(page.getByText("유저 A 프로젝트")).toHaveCount(0);
// 3) 다시 유저 A 로 전환했을 때, /projects 에서는 유저 A 의 프로젝트만 보여야 한다.
currentUser = "A";
await page.goto("/projects");
await expect(page.getByText("유저 A 프로젝트")).toBeVisible();
await expect(page.getByText("user-a-project")).toBeVisible();
await expect(page.getByText("유저 B 프로젝트")).toHaveCount(0);
});
+9
View File
@@ -88,6 +88,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 자동저장", () => {
it("blocks 와 projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다", () => {
const storageProto = Object.getPrototypeOf(window.localStorage);
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 버튼 블록 스타일", () => {
it("크기/변형/색상/패딩/텍스트 정렬이 에디터 버튼 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
function hexToRgb(hex: string) {
const clean = hex.replace("#", "");
const int = parseInt(clean, 16);
+9
View File
@@ -86,6 +86,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
render(<EditorPage />);
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
function hexToRgb(hex: string) {
const clean = hex.replace("#", "");
const int = parseInt(clean, 16);
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 이미지 블록 스타일", () => {
it("widthMode=fixed 일 때 widthPx / borderRadiusPx 가 에디터 이미지 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
@@ -90,6 +90,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
function openJsonModal() {
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
+9
View File
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 레이아웃 스크롤 컨테이너", () => {
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
render(<EditorPage />);
+9
View File
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
function hexToRgb(hex: string) {
const clean = hex.replace("#", "");
const int = parseInt(clean, 16);
+9
View File
@@ -76,6 +76,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
function setupThreeBlocks() {
const blocks: Block[] = [
{
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, waitFor } from "@testing-library/react";
import EditorPage from "@/app/editor/page";
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
// editorStore 는 이미 여러 유닛 테스트에서 사용 중이므로 실제 store 를 그대로 사용해도 무방하다.
describe("EditorPage 인증 가드", () => {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("/api/auth/me 가 401 을 반환하면 /login 으로 리다이렉트해야 한다", async () => {
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/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
// 기타 요청은 200 으로 처리.
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<EditorPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
});
+9
View File
@@ -73,6 +73,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 페이지/캔버스 배경", () => {
it("main 요소는 bodyBgColorHex 로 직접 배경색을 갖지 않아야 하고, 캔버스 래퍼만 배경색을 가져야 한다", () => {
const { container } = render(<EditorPage />);
+9
View File
@@ -89,6 +89,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
afterEach(() => {
cleanup();
});
+93 -7
View File
@@ -96,6 +96,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 프로젝트 저장/불러오기", () => {
it("프로젝트 저장 버튼 클릭 시 로컬스토리지와 서버에 상태를 저장해야 하고, projectConfig 와 제목/주소 입력이 동기화되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue({
@@ -135,7 +144,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
fetchMock.mock.calls.some(
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
),
).toBe(true);
});
const localKey = "pb:project:save-test-slug";
@@ -147,7 +160,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
expect(Array.isArray(parsed.contentJson)).toBe(true);
expect(parsed.contentJson[0].id).toBe("blk_1");
const [url, options] = fetchMock.mock.calls[0] as any;
const projectCall = fetchMock.mock.calls.find(
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
) as any;
const [url, options] = projectCall;
expect(url).toBe("/api/projects");
expect(options.method).toBe("POST");
expect(options.headers["Content-Type"]).toBe("application/json");
@@ -162,6 +179,55 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
expect(message).toBeTruthy();
});
it("서버가 409 를 반환하면 이미 사용 중인 프로젝트 주소라는 에러 메시지를 보여줘야 한다", async () => {
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 === "POST") {
return Promise.resolve({
ok: false,
status: 409,
json: async () => ({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }),
} as any);
}
// 인증 가드용 /api/auth/me 등은 성공 응답만 내려주면 된다.
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({}),
} as any);
});
vi.stubGlobal("fetch", fetchMock);
const { rerender } = render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "충돌 테스트 프로젝트" } });
fireEvent.change(slugInput, { target: { value: "conflict-slug" } });
rerender(<EditorPage />);
const saveButton = screen.getByText("저장 (로컬 + 서버)");
fireEvent.click(saveButton);
const message = await screen.findByText("이미 다른 사용자가 사용 중인 프로젝트 주소입니다.");
expect(message).toBeTruthy();
});
it("로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 불러와야 한다", async () => {
const slug = "local-test-slug";
const localKey = `pb:project:${slug}`;
@@ -210,7 +276,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
});
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
expect(fetchMock).not.toHaveBeenCalled();
const serverCalls = fetchMock.mock.calls.filter(
([url]: any[]) => typeof url === "string" && url.startsWith("/api/projects"),
);
expect(serverCalls.length).toBe(0);
const message = await screen.findByText(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
expect(message).toBeTruthy();
@@ -253,10 +323,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
fireEvent.click(loadButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
fetchMock.mock.calls.some(
([url]: any[]) => url === `/api/projects/${slug}`,
),
).toBe(true);
});
const [url] = fetchMock.mock.calls[0] as any;
const serverCall = fetchMock.mock.calls.find(
([url]: any[]) => url === `/api/projects/${slug}`,
) as any;
const [url] = serverCall;
expect(url).toBe(`/api/projects/${slug}`);
await waitFor(() => {
@@ -293,10 +371,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
fireEvent.click(deleteMenuItem);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
fetchMock.mock.calls.some(
([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE",
),
).toBe(true);
});
const [url, options] = fetchMock.mock.calls[0] as any;
const deleteCall = fetchMock.mock.calls.find(
([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE",
) as any;
const [url, options] = deleteCall;
expect(url).toBe(`/api/projects/${slug}`);
expect(options.method).toBe("DELETE");
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
// 섹션 배경 이미지 TDD
// - backgroundImage* 속성이 EditorPage 섹션 wrapper 스타일에 반영되는지 검증한다.
+9
View File
@@ -76,6 +76,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 섹션 레이아웃 속성", () => {
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 editor-section 스타일에 반영되어야 한다", () => {
const section: Block = {
+9
View File
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 선택 포커스", () => {
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
const blocks: Block[] = [
+9
View File
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 텍스트 블록 스타일", () => {
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 에디터 텍스트 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
// 단순 유튜브 URL, 업로드 비디오 URL 케이스에 대한 에디터 렌더링을 검증한다.
describe("EditorPage - 비디오 블록 스타일", () => {
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 비율 wrapper 를 가져야 한다", () => {
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import LoginPage from "@/app/login/page";
// next/navigation 의 useRouter 를 목으로 대체해 리다이렉트 동작을 검증한다.
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
describe("LoginPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
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/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/login" && method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ id: "1", email: "user@example.com" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "로그인" });
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
await waitFor(() => {
const loginCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/login" && options?.method === "POST";
});
expect(loginCall).toBeDefined();
});
const loginCall = fetchMock.mock.calls.find(([url]) => url === "/api/auth/login") as any;
const [url, options] = loginCall;
expect(url).toBe("/api/auth/login");
expect(options.method).toBe("POST");
expect(options.headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(options.body);
expect(body.email).toBe("user@example.com");
expect(body.password).toBe("securePass1");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
});
});
it("로그인 실패 시 에러 메시지를 화면에 표시해야 한다", async () => {
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/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/login" && method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "로그인" });
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
fireEvent.change(passwordInput, { target: { value: "wrongpass" } });
fireEvent.click(submitButton);
const errorText = await screen.findByText(/이메일 또는 비밀번호가 올바르지 않습니다./);
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /login 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/projects");
});
});
});
+9
View File
@@ -56,6 +56,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("PreviewPage - 자동 복원", () => {
it("pb:autosave:<slug> 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 상태를 복원해야 한다", () => {
const savedBlocks: Block[] = [
+9
View File
@@ -39,6 +39,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
afterEach(() => {
cleanup();
});
+19 -2
View File
@@ -52,6 +52,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("PreviewPage - ZIP Export", () => {
it("프리뷰 헤더에 프로젝트 목록 링크가 노출되고 /projects 로 연결되어야 한다", () => {
render(<PreviewPage />);
@@ -83,10 +92,18 @@ describe("PreviewPage - ZIP Export", () => {
fireEvent.click(exportButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
fetchMock.mock.calls.some(
([url, options]: any[]) => url === "/api/export" && options?.method === "POST",
),
).toBe(true);
});
const [url, options] = fetchMock.mock.calls[0] as any;
const exportCall = fetchMock.mock.calls.find(
([url, options]: any[]) => url === "/api/export" && options?.method === "POST",
) as any;
const [url, options] = exportCall;
expect(url).toBe("/api/export");
expect(options.method).toBe("POST");
expect(options.headers["Content-Type"]).toBe("application/json");
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, waitFor } from "@testing-library/react";
import PreviewPage from "@/app/preview/page";
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
describe("PreviewPage 인증 가드", () => {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("/api/auth/me 가 401 을 반환하면 /login 으로 리다이렉트해야 한다", async () => {
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/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
// 기타 요청은 200 으로 처리하되, 테스트에서는 사용하지 않는다.
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<PreviewPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
});
+235
View File
@@ -2,6 +2,16 @@ import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import ProjectsPage from "@/app/projects/page";
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
// ProjectsPage 프로젝트 목록 TDD
// - 마운트 시 /api/projects 로 GET 요청을 보내고, 반환된 프로젝트 목록을 테이블로 렌더링해야 한다.
// - 각 프로젝트 행에는 /editor?slug=..., /preview?slug=... 로 이동하는 "편집" / "미리보기" 링크가 있어야 한다.
@@ -228,4 +238,229 @@ describe("ProjectsPage - 프로젝트 목록", () => {
confirmSpy.mockRestore();
});
it("/api/projects 가 401 을 반환하면 로그인 페이지로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
it("헤더의 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", 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().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/logout" && method === "POST") {
return Promise.resolve(new Response(JSON.stringify({ message: "ok" }), { status: 200 }));
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
fireEvent.click(logoutButton);
await waitFor(() => {
const logoutCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/logout" && options?.method === "POST";
});
expect(logoutCall).toBeDefined();
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
it("/api/projects 가 500 을 반환하면 에러 메시지를 표시해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response("server error", {
status: 500,
headers: { "Content-Type": "text/plain" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
expect(
await screen.findByText(
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
),
).toBeTruthy();
});
it("단일 프로젝트 삭제가 실패하면 에러 메시지를 표시해야 한다", async () => {
const projects = [
{
id: "1",
title: "삭제 실패 프로젝트",
slug: "delete-fail-project",
status: "DRAFT",
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/projects/delete-fail-project" && method === "DELETE") {
return Promise.resolve(new Response("fail", { status: 500 }));
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
render(<ProjectsPage />);
expect(await screen.findByText("삭제 실패 프로젝트")).toBeTruthy();
const deleteButton = screen.getByText("삭제");
fireEvent.click(deleteButton);
expect(
await screen.findByText("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."),
).toBeTruthy();
// 삭제 실패이므로 여전히 목록에 남아 있어야 한다.
expect(screen.getByText("삭제 실패 프로젝트")).toBeTruthy();
confirmSpy.mockRestore();
});
it("일괄 삭제에서 일부만 성공하면 에러 메시지를 표시해야 한다", async () => {
const projects = [
{
id: "1",
title: "성공 프로젝트",
slug: "success-project",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
{
id: "2",
title: "실패 프로젝트",
slug: "fail-project",
status: "DRAFT",
createdAt: "2025-01-02T00:00:00.000Z",
updatedAt: "2025-01-02T00: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/projects/success-project" && method === "DELETE") {
return Promise.resolve(new Response(null, { status: 200 }));
}
if (url === "/api/projects/fail-project" && method === "DELETE") {
return Promise.resolve(new Response("fail", { status: 500 }));
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
render(<ProjectsPage />);
expect(await screen.findByText("성공 프로젝트")).toBeTruthy();
expect(await screen.findByText("실패 프로젝트")).toBeTruthy();
const checkboxSuccess = screen.getByLabelText("성공 프로젝트 선택");
const checkboxFail = screen.getByLabelText("실패 프로젝트 선택");
fireEvent.click(checkboxSuccess);
fireEvent.click(checkboxFail);
const bulkDeleteButton = screen.getByText("선택 삭제");
fireEvent.click(bulkDeleteButton);
expect(
await screen.findByText(
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
),
).toBeTruthy();
// 성공 프로젝트는 목록에서 사라지고, 실패 프로젝트는 남아 있어야 한다.
expect(screen.queryByText("성공 프로젝트")).toBeNull();
expect(screen.getByText("실패 프로젝트")).toBeTruthy();
confirmSpy.mockRestore();
});
});
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import SignupPage from "@/app/signup/page";
// next/navigation 의 useRouter 를 목으로 대체해 리다이렉트 동작을 검증한다.
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
describe("SignupPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
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/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/signup" && method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ id: "1", email: "new@example.com" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "회원가입" });
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
await waitFor(() => {
const signupCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/signup" && options?.method === "POST";
});
expect(signupCall).toBeDefined();
});
const signupCall = fetchMock.mock.calls.find(([url]) => url === "/api/auth/signup") as any;
const [url, options] = signupCall;
expect(url).toBe("/api/auth/signup");
expect(options.method).toBe("POST");
expect(options.headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(options.body);
expect(body.email).toBe("new@example.com");
expect(body.password).toBe("securePass1");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
});
});
it("회원가입 실패 시 에러 메시지를 화면에 표시해야 한다", async () => {
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/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/signup" && method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ message: "이미 가입된 이메일입니다." }), {
status: 409,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "회원가입" });
fireEvent.change(emailInput, { target: { value: "dup@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
const errorText = await screen.findByText(/이미 가입된 이메일입니다./);
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /signup 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/projects");
});
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach } from "vitest";
// 앞으로 구현할 인증/보안 유틸리티 모듈을 대상으로 TDD를 진행한다.
// - 비밀번호 해시/검증 (bcrypt 기반)
// - JWT 액세스 토큰 발급/검증
// - 민감정보 JSON 암호화/복호화
import {
hashPassword,
verifyPassword,
signAccessToken,
verifyAccessToken,
encryptJson,
decryptJson,
} from "@/features/auth/authCrypto";
interface TestUser {
id: string;
email: string;
tokenVersion: number;
}
describe("authCrypto 유틸리티", () => {
beforeEach(() => {
// 테스트 환경에서 사용할 JWT/암호화 키를 설정한다.
process.env.AUTH_JWT_SECRET = "test-jwt-secret-key";
// 32바이트 키를 hex 로 표현 (예시)
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
});
describe("비밀번호 해시/검증", () => {
it("8자 이상 비밀번호를 bcrypt 해시로 안전하게 저장하고, 검증에 성공해야 한다", async () => {
const password = "securePass1";
const hash = await hashPassword(password);
expect(hash).toBeTypeOf("string");
expect(hash).not.toBe(password);
const ok = await verifyPassword(password, hash);
const fail = await verifyPassword("wrongPass", hash);
expect(ok).toBe(true);
expect(fail).toBe(false);
});
it("8자 미만 비밀번호는 해시 시도 시 오류를 발생시켜야 한다", async () => {
const shortPassword = "short"; // 5자
await expect(hashPassword(shortPassword)).rejects.toThrow();
});
});
describe("JWT 액세스 토큰", () => {
it("유저 정보를 기반으로 JWT 액세스 토큰을 발급하고, 검증 시 동일한 정보가 나와야 한다", async () => {
const user: TestUser = {
id: "user-1",
email: "user@example.com",
tokenVersion: 1,
};
const token = await signAccessToken(user);
expect(token).toBeTypeOf("string");
expect(token.length).toBeGreaterThan(10);
const payload = await verifyAccessToken(token);
expect(payload).not.toBeNull();
expect(payload?.sub).toBe(user.id);
expect(payload?.email).toBe(user.email);
expect(payload?.tokenVersion).toBe(user.tokenVersion);
const anyPayload = payload as any;
const iat = anyPayload.iat;
const exp = anyPayload.exp;
expect(typeof iat).toBe("number");
expect(typeof exp).toBe("number");
expect(exp > iat).toBe(true);
});
it("잘못된 토큰이나 시크릿으로 검증할 경우 null 을 반환해야 한다", async () => {
const user: TestUser = {
id: "user-2",
email: "user2@example.com",
tokenVersion: 3,
};
const token = await signAccessToken(user);
// 시크릿을 바꿔서 검증 실패 상황을 만든다.
process.env.AUTH_JWT_SECRET = "other-secret";
const payload = await verifyAccessToken(token);
expect(payload).toBeNull();
});
});
describe("민감정보 JSON 암호화/복호화", () => {
it("JSON 객체를 암호화한 뒤 복호화하면 원래 객체와 동일해야 한다", async () => {
const original = {
cardToken: "tok_123",
customerId: "cus_456",
meta: { plan: "pro", country: "KR" },
};
const ciphertext = await encryptJson(original);
expect(ciphertext).toBeTypeOf("string");
expect(ciphertext).not.toContain("tok_123");
expect(ciphertext).not.toContain("cus_456");
const decrypted = await decryptJson<typeof original>(ciphertext);
expect(decrypted).toEqual(original);
});
it("암호화 문자열이 손상되었거나 키가 잘못된 경우 복호화 시 오류를 발생시켜야 한다", async () => {
const original = { secret: "value" };
const ciphertext = await encryptJson(original);
// 중간 일부를 잘라 손상시킨다.
const broken = ciphertext.slice(0, Math.floor(ciphertext.length / 2));
await expect(decryptJson(broken as string)).rejects.toThrow();
// 키를 바꿔서 복호화 시도
process.env.AUTH_ENCRYPTION_KEY = "ffffffffffffffffffffffffffffffff";
await expect(decryptJson(ciphertext)).rejects.toThrow();
});
});
});