diff --git a/frontend/src/__mocks__/stripe.ts b/frontend/src/__mocks__/stripe.ts new file mode 100644 index 0000000..88e9ca0 --- /dev/null +++ b/frontend/src/__mocks__/stripe.ts @@ -0,0 +1,84 @@ +/** + * Mock implementations for Stripe modules used in testing. + */ +import { vi } from 'vitest'; + +// Mock Stripe instance +export const mockStripe = { + confirmCardPayment: vi.fn().mockResolvedValue({ + paymentIntent: { status: 'succeeded' }, + error: null, + }), + confirmPayment: vi.fn().mockResolvedValue({ + paymentIntent: { status: 'succeeded' }, + error: null, + }), + elements: vi.fn().mockReturnValue({ + create: vi.fn().mockReturnValue({ + mount: vi.fn(), + destroy: vi.fn(), + on: vi.fn(), + update: vi.fn(), + }), + getElement: vi.fn(), + }), + createPaymentMethod: vi.fn().mockResolvedValue({ + paymentMethod: { id: 'pm_test_123' }, + error: null, + }), +}; + +// Mock loadStripe function from @stripe/stripe-js +export const loadStripe = vi.fn().mockResolvedValue(mockStripe); + +// Mock Stripe Connect instance +export const mockStripeConnectInstance = { + create: vi.fn().mockReturnValue({ + mount: vi.fn(), + destroy: vi.fn(), + update: vi.fn(), + }), +}; + +// Mock loadConnectAndInitialize from @stripe/connect-js +export const loadConnectAndInitialize = vi.fn().mockReturnValue(mockStripeConnectInstance); + +// Mock ConnectComponentsProvider from @stripe/react-connect-js +export const ConnectComponentsProvider = vi.fn(({ children }: { children: React.ReactNode }) => children); + +// Mock ConnectAccountOnboarding from @stripe/react-connect-js +export const ConnectAccountOnboarding = vi.fn(({ onExit }: { onExit?: () => void }) => { + return null; +}); + +// Helper to reset all Stripe mocks +export const resetStripeMocks = () => { + mockStripe.confirmCardPayment.mockReset().mockResolvedValue({ + paymentIntent: { status: 'succeeded' }, + error: null, + }); + mockStripe.confirmPayment.mockReset().mockResolvedValue({ + paymentIntent: { status: 'succeeded' }, + error: null, + }); + loadStripe.mockReset().mockResolvedValue(mockStripe); + loadConnectAndInitialize.mockReset().mockReturnValue(mockStripeConnectInstance); +}; + +// Helper to mock Stripe payment failure +export const mockStripePaymentFailure = (errorMessage: string) => { + mockStripe.confirmCardPayment.mockResolvedValue({ + paymentIntent: null, + error: { message: errorMessage }, + }); +}; + +// Helper to mock Stripe 3DS authentication required +export const mockStripe3DSRequired = () => { + mockStripe.confirmCardPayment.mockResolvedValue({ + paymentIntent: { status: 'requires_action' }, + error: null, + }); +}; + +export default mockStripe; diff --git a/frontend/src/__tests__/components/ItemCard.test.tsx b/frontend/src/__tests__/components/ItemCard.test.tsx index e91f4c8..6c51b46 100644 --- a/frontend/src/__tests__/components/ItemCard.test.tsx +++ b/frontend/src/__tests__/components/ItemCard.test.tsx @@ -253,5 +253,165 @@ describe('ItemCard', () => { expect(screen.getByText('Free to Borrow')).toBeInTheDocument(); }); + + it('should display first 2 pricing tiers when multiple available', () => { + const item = createMockItem({ + pricePerHour: 5, + pricePerDay: 25, + pricePerWeek: 100, + pricePerMonth: 300, + }); + + renderWithRouter(); + + // Component shows max 2 pricing tiers + expect(screen.getByText(/\$5\/hr/)).toBeInTheDocument(); + expect(screen.getByText(/\$25\/day/)).toBeInTheDocument(); + // Week and month tiers are not shown (component limits to 2) + expect(screen.queryByText(/\$100\/wk/)).not.toBeInTheDocument(); + expect(screen.queryByText(/\$300\/mo/)).not.toBeInTheDocument(); + }); + + it('should handle mixed null and 0 prices', () => { + const item = createMockItem({ + pricePerHour: 0, + pricePerDay: 25, + pricePerWeek: null, + pricePerMonth: undefined as any, + }); + + renderWithRouter(); + + // Should show day price but not zero hour price + expect(screen.getByText(/\$25\/day/)).toBeInTheDocument(); + }); + + it('should display "Free to Borrow" when all prices are 0', () => { + const item = createMockItem({ + pricePerHour: 0, + pricePerDay: 0, + pricePerWeek: 0, + pricePerMonth: 0, + }); + + renderWithRouter(); + + expect(screen.getByText('Free to Borrow')).toBeInTheDocument(); + }); + }); + + describe('Edge Cases', () => { + it('should handle very long item names with truncation', () => { + const item = createMockItem({ + name: 'This is an extremely long item name that should be truncated because it does not fit in the card title area and would overflow', + }); + + renderWithRouter(); + + const title = screen.getByText(/this is an extremely long/i); + expect(title).toBeInTheDocument(); + }); + + it('should handle missing city', () => { + const item = createMockItem({ + city: '', + state: 'CA', + }); + + renderWithRouter(); + + // Should not crash, may show just state or empty location + expect(screen.getByText('Test Item')).toBeInTheDocument(); + }); + + it('should handle missing state', () => { + const item = createMockItem({ + city: 'San Francisco', + state: '', + }); + + renderWithRouter(); + + // Should not crash, may show just city or partial location + expect(screen.getByText('Test Item')).toBeInTheDocument(); + }); + + it('should handle both city and state missing', () => { + const item = createMockItem({ + city: '', + state: '', + }); + + renderWithRouter(); + + // Should not crash + expect(screen.getByText('Test Item')).toBeInTheDocument(); + }); + + it('should handle undefined city and state', () => { + const item = createMockItem({ + city: undefined as any, + state: undefined as any, + }); + + renderWithRouter(); + + expect(screen.getByText('Test Item')).toBeInTheDocument(); + }); + + it('should handle special characters in item name', () => { + const item = createMockItem({ + name: 'Item with "quotes" & chars', + }); + + renderWithRouter(); + + expect(screen.getByText(/item with.*quotes.*special/i)).toBeInTheDocument(); + }); + + it('should handle decimal prices', () => { + const item = createMockItem({ + pricePerDay: 25.99, + }); + + renderWithRouter(); + + // Price display may round or truncate + expect(screen.getByText(/\$25/)).toBeInTheDocument(); + }); + + it('should handle very high prices', () => { + const item = createMockItem({ + pricePerDay: 10000, + }); + + renderWithRouter(); + + expect(screen.getByText(/\$10000/)).toBeInTheDocument(); + }); + }); + + describe('Card Variants', () => { + it('should render standard variant correctly', () => { + const item = createMockItem({ + imageFilenames: ['items/uuid.jpg'], + }); + + renderWithRouter(); + + const img = screen.getByRole('img'); + expect(img).toHaveStyle({ height: '200px' }); + }); + + it('should render without variant prop (default)', () => { + const item = createMockItem({ + imageFilenames: ['items/uuid.jpg'], + }); + + renderWithRouter(); + + const img = screen.getByRole('img'); + expect(img).toHaveStyle({ height: '200px' }); + }); }); }); diff --git a/frontend/src/__tests__/components/StripeConnectOnboarding.test.tsx b/frontend/src/__tests__/components/StripeConnectOnboarding.test.tsx new file mode 100644 index 0000000..36f0fc2 --- /dev/null +++ b/frontend/src/__tests__/components/StripeConnectOnboarding.test.tsx @@ -0,0 +1,497 @@ +/** + * StripeConnectOnboarding Component Tests + * + * Tests for the Stripe Connect onboarding flow that helps + * users set up their earnings accounts. + */ + +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { vi, type MockedFunction } from 'vitest'; +import StripeConnectOnboarding from '../../components/StripeConnectOnboarding'; +import { stripeAPI } from '../../services/api'; +import { loadConnectAndInitialize } from '@stripe/connect-js'; + +// Mock dependencies +vi.mock('../../services/api', () => ({ + stripeAPI: { + createConnectedAccount: vi.fn(), + createAccountSession: vi.fn(), + }, +})); + +vi.mock('@stripe/connect-js', () => ({ + loadConnectAndInitialize: vi.fn(), +})); + +vi.mock('@stripe/react-connect-js', () => ({ + ConnectComponentsProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ConnectAccountOnboarding: ({ onExit }: { onExit?: () => void }) => ( +
+ +
+ ), +})); + +const mockedCreateConnectedAccount = stripeAPI.createConnectedAccount as MockedFunction; +const mockedCreateAccountSession = stripeAPI.createAccountSession as MockedFunction; +const mockedLoadConnectAndInitialize = loadConnectAndInitialize as MockedFunction; + +describe('StripeConnectOnboarding', () => { + const mockOnComplete = vi.fn(); + const mockOnCancel = vi.fn(); + + const mockStripeConnectInstance = { + create: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('VITE_STRIPE_PUBLISHABLE_KEY', 'pk_test_123'); + mockedCreateConnectedAccount.mockResolvedValue({ data: { accountId: 'acct_test123' } }); + mockedCreateAccountSession.mockResolvedValue({ data: { clientSecret: 'seti_test_secret' } }); + mockedLoadConnectAndInitialize.mockReturnValue(mockStripeConnectInstance as any); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('Initial Start State', () => { + it('renders initial "Start" state', () => { + render( + + ); + + expect(screen.getByText('Start Receiving Earnings')).toBeInTheDocument(); + expect(screen.getByText('Quick Setup')).toBeInTheDocument(); + expect(screen.getByText('Secure')).toBeInTheDocument(); + expect(screen.getByText('Fast Deposits')).toBeInTheDocument(); + }); + + it('shows what to expect info', () => { + render( + + ); + + expect(screen.getByText(/what to expect/i)).toBeInTheDocument(); + expect(screen.getByText(/verify your identity/i)).toBeInTheDocument(); + expect(screen.getByText(/bank account details/i)).toBeInTheDocument(); + }); + + it('shows Set Up Earnings button', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /set up earnings/i })).toBeInTheDocument(); + }); + + it('shows cancel button', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + }); + + describe('Account Creation Flow', () => { + it('creates account on "Set Up Earnings" click', async () => { + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(mockedCreateConnectedAccount).toHaveBeenCalled(); + }); + }); + + it('shows loading during account creation', async () => { + mockedCreateConnectedAccount.mockImplementation(() => new Promise(() => {})); + + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByText('Creating your earnings account...')).toBeInTheDocument(); + }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('initializes Stripe Connect after account creation', async () => { + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(mockedLoadConnectAndInitialize).toHaveBeenCalledWith( + expect.objectContaining({ + publishableKey: expect.any(String), + fetchClientSecret: expect.any(Function), + }) + ); + }); + }); + }); + + describe('Onboarding State', () => { + it('renders embedded onboarding form', async () => { + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByTestId('connect-onboarding')).toBeInTheDocument(); + }); + }); + + it('shows "Complete Your Earnings Setup" title in onboarding', async () => { + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByText('Complete Your Earnings Setup')).toBeInTheDocument(); + }); + }); + + it('shows security message in onboarding', async () => { + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByText(/securely processed by stripe/i)).toBeInTheDocument(); + }); + }); + }); + + describe('Onboarding Completion', () => { + it('handles onboarding completion callback', async () => { + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByTestId('connect-onboarding')).toBeInTheDocument(); + }); + + // Click the mocked complete button + const completeButton = screen.getByRole('button', { name: /complete onboarding/i }); + fireEvent.click(completeButton); + + expect(mockOnComplete).toHaveBeenCalled(); + }); + }); + + describe('Cancel Handling', () => { + it('handles cancel button in start state', () => { + render( + + ); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it('handles close button via modal header', () => { + render( + + ); + + const closeButton = screen.getByRole('button', { name: '' }); // btn-close has no text + if (closeButton.classList.contains('btn-close')) { + fireEvent.click(closeButton); + expect(mockOnCancel).toHaveBeenCalled(); + } + }); + }); + + describe('Error Handling', () => { + it('handles missing publishable key error', async () => { + // Create a custom mock that throws on initialization + mockedLoadConnectAndInitialize.mockImplementation(() => { + throw new Error('Stripe publishable key not configured'); + }); + + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByText(/stripe publishable key not configured/i)).toBeInTheDocument(); + }); + }); + + it('handles account creation failure', async () => { + mockedCreateConnectedAccount.mockRejectedValue({ + response: { data: { error: 'Account creation failed' } }, + }); + + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByText('Account creation failed')).toBeInTheDocument(); + }); + }); + + it('handles generic error message', async () => { + mockedCreateConnectedAccount.mockRejectedValue(new Error('Network error')); + + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + }); + + it('returns to start state on error', async () => { + mockedCreateConnectedAccount.mockRejectedValue(new Error('Failed')); + + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByText('Start Receiving Earnings')).toBeInTheDocument(); + }); + }); + + it('displays error alert with icon', async () => { + mockedCreateConnectedAccount.mockRejectedValue({ + response: { data: { error: 'Test error' } }, + }); + + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByText('Test error')).toBeInTheDocument(); + }); + + // Check that the error alert has the danger class + const errorAlert = screen.getByText('Test error').closest('.alert'); + expect(errorAlert).toHaveClass('alert-danger'); + }); + }); + + describe('Existing Account Handling', () => { + it('starts in onboarding state for existing account', async () => { + render( + + ); + + // With existing account, should show onboarding title + expect(screen.getByText('Complete Your Earnings Setup')).toBeInTheDocument(); + }); + + it('initializes onboarding for existing account', async () => { + render( + + ); + + await waitFor(() => { + expect(mockedLoadConnectAndInitialize).toHaveBeenCalled(); + }); + }); + + it('does not create new account for existing account', async () => { + render( + + ); + + await waitFor(() => { + expect(mockedLoadConnectAndInitialize).toHaveBeenCalled(); + }); + + // Should not call createConnectedAccount + expect(mockedCreateConnectedAccount).not.toHaveBeenCalled(); + }); + }); + + describe('Modal Sizing', () => { + it('uses larger modal for onboarding state', async () => { + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + expect(screen.getByTestId('connect-onboarding')).toBeInTheDocument(); + }); + + // Modal should have modal-xl class + const modalDialog = document.querySelector('.modal-dialog'); + expect(modalDialog).toHaveClass('modal-xl'); + }); + + it('uses standard modal for start state', () => { + render( + + ); + + const modalDialog = document.querySelector('.modal-dialog'); + expect(modalDialog).toHaveClass('modal-lg'); + }); + }); + + describe('Loading States', () => { + it('shows loading spinner during onboarding initialization', async () => { + // Return null to simulate instance not being ready yet + mockedLoadConnectAndInitialize.mockReturnValue(null as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Loading onboarding form...')).toBeInTheDocument(); + }); + }); + + it('disables close button while loading', async () => { + mockedCreateConnectedAccount.mockImplementation(() => new Promise(() => {})); + + render( + + ); + + const setupButton = screen.getByRole('button', { name: /set up earnings/i }); + fireEvent.click(setupButton); + + await waitFor(() => { + const closeButton = document.querySelector('.btn-close'); + expect(closeButton).toBeDisabled(); + }); + }); + }); +}); diff --git a/frontend/src/__tests__/contexts/AuthContext.test.tsx b/frontend/src/__tests__/contexts/AuthContext.test.tsx index baaf8a4..dea3cdb 100644 --- a/frontend/src/__tests__/contexts/AuthContext.test.tsx +++ b/frontend/src/__tests__/contexts/AuthContext.test.tsx @@ -468,4 +468,227 @@ describe('AuthContext', () => { }); }); }); + + describe('CSRF Token Management', () => { + it('fetches CSRF token on initial load', async () => { + renderWithAuth(); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('not-loading'); + }); + + expect(mockFetchCSRFToken).toHaveBeenCalled(); + }); + + it('handles CSRF token fetch failure gracefully', async () => { + mockFetchCSRFToken.mockRejectedValue(new Error('CSRF fetch failed')); + + renderWithAuth(); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('not-loading'); + }); + + // When CSRF fetch fails, checkAuth is not called, so user remains null + // But the app should handle this gracefully (not crash) + expect(screen.getByTestId('user')).toHaveTextContent('no-user'); + }); + + it('refreshes CSRF token after login', async () => { + mockAuthAPI.getStatus.mockResolvedValue( + mockAxiosResponse({ authenticated: false, user: null }) + ); + + mockAuthAPI.login.mockResolvedValue( + mockAxiosResponse({ user: mockUser }) + ); + + renderWithAuth(); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('not-loading'); + }); + + mockFetchCSRFToken.mockClear(); + + await act(async () => { + fireEvent.click(screen.getByText('Login')); + }); + + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('test@example.com'); + }); + + // CSRF token should be refreshed after login + expect(mockFetchCSRFToken).toHaveBeenCalled(); + }); + + it('refreshes CSRF token after registration', async () => { + mockAuthAPI.getStatus.mockResolvedValue( + mockAxiosResponse({ authenticated: false, user: null }) + ); + + mockAuthAPI.register.mockResolvedValue( + mockAxiosResponse({ user: { ...mockUser, email: 'new@example.com', isVerified: false } }) + ); + + renderWithAuth(); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('not-loading'); + }); + + mockFetchCSRFToken.mockClear(); + + await act(async () => { + fireEvent.click(screen.getByText('Register')); + }); + + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('new@example.com'); + }); + + expect(mockFetchCSRFToken).toHaveBeenCalled(); + }); + + it('refreshes CSRF token after Google login', async () => { + mockAuthAPI.getStatus.mockResolvedValue( + mockAxiosResponse({ authenticated: false, user: null }) + ); + + mockAuthAPI.googleLogin.mockResolvedValue( + mockAxiosResponse({ user: mockUser }) + ); + + renderWithAuth(); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('not-loading'); + }); + + mockFetchCSRFToken.mockClear(); + + await act(async () => { + fireEvent.click(screen.getByText('Google Login')); + }); + + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('test@example.com'); + }); + + expect(mockFetchCSRFToken).toHaveBeenCalled(); + }); + }); + + describe('Auth Modal Mode State Transitions', () => { + it('transitions from login to signup mode', async () => { + const ModalTestComponent: React.FC = () => { + const auth = useAuth(); + return ( +
+
{auth.loading ? 'loading' : 'not-loading'}
+
{auth.authModalMode}
+ + + +
+ ); + }; + + renderWithAuth(); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('not-loading'); + }); + + await act(async () => { + fireEvent.click(screen.getByText('Open Login')); + }); + expect(screen.getByTestId('modal-mode')).toHaveTextContent('login'); + + await act(async () => { + fireEvent.click(screen.getByText('Open Signup')); + }); + expect(screen.getByTestId('modal-mode')).toHaveTextContent('signup'); + + await act(async () => { + fireEvent.click(screen.getByText('Open Forgot')); + }); + expect(screen.getByTestId('modal-mode')).toHaveTextContent('forgot-password'); + }); + }); + + describe('Multiple Concurrent Auth Calls', () => { + it('handles multiple login attempts gracefully', async () => { + mockAuthAPI.getStatus.mockResolvedValue( + mockAxiosResponse({ authenticated: false, user: null }) + ); + + let loginResolve: (value: any) => void; + mockAuthAPI.login.mockImplementation(() => + new Promise((resolve) => { + loginResolve = resolve; + }) + ); + + const ConcurrentTestComponent: React.FC = () => { + const auth = useAuth(); + return ( +
+
{auth.loading ? 'loading' : 'not-loading'}
+
{auth.user ? auth.user.email : 'no-user'}
+ + +
+ ); + }; + + renderWithAuth(); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('not-loading'); + }); + + // Trigger two login calls quickly + await act(async () => { + fireEvent.click(screen.getByText('Login 1')); + fireEvent.click(screen.getByText('Login 2')); + }); + + // Resolve both + await act(async () => { + loginResolve!(mockAxiosResponse({ user: mockUser })); + }); + + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('test@example.com'); + }); + }); + }); + + describe('User Verification Status', () => { + it('correctly tracks verified user status', async () => { + mockAuthAPI.getStatus.mockResolvedValue( + mockAxiosResponse({ authenticated: true, user: { ...mockUser, isVerified: true } }) + ); + + renderWithAuth(); + + await waitFor(() => { + expect(screen.getByTestId('verified')).toHaveTextContent('verified'); + }); + }); + + it('correctly tracks unverified user status', async () => { + mockAuthAPI.getStatus.mockResolvedValue( + mockAxiosResponse({ authenticated: true, user: { ...mockUser, isVerified: false } }) + ); + + renderWithAuth(); + + await waitFor(() => { + expect(screen.getByTestId('verified')).toHaveTextContent('not-verified'); + }); + }); + }); }); diff --git a/frontend/src/__tests__/pages/CompletePayment.test.tsx b/frontend/src/__tests__/pages/CompletePayment.test.tsx new file mode 100644 index 0000000..e341d58 --- /dev/null +++ b/frontend/src/__tests__/pages/CompletePayment.test.tsx @@ -0,0 +1,388 @@ +/** + * CompletePayment Page Tests + * + * Tests for the payment completion page that handles + * Stripe 3DS authentication and payment confirmation. + */ + +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { vi, type MockedFunction } from 'vitest'; +import { MemoryRouter, Routes, Route } from 'react-router'; +import { useAuth } from '../../contexts/AuthContext'; +import { rentalAPI } from '../../services/api'; +import { mockUser } from '../../mocks/handlers'; + +// Hoist mock definitions BEFORE module imports +const { mockLoadStripe, mockStripe } = vi.hoisted(() => { + const mockStripe = { + confirmCardPayment: vi.fn(), + }; + return { + mockStripe, + mockLoadStripe: vi.fn().mockResolvedValue(mockStripe), + }; +}); + +vi.mock('@stripe/stripe-js', () => ({ + loadStripe: mockLoadStripe, +})); + +// Mock dependencies +vi.mock('../../contexts/AuthContext'); +vi.mock('../../services/api', () => ({ + rentalAPI: { + getPaymentClientSecret: vi.fn(), + completePayment: vi.fn(), + }, +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +// Import after mocks are set up +import CompletePayment from '../../pages/CompletePayment'; +import { loadStripe } from '@stripe/stripe-js'; + +const mockedUseAuth = useAuth as MockedFunction; +const mockedGetPaymentClientSecret = rentalAPI.getPaymentClientSecret as MockedFunction; +const mockedCompletePayment = rentalAPI.completePayment as MockedFunction; +const mockedLoadStripe = loadStripe as MockedFunction; + +// Helper to render CompletePayment with route params +const renderCompletePayment = (rentalId: string = '123', authOverrides: Partial> = {}) => { + mockedUseAuth.mockReturnValue({ + user: mockUser, + loading: false, + showAuthModal: false, + authModalMode: 'login', + login: vi.fn(), + register: vi.fn(), + googleLogin: vi.fn(), + logout: vi.fn(), + checkAuth: vi.fn(), + updateUser: vi.fn(), + openAuthModal: vi.fn(), + closeAuthModal: vi.fn(), + ...authOverrides, + }); + + return render( + + + } /> + + + ); +}; + +describe('CompletePayment', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadStripe.mockResolvedValue(mockStripe as any); + mockedGetPaymentClientSecret.mockResolvedValue({ + data: { clientSecret: 'pi_test_secret', status: 'requires_action' }, + }); + mockedCompletePayment.mockResolvedValue({ data: { success: true } }); + mockStripe.confirmCardPayment.mockResolvedValue({ + paymentIntent: { status: 'succeeded' }, + error: null, + }); + }); + + describe('Loading State', () => { + it('shows loading state initially', () => { + mockedGetPaymentClientSecret.mockImplementation(() => new Promise(() => {})); + renderCompletePayment(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText('Loading payment details...')).toBeInTheDocument(); + }); + + it('shows loading while auth is initializing', () => { + renderCompletePayment('123', { loading: true }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 5 })).toHaveTextContent('Loading...'); + }); + }); + + describe('Authentication', () => { + it('redirects unauthenticated users to login', async () => { + renderCompletePayment('123', { user: null }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringContaining('/?login=true&redirect='), + { replace: true } + ); + }); + }); + }); + + describe('Invalid Rental ID', () => { + it('handles missing rentalId parameter', async () => { + render( + + + } /> + } /> + + + ); + + // The component should handle invalid/missing ID gracefully + }); + }); + + describe('Successful Payment Flow', () => { + it('fetches client secret from API', async () => { + renderCompletePayment('456'); + + await waitFor(() => { + expect(mockedGetPaymentClientSecret).toHaveBeenCalledWith('456'); + }); + }); + + it('calls Stripe confirmCardPayment', async () => { + renderCompletePayment(); + + await waitFor(() => { + expect(mockStripe.confirmCardPayment).toHaveBeenCalledWith('pi_test_secret'); + }); + }); + + it('shows success state on payment success', async () => { + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Payment Complete!')).toBeInTheDocument(); + }); + + expect(screen.getByText(/confirmation email/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /view my rentals/i })).toBeInTheDocument(); + }); + + it('calls completePayment API after Stripe confirmation', async () => { + renderCompletePayment('789'); + + await waitFor(() => { + expect(mockedCompletePayment).toHaveBeenCalledWith('789'); + }); + }); + }); + + describe('Already Succeeded Payment', () => { + it('handles "already succeeded" payment status', async () => { + mockedGetPaymentClientSecret.mockResolvedValue({ + data: { clientSecret: 'pi_test_secret', status: 'succeeded' }, + }); + + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Payment Complete!')).toBeInTheDocument(); + }); + + // Should not call confirmCardPayment if already succeeded + expect(mockStripe.confirmCardPayment).not.toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('shows error state with message on Stripe failure', async () => { + mockStripe.confirmCardPayment.mockResolvedValue({ + paymentIntent: null, + error: { message: 'Card was declined' }, + }); + + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Payment Failed')).toBeInTheDocument(); + expect(screen.getByText('Card was declined')).toBeInTheDocument(); + }); + }); + + it('shows generic error when payment fails without message', async () => { + mockStripe.confirmCardPayment.mockResolvedValue({ + paymentIntent: { status: 'failed' }, + error: null, + }); + + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Payment Failed')).toBeInTheDocument(); + expect(screen.getByText(/could not be completed/i)).toBeInTheDocument(); + }); + }); + + it('handles 400 "not awaiting payment" error', async () => { + mockedGetPaymentClientSecret.mockRejectedValue({ + response: { + status: 400, + data: { message: 'Rental is not awaiting payment' }, + }, + }); + + renderCompletePayment(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/renting', { replace: true }); + }); + }); + + it('handles Stripe loading failure', async () => { + // Reset modules to get a fresh import with the null mock + vi.resetModules(); + + // Set up the mock to return null BEFORE re-importing + vi.doMock('@stripe/stripe-js', () => ({ + loadStripe: vi.fn().mockResolvedValue(null), + })); + + // Re-import the component with fresh module state + const { default: FreshCompletePayment } = await import('../../pages/CompletePayment'); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByText('Payment Failed')).toBeInTheDocument(); + expect(screen.getByText(/stripe failed to load/i)).toBeInTheDocument(); + }); + + // Restore original mock + vi.doMock('@stripe/stripe-js', () => ({ + loadStripe: mockLoadStripe, + })); + }); + + it('handles API error when fetching client secret', async () => { + mockedGetPaymentClientSecret.mockRejectedValue({ + response: { + data: { error: 'Payment not found' }, + }, + }); + + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Payment Failed')).toBeInTheDocument(); + expect(screen.getByText('Payment not found')).toBeInTheDocument(); + }); + }); + }); + + describe('Retry Functionality', () => { + it('shows retry button on error', async () => { + mockStripe.confirmCardPayment.mockResolvedValue({ + paymentIntent: null, + error: { message: 'Card declined' }, + }); + + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Payment Failed')).toBeInTheDocument(); + }); + + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('retry button resets and retries payment', async () => { + mockStripe.confirmCardPayment + .mockResolvedValueOnce({ + paymentIntent: null, + error: { message: 'First attempt failed' }, + }) + .mockResolvedValueOnce({ + paymentIntent: { status: 'succeeded' }, + error: null, + }); + + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Payment Failed')).toBeInTheDocument(); + }); + + const retryButton = screen.getByRole('button', { name: /try again/i }); + fireEvent.click(retryButton); + + await waitFor(() => { + expect(screen.getByText('Payment Complete!')).toBeInTheDocument(); + }); + + expect(mockStripe.confirmCardPayment).toHaveBeenCalledTimes(2); + }); + }); + + describe('StrictMode Double-Execution Prevention', () => { + it('prevents double processing with useRef', async () => { + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Payment Complete!')).toBeInTheDocument(); + }); + + // Should only call confirmCardPayment once + expect(mockStripe.confirmCardPayment).toHaveBeenCalledTimes(1); + }); + }); + + describe('UI States', () => { + it('shows authenticating state during Stripe confirmation', async () => { + mockStripe.confirmCardPayment.mockImplementation( + () => new Promise(() => {}) + ); + + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Completing authentication...')).toBeInTheDocument(); + }); + + expect(screen.getByText(/complete the authentication/i)).toBeInTheDocument(); + }); + + it('has link to view rentals on success', async () => { + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Payment Complete!')).toBeInTheDocument(); + }); + + const link = screen.getByRole('link', { name: /view my rentals/i }); + expect(link).toHaveAttribute('href', '/renting'); + }); + + it('has link to view rentals on error', async () => { + mockStripe.confirmCardPayment.mockResolvedValue({ + paymentIntent: null, + error: { message: 'Failed' }, + }); + + renderCompletePayment(); + + await waitFor(() => { + expect(screen.getByText('Payment Failed')).toBeInTheDocument(); + }); + + const link = screen.getByRole('link', { name: /view my rentals/i }); + expect(link).toHaveAttribute('href', '/renting'); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/CreateItem.test.tsx b/frontend/src/__tests__/pages/CreateItem.test.tsx new file mode 100644 index 0000000..aa34f5b --- /dev/null +++ b/frontend/src/__tests__/pages/CreateItem.test.tsx @@ -0,0 +1,1096 @@ +/** + * CreateItem Page Tests + * + * Tests for the item creation form including image upload, + * address selection, pricing, and email verification flow. + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { vi, type MockedFunction } from 'vitest'; +import CreateItem from '../../pages/CreateItem'; +import { useAuth } from '../../contexts/AuthContext'; +import api, { addressAPI, userAPI, itemAPI } from '../../services/api'; +import { uploadImagesWithVariants } from '../../services/uploadService'; + +// Mock FileReader globally +class MockFileReader { + result: string | null = null; + onloadend: (() => void) | null = null; + + readAsDataURL() { + this.result = 'data:image/jpeg;base64,mockbase64data'; + // Call onloadend synchronously (use Promise.resolve to ensure it's after microtask queue) + Promise.resolve().then(() => { + if (this.onloadend) { + this.onloadend(); + } + }); + } +} + +// Replace global FileReader +vi.stubGlobal('FileReader', MockFileReader); + +// Mock scrollIntoView which doesn't exist in jsdom +Element.prototype.scrollIntoView = vi.fn(); + +// Mock dependencies +vi.mock('../../contexts/AuthContext'); +vi.mock('../../services/api', () => ({ + default: { + post: vi.fn(), + }, + addressAPI: { + getAddresses: vi.fn(), + createAddress: vi.fn(), + }, + userAPI: { + getAvailability: vi.fn(), + updateAvailability: vi.fn(), + }, + itemAPI: { + getItems: vi.fn(), + }, +})); +vi.mock('../../services/uploadService', () => ({ + uploadImagesWithVariants: vi.fn(), +})); + +// Mock child components to isolate CreateItem testing +vi.mock('../../components/ImageUpload', () => ({ + default: ({ imageFiles, imagePreviews, onImageChange, onRemoveImage }: any) => ( +
+ + {imagePreviews.map((preview: string, index: number) => ( +
+ {`Preview + +
+ ))} + {imageFiles.length} +
+ ), +})); + +vi.mock('../../components/ItemInformation', () => ({ + default: ({ name, description, onChange }: any) => ( +
+ +