사용자 로그인, 가입 처리
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -76,6 +76,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function setupThreeBlocks() {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 />);
|
||||
|
||||
@@ -89,6 +89,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
@@ -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 스타일에 반영되는지 검증한다.
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 를 가져야 한다", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -39,6 +39,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user