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 으로 요청을 보내고 /dashboard 로 이동해야 한다", 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();
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
const passwordInput = screen.getByLabelText("Password") as HTMLInputElement;
const confirmInput = screen.getByLabelText("Confirm password") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "Sign up" });
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.change(confirmInput, { 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("/dashboard");
});
});
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();
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
const passwordInput = screen.getByLabelText("Password") as HTMLInputElement;
const confirmInput = screen.getByLabelText("Confirm password") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "Sign up" });
fireEvent.change(emailInput, { target: { value: "dup@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.change(confirmInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
const errorText = await screen.findByText(/이미 가입된 이메일입니다./);
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /signup 에 접근하면 /dashboard 로 리다이렉트해야 한다", 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();
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
it("회원가입 페이지는 전역 라이트/다크 테마 배경 클래스를 사용해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render();
const main = await screen.findByRole("main");
const className = (main as HTMLElement).className;
expect(className).toContain("bg-slate-100");
expect(className).toContain("dark:bg-slate-950");
});
it("회원가입 폼의 비밀번호/비밀번호 확인 입력은 이메일 입력과 동일한 라이트/다크 테마 클래스를 사용해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render();
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
const passwordInput = screen.getByLabelText("Password") as HTMLInputElement;
const confirmInput = screen.getByLabelText("Confirm password") as HTMLInputElement;
const emailClass = emailInput.className;
const passwordClass = passwordInput.className;
const confirmClass = confirmInput.className;
expect(emailClass).toContain("border-slate-300");
expect(emailClass).toContain("bg-white");
expect(emailClass).toContain("text-slate-900");
expect(emailClass).toContain("dark:border-slate-700");
expect(emailClass).toContain("dark:bg-slate-950");
expect(emailClass).toContain("dark:text-slate-50");
expect(passwordClass).toBe(emailClass);
expect(confirmClass).toBe(emailClass);
});
it("회원가입 폼에는 비밀번호 확인 입력 필드가 있어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render();
const confirmInput = await screen.findByLabelText("Confirm password");
expect(confirmInput).toBeTruthy();
});
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({ 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();
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
const passwordInput = screen.getByLabelText("Password") as HTMLInputElement;
const confirmInput = screen.getByLabelText("Confirm password") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "Sign up" });
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.change(confirmInput, { target: { value: "differentPass2" } });
fireEvent.click(submitButton);
const errorText = await screen.findByText("Password and confirmation do not match.");
expect(errorText).toBeTruthy();
const signupCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/signup" && options?.method === "POST";
});
expect(signupCall).toBeUndefined();
});
it("짧고 단순한 비밀번호는 난이도가 약함으로 표시되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render();
const passwordInput = screen.getByLabelText("Password") as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: "abc12345" } });
const weakText = await screen.findByText("Password strength: Weak");
expect(weakText).toBeTruthy();
const bar = await screen.findByTestId("password-strength-bar");
const barClass = bar.className;
expect(barClass).toContain("w-1/3");
expect(barClass).toContain("bg-red-500");
});
it("대소문자와 숫자를 섞은 비밀번호는 난이도가 보통으로 표시되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render();
const passwordInput = screen.getByLabelText("Password") as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: "Abc12345" } });
const mediumText = await screen.findByText("Password strength: Medium");
expect(mediumText).toBeTruthy();
const bar = await screen.findByTestId("password-strength-bar");
const barClass = bar.className;
expect(barClass).toContain("w-2/3");
expect(barClass).toContain("bg-amber-500");
});
});