사용자 로그인, 가입 처리
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
+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();
});
});
});