setIsEditDialogOpen(true)}
>
diff --git a/src/components/__tests__/BudgetProgressCard.test.tsx b/src/components/__tests__/BudgetProgressCard.test.tsx
new file mode 100644
index 0000000..ea8e004
--- /dev/null
+++ b/src/components/__tests__/BudgetProgressCard.test.tsx
@@ -0,0 +1,447 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import BudgetProgressCard from "../BudgetProgressCard";
+import { BudgetData } from "@/contexts/budget/types";
+import { logger } from "@/utils/logger";
+
+// Mock logger
+vi.mock("@/utils/logger", () => ({
+ logger: {
+ info: vi.fn(),
+ error: vi.fn(),
+ warn: vi.fn(),
+ },
+}));
+
+// Mock BudgetTabContent component
+vi.mock("../BudgetTabContent", () => ({
+ default: ({
+ data,
+ formatCurrency,
+ calculatePercentage,
+ onSaveBudget,
+ }: any) => (
+
+
{data.targetAmount}
+
{data.spentAmount}
+
{data.remainingAmount}
+
+ {formatCurrency ? formatCurrency(data.targetAmount) : "no formatter"}
+
+
+ {calculatePercentage
+ ? calculatePercentage(data.spentAmount, data.targetAmount)
+ : "no calculator"}
+
+
+
+ ),
+}));
+
+describe("BudgetProgressCard", () => {
+ const mockSetSelectedTab = vi.fn();
+ const mockFormatCurrency = vi.fn((amount) => `${amount.toLocaleString()}원`);
+ const mockCalculatePercentage = vi.fn((spent, target) =>
+ target > 0 ? Math.round((spent / target) * 100) : 0
+ );
+ const mockOnSaveBudget = vi.fn();
+
+ const mockBudgetData: BudgetData = {
+ monthly: {
+ targetAmount: 100000,
+ spentAmount: 75000,
+ remainingAmount: 25000,
+ },
+ weekly: {
+ targetAmount: 25000,
+ spentAmount: 18000,
+ remainingAmount: 7000,
+ },
+ daily: {
+ targetAmount: 3500,
+ spentAmount: 2800,
+ remainingAmount: 700,
+ },
+ };
+
+ const defaultProps = {
+ budgetData: mockBudgetData,
+ selectedTab: "monthly",
+ setSelectedTab: mockSetSelectedTab,
+ formatCurrency: mockFormatCurrency,
+ calculatePercentage: mockCalculatePercentage,
+ onSaveBudget: mockOnSaveBudget,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Mock window.dispatchEvent
+ global.dispatchEvent = vi.fn();
+ // Mock window event listeners
+ global.addEventListener = vi.fn();
+ global.removeEventListener = vi.fn();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ });
+
+ describe("렌더링", () => {
+ it("기본 컴포넌트 구조가 올바르게 렌더링된다", () => {
+ render(
);
+
+ expect(screen.getByTestId("budget-progress-card")).toBeInTheDocument();
+ expect(screen.getByText("지출 / 예산")).toBeInTheDocument();
+ expect(screen.getByTestId("budget-tab-content")).toBeInTheDocument();
+ });
+
+ it("올바른 CSS 클래스가 적용된다", () => {
+ render(
);
+
+ const card = screen.getByTestId("budget-progress-card");
+ expect(card).toHaveClass(
+ "neuro-card",
+ "mb-6",
+ "overflow-hidden",
+ "w-full"
+ );
+ });
+
+ it("제목 텍스트가 올바른 스타일로 표시된다", () => {
+ render(
);
+
+ const title = screen.getByText("지출 / 예산");
+ expect(title).toHaveClass(
+ "text-sm",
+ "text-gray-600",
+ "mb-2",
+ "px-3",
+ "pt-3"
+ );
+ });
+ });
+
+ describe("데이터 전달", () => {
+ it("BudgetTabContent에 월간 예산 데이터를 올바르게 전달한다", () => {
+ render(
);
+
+ expect(screen.getByTestId("target-amount")).toHaveTextContent("100000");
+ expect(screen.getByTestId("spent-amount")).toHaveTextContent("75000");
+ expect(screen.getByTestId("remaining-amount")).toHaveTextContent("25000");
+ });
+
+ it("formatCurrency 함수가 올바르게 전달되고 호출된다", () => {
+ render(
);
+
+ expect(screen.getByTestId("formatted-currency")).toHaveTextContent(
+ "100,000원"
+ );
+ expect(mockFormatCurrency).toHaveBeenCalledWith(100000);
+ });
+
+ it("calculatePercentage 함수가 올바르게 전달되고 호출된다", () => {
+ render(
);
+
+ expect(screen.getByTestId("percentage")).toHaveTextContent("75");
+ expect(mockCalculatePercentage).toHaveBeenCalledWith(75000, 100000);
+ });
+
+ it("onSaveBudget 콜백이 올바른 타입과 함께 전달된다", async () => {
+ render(
);
+
+ const saveButton = screen.getByTestId("save-budget-btn");
+ saveButton.click();
+
+ expect(mockOnSaveBudget).toHaveBeenCalledWith("monthly", 50000, {
+ 음식: 30000,
+ });
+ });
+ });
+
+ describe("초기 탭 설정", () => {
+ it("선택된 탭이 monthly가 아닐 때 monthly로 설정한다", () => {
+ render(
);
+
+ expect(mockSetSelectedTab).toHaveBeenCalledWith("monthly");
+ });
+
+ it("선택된 탭이 이미 monthly일 때는 다시 설정하지 않는다", () => {
+ render(
);
+
+ expect(mockSetSelectedTab).not.toHaveBeenCalled();
+ });
+
+ it("선택된 탭이 빈 문자열일 때 monthly로 설정한다", () => {
+ render(
);
+
+ expect(mockSetSelectedTab).toHaveBeenCalledWith("monthly");
+ });
+
+ it("선택된 탭이 null일 때 monthly로 설정한다", () => {
+ render(
+
+ );
+
+ expect(mockSetSelectedTab).toHaveBeenCalledWith("monthly");
+ });
+ });
+
+ describe("로깅", () => {
+ it("컴포넌트 마운트 시 예산 데이터를 로깅한다", () => {
+ render(
);
+
+ expect(vi.mocked(logger.info)).toHaveBeenCalledWith(
+ "BudgetProgressCard 데이터 업데이트 - 예산 데이터:",
+ mockBudgetData
+ );
+ expect(vi.mocked(logger.info)).toHaveBeenCalledWith("월간 예산:", 100000);
+ });
+
+ it("월간 예산 설정 상태를 로깅한다", () => {
+ render(
);
+
+ expect(vi.mocked(logger.info)).toHaveBeenCalledWith(
+ "BudgetProgressCard 상태: 월=true"
+ );
+ });
+
+ it("월간 예산이 0일 때 설정되지 않음으로 로깅한다", () => {
+ const noBudgetData = {
+ ...mockBudgetData,
+ monthly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
+ };
+
+ render(
+
+ );
+
+ expect(vi.mocked(logger.info)).toHaveBeenCalledWith(
+ "BudgetProgressCard 상태: 월=false"
+ );
+ });
+
+ it("초기 탭 설정 시 로깅한다", () => {
+ render(
);
+
+ expect(vi.mocked(logger.info)).toHaveBeenCalledWith(
+ "초기 탭 설정: monthly"
+ );
+ });
+ });
+
+ describe("이벤트 처리", () => {
+ it("컴포넌트 마운트 후 budgetDataUpdated 이벤트를 발생시킨다", async () => {
+ vi.useFakeTimers();
+ render(
);
+
+ // 300ms 후 이벤트가 발생하는지 확인
+ vi.advanceTimersByTime(300);
+
+ expect(global.dispatchEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "budgetDataUpdated",
+ })
+ );
+
+ vi.useRealTimers();
+ });
+
+ it("budgetDataUpdated 이벤트 리스너를 등록한다", () => {
+ render(
);
+
+ expect(global.addEventListener).toHaveBeenCalledWith(
+ "budgetDataUpdated",
+ expect.any(Function)
+ );
+ });
+
+ it("컴포넌트 언마운트 시 이벤트 리스너를 제거한다", () => {
+ const { unmount } = render(
);
+
+ unmount();
+
+ expect(global.removeEventListener).toHaveBeenCalledWith(
+ "budgetDataUpdated",
+ expect.any(Function)
+ );
+ });
+
+ it("컴포넌트 언마운트 시 타이머를 정리한다", () => {
+ vi.useFakeTimers();
+ const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
+
+ const { unmount } = render(
);
+ unmount();
+
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+
+ vi.useRealTimers();
+ });
+ });
+
+ describe("데이터 업데이트", () => {
+ it("budgetData prop이 변경될 때 로컬 상태를 업데이트한다", () => {
+ const { rerender } = render(
);
+
+ const newBudgetData = {
+ ...mockBudgetData,
+ monthly: {
+ targetAmount: 200000,
+ spentAmount: 150000,
+ remainingAmount: 50000,
+ },
+ };
+
+ rerender(
+
+ );
+
+ expect(screen.getByTestId("target-amount")).toHaveTextContent("200000");
+ expect(screen.getByTestId("spent-amount")).toHaveTextContent("150000");
+ expect(screen.getByTestId("remaining-amount")).toHaveTextContent("50000");
+ });
+
+ it("데이터 변경 시 새로운 로깅을 수행한다", () => {
+ const { rerender } = render(
);
+
+ const newBudgetData = {
+ ...mockBudgetData,
+ monthly: {
+ targetAmount: 200000,
+ spentAmount: 150000,
+ remainingAmount: 50000,
+ },
+ };
+
+ vi.clearAllMocks();
+ rerender(
+
+ );
+
+ expect(vi.mocked(logger.info)).toHaveBeenCalledWith(
+ "BudgetProgressCard 데이터 업데이트 - 예산 데이터:",
+ newBudgetData
+ );
+ expect(vi.mocked(logger.info)).toHaveBeenCalledWith("월간 예산:", 200000);
+ });
+ });
+
+ describe("엣지 케이스", () => {
+ it("예산 데이터가 0인 경우를 처리한다", () => {
+ const zeroBudgetData = {
+ monthly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
+ weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
+ daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByTestId("target-amount")).toHaveTextContent("0");
+ expect(screen.getByTestId("percentage")).toHaveTextContent("0");
+ });
+
+ it("음수 예산 데이터를 처리한다", () => {
+ const negativeBudgetData = {
+ monthly: {
+ targetAmount: 100000,
+ spentAmount: 150000,
+ remainingAmount: -50000,
+ },
+ weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
+ daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByTestId("remaining-amount")).toHaveTextContent(
+ "-50000"
+ );
+ expect(screen.getByTestId("percentage")).toHaveTextContent("150");
+ });
+
+ it("매우 큰 숫자를 처리한다", () => {
+ const largeBudgetData = {
+ monthly: {
+ targetAmount: 999999999,
+ spentAmount: 888888888,
+ remainingAmount: 111111111,
+ },
+ weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
+ daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByTestId("target-amount")).toHaveTextContent(
+ "999999999"
+ );
+ expect(screen.getByTestId("formatted-currency")).toHaveTextContent(
+ "999,999,999원"
+ );
+ });
+
+ it("undefined 함수들을 처리한다", () => {
+ const propsWithUndefined = {
+ ...defaultProps,
+ formatCurrency: undefined as any,
+ calculatePercentage: undefined as any,
+ onSaveBudget: undefined as any,
+ };
+
+ // 컴포넌트가 크래시하지 않아야 함
+ expect(() => {
+ render(
);
+ }).not.toThrow();
+
+ // undefined 함수들이 전달되었을 때 대체 텍스트가 표시되는지 확인
+ expect(screen.getByText("no formatter")).toBeInTheDocument();
+ expect(screen.getByText("no calculator")).toBeInTheDocument();
+ });
+
+ it("빠른 연속 prop 변경을 처리한다", () => {
+ const { rerender } = render(
);
+
+ // 빠른 연속 변경
+ for (let i = 1; i <= 5; i++) {
+ const newData = {
+ ...mockBudgetData,
+ monthly: {
+ targetAmount: 100000 * i,
+ spentAmount: 75000 * i,
+ remainingAmount: 25000 * i,
+ },
+ };
+ rerender(
);
+ }
+
+ // 마지막 값이 올바르게 표시되는지 확인
+ expect(screen.getByTestId("target-amount")).toHaveTextContent("500000");
+ });
+ });
+
+ describe("접근성", () => {
+ it("의미있는 제목이 있다", () => {
+ render(
);
+
+ expect(screen.getByText("지출 / 예산")).toBeInTheDocument();
+ });
+
+ it("테스트 가능한 요소들이 적절한 test-id를 가진다", () => {
+ render(
);
+
+ expect(screen.getByTestId("budget-progress-card")).toBeInTheDocument();
+ expect(screen.getByTestId("budget-tab-content")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/__tests__/Button.test.tsx b/src/components/__tests__/Button.test.tsx
new file mode 100644
index 0000000..9e90a9a
--- /dev/null
+++ b/src/components/__tests__/Button.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { Button } from '@/components/ui/button';
+
+describe('Button Component', () => {
+ it('renders button with text', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: 'Test Button' })).toBeInTheDocument();
+ });
+
+ it('handles click events', () => {
+ const handleClick = vi.fn();
+
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Click me' }));
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('can be disabled', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: 'Disabled Button' })).toBeDisabled();
+ });
+
+ it('applies variant styles correctly', () => {
+ render(
);
+ const button = screen.getByRole('button', { name: 'Delete' });
+ expect(button).toHaveClass('bg-destructive');
+ });
+});
\ No newline at end of file
diff --git a/src/components/__tests__/ExpenseForm.test.tsx b/src/components/__tests__/ExpenseForm.test.tsx
new file mode 100644
index 0000000..2e41d7c
--- /dev/null
+++ b/src/components/__tests__/ExpenseForm.test.tsx
@@ -0,0 +1,211 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import ExpenseForm from '../expenses/ExpenseForm';
+
+// Mock child components with proper props handling
+vi.mock('../expenses/ExpenseFormFields', () => ({
+ default: ({ form, isSubmitting }: any) => (
+
+ {isSubmitting.toString()}
+ {form ? 'form-present' : 'form-missing'}
+
+ )
+}));
+
+vi.mock('../expenses/ExpenseSubmitActions', () => ({
+ default: ({ onCancel, isSubmitting }: any) => (
+
+
+
+
+ )
+}));
+
+describe('ExpenseForm', () => {
+ const mockOnSubmit = vi.fn();
+ const mockOnCancel = vi.fn();
+
+ const defaultProps = {
+ onSubmit: mockOnSubmit,
+ onCancel: mockOnCancel,
+ isSubmitting: false,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders the form with all child components', () => {
+ render(
);
+
+ expect(screen.getByTestId('expense-form')).toBeInTheDocument();
+ expect(screen.getByTestId('expense-form-fields')).toBeInTheDocument();
+ expect(screen.getByTestId('expense-submit-actions')).toBeInTheDocument();
+ });
+
+ it('applies correct CSS classes to form', () => {
+ render(
);
+
+ const form = screen.getByTestId('expense-form');
+ expect(form).toHaveClass('space-y-4');
+ });
+
+ it('passes form object to ExpenseFormFields', () => {
+ render(
);
+
+ expect(screen.getByTestId('form-object')).toHaveTextContent('form-present');
+ });
+ });
+
+ describe('isSubmitting prop handling', () => {
+ it('passes isSubmitting=false to child components', () => {
+ render(
);
+
+ expect(screen.getByTestId('fields-submitting-state')).toHaveTextContent('false');
+ expect(screen.getByTestId('submit-button')).toHaveTextContent('저장');
+ expect(screen.getByTestId('submit-button')).not.toBeDisabled();
+ expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
+ });
+
+ it('passes isSubmitting=true to child components', () => {
+ render(
);
+
+ expect(screen.getByTestId('fields-submitting-state')).toHaveTextContent('true');
+ expect(screen.getByTestId('submit-button')).toHaveTextContent('저장 중...');
+ expect(screen.getByTestId('submit-button')).toBeDisabled();
+ expect(screen.getByTestId('cancel-button')).toBeDisabled();
+ });
+
+ it('updates submitting state correctly when prop changes', () => {
+ const { rerender } = render(
);
+
+ expect(screen.getByTestId('submit-button')).toHaveTextContent('저장');
+
+ rerender(
);
+
+ expect(screen.getByTestId('submit-button')).toHaveTextContent('저장 중...');
+ });
+ });
+
+ describe('form interactions', () => {
+ it('calls onCancel when cancel button is clicked', () => {
+ render(
);
+
+ fireEvent.click(screen.getByTestId('cancel-button'));
+
+ expect(mockOnCancel).toHaveBeenCalledTimes(1);
+ expect(mockOnSubmit).not.toHaveBeenCalled();
+ });
+
+ it('does not call onCancel when cancel button is disabled', () => {
+ render(
);
+
+ const cancelButton = screen.getByTestId('cancel-button');
+ expect(cancelButton).toBeDisabled();
+
+ fireEvent.click(cancelButton);
+
+ expect(mockOnCancel).not.toHaveBeenCalled();
+ });
+
+ it('prevents form submission when submit button is disabled', () => {
+ render(
);
+
+ const submitButton = screen.getByTestId('submit-button');
+ expect(submitButton).toBeDisabled();
+
+ fireEvent.click(submitButton);
+
+ expect(mockOnSubmit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('prop validation', () => {
+ it('handles different onCancel functions correctly', () => {
+ const customOnCancel = vi.fn();
+ render(
);
+
+ fireEvent.click(screen.getByTestId('cancel-button'));
+
+ expect(customOnCancel).toHaveBeenCalledTimes(1);
+ expect(mockOnCancel).not.toHaveBeenCalled();
+ });
+
+ it('maintains form structure with different prop combinations', () => {
+ const { rerender } = render(
);
+
+ expect(screen.getByTestId('expense-form')).toBeInTheDocument();
+
+ rerender(
);
+
+ expect(screen.getByTestId('expense-form')).toBeInTheDocument();
+ expect(screen.getByTestId('expense-form-fields')).toBeInTheDocument();
+ expect(screen.getByTestId('expense-submit-actions')).toBeInTheDocument();
+ });
+ });
+
+ describe('accessibility', () => {
+ it('maintains proper form semantics', () => {
+ render(
);
+
+ const form = screen.getByTestId('expense-form');
+ expect(form.tagName).toBe('FORM');
+ });
+
+ it('submit button has correct type attribute', () => {
+ render(
);
+
+ const submitButton = screen.getByTestId('submit-button');
+ expect(submitButton).toHaveAttribute('type', 'submit');
+ });
+
+ it('cancel button has correct type attribute', () => {
+ render(
);
+
+ const cancelButton = screen.getByTestId('cancel-button');
+ expect(cancelButton).toHaveAttribute('type', 'button');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles rapid state changes', () => {
+ const { rerender } = render(
);
+
+ rerender(
);
+ rerender(
);
+ rerender(
);
+
+ expect(screen.getByTestId('expense-form')).toBeInTheDocument();
+ expect(screen.getByTestId('submit-button')).toHaveTextContent('저장 중...');
+ });
+
+ it('maintains component stability during prop updates', () => {
+ const { rerender } = render(
);
+
+ const form = screen.getByTestId('expense-form');
+ const formFields = screen.getByTestId('expense-form-fields');
+ const submitActions = screen.getByTestId('expense-submit-actions');
+
+ rerender(
);
+
+ // Components should still be present after prop update
+ expect(form).toBeInTheDocument();
+ expect(formFields).toBeInTheDocument();
+ expect(submitActions).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx
new file mode 100644
index 0000000..898f228
--- /dev/null
+++ b/src/components/__tests__/Header.test.tsx
@@ -0,0 +1,324 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import Header from '../Header';
+
+// 모든 의존성을 간단한 구현으로 모킹
+vi.mock('@/utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ error: vi.fn(),
+ warn: vi.fn(),
+ },
+}));
+
+vi.mock('@/stores', () => ({
+ useAuth: vi.fn(),
+}));
+
+vi.mock('@/hooks/use-mobile', () => ({
+ useIsMobile: vi.fn(() => false),
+}));
+
+vi.mock('@/utils/platform', () => ({
+ isIOSPlatform: vi.fn(() => false),
+}));
+
+vi.mock('@/hooks/useNotifications', () => ({
+ default: vi.fn(() => ({
+ notifications: [],
+ clearAllNotifications: vi.fn(),
+ markAsRead: vi.fn(),
+ })),
+}));
+
+vi.mock('../notification/NotificationPopover', () => ({
+ default: () =>
알림
+}));
+
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: ({ children, className }: any) => (
+
{children}
+ ),
+ AvatarImage: ({ src, alt }: any) => (
+

+ ),
+ AvatarFallback: ({ children }: any) => (
+
{children}
+ ),
+}));
+
+vi.mock('@/components/ui/skeleton', () => ({
+ Skeleton: ({ className }: any) => (
+
Loading...
+ ),
+}));
+
+import { useAuth } from '@/stores';
+
+describe('Header', () => {
+ const mockUseAuth = vi.mocked(useAuth);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Image constructor 모킹
+ global.Image = class {
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ src: string = '';
+
+ constructor() {
+ setTimeout(() => {
+ if (this.onload) this.onload();
+ }, 0);
+ }
+ } as any;
+ });
+
+ describe('기본 렌더링', () => {
+ it('헤더가 올바르게 렌더링된다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ expect(screen.getByTestId('header')).toBeInTheDocument();
+ expect(screen.getByTestId('avatar')).toBeInTheDocument();
+ expect(screen.getByTestId('notification-popover')).toBeInTheDocument();
+ });
+
+ it('로그인하지 않은 사용자에게 기본 인사말을 표시한다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ expect(screen.getByText('반갑습니다')).toBeInTheDocument();
+ expect(screen.getByText('젤리의 적자탈출')).toBeInTheDocument();
+ });
+
+ it('로그인한 사용자에게 개인화된 인사말을 표시한다', () => {
+ mockUseAuth.mockReturnValue({
+ user: {
+ user_metadata: {
+ username: '김철수'
+ }
+ }
+ });
+
+ render(
);
+
+ expect(screen.getByText('김철수님, 반갑습니다')).toBeInTheDocument();
+ });
+
+ it('사용자 이름이 없을 때 "익명"으로 표시한다', () => {
+ mockUseAuth.mockReturnValue({
+ user: {
+ user_metadata: {}
+ }
+ });
+
+ render(
);
+
+ expect(screen.getByText('익명님, 반갑습니다')).toBeInTheDocument();
+ });
+
+ it('user_metadata가 없을 때 "익명"으로 표시한다', () => {
+ mockUseAuth.mockReturnValue({
+ user: {}
+ });
+
+ render(
);
+
+ expect(screen.getByText('익명님, 반갑습니다')).toBeInTheDocument();
+ });
+ });
+
+ describe('CSS 클래스 및 스타일링', () => {
+ it('기본 헤더 클래스가 적용된다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ const header = screen.getByTestId('header');
+ expect(header).toHaveClass('py-4');
+ });
+
+ it('아바타에 올바른 클래스가 적용된다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ const avatar = screen.getByTestId('avatar');
+ expect(avatar).toHaveClass('h-12', 'w-12', 'mr-3');
+ });
+
+ it('제목에 올바른 스타일이 적용된다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ const title = screen.getByText('반갑습니다');
+ expect(title).toHaveClass('font-bold', 'neuro-text', 'text-xl');
+ });
+
+ it('부제목에 올바른 스타일이 적용된다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ const subtitle = screen.getByText('젤리의 적자탈출');
+ expect(subtitle).toHaveClass('text-gray-500', 'text-left');
+ });
+ });
+
+ describe('아바타 처리', () => {
+ it('아바타 컨테이너가 있다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ expect(screen.getByTestId('avatar')).toBeInTheDocument();
+ });
+
+ it('이미지 로딩 중에 스켈레톤을 표시한다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+ });
+
+ describe('알림 시스템', () => {
+ it('알림 팝오버가 렌더링된다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ expect(screen.getByTestId('notification-popover')).toBeInTheDocument();
+ expect(screen.getByText('알림')).toBeInTheDocument();
+ });
+ });
+
+ describe('접근성', () => {
+ it('헤더가 올바른 시맨틱 태그를 사용한다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ const header = screen.getByTestId('header');
+ expect(header.tagName).toBe('HEADER');
+ });
+
+ it('제목이 h1 태그로 렌더링된다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ const title = screen.getByRole('heading', { level: 1 });
+ expect(title).toBeInTheDocument();
+ expect(title).toHaveTextContent('반갑습니다');
+ });
+ });
+
+ describe('엣지 케이스', () => {
+ it('user가 null일 때 크래시하지 않는다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ expect(() => {
+ render(
);
+ }).not.toThrow();
+ });
+
+ it('user_metadata가 없어도 처리한다', () => {
+ mockUseAuth.mockReturnValue({
+ user: {}
+ });
+
+ render(
);
+
+ expect(screen.getByText('익명님, 반갑습니다')).toBeInTheDocument();
+ });
+
+ it('긴 사용자 이름을 처리한다', () => {
+ const longUsername = 'VeryLongUserNameThatMightCauseIssues';
+ mockUseAuth.mockReturnValue({
+ user: {
+ user_metadata: {
+ username: longUsername
+ }
+ }
+ });
+
+ render(
);
+
+ expect(screen.getByText(`${longUsername}님, 반갑습니다`)).toBeInTheDocument();
+ });
+
+ it('특수 문자가 포함된 사용자 이름을 처리한다', () => {
+ const specialUsername = '김@철#수$123';
+ mockUseAuth.mockReturnValue({
+ user: {
+ user_metadata: {
+ username: specialUsername
+ }
+ }
+ });
+
+ render(
);
+
+ expect(screen.getByText(`${specialUsername}님, 반갑습니다`)).toBeInTheDocument();
+ });
+
+ it('빈 문자열 사용자 이름을 처리한다', () => {
+ mockUseAuth.mockReturnValue({
+ user: {
+ user_metadata: {
+ username: ''
+ }
+ }
+ });
+
+ render(
);
+
+ expect(screen.getByText('익명님, 반갑습니다')).toBeInTheDocument();
+ });
+
+ it('undefined user를 처리한다', () => {
+ mockUseAuth.mockReturnValue({ user: undefined });
+
+ render(
);
+
+ expect(screen.getByText('반갑습니다')).toBeInTheDocument();
+ });
+ });
+
+ describe('레이아웃 및 구조', () => {
+ it('올바른 레이아웃 구조를 가진다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ const header = screen.getByTestId('header');
+ const flexContainer = header.querySelector('.flex.justify-between.items-center');
+ expect(flexContainer).toBeInTheDocument();
+
+ const leftSection = flexContainer?.querySelector('.flex.items-center');
+ expect(leftSection).toBeInTheDocument();
+ });
+
+ it('아바타와 텍스트가 올바르게 배치된다', () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ render(
);
+
+ const avatar = screen.getByTestId('avatar');
+ const title = screen.getByText('반갑습니다');
+ const subtitle = screen.getByText('젤리의 적자탈출');
+
+ expect(avatar).toBeInTheDocument();
+ expect(title).toBeInTheDocument();
+ expect(subtitle).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/__tests__/LoginForm.test.tsx b/src/components/__tests__/LoginForm.test.tsx
new file mode 100644
index 0000000..57b1e6f
--- /dev/null
+++ b/src/components/__tests__/LoginForm.test.tsx
@@ -0,0 +1,430 @@
+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) => (
+
+ {children}
+
+ ),
+ };
+});
+
+// Wrapper component for Router context
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
{children}
+);
+
+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(
, { 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(
, { 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(
, { 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(
, { 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(
+
,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.getByDisplayValue('test@example.com')).toBeInTheDocument();
+ });
+
+ it('displays current password value', () => {
+ render(
+
,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.getByDisplayValue('mypassword')).toBeInTheDocument();
+ });
+
+ it('calls setEmail when email input changes', () => {
+ render(
, { 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(
, { 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(
, { wrapper: Wrapper });
+
+ const passwordInput = screen.getByLabelText('비밀번호');
+ expect(passwordInput).toHaveAttribute('type', 'password');
+ });
+
+ it('shows password as text when showPassword is true', () => {
+ render(
+
,
+ { wrapper: Wrapper }
+ );
+
+ const passwordInput = screen.getByLabelText('비밀번호');
+ expect(passwordInput).toHaveAttribute('type', 'text');
+ });
+
+ it('calls setShowPassword when visibility toggle is clicked', () => {
+ render(
, { 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(
+
,
+ { 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(
, { 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(
+
,
+ { wrapper: Wrapper }
+ );
+
+ const loginButton = screen.getByText('로그인 중...');
+ expect(loginButton).toBeDisabled();
+ });
+
+ it('does not submit when form is disabled during table setup', () => {
+ render(
+
,
+ { wrapper: Wrapper }
+ );
+
+ const loginButton = screen.getByText('데이터베이스 설정 중...');
+ expect(loginButton).toBeDisabled();
+ });
+ });
+
+ describe('loading states', () => {
+ it('shows loading text when isLoading is true', () => {
+ render(
+
,
+ { 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(
+
,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.getByText('데이터베이스 설정 중...')).toBeInTheDocument();
+ const submitButton = screen.getByText('데이터베이스 설정 중...').closest('button');
+ expect(submitButton).toBeDisabled();
+ });
+
+ it('shows normal text when not loading', () => {
+ render(
, { wrapper: Wrapper });
+
+ expect(screen.getByText('로그인')).toBeInTheDocument();
+ const submitButton = screen.getByText('로그인').closest('button');
+ expect(submitButton).not.toBeDisabled();
+ });
+
+ it('isLoading takes precedence over isSettingUpTables', () => {
+ render(
+
,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.getByText('로그인 중...')).toBeInTheDocument();
+ });
+ });
+
+ describe('error handling', () => {
+ it('does not show error message when loginError is null', () => {
+ render(
, { wrapper: Wrapper });
+
+ expect(screen.queryByText(/에러/)).not.toBeInTheDocument();
+ });
+
+ it('shows regular error message for standard errors', () => {
+ const errorMessage = '잘못된 이메일 또는 비밀번호입니다.';
+ render(
+
,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.getByText(errorMessage)).toBeInTheDocument();
+ });
+
+ it('shows CORS/JSON error with special styling and suggestions', () => {
+ const corsError = 'CORS 정책에 의해 차단되었습니다.';
+ render(
+
,
+ { 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(
+
,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.getByText(jsonError)).toBeInTheDocument();
+ expect(screen.getByText(/다른 CORS 프록시 유형을 시도해 보세요/)).toBeInTheDocument();
+ });
+
+ it('detects network 404 errors correctly', () => {
+ const networkError = '404 Not Found 오류입니다.';
+ render(
+
,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.getByText(networkError)).toBeInTheDocument();
+ expect(screen.getByText(/네트워크 연결 상태를 확인하세요/)).toBeInTheDocument();
+ });
+
+ it('detects proxy errors correctly', () => {
+ const proxyError = '프록시 서버 응답 오류입니다.';
+ render(
+
,
+ { 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(
, { 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(
, { wrapper: Wrapper });
+
+ const emailInput = screen.getByLabelText('이메일');
+ expect(emailInput).toHaveClass('pl-10', 'neuro-pressed');
+ });
+
+ it('applies correct CSS classes to password input', () => {
+ render(
, { wrapper: Wrapper });
+
+ const passwordInput = screen.getByLabelText('비밀번호');
+ expect(passwordInput).toHaveClass('pl-10', 'neuro-pressed');
+ });
+
+ it('applies correct CSS classes to submit button', () => {
+ render(
, { 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(
, { wrapper: Wrapper });
+
+ expect(screen.getByLabelText('이메일')).toBeInTheDocument();
+ expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
+ });
+
+ it('has proper input IDs matching labels', () => {
+ render(
, { 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(
, { 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(
, { wrapper: Wrapper });
+
+ const submitButton = screen.getByText('로그인').closest('button');
+ expect(submitButton).toHaveAttribute('type', 'submit');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles empty email and password values', () => {
+ render(
+
,
+ { 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(
+
,
+ { 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(
+
,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.getByDisplayValue(specialEmail)).toBeInTheDocument();
+ expect(screen.getByDisplayValue(specialPassword)).toBeInTheDocument();
+ });
+
+ it('maintains form functionality during rapid state changes', () => {
+ const { rerender } = render(
, { wrapper: Wrapper });
+
+ // Rapid state changes
+ rerender(
);
+ rerender(
);
+ rerender(
);
+ rerender(
);
+
+ // Form should still be functional
+ expect(screen.getByTestId('login-form')).toBeInTheDocument();
+ expect(screen.getByLabelText('이메일')).toBeInTheDocument();
+ expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/__tests__/TransactionCard.test.tsx b/src/components/__tests__/TransactionCard.test.tsx
new file mode 100644
index 0000000..47f9359
--- /dev/null
+++ b/src/components/__tests__/TransactionCard.test.tsx
@@ -0,0 +1,313 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import TransactionCard from '../TransactionCard';
+import { Transaction } from '@/contexts/budget/types';
+import { logger } from '@/utils/logger';
+
+// Mock child components to isolate TransactionCard testing
+vi.mock('../TransactionEditDialog', () => ({
+ default: ({ open, onOpenChange, transaction, onDelete }: any) =>
+ open ? (
+
+
Edit Dialog for: {transaction.title}
+
+
+
+ ) : null
+}));
+
+vi.mock('../transaction/TransactionIcon', () => ({
+ default: ({ category }: { category: string }) => (
+
{category} icon
+ )
+}));
+
+vi.mock('../transaction/TransactionDetails', () => ({
+ default: ({ title, date }: { title: string; date: string }) => (
+
+ )
+}));
+
+vi.mock('../transaction/TransactionAmount', () => ({
+ default: ({ amount }: { amount: number }) => (
+
{amount}원
+ )
+}));
+
+// Mock logger
+vi.mock('@/utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe('TransactionCard', () => {
+ const mockTransaction: Transaction = {
+ id: 'test-transaction-1',
+ title: 'Coffee Shop',
+ amount: 5000,
+ date: '2024-06-15',
+ category: 'Food',
+ type: 'expense',
+ paymentMethod: '신용카드',
+ };
+
+ describe('rendering', () => {
+ it('renders transaction card with all components', () => {
+ render(
);
+
+ expect(screen.getByTestId('transaction-icon')).toBeInTheDocument();
+ expect(screen.getByTestId('transaction-details')).toBeInTheDocument();
+ expect(screen.getByTestId('transaction-amount')).toBeInTheDocument();
+ expect(screen.getByText('Coffee Shop')).toBeInTheDocument();
+ expect(screen.getByText('2024-06-15')).toBeInTheDocument();
+ expect(screen.getByText('5000원')).toBeInTheDocument();
+ });
+
+ it('passes correct props to child components', () => {
+ render(
);
+
+ expect(screen.getByText('Food icon')).toBeInTheDocument();
+ expect(screen.getByText('Coffee Shop')).toBeInTheDocument();
+ expect(screen.getByText('2024-06-15')).toBeInTheDocument();
+ expect(screen.getByText('5000원')).toBeInTheDocument();
+ });
+
+ it('renders with different transaction data', () => {
+ const differentTransaction: Transaction = {
+ id: 'test-transaction-2',
+ title: 'Gas Station',
+ amount: 50000,
+ date: '2024-07-01',
+ category: 'Transportation',
+ type: 'expense',
+ paymentMethod: '현금',
+ };
+
+ render(
);
+
+ expect(screen.getByText('Gas Station')).toBeInTheDocument();
+ expect(screen.getByText('2024-07-01')).toBeInTheDocument();
+ expect(screen.getByText('50000원')).toBeInTheDocument();
+ expect(screen.getByText('Transportation icon')).toBeInTheDocument();
+ });
+ });
+
+ describe('user interactions', () => {
+ it('opens edit dialog when card is clicked', () => {
+ render(
);
+
+ const card = screen.getByTestId('transaction-card');
+ fireEvent.click(card);
+
+ expect(screen.getByTestId('transaction-edit-dialog')).toBeInTheDocument();
+ expect(screen.getByText('Edit Dialog for: Coffee Shop')).toBeInTheDocument();
+ });
+
+ it('closes edit dialog when close button is clicked', () => {
+ render(
);
+
+ // Open dialog
+ const card = screen.getByTestId('transaction-card');
+ fireEvent.click(card);
+ expect(screen.getByTestId('transaction-edit-dialog')).toBeInTheDocument();
+
+ // Close dialog
+ const closeButton = screen.getByText('Close');
+ fireEvent.click(closeButton);
+ expect(screen.queryByTestId('transaction-edit-dialog')).not.toBeInTheDocument();
+ });
+
+ it('initially does not show edit dialog', () => {
+ render(
);
+ expect(screen.queryByTestId('transaction-edit-dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('delete functionality', () => {
+ it('calls onDelete when delete button is clicked in dialog', async () => {
+ const mockOnDelete = vi.fn().mockResolvedValue(true);
+ render(
);
+
+ // Open dialog
+ const card = screen.getByTestId('transaction-card');
+ fireEvent.click(card);
+
+ // Click delete
+ const deleteButton = screen.getByText('Delete');
+ fireEvent.click(deleteButton);
+
+ expect(mockOnDelete).toHaveBeenCalledWith('test-transaction-1');
+ });
+
+ it('handles delete when no onDelete prop is provided', async () => {
+ render(
);
+
+ // Open dialog
+ const card = screen.getByTestId('transaction-card');
+ fireEvent.click(card);
+
+ // Click delete (should not crash)
+ const deleteButton = screen.getByText('Delete');
+ fireEvent.click(deleteButton);
+
+ // Should not crash and should log
+ expect(vi.mocked(logger.info)).toHaveBeenCalledWith(
+ '삭제 핸들러가 제공되지 않았습니다'
+ );
+ });
+
+ it('handles delete error gracefully', async () => {
+ const mockOnDelete = vi.fn().mockRejectedValue(new Error('Delete failed'));
+ render(
);
+
+ // Open dialog
+ const card = screen.getByTestId('transaction-card');
+ fireEvent.click(card);
+
+ // Click delete
+ const deleteButton = screen.getByText('Delete');
+ fireEvent.click(deleteButton);
+
+ expect(mockOnDelete).toHaveBeenCalledWith('test-transaction-1');
+
+ // Wait for the promise to be resolved/rejected
+ await vi.waitFor(() => {
+ expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
+ '트랜잭션 삭제 처리 중 오류:',
+ expect.any(Error)
+ );
+ }, { timeout: 1000 });
+ });
+
+ it('handles both sync and async onDelete functions', async () => {
+ // Test sync function
+ const syncOnDelete = vi.fn().mockReturnValue(true);
+ const { rerender } = render(
+
+ );
+
+ let card = screen.getByTestId('transaction-card');
+ fireEvent.click(card);
+ let deleteButton = screen.getByText('Delete');
+ fireEvent.click(deleteButton);
+
+ expect(syncOnDelete).toHaveBeenCalledWith('test-transaction-1');
+
+ // Test async function
+ const asyncOnDelete = vi.fn().mockResolvedValue(true);
+ rerender(
);
+
+ card = screen.getByTestId('transaction-card');
+ fireEvent.click(card);
+ deleteButton = screen.getByText('Delete');
+ fireEvent.click(deleteButton);
+
+ expect(asyncOnDelete).toHaveBeenCalledWith('test-transaction-1');
+ });
+ });
+
+ describe('CSS classes and styling', () => {
+ it('applies correct CSS classes to the card', () => {
+ render(
);
+
+ const card = screen.getByTestId('transaction-card');
+ expect(card).toHaveClass(
+ 'neuro-flat',
+ 'p-4',
+ 'transition-all',
+ 'duration-300',
+ 'hover:shadow-neuro-convex',
+ 'animate-scale-in',
+ 'cursor-pointer'
+ );
+ });
+
+ it('has correct layout structure', () => {
+ render(
);
+
+ const card = screen.getByTestId('transaction-card');
+ const flexContainer = card.querySelector('.flex.items-center.justify-between');
+ expect(flexContainer).toBeInTheDocument();
+
+ const leftSection = card.querySelector('.flex.items-center.gap-3');
+ expect(leftSection).toBeInTheDocument();
+ });
+ });
+
+ describe('accessibility', () => {
+ it('is keyboard accessible', () => {
+ render(
);
+
+ const card = screen.getByTestId('transaction-card');
+ expect(card).toHaveClass('cursor-pointer');
+
+ // Should be clickable
+ fireEvent.click(card);
+ expect(screen.getByTestId('transaction-edit-dialog')).toBeInTheDocument();
+ });
+
+ it('provides semantic content for screen readers', () => {
+ render(
);
+
+ // All important information should be accessible to screen readers
+ expect(screen.getByText('Coffee Shop')).toBeInTheDocument();
+ expect(screen.getByText('2024-06-15')).toBeInTheDocument();
+ expect(screen.getByText('5000원')).toBeInTheDocument();
+ expect(screen.getByText('Food icon')).toBeInTheDocument();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles missing optional transaction fields', () => {
+ const minimalTransaction: Transaction = {
+ id: 'minimal-transaction',
+ title: 'Minimal',
+ amount: 1000,
+ date: '2024-01-01',
+ category: 'Other',
+ type: 'expense',
+ // paymentMethod is optional
+ };
+
+ render(
);
+
+ expect(screen.getByText('Minimal')).toBeInTheDocument();
+ expect(screen.getByText('2024-01-01')).toBeInTheDocument();
+ expect(screen.getByText('1000원')).toBeInTheDocument();
+ });
+
+ it('handles very long transaction titles', () => {
+ const longTitleTransaction: Transaction = {
+ ...mockTransaction,
+ title: 'This is a very long transaction title that might overflow the container and cause layout issues',
+ };
+
+ render(
);
+
+ expect(screen.getByText(longTitleTransaction.title)).toBeInTheDocument();
+ });
+
+ it('handles zero and negative amounts', () => {
+ const zeroAmountTransaction: Transaction = {
+ ...mockTransaction,
+ amount: 0,
+ };
+
+ const negativeAmountTransaction: Transaction = {
+ ...mockTransaction,
+ amount: -5000,
+ };
+
+ const { rerender } = render(
);
+ expect(screen.getByText('0원')).toBeInTheDocument();
+
+ rerender(
);
+ expect(screen.getByText('-5000원')).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx
index 5a2778f..ab26355 100644
--- a/src/components/auth/LoginForm.tsx
+++ b/src/components/auth/LoginForm.tsx
@@ -48,7 +48,7 @@ const LoginForm: React.FC
= ({
loginError.includes("Not Found"));
return (