🚀 성능 최적화 (Task 8): - React.lazy를 활용한 코드 스플리팅 구현 - React.memo, useMemo, useCallback을 통한 메모이제이션 최적화 - 초기 번들 크기 87% 감소 (470kB → 62kB) - 백그라운드 동기화 간격 최적화 (5분 → 30초) 📦 Vercel 배포 인프라 구축 (Task 9): - vercel.json 배포 설정 및 보안 헤더 구성 - GitHub Actions 자동 배포 워크플로우 설정 - 환경별 배포 및 미리보기 시스템 구현 - 자동화된 배포 스크립트 및 환경 변수 관리 - 포괄적인 배포 가이드 및 체크리스트 작성 🔧 코드 품질 개선: - ESLint 주요 오류 수정 (사용하지 않는 변수/import 정리) - 테스트 커버리지 확장 (229개 테스트 통과) - TypeScript 타입 안전성 강화 - Prettier 코드 포맷팅 적용 ⚠️ 참고: 테스트 파일의 any 타입 및 일부 경고는 향후 개선 예정 🛠️ Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
import { BrowserRouter } from "react-router-dom";
|
|
import LoginForm from "../auth/LoginForm";
|
|
|
|
// Mock react-router-dom Link component
|
|
vi.mock("react-router-dom", async () => {
|
|
const actual = await vi.importActual("react-router-dom");
|
|
return {
|
|
...actual,
|
|
Link: ({ to, children, className }: any) => (
|
|
<a href={to} className={className} data-testid="forgot-password-link">
|
|
{children}
|
|
</a>
|
|
),
|
|
};
|
|
});
|
|
|
|
// Wrapper component for Router context
|
|
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
<BrowserRouter>{children}</BrowserRouter>
|
|
);
|
|
|
|
describe("LoginForm", () => {
|
|
const mockSetEmail = vi.fn();
|
|
const mockSetPassword = vi.fn();
|
|
const mockSetShowPassword = vi.fn();
|
|
const mockHandleLogin = vi.fn();
|
|
|
|
const defaultProps = {
|
|
email: "",
|
|
setEmail: mockSetEmail,
|
|
password: "",
|
|
setPassword: mockSetPassword,
|
|
showPassword: false,
|
|
setShowPassword: mockSetShowPassword,
|
|
isLoading: false,
|
|
isSettingUpTables: false,
|
|
loginError: null,
|
|
handleLogin: mockHandleLogin,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("rendering", () => {
|
|
it("renders the login form with all fields", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
expect(screen.getByTestId("login-form")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("비밀번호")).toBeInTheDocument();
|
|
expect(screen.getByText("로그인")).toBeInTheDocument();
|
|
expect(screen.getByTestId("forgot-password-link")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders email field with correct attributes", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const emailInput = screen.getByLabelText("이메일");
|
|
expect(emailInput).toHaveAttribute("type", "email");
|
|
expect(emailInput).toHaveAttribute("id", "email");
|
|
expect(emailInput).toHaveAttribute("placeholder", "your@email.com");
|
|
});
|
|
|
|
it("renders password field with correct attributes", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const passwordInput = screen.getByLabelText("비밀번호");
|
|
expect(passwordInput).toHaveAttribute("type", "password");
|
|
expect(passwordInput).toHaveAttribute("id", "password");
|
|
expect(passwordInput).toHaveAttribute("placeholder", "••••••••");
|
|
});
|
|
|
|
it("renders forgot password link", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const forgotLink = screen.getByTestId("forgot-password-link");
|
|
expect(forgotLink).toHaveAttribute("href", "/forgot-password");
|
|
expect(forgotLink).toHaveTextContent("비밀번호를 잊으셨나요?");
|
|
});
|
|
});
|
|
|
|
describe("form values", () => {
|
|
it("displays current email value", () => {
|
|
render(<LoginForm {...defaultProps} email="test@example.com" />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
expect(screen.getByDisplayValue("test@example.com")).toBeInTheDocument();
|
|
});
|
|
|
|
it("displays current password value", () => {
|
|
render(<LoginForm {...defaultProps} password="mypassword" />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
expect(screen.getByDisplayValue("mypassword")).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls setEmail when email input changes", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const emailInput = screen.getByLabelText("이메일");
|
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
|
|
|
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
|
|
});
|
|
|
|
it("calls setPassword when password input changes", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const passwordInput = screen.getByLabelText("비밀번호");
|
|
fireEvent.change(passwordInput, { target: { value: "newpassword" } });
|
|
|
|
expect(mockSetPassword).toHaveBeenCalledWith("newpassword");
|
|
});
|
|
});
|
|
|
|
describe("password visibility toggle", () => {
|
|
it("shows password as hidden by default", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const passwordInput = screen.getByLabelText("비밀번호");
|
|
expect(passwordInput).toHaveAttribute("type", "password");
|
|
});
|
|
|
|
it("shows password as text when showPassword is true", () => {
|
|
render(<LoginForm {...defaultProps} showPassword={true} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
const passwordInput = screen.getByLabelText("비밀번호");
|
|
expect(passwordInput).toHaveAttribute("type", "text");
|
|
});
|
|
|
|
it("calls setShowPassword when visibility toggle is clicked", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
// Find the password toggle button (the one that's not the submit button)
|
|
const buttons = screen.getAllByRole("button");
|
|
const toggleButton = buttons.find(
|
|
(btn) => btn.getAttribute("type") === "button"
|
|
);
|
|
|
|
fireEvent.click(toggleButton!);
|
|
|
|
expect(mockSetShowPassword).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("calls setShowPassword with opposite value", () => {
|
|
render(<LoginForm {...defaultProps} showPassword={true} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
// Find the password toggle button (the one that's not the submit button)
|
|
const buttons = screen.getAllByRole("button");
|
|
const toggleButton = buttons.find(
|
|
(btn) => btn.getAttribute("type") === "button"
|
|
);
|
|
|
|
fireEvent.click(toggleButton!);
|
|
|
|
expect(mockSetShowPassword).toHaveBeenCalledWith(false);
|
|
});
|
|
});
|
|
|
|
describe("form submission", () => {
|
|
it("calls handleLogin when form is submitted", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const form = screen.getByTestId("login-form");
|
|
fireEvent.submit(form);
|
|
|
|
expect(mockHandleLogin).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not submit when form is disabled during loading", () => {
|
|
render(<LoginForm {...defaultProps} isLoading={true} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
const loginButton = screen.getByText("로그인 중...");
|
|
expect(loginButton).toBeDisabled();
|
|
});
|
|
|
|
it("does not submit when form is disabled during table setup", () => {
|
|
render(<LoginForm {...defaultProps} isSettingUpTables={true} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
const loginButton = screen.getByText("데이터베이스 설정 중...");
|
|
expect(loginButton).toBeDisabled();
|
|
});
|
|
});
|
|
|
|
describe("loading states", () => {
|
|
it("shows loading text when isLoading is true", () => {
|
|
render(<LoginForm {...defaultProps} isLoading={true} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
expect(screen.getByText("로그인 중...")).toBeInTheDocument();
|
|
const submitButton = screen.getByText("로그인 중...").closest("button");
|
|
expect(submitButton).toBeDisabled();
|
|
});
|
|
|
|
it("shows table setup text when isSettingUpTables is true", () => {
|
|
render(<LoginForm {...defaultProps} isSettingUpTables={true} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
expect(screen.getByText("데이터베이스 설정 중...")).toBeInTheDocument();
|
|
const submitButton = screen
|
|
.getByText("데이터베이스 설정 중...")
|
|
.closest("button");
|
|
expect(submitButton).toBeDisabled();
|
|
});
|
|
|
|
it("shows normal text when not loading", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
expect(screen.getByText("로그인")).toBeInTheDocument();
|
|
const submitButton = screen.getByText("로그인").closest("button");
|
|
expect(submitButton).not.toBeDisabled();
|
|
});
|
|
|
|
it("isLoading takes precedence over isSettingUpTables", () => {
|
|
render(
|
|
<LoginForm
|
|
{...defaultProps}
|
|
isLoading={true}
|
|
isSettingUpTables={true}
|
|
/>,
|
|
{ wrapper: Wrapper }
|
|
);
|
|
|
|
expect(screen.getByText("로그인 중...")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("does not show error message when loginError is null", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
expect(screen.queryByText(/에러/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows regular error message for standard errors", () => {
|
|
const errorMessage = "잘못된 이메일 또는 비밀번호입니다.";
|
|
render(<LoginForm {...defaultProps} loginError={errorMessage} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows CORS/JSON error with special styling and suggestions", () => {
|
|
const corsError = "CORS 정책에 의해 차단되었습니다.";
|
|
render(<LoginForm {...defaultProps} loginError={corsError} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
expect(screen.getByText(corsError)).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText(/다른 CORS 프록시 유형을 시도해 보세요/)
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText(/HTTPS URL을 사용하는 Supabase 인스턴스로 변경/)
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText(/네트워크 연결 상태를 확인하세요/)
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("detects JSON errors correctly", () => {
|
|
const jsonError = "JSON 파싱 오류가 발생했습니다.";
|
|
render(<LoginForm {...defaultProps} loginError={jsonError} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
expect(screen.getByText(jsonError)).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText(/다른 CORS 프록시 유형을 시도해 보세요/)
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("detects network 404 errors correctly", () => {
|
|
const networkError = "404 Not Found 오류입니다.";
|
|
render(<LoginForm {...defaultProps} loginError={networkError} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
expect(screen.getByText(networkError)).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText(/네트워크 연결 상태를 확인하세요/)
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("detects proxy errors correctly", () => {
|
|
const proxyError = "프록시 서버 응답 오류입니다.";
|
|
render(<LoginForm {...defaultProps} loginError={proxyError} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
expect(screen.getByText(proxyError)).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText(/다른 CORS 프록시 유형을 시도해 보세요/)
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("CSS classes and styling", () => {
|
|
it("applies correct CSS classes to form container", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const container = screen.getByTestId("login-form").parentElement;
|
|
expect(container).toHaveClass("neuro-flat", "p-8", "mb-6");
|
|
});
|
|
|
|
it("applies correct CSS classes to email input", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const emailInput = screen.getByLabelText("이메일");
|
|
expect(emailInput).toHaveClass("pl-10", "neuro-pressed");
|
|
});
|
|
|
|
it("applies correct CSS classes to password input", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const passwordInput = screen.getByLabelText("비밀번호");
|
|
expect(passwordInput).toHaveClass("pl-10", "neuro-pressed");
|
|
});
|
|
|
|
it("applies correct CSS classes to submit button", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const submitButton = screen.getByRole("button", { name: /로그인/ });
|
|
expect(submitButton).toHaveClass(
|
|
"w-full",
|
|
"hover:bg-neuro-income/80",
|
|
"text-white",
|
|
"h-auto",
|
|
"bg-neuro-income",
|
|
"text-lg"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("accessibility", () => {
|
|
it("has proper form labels", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("비밀번호")).toBeInTheDocument();
|
|
});
|
|
|
|
it("has proper input IDs matching labels", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const emailInput = screen.getByLabelText("이메일");
|
|
const passwordInput = screen.getByLabelText("비밀번호");
|
|
|
|
expect(emailInput).toHaveAttribute("id", "email");
|
|
expect(passwordInput).toHaveAttribute("id", "password");
|
|
});
|
|
|
|
it("password toggle button has correct type", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
// Find the eye icon button (the one that's not the submit button)
|
|
const buttons = screen.getAllByRole("button");
|
|
const toggleButton = buttons.find(
|
|
(button) => button.getAttribute("type") === "button"
|
|
);
|
|
|
|
expect(toggleButton).toHaveAttribute("type", "button");
|
|
});
|
|
|
|
it("submit button has correct type", () => {
|
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
|
|
|
const submitButton = screen.getByText("로그인").closest("button");
|
|
expect(submitButton).toHaveAttribute("type", "submit");
|
|
});
|
|
});
|
|
|
|
describe("edge cases", () => {
|
|
it("handles empty email and password values", () => {
|
|
render(<LoginForm {...defaultProps} email="" password="" />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
const emailInput = screen.getByLabelText("이메일");
|
|
const passwordInput = screen.getByLabelText("비밀번호");
|
|
|
|
expect(emailInput).toHaveValue("");
|
|
expect(passwordInput).toHaveValue("");
|
|
});
|
|
|
|
it("handles very long error messages", () => {
|
|
const longError = "A".repeat(1000);
|
|
render(<LoginForm {...defaultProps} loginError={longError} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
expect(screen.getByText(longError)).toBeInTheDocument();
|
|
});
|
|
|
|
it("handles special characters in email and password", () => {
|
|
const specialEmail = "test+tag@example-domain.co.uk";
|
|
const specialPassword = "P@ssw0rd!#$%";
|
|
|
|
render(
|
|
<LoginForm
|
|
{...defaultProps}
|
|
email={specialEmail}
|
|
password={specialPassword}
|
|
/>,
|
|
{ wrapper: Wrapper }
|
|
);
|
|
|
|
expect(screen.getByDisplayValue(specialEmail)).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue(specialPassword)).toBeInTheDocument();
|
|
});
|
|
|
|
it("maintains form functionality during rapid state changes", () => {
|
|
const { rerender } = render(<LoginForm {...defaultProps} />, {
|
|
wrapper: Wrapper,
|
|
});
|
|
|
|
// Rapid state changes
|
|
rerender(<LoginForm {...defaultProps} isLoading={true} />);
|
|
rerender(<LoginForm {...defaultProps} isSettingUpTables={true} />);
|
|
rerender(<LoginForm {...defaultProps} loginError="Error" />);
|
|
rerender(<LoginForm {...defaultProps} />);
|
|
|
|
// Form should still be functional
|
|
expect(screen.getByTestId("login-form")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("비밀번호")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|