More frontend tests

This commit is contained in:
jackiettran
2026-01-20 14:19:22 -05:00
parent 28554acc2d
commit fcce10e664
19 changed files with 7055 additions and 9 deletions

View File

@@ -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;

View File

@@ -253,5 +253,165 @@ describe('ItemCard', () => {
expect(screen.getByText('Free to Borrow')).toBeInTheDocument(); 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(<ItemCard item={item} />);
// 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(<ItemCard item={item} />);
// 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(<ItemCard item={item} />);
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(<ItemCard item={item} />);
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(<ItemCard item={item} />);
// 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(<ItemCard item={item} />);
// 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(<ItemCard item={item} />);
// 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(<ItemCard item={item} />);
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
it('should handle special characters in item name', () => {
const item = createMockItem({
name: 'Item with "quotes" & <special> chars',
});
renderWithRouter(<ItemCard item={item} />);
expect(screen.getByText(/item with.*quotes.*special/i)).toBeInTheDocument();
});
it('should handle decimal prices', () => {
const item = createMockItem({
pricePerDay: 25.99,
});
renderWithRouter(<ItemCard item={item} />);
// Price display may round or truncate
expect(screen.getByText(/\$25/)).toBeInTheDocument();
});
it('should handle very high prices', () => {
const item = createMockItem({
pricePerDay: 10000,
});
renderWithRouter(<ItemCard item={item} />);
expect(screen.getByText(/\$10000/)).toBeInTheDocument();
});
});
describe('Card Variants', () => {
it('should render standard variant correctly', () => {
const item = createMockItem({
imageFilenames: ['items/uuid.jpg'],
});
renderWithRouter(<ItemCard item={item} variant="standard" />);
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(<ItemCard item={item} />);
const img = screen.getByRole('img');
expect(img).toHaveStyle({ height: '200px' });
});
}); });
}); });

View File

@@ -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 }) => (
<div data-testid="connect-provider">{children}</div>
),
ConnectAccountOnboarding: ({ onExit }: { onExit?: () => void }) => (
<div data-testid="connect-onboarding">
<button onClick={onExit}>Complete Onboarding</button>
</div>
),
}));
const mockedCreateConnectedAccount = stripeAPI.createConnectedAccount as MockedFunction<typeof stripeAPI.createConnectedAccount>;
const mockedCreateAccountSession = stripeAPI.createAccountSession as MockedFunction<typeof stripeAPI.createAccountSession>;
const mockedLoadConnectAndInitialize = loadConnectAndInitialize as MockedFunction<typeof loadConnectAndInitialize>;
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
expect(screen.getByRole('button', { name: /set up earnings/i })).toBeInTheDocument();
});
it('shows cancel button', () => {
render(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
});
describe('Account Creation Flow', () => {
it('creates account on "Set Up Earnings" click', async () => {
render(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
it('handles close button via modal header', () => {
render(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
hasExistingAccount={true}
/>
);
// With existing account, should show onboarding title
expect(screen.getByText('Complete Your Earnings Setup')).toBeInTheDocument();
});
it('initializes onboarding for existing account', async () => {
render(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
hasExistingAccount={true}
/>
);
await waitFor(() => {
expect(mockedLoadConnectAndInitialize).toHaveBeenCalled();
});
});
it('does not create new account for existing account', async () => {
render(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
hasExistingAccount={true}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
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(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
hasExistingAccount={true}
/>
);
await waitFor(() => {
expect(screen.getByText('Loading onboarding form...')).toBeInTheDocument();
});
});
it('disables close button while loading', async () => {
mockedCreateConnectedAccount.mockImplementation(() => new Promise(() => {}));
render(
<StripeConnectOnboarding
onComplete={mockOnComplete}
onCancel={mockOnCancel}
/>
);
const setupButton = screen.getByRole('button', { name: /set up earnings/i });
fireEvent.click(setupButton);
await waitFor(() => {
const closeButton = document.querySelector('.btn-close');
expect(closeButton).toBeDisabled();
});
});
});
});

View File

@@ -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 (
<div>
<div data-testid="loading">{auth.loading ? 'loading' : 'not-loading'}</div>
<div data-testid="modal-mode">{auth.authModalMode}</div>
<button onClick={() => auth.openAuthModal('login')}>Open Login</button>
<button onClick={() => auth.openAuthModal('signup')}>Open Signup</button>
<button onClick={() => auth.openAuthModal('forgot-password')}>Open Forgot</button>
</div>
);
};
renderWithAuth(<ModalTestComponent />);
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 (
<div>
<div data-testid="loading">{auth.loading ? 'loading' : 'not-loading'}</div>
<div data-testid="user">{auth.user ? auth.user.email : 'no-user'}</div>
<button onClick={() => auth.login('test@example.com', 'pass')}>Login 1</button>
<button onClick={() => auth.login('test@example.com', 'pass')}>Login 2</button>
</div>
);
};
renderWithAuth(<ConcurrentTestComponent />);
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');
});
});
});
}); });

View File

@@ -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<typeof import('react-router')>();
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<typeof useAuth>;
const mockedGetPaymentClientSecret = rentalAPI.getPaymentClientSecret as MockedFunction<typeof rentalAPI.getPaymentClientSecret>;
const mockedCompletePayment = rentalAPI.completePayment as MockedFunction<typeof rentalAPI.completePayment>;
const mockedLoadStripe = loadStripe as MockedFunction<typeof loadStripe>;
// Helper to render CompletePayment with route params
const renderCompletePayment = (rentalId: string = '123', authOverrides: Partial<ReturnType<typeof useAuth>> = {}) => {
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(
<MemoryRouter initialEntries={[`/complete-payment/${rentalId}`]}>
<Routes>
<Route path="/complete-payment/:rentalId" element={<CompletePayment />} />
</Routes>
</MemoryRouter>
);
};
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(
<MemoryRouter initialEntries={['/complete-payment/']}>
<Routes>
<Route path="/complete-payment/" element={<CompletePayment />} />
<Route path="/complete-payment/:rentalId" element={<CompletePayment />} />
</Routes>
</MemoryRouter>
);
// 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(
<MemoryRouter initialEntries={['/complete-payment/123']}>
<Routes>
<Route path="/complete-payment/:rentalId" element={<FreshCompletePayment />} />
</Routes>
</MemoryRouter>
);
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');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,919 @@
/**
* EditItem Page Tests
*
* Tests for the item editing form including loading existing data,
* image management, authorization, and form submission.
*/
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router';
import { vi, type MockedFunction } from 'vitest';
import EditItem from '../../pages/EditItem';
import { useAuth } from '../../contexts/AuthContext';
import { itemAPI, rentalAPI, addressAPI, userAPI } from '../../services/api';
import { uploadImagesWithVariants, getImageUrl } 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', () => ({
itemAPI: {
getItem: vi.fn(),
updateItem: vi.fn(),
getItems: vi.fn(),
},
rentalAPI: {
getListings: vi.fn(),
},
addressAPI: {
getAddresses: vi.fn(),
},
userAPI: {
updateAvailability: vi.fn(),
},
}));
vi.mock('../../services/uploadService', () => ({
uploadImagesWithVariants: vi.fn(),
getImageUrl: vi.fn(),
}));
// Mock child components
vi.mock('../../components/ImageUpload', () => ({
default: ({ imageFiles, imagePreviews, onImageChange, onRemoveImage }: any) => (
<div data-testid="image-upload">
<input
type="file"
data-testid="image-input"
onChange={onImageChange}
multiple
accept="image/*"
/>
{imagePreviews.map((preview: string, index: number) => (
<div key={index} data-testid={`image-preview-${index}`}>
<img src={preview} alt={`Preview ${index}`} />
<button
data-testid={`remove-image-${index}`}
onClick={() => onRemoveImage(index)}
>
Remove
</button>
</div>
))}
<span data-testid="preview-count">{imagePreviews.length}</span>
<span data-testid="new-file-count">{imageFiles.length}</span>
</div>
),
}));
vi.mock('../../components/ItemInformation', () => ({
default: ({ name, description, onChange }: any) => (
<div data-testid="item-information">
<input
id="name"
name="name"
value={name}
onChange={onChange}
data-testid="item-name"
placeholder="Item name"
/>
<textarea
name="description"
value={description}
onChange={onChange}
data-testid="item-description"
placeholder="Description"
/>
</div>
),
}));
vi.mock('../../components/LocationForm', () => ({
default: ({
data,
userAddresses,
selectedAddressId,
addressesLoading,
onChange,
onAddressSelect,
formatAddressDisplay,
}: any) => (
<div data-testid="location-form">
{addressesLoading ? (
<span data-testid="addresses-loading">Loading addresses...</span>
) : (
<>
{userAddresses.length > 0 && (
<select
data-testid="address-select"
value={selectedAddressId}
onChange={(e) => onAddressSelect(e.target.value)}
>
<option value="">Select an address</option>
{userAddresses.map((addr: any) => (
<option key={addr.id} value={addr.id}>
{formatAddressDisplay(addr)}
</option>
))}
<option value="new">Enter new address</option>
</select>
)}
<input
id="address1"
name="address1"
value={data.address1}
onChange={onChange}
data-testid="address1"
placeholder="Address"
/>
<input
id="city"
name="city"
value={data.city}
onChange={onChange}
data-testid="city"
placeholder="City"
/>
<input
id="state"
name="state"
value={data.state}
onChange={onChange}
data-testid="state"
placeholder="State"
/>
<input
id="zipCode"
name="zipCode"
value={data.zipCode}
onChange={onChange}
data-testid="zipCode"
placeholder="ZIP Code"
/>
</>
)}
</div>
),
}));
vi.mock('../../components/AvailabilitySettings', () => ({
default: ({ data }: any) => (
<div data-testid="availability-settings">
<span data-testid="available-after">{data.generalAvailableAfter}</span>
<span data-testid="available-before">{data.generalAvailableBefore}</span>
</div>
),
}));
vi.mock('../../components/PricingForm', () => ({
default: ({
pricePerHour,
pricePerDay,
pricePerWeek,
pricePerMonth,
replacementCost,
showAdvancedPricing,
onChange,
}: any) => (
<div data-testid="pricing-form">
<input
name="pricePerHour"
type="number"
value={pricePerHour}
onChange={onChange}
data-testid="price-per-hour"
placeholder="Price per hour"
/>
<input
name="pricePerDay"
type="number"
value={pricePerDay}
onChange={onChange}
data-testid="price-per-day"
placeholder="Price per day"
/>
<input
name="pricePerWeek"
type="number"
value={pricePerWeek}
onChange={onChange}
data-testid="price-per-week"
placeholder="Price per week"
/>
<input
name="pricePerMonth"
type="number"
value={pricePerMonth}
onChange={onChange}
data-testid="price-per-month"
placeholder="Price per month"
/>
<input
id="replacementCost"
name="replacementCost"
type="number"
value={replacementCost}
onChange={onChange}
data-testid="replacement-cost"
placeholder="Replacement cost"
/>
<span data-testid="advanced-visible">{showAdvancedPricing ? 'yes' : 'no'}</span>
</div>
),
}));
vi.mock('../../components/RulesForm', () => ({
default: ({ rules, onChange }: any) => (
<div data-testid="rules-form">
<textarea
name="rules"
value={rules}
onChange={onChange}
data-testid="rules-input"
placeholder="Rules"
/>
</div>
),
}));
const mockNavigate = vi.fn();
vi.mock('react-router', async () => {
const actual = await vi.importActual('react-router');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>;
const mockedGetItem = itemAPI.getItem as MockedFunction<typeof itemAPI.getItem>;
const mockedUpdateItem = itemAPI.updateItem as MockedFunction<typeof itemAPI.updateItem>;
const mockedGetItems = itemAPI.getItems as MockedFunction<typeof itemAPI.getItems>;
const mockedGetListings = rentalAPI.getListings as MockedFunction<typeof rentalAPI.getListings>;
const mockedGetAddresses = addressAPI.getAddresses as MockedFunction<typeof addressAPI.getAddresses>;
const mockedUpdateAvailability = userAPI.updateAvailability as MockedFunction<typeof userAPI.updateAvailability>;
const mockedUploadImagesWithVariants = uploadImagesWithVariants as MockedFunction<typeof uploadImagesWithVariants>;
const mockedGetImageUrl = getImageUrl as MockedFunction<typeof getImageUrl>;
// Helper to render with Router and route params
const renderWithRouter = (itemId: string = 'item-123') => {
return render(
<MemoryRouter initialEntries={[`/items/${itemId}/edit`]}>
<Routes>
<Route path="/items/:id/edit" element={<EditItem />} />
</Routes>
</MemoryRouter>
);
};
const mockUser = {
id: 'user-123',
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
isVerified: true,
};
const mockItem = {
id: 'item-123',
name: 'Test Camera',
description: 'A nice camera for rent',
pricePerHour: null,
pricePerDay: 50,
pricePerWeek: null,
pricePerMonth: null,
replacementCost: 500,
address1: '123 Main St',
address2: '',
city: 'New York',
state: 'NY',
zipCode: '10001',
country: 'US',
latitude: 40.7128,
longitude: -74.006,
rules: 'Handle with care',
imageFilenames: ['items/existing-image.jpg'],
ownerId: 'user-123',
availableAfter: '09:00',
availableBefore: '18:00',
specifyTimesPerDay: false,
weeklyTimes: {
sunday: { availableAfter: '09:00', availableBefore: '18:00' },
monday: { availableAfter: '09:00', availableBefore: '18:00' },
tuesday: { availableAfter: '09:00', availableBefore: '18:00' },
wednesday: { availableAfter: '09:00', availableBefore: '18:00' },
thursday: { availableAfter: '09:00', availableBefore: '18:00' },
friday: { availableAfter: '09:00', availableBefore: '18:00' },
saturday: { availableAfter: '09:00', availableBefore: '18:00' },
},
};
const mockAddress = {
id: 'addr-1',
address1: '456 Oak Ave',
address2: '',
city: 'Boston',
state: 'MA',
zipCode: '02101',
country: 'US',
latitude: 42.3601,
longitude: -71.0589,
};
describe('EditItem', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedUseAuth.mockReturnValue({
user: mockUser,
isAuthenticated: true,
loading: false,
checkAuth: vi.fn(),
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
googleLogin: vi.fn(),
openAuthModal: vi.fn(),
closeAuthModal: vi.fn(),
setAuthModalMode: vi.fn(),
isAuthModalOpen: false,
authModalMode: 'login',
updateUser: vi.fn(),
} as any);
mockedGetItem.mockResolvedValue({ data: mockItem });
mockedGetListings.mockResolvedValue({ data: [] });
mockedGetAddresses.mockResolvedValue({ data: [] });
mockedGetItems.mockResolvedValue({ data: { items: [mockItem] } });
mockedUpdateItem.mockResolvedValue({ data: mockItem });
mockedUpdateAvailability.mockResolvedValue({ data: {} });
mockedUploadImagesWithVariants.mockResolvedValue([
{ baseKey: 'items/new-upload.jpg' },
]);
mockedGetImageUrl.mockImplementation((key: string) => `https://s3.amazonaws.com/${key}`);
});
describe('Loading State', () => {
it('shows loading spinner while fetching item', async () => {
mockedGetItem.mockImplementation(() => new Promise(() => {})); // Never resolves
renderWithRouter();
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('fetches item data on mount', async () => {
renderWithRouter();
await waitFor(() => {
expect(mockedGetItem).toHaveBeenCalledWith('item-123');
});
});
it('fetches accepted rentals on mount', async () => {
renderWithRouter();
await waitFor(() => {
expect(mockedGetListings).toHaveBeenCalled();
});
});
it('fetches user addresses on mount', async () => {
renderWithRouter();
await waitFor(() => {
expect(mockedGetAddresses).toHaveBeenCalled();
});
});
});
describe('Authorization', () => {
it('shows error when user is not the owner', async () => {
mockedGetItem.mockResolvedValue({
data: { ...mockItem, ownerId: 'different-user' },
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'You are not authorized to edit this item'
);
});
});
it('allows owner to edit their item', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Edit Listing')).toBeInTheDocument();
});
expect(screen.queryByText('not authorized')).not.toBeInTheDocument();
});
});
describe('Form Population', () => {
it('populates form with existing item data', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
expect(screen.getByTestId('item-description')).toHaveValue('A nice camera for rent');
expect(screen.getByTestId('address1')).toHaveValue('123 Main St');
expect(screen.getByTestId('city')).toHaveValue('New York');
expect(screen.getByTestId('state')).toHaveValue('NY');
expect(screen.getByTestId('zipCode')).toHaveValue('10001');
expect(screen.getByTestId('price-per-day')).toHaveValue(50);
expect(screen.getByTestId('replacement-cost')).toHaveValue(500);
expect(screen.getByTestId('rules-input')).toHaveValue('Handle with care');
});
it('displays existing images', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('preview-count')).toHaveTextContent('1');
});
});
it('populates availability settings from item', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('available-after')).toHaveTextContent('09:00');
expect(screen.getByTestId('available-before')).toHaveTextContent('18:00');
});
});
it('auto-expands advanced pricing when multiple tiers set', async () => {
mockedGetItem.mockResolvedValue({
data: {
...mockItem,
pricePerHour: 10,
pricePerDay: 50,
pricePerWeek: 200,
},
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('advanced-visible')).toHaveTextContent('yes');
});
});
});
describe('Image Management', () => {
it('allows adding new images', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('image-upload')).toBeInTheDocument();
});
const file = new File(['test'], 'new-image.jpg', { type: 'image/jpeg' });
const input = screen.getByTestId('image-input');
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByTestId('new-file-count')).toHaveTextContent('1');
});
});
it('allows removing existing images', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('preview-count')).toHaveTextContent('1');
});
fireEvent.click(screen.getByTestId('remove-image-0'));
await waitFor(() => {
expect(screen.getByTestId('preview-count')).toHaveTextContent('0');
});
});
it('allows removing new images', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('image-upload')).toBeInTheDocument();
});
// Add a new image first
const file = new File(['test'], 'new-image.jpg', { type: 'image/jpeg' });
const input = screen.getByTestId('image-input');
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
// Should have 1 existing + 1 new = 2 total previews
expect(screen.getByTestId('preview-count')).toHaveTextContent('2');
});
// Remove the new image (index 1)
fireEvent.click(screen.getByTestId('remove-image-1'));
await waitFor(() => {
expect(screen.getByTestId('preview-count')).toHaveTextContent('1');
});
});
});
describe('Form Validation', () => {
it('shows error when submitting with no images', async () => {
mockedGetItem.mockResolvedValue({
data: { ...mockItem, imageFilenames: [] },
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Edit Listing')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'At least one image is required'
);
});
});
it('shows error when name is empty', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
// Clear the name
fireEvent.change(screen.getByTestId('item-name'), {
target: { value: '' },
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Item name is required');
});
});
it('shows error when address is empty', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
// Clear the address
fireEvent.change(screen.getByTestId('address1'), {
target: { value: '' },
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Address is required');
});
});
it('shows error when replacement cost is empty', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
// Clear replacement cost
fireEvent.change(screen.getByTestId('replacement-cost'), {
target: { value: '' },
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Replacement cost is required');
});
});
});
describe('Successful Submission', () => {
it('submits form with existing images', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(mockedUpdateItem).toHaveBeenCalledWith(
'item-123',
expect.objectContaining({
name: 'Test Camera',
imageFilenames: ['items/existing-image.jpg'],
})
);
});
});
it('submits form with new and existing images', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
// Add a new image
const file = new File(['test'], 'new-image.jpg', { type: 'image/jpeg' });
const input = screen.getByTestId('image-input');
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByTestId('new-file-count')).toHaveTextContent('1');
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(mockedUploadImagesWithVariants).toHaveBeenCalledWith(
'item',
expect.any(Array)
);
});
await waitFor(() => {
expect(mockedUpdateItem).toHaveBeenCalledWith(
'item-123',
expect.objectContaining({
imageFilenames: ['items/existing-image.jpg', 'items/new-upload.jpg'],
})
);
});
});
it('shows success message and redirects', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(screen.getByText('Item updated successfully! Redirecting...')).toBeInTheDocument();
});
// Wait for the redirect (setTimeout 1500ms in the component)
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/items/item-123');
}, { timeout: 3000 });
});
it('shows loading state during submission', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
// Create a promise that doesn't resolve immediately
let resolvePromise: ((value: any) => void) | undefined;
mockedUpdateItem.mockImplementation(
() => new Promise((resolve) => { resolvePromise = resolve; })
);
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Updating...' })).toBeDisabled();
});
// Resolve the promise to clean up
resolvePromise?.({ data: mockItem });
});
it('saves availability when user has only this item', async () => {
mockedGetItems.mockResolvedValue({ data: { items: [mockItem] } });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(mockedUpdateAvailability).toHaveBeenCalledWith(
expect.objectContaining({
generalAvailableAfter: '09:00',
generalAvailableBefore: '18:00',
})
);
});
});
it('does not save availability when user has other items', async () => {
const otherItem = { ...mockItem, id: 'item-456' };
mockedGetItems.mockResolvedValue({ data: { items: [mockItem, otherItem] } });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(mockedUpdateItem).toHaveBeenCalled();
});
// Wait a bit more to ensure no late calls
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockedUpdateAvailability).not.toHaveBeenCalled();
});
});
describe('Error Handling', () => {
it('displays error when item fetch fails', async () => {
mockedGetItem.mockRejectedValue({
response: { data: { message: 'Item not found' } },
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Item not found');
});
});
it('displays error when update fails', async () => {
mockedUpdateItem.mockRejectedValue({
response: { data: { message: 'Update failed' } },
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Update failed');
});
});
it('displays generic error for unknown failures', async () => {
mockedUpdateItem.mockRejectedValue(new Error('Network error'));
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('item-name')).toHaveValue('Test Camera');
});
fireEvent.click(screen.getByRole('button', { name: 'Update Listing' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Network error');
});
});
});
describe('Navigation', () => {
it('navigates back when clicking Back button', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Edit Listing')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(mockNavigate).toHaveBeenCalledWith(-1);
});
});
describe('Address Selection', () => {
it('displays address dropdown when user has addresses', async () => {
mockedGetAddresses.mockResolvedValue({ data: [mockAddress] });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('address-select')).toBeInTheDocument();
});
});
it('allows selecting a saved address', async () => {
mockedGetAddresses.mockResolvedValue({ data: [mockAddress] });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('address-select')).toBeInTheDocument();
});
fireEvent.change(screen.getByTestId('address-select'), {
target: { value: 'addr-1' },
});
await waitFor(() => {
expect(screen.getByTestId('address1')).toHaveValue('456 Oak Ave');
expect(screen.getByTestId('city')).toHaveValue('Boston');
expect(screen.getByTestId('state')).toHaveValue('MA');
});
});
it('clears address when selecting "new"', async () => {
mockedGetAddresses.mockResolvedValue({ data: [mockAddress] });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('address-select')).toBeInTheDocument();
});
// First select the saved address
fireEvent.change(screen.getByTestId('address-select'), {
target: { value: 'addr-1' },
});
await waitFor(() => {
expect(screen.getByTestId('address1')).toHaveValue('456 Oak Ave');
});
// Then select "new"
fireEvent.change(screen.getByTestId('address-select'), {
target: { value: 'new' },
});
await waitFor(() => {
expect(screen.getByTestId('address1')).toHaveValue('');
expect(screen.getByTestId('city')).toHaveValue('');
});
});
});
describe('Pricing Tiers', () => {
it('sets pricing unit based on existing item data', async () => {
mockedGetItem.mockResolvedValue({
data: { ...mockItem, pricePerDay: 50 },
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('price-per-day')).toHaveValue(50);
});
});
it('prioritizes hourly pricing when set', async () => {
mockedGetItem.mockResolvedValue({
data: { ...mockItem, pricePerHour: 10, pricePerDay: 50 },
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('price-per-hour')).toHaveValue(10);
expect(screen.getByTestId('price-per-day')).toHaveValue(50);
});
});
});
});

View File

@@ -0,0 +1,235 @@
/**
* GoogleCallback Page Tests
*
* Tests for the Google OAuth callback page that handles
* the authorization code exchange after Google Sign-In.
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { vi, type MockedFunction } from 'vitest';
import { MemoryRouter, Routes, Route } from 'react-router';
import GoogleCallback from '../../pages/GoogleCallback';
import { useAuth } from '../../contexts/AuthContext';
import { fetchCSRFToken } from '../../services/api';
// Mock the dependencies
vi.mock('../../contexts/AuthContext');
vi.mock('../../services/api', () => ({
fetchCSRFToken: vi.fn(),
}));
const mockNavigate = vi.fn();
vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
return {
...actual,
useNavigate: () => mockNavigate,
};
});
const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>;
const mockedFetchCSRFToken = fetchCSRFToken as MockedFunction<typeof fetchCSRFToken>;
// Helper to render GoogleCallback with route params
const renderGoogleCallback = (searchParams: string = '') => {
return render(
<MemoryRouter initialEntries={[`/auth/google/callback${searchParams}`]}>
<Routes>
<Route path="/auth/google/callback" element={<GoogleCallback />} />
</Routes>
</MemoryRouter>
);
};
describe('GoogleCallback', () => {
const mockGoogleLogin = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockedUseAuth.mockReturnValue({
user: null,
loading: false,
showAuthModal: false,
authModalMode: 'login',
login: vi.fn(),
register: vi.fn(),
googleLogin: mockGoogleLogin,
logout: vi.fn(),
checkAuth: vi.fn(),
updateUser: vi.fn(),
openAuthModal: vi.fn(),
closeAuthModal: vi.fn(),
});
mockedFetchCSRFToken.mockResolvedValue('test-csrf-token');
mockGoogleLogin.mockResolvedValue({});
});
describe('Loading State', () => {
it('renders loading state initially', () => {
renderGoogleCallback('?code=valid-auth-code');
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('Completing Google Sign-In...')).toBeInTheDocument();
expect(screen.getByText('Please wait while we log you in.')).toBeInTheDocument();
});
});
describe('Successful Authentication', () => {
it('extracts authorization code from URL params', async () => {
renderGoogleCallback('?code=test-auth-code-123');
await waitFor(() => {
expect(mockedFetchCSRFToken).toHaveBeenCalled();
expect(mockGoogleLogin).toHaveBeenCalledWith('test-auth-code-123');
});
});
it('calls fetchCSRFToken then googleLogin with code', async () => {
renderGoogleCallback('?code=valid-code');
await waitFor(() => {
expect(mockedFetchCSRFToken).toHaveBeenCalled();
});
await waitFor(() => {
expect(mockGoogleLogin).toHaveBeenCalledWith('valid-code');
});
});
it('navigates to "/" on successful login', async () => {
renderGoogleCallback('?code=valid-code');
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
});
});
});
describe('Error States', () => {
it('displays error when OAuth error param present', async () => {
renderGoogleCallback('?error=access_denied');
await waitFor(() => {
expect(screen.getByText('Sign-In Failed')).toBeInTheDocument();
expect(screen.getByText('Google Sign-In was cancelled or failed. Please try again.')).toBeInTheDocument();
});
expect(mockGoogleLogin).not.toHaveBeenCalled();
});
it('displays error when code is missing', async () => {
renderGoogleCallback();
await waitFor(() => {
expect(screen.getByText('Sign-In Failed')).toBeInTheDocument();
expect(screen.getByText('No authorization code received from Google.')).toBeInTheDocument();
});
expect(mockGoogleLogin).not.toHaveBeenCalled();
});
it('displays error when googleLogin fails', async () => {
mockGoogleLogin.mockRejectedValue({
response: {
data: { error: 'Invalid authorization code' },
},
});
renderGoogleCallback('?code=invalid-code');
await waitFor(() => {
expect(screen.getByText('Sign-In Failed')).toBeInTheDocument();
expect(screen.getByText('Invalid authorization code')).toBeInTheDocument();
});
});
it('displays generic error when googleLogin fails without specific message', async () => {
mockGoogleLogin.mockRejectedValue(new Error('Network error'));
renderGoogleCallback('?code=valid-code');
await waitFor(() => {
expect(screen.getByText('Sign-In Failed')).toBeInTheDocument();
expect(screen.getByText('Failed to sign in with Google. Please try again.')).toBeInTheDocument();
});
});
it('displays error when CSRF token fetch fails', async () => {
mockedFetchCSRFToken.mockResolvedValue('');
renderGoogleCallback('?code=valid-code');
await waitFor(() => {
expect(screen.getByText('Sign-In Failed')).toBeInTheDocument();
expect(screen.getByText('Failed to initialize security token. Please try again.')).toBeInTheDocument();
});
expect(mockGoogleLogin).not.toHaveBeenCalled();
});
});
describe('Error Recovery', () => {
it('renders return to home button on error', async () => {
renderGoogleCallback('?error=access_denied');
await waitFor(() => {
expect(screen.getByText('Sign-In Failed')).toBeInTheDocument();
});
const returnButton = screen.getByRole('button', { name: /return to home/i });
expect(returnButton).toBeInTheDocument();
});
it('navigates home when return button clicked', async () => {
renderGoogleCallback('?error=access_denied');
await waitFor(() => {
expect(screen.getByText('Sign-In Failed')).toBeInTheDocument();
});
const returnButton = screen.getByRole('button', { name: /return to home/i });
returnButton.click();
expect(mockNavigate).toHaveBeenCalledWith('/');
});
});
describe('StrictMode Double-Execution Prevention', () => {
it('prevents double processing with useRef', async () => {
// First render
const { unmount } = renderGoogleCallback('?code=valid-code');
await waitFor(() => {
expect(mockGoogleLogin).toHaveBeenCalledTimes(1);
});
// Unmount and remount (simulating StrictMode behavior)
unmount();
// The component uses useRef to track hasProcessed, so multiple
// rapid re-renders within the same instance should not cause double calls
expect(mockGoogleLogin).toHaveBeenCalledTimes(1);
});
});
describe('UI Elements', () => {
it('shows spinner during processing', () => {
renderGoogleCallback('?code=valid-code');
const spinner = screen.getByRole('status');
expect(spinner).toHaveClass('spinner-border');
});
it('shows error icon on failure', async () => {
renderGoogleCallback('?error=access_denied');
await waitFor(() => {
const errorIcon = document.querySelector('.bi-exclamation-circle');
expect(errorIcon).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,505 @@
/**
* ItemDetail Page Tests
*
* Tests for the item detail page including viewing, rental flow,
* admin actions, and image gallery navigation.
*/
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 ItemDetail from '../../pages/ItemDetail';
import { useAuth } from '../../contexts/AuthContext';
import { itemAPI, rentalAPI } from '../../services/api';
import { mockUser, mockAdminUser, mockItemWithOwner, createMockRental } from '../../mocks/handlers';
// Mock dependencies
vi.mock('../../contexts/AuthContext');
vi.mock('../../services/api', () => ({
itemAPI: {
getItem: vi.fn(),
adminSoftDeleteItem: vi.fn(),
adminRestoreItem: vi.fn(),
},
rentalAPI: {
getRentals: vi.fn(),
getRentalCostPreview: vi.fn(),
},
}));
// Mock child components
vi.mock('../../services/uploadService', () => ({
getImageUrl: vi.fn((filename, variant) => `https://test-bucket.s3.amazonaws.com/${variant}/${filename}`),
}));
vi.mock('react-datepicker', () => ({
default: ({ selected, onChange, minDate, placeholderText, className }: any) => (
<input
type="text"
className={className}
placeholder={placeholderText}
value={selected ? selected.toLocaleDateString() : ''}
onChange={(e) => onChange(new Date(e.target.value))}
data-testid="date-picker"
/>
),
}));
vi.mock('../../components/GoogleMapWithRadius', () => ({
default: () => <div data-testid="google-map">Map</div>,
}));
vi.mock('../../components/ItemReviews', () => ({
default: ({ itemId }: { itemId: string }) => <div data-testid="item-reviews">Reviews for {itemId}</div>,
}));
vi.mock('../../components/ConfirmationModal', () => ({
default: ({ show, onConfirm, onClose, title, loading, showReasonInput, reason, onReasonChange }: any) =>
show ? (
<div data-testid="confirmation-modal">
<h5>{title}</h5>
{showReasonInput && (
<input
data-testid="deletion-reason"
value={reason}
onChange={(e) => onReasonChange(e.target.value)}
/>
)}
<button onClick={onClose}>Cancel</button>
<button onClick={onConfirm} disabled={loading}>
{loading ? 'Processing...' : 'Confirm'}
</button>
</div>
) : null,
}));
vi.mock('../../components/Avatar', () => ({
default: ({ user }: { user: any }) => <div data-testid="avatar">{user?.firstName}</div>,
}));
const mockNavigate = vi.fn();
vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
return {
...actual,
useNavigate: () => mockNavigate,
};
});
const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>;
const mockedGetItem = itemAPI.getItem as MockedFunction<typeof itemAPI.getItem>;
const mockedGetRentals = rentalAPI.getRentals as MockedFunction<typeof rentalAPI.getRentals>;
const mockedGetCostPreview = rentalAPI.getRentalCostPreview as MockedFunction<typeof rentalAPI.getRentalCostPreview>;
const mockedAdminSoftDelete = itemAPI.adminSoftDeleteItem as MockedFunction<typeof itemAPI.adminSoftDeleteItem>;
const mockedAdminRestore = itemAPI.adminRestoreItem as MockedFunction<typeof itemAPI.adminRestoreItem>;
// Helper to render ItemDetail with route params
const renderItemDetail = (itemId: string = '1', authOverrides: Partial<ReturnType<typeof useAuth>> = {}) => {
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(
<MemoryRouter initialEntries={[`/items/${itemId}`]}>
<Routes>
<Route path="/items/:id" element={<ItemDetail />} />
</Routes>
</MemoryRouter>
);
};
describe('ItemDetail', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedGetItem.mockResolvedValue({ data: mockItemWithOwner });
mockedGetRentals.mockResolvedValue({ data: [] });
mockedGetCostPreview.mockResolvedValue({ data: { baseAmount: 50 } });
// Mock clipboard API
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
describe('Loading State', () => {
it('shows loading spinner while fetching item', () => {
mockedGetItem.mockImplementation(() => new Promise(() => {}));
renderItemDetail();
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('displays error when item fetch fails', async () => {
mockedGetItem.mockRejectedValue({
response: { data: { message: 'Item not found' } },
});
renderItemDetail();
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('Item not found')).toBeInTheDocument();
});
});
it('displays default error when no message provided', async () => {
mockedGetItem.mockRejectedValue(new Error('Network error'));
renderItemDetail('1', { user: null });
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('Failed to fetch item')).toBeInTheDocument();
});
});
});
describe('Item Display', () => {
it('fetches and displays item details', async () => {
renderItemDetail();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByText('A test item for rental')).toBeInTheDocument();
expect(mockedGetItem).toHaveBeenCalledWith('1');
});
it('displays item pricing', async () => {
renderItemDetail();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
// mockItemWithOwner has pricePerHour: 10, which is displayed first
// The pricing text may be split across multiple text nodes due to JSX whitespace
await waitFor(() => {
const pricingCard = document.getElementById('mobile-pricing-card');
expect(pricingCard).toBeInTheDocument();
expect(pricingCard?.textContent).toContain('$10');
expect(pricingCard?.textContent).toContain('/Hour');
});
});
it('displays owner information', async () => {
renderItemDetail();
await waitFor(() => {
expect(screen.getByTestId('avatar')).toBeInTheDocument();
});
});
it('displays reviews section', async () => {
renderItemDetail();
await waitFor(() => {
expect(screen.getByTestId('item-reviews')).toBeInTheDocument();
});
});
it('displays map', async () => {
renderItemDetail();
await waitFor(() => {
expect(screen.getByTestId('google-map')).toBeInTheDocument();
});
});
});
describe('Image Gallery', () => {
it('navigates image gallery via thumbnails', async () => {
const itemWithMultipleImages = {
...mockItemWithOwner,
imageFilenames: ['image1.jpg', 'image2.jpg', 'image3.jpg'],
};
mockedGetItem.mockResolvedValue({ data: itemWithMultipleImages });
renderItemDetail();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
// Find and click the second thumbnail
const thumbnails = screen.getAllByAltText(/Test Item \d/);
expect(thumbnails).toHaveLength(3);
fireEvent.click(thumbnails[1]);
// Main image should update (the src should change)
const mainImage = screen.getByAltText('Test Item');
expect(mainImage).toBeInTheDocument();
});
});
describe('Owner View', () => {
it('shows edit button for owner', async () => {
const ownerUser = { ...mockUser, id: '2' }; // mockItemWithOwner has ownerId: '2'
renderItemDetail('1', { user: ownerUser as any });
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /edit listing/i })).toBeInTheDocument();
});
it('navigates to edit page on edit click', async () => {
const ownerUser = { ...mockUser, id: '2' };
renderItemDetail('1', { user: ownerUser as any });
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const editButton = screen.getByRole('button', { name: /edit listing/i });
fireEvent.click(editButton);
expect(mockNavigate).toHaveBeenCalledWith('/items/1/edit');
});
it('does not show rental controls for owner', async () => {
const ownerUser = { ...mockUser, id: '2' };
renderItemDetail('1', { user: ownerUser as any });
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /rent now/i })).not.toBeInTheDocument();
});
});
describe('Admin Actions', () => {
const adminUser = { ...mockAdminUser };
it('shows admin delete button for non-deleted items', async () => {
renderItemDetail('1', { user: adminUser as any });
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
it('shows admin restore button for deleted items', async () => {
const deletedItem = { ...mockItemWithOwner, isDeleted: true };
mockedGetItem.mockResolvedValue({ data: deletedItem });
renderItemDetail('1', { user: adminUser as any });
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /restore/i })).toBeInTheDocument();
});
it('opens confirmation modal for soft delete', async () => {
renderItemDetail('1', { user: adminUser as any });
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const deleteButton = screen.getByRole('button', { name: /delete/i });
fireEvent.click(deleteButton);
expect(screen.getByTestId('confirmation-modal')).toBeInTheDocument();
expect(screen.getByText('Confirm Delete')).toBeInTheDocument();
});
it('performs soft delete with reason', async () => {
mockedAdminSoftDelete.mockResolvedValue({ data: {} });
renderItemDetail('1', { user: adminUser as any });
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const deleteButton = screen.getByRole('button', { name: /delete/i });
fireEvent.click(deleteButton);
const reasonInput = screen.getByTestId('deletion-reason');
fireEvent.change(reasonInput, { target: { value: 'Policy violation' } });
const confirmButton = screen.getByRole('button', { name: /confirm/i });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockedAdminSoftDelete).toHaveBeenCalledWith('1', 'Policy violation');
});
});
it('performs restore', async () => {
const deletedItem = { ...mockItemWithOwner, isDeleted: true };
mockedGetItem.mockResolvedValue({ data: deletedItem });
mockedAdminRestore.mockResolvedValue({ data: {} });
renderItemDetail('1', { user: adminUser as any });
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const restoreButton = screen.getByRole('button', { name: /restore/i });
fireEvent.click(restoreButton);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockedAdminRestore).toHaveBeenCalledWith('1');
});
});
it('displays deleted item warning for admins', async () => {
const deletedItem = {
...mockItemWithOwner,
isDeleted: true,
deletionReason: 'Terms violation',
deletedAt: new Date().toISOString(),
};
mockedGetItem.mockResolvedValue({ data: deletedItem });
renderItemDetail('1', { user: adminUser as any });
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByText(/item soft deleted/i)).toBeInTheDocument();
});
});
describe('Rental Flow', () => {
it('shows "already renting" warning for active rentals', async () => {
// Create rental with matching item id and active status
// The component checks: rental.item?.id === id && ["pending", "confirmed", "active"].includes(rental.status)
const activeRental = {
id: 'rental-1',
itemId: '1',
renterId: '1',
ownerId: '2',
item: { id: '1', name: 'Test Item' }, // Simplified item with matching id
status: 'active',
displayStatus: 'active',
startDateTime: new Date().toISOString(),
endDateTime: new Date(Date.now() + 86400000).toISOString(),
totalAmount: 25,
paymentStatus: 'paid',
};
mockedGetRentals.mockResolvedValue({ data: [activeRental] });
renderItemDetail();
// Wait for item to load first
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
// Wait for getRentals to be called and verify the mock data
await waitFor(() => {
expect(mockedGetRentals).toHaveBeenCalled();
});
// The warning should appear in the alert element
// Use a more flexible wait for the component state to update
await waitFor(() => {
const warnings = screen.queryAllByRole('alert');
const activeRentalWarning = warnings.find(el =>
el.textContent?.includes('already have an active rental')
);
expect(activeRentalWarning).toBeTruthy();
}, { timeout: 3000 });
});
it('rent button disabled when invalid selection', async () => {
renderItemDetail();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
// Find the Rent Now button(s) - there may be multiple for mobile/desktop
const rentButtons = screen.getAllByRole('button', { name: /rent now/i });
rentButtons.forEach((button) => {
expect(button).toBeDisabled();
});
});
});
describe('Copy Link', () => {
it('copies link to clipboard', async () => {
renderItemDetail();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const copyButton = screen.getByRole('button', { name: /copy link/i });
fireEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('/items/1')
);
});
});
describe('Unauthenticated User', () => {
it('shows item details without rental controls for unauthenticated user', async () => {
renderItemDetail('1', { user: null });
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
// Should show item details
expect(screen.getByText('A test item for rental')).toBeInTheDocument();
});
});
describe('Cancellation Policy', () => {
it('displays cancellation policy', async () => {
renderItemDetail();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByText('Cancellation Policy')).toBeInTheDocument();
expect(screen.getByText(/full refund.*48\+/i)).toBeInTheDocument();
});
it('displays replacement cost', async () => {
renderItemDetail();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByText(/replacement cost.*\$500/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,567 @@
/**
* ItemList Page Tests
*
* Tests for the item browsing page with search, filters,
* pagination, and map/list view modes.
*/
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 ItemList from '../../pages/ItemList';
import { useAuth } from '../../contexts/AuthContext';
import { itemAPI } from '../../services/api';
import { mockUser, createMockItem, createMockPaginatedResponse } from '../../mocks/handlers';
// Mock dependencies
vi.mock('../../contexts/AuthContext');
vi.mock('../../services/api', () => ({
itemAPI: {
getItems: vi.fn(),
},
}));
// Mock child components
vi.mock('../../components/ItemCard', () => ({
default: ({ item }: { item: any }) => (
<div data-testid={`item-card-${item.id}`}>
<span>{item.name}</span>
<span>${item.pricePerDay}/day</span>
</div>
),
}));
vi.mock('../../components/SearchResultsMap', () => ({
default: ({ items, onItemSelect }: any) => (
<div data-testid="search-results-map">
<span>Map with {items.length} items</span>
{items.map((item: any) => (
<button key={item.id} onClick={() => onItemSelect(item)}>
Select {item.name}
</button>
))}
</div>
),
}));
vi.mock('../../components/FilterPanel', () => ({
default: ({ show, onClose, onApplyFilters }: any) =>
show ? (
<div data-testid="filter-panel">
<button onClick={onClose}>Close Filters</button>
<button
onClick={() =>
onApplyFilters({
lat: '37.7749',
lng: '-122.4194',
radius: '10',
locationName: 'San Francisco',
})
}
>
Apply Filter
</button>
</div>
) : null,
}));
const mockNavigate = vi.fn();
vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
return {
...actual,
useNavigate: () => mockNavigate,
};
});
const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>;
const mockedGetItems = itemAPI.getItems as MockedFunction<typeof itemAPI.getItems>;
// Helper to render ItemList with route params
const renderItemList = (searchParams: string = '', authOverrides: Partial<ReturnType<typeof useAuth>> = {}) => {
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(
<MemoryRouter initialEntries={[`/items${searchParams}`]}>
<Routes>
<Route path="/items" element={<ItemList />} />
</Routes>
</MemoryRouter>
);
};
describe('ItemList', () => {
const mockItems = [
createMockItem({ id: '1', name: 'Camping Tent', pricePerDay: 25, isAvailable: true }),
createMockItem({ id: '2', name: 'Mountain Bike', pricePerDay: 50, isAvailable: true }),
createMockItem({ id: '3', name: 'Kayak', pricePerDay: 40, isAvailable: true }),
];
beforeEach(() => {
vi.clearAllMocks();
mockedGetItems.mockResolvedValue({
data: createMockPaginatedResponse(mockItems, 1, 1, 3),
});
// Mock window.scrollTo
Object.defineProperty(window, 'scrollTo', {
value: vi.fn(),
writable: true,
});
// Mock window.innerWidth for responsive behavior
Object.defineProperty(window, 'innerWidth', {
value: 1024,
writable: true,
});
});
describe('Loading State', () => {
it('shows loading spinner while fetching items', () => {
mockedGetItems.mockImplementation(() => new Promise(() => {}));
renderItemList();
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Fetching and Displaying Items', () => {
it('fetches items on mount', async () => {
renderItemList();
await waitFor(() => {
expect(mockedGetItems).toHaveBeenCalled();
});
});
it('displays items in grid', async () => {
renderItemList();
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
expect(screen.getByTestId('item-card-2')).toBeInTheDocument();
expect(screen.getByTestId('item-card-3')).toBeInTheDocument();
});
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
expect(screen.getByText('Mountain Bike')).toBeInTheDocument();
expect(screen.getByText('Kayak')).toBeInTheDocument();
});
it('displays item count', async () => {
renderItemList();
await waitFor(() => {
expect(screen.getByText('3 items found')).toBeInTheDocument();
});
});
it('filters out unavailable items', async () => {
const itemsWithUnavailable = [
...mockItems,
createMockItem({ id: '4', name: 'Unavailable Item', isAvailable: false }),
];
mockedGetItems.mockResolvedValue({
data: createMockPaginatedResponse(itemsWithUnavailable, 1, 1, 4),
});
renderItemList();
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
// Unavailable item should not be rendered
expect(screen.queryByTestId('item-card-4')).not.toBeInTheDocument();
});
});
describe('Empty Results', () => {
it('handles empty results gracefully', async () => {
mockedGetItems.mockResolvedValue({
data: createMockPaginatedResponse([], 1, 1, 0),
});
renderItemList();
await waitFor(() => {
expect(screen.getByText('No items found')).toBeInTheDocument();
});
expect(screen.getByText(/try adjusting your search criteria/i)).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('displays error message on API failure', async () => {
mockedGetItems.mockRejectedValue({
response: { data: { message: 'Failed to fetch items' } },
});
renderItemList();
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('Failed to fetch items')).toBeInTheDocument();
});
});
});
describe('Filters from URL Params', () => {
it('applies filters from URL params', async () => {
renderItemList('?search=tent&city=Seattle');
await waitFor(() => {
expect(mockedGetItems).toHaveBeenCalledWith(
expect.objectContaining({
search: 'tent',
city: 'Seattle',
})
);
});
});
it('applies location filters from URL', async () => {
renderItemList('?lat=37.7749&lng=-122.4194&radius=25');
await waitFor(() => {
expect(mockedGetItems).toHaveBeenCalledWith(
expect.objectContaining({
lat: '37.7749',
lng: '-122.4194',
radius: '25',
})
);
});
});
});
describe('Filter Panel', () => {
it('toggles filter panel', async () => {
renderItemList();
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
// Filter panel should not be visible initially
expect(screen.queryByTestId('filter-panel')).not.toBeInTheDocument();
// Click filter button
const filterButton = screen.getByTestId('filter-button');
fireEvent.click(filterButton);
// Filter panel should be visible
expect(screen.getByTestId('filter-panel')).toBeInTheDocument();
// Close filter panel
const closeButton = screen.getByRole('button', { name: /close filters/i });
fireEvent.click(closeButton);
expect(screen.queryByTestId('filter-panel')).not.toBeInTheDocument();
});
it('applies filters and updates URL', async () => {
renderItemList();
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
// Open filter panel
const filterButton = screen.getByTestId('filter-button');
fireEvent.click(filterButton);
// Apply filter
const applyButton = screen.getByRole('button', { name: /apply filter/i });
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('lat=37.7749'),
{ replace: true }
);
});
});
});
describe('View Mode Toggle', () => {
it('defaults to list view', async () => {
renderItemList();
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
// Items should be displayed, not map
expect(screen.queryByTestId('search-results-map')).not.toBeInTheDocument();
});
it('switches to map view', async () => {
renderItemList();
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
// Click map view button
const mapButton = screen.getByRole('button', { name: /map/i });
fireEvent.click(mapButton);
await waitFor(() => {
expect(screen.getByTestId('search-results-map')).toBeInTheDocument();
});
// Items should not be displayed directly
expect(screen.queryByTestId('item-card-1')).not.toBeInTheDocument();
});
it('switches back to list view', async () => {
renderItemList();
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
// Switch to map
const mapButton = screen.getByRole('button', { name: /map/i });
fireEvent.click(mapButton);
await waitFor(() => {
expect(screen.getByTestId('search-results-map')).toBeInTheDocument();
});
// Switch back to list
const listButton = screen.getByRole('button', { name: /list/i });
fireEvent.click(listButton);
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
});
it('navigates to item on map selection', async () => {
renderItemList();
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
// Switch to map
const mapButton = screen.getByRole('button', { name: /map/i });
fireEvent.click(mapButton);
await waitFor(() => {
expect(screen.getByTestId('search-results-map')).toBeInTheDocument();
});
// Select item on map
const selectButton = screen.getByRole('button', { name: /select camping tent/i });
fireEvent.click(selectButton);
expect(mockNavigate).toHaveBeenCalledWith('/items/1');
});
});
describe('Pagination', () => {
it('displays pagination when multiple pages exist', async () => {
mockedGetItems.mockResolvedValue({
data: createMockPaginatedResponse(mockItems, 1, 3, 60),
});
renderItemList();
await waitFor(() => {
expect(screen.getByText(/\(page 1 of 3\)/)).toBeInTheDocument();
});
// Pagination buttons should exist
expect(screen.getByLabelText('Previous page')).toBeInTheDocument();
expect(screen.getByLabelText('Next page')).toBeInTheDocument();
});
it('navigates to next page', async () => {
mockedGetItems.mockResolvedValue({
data: createMockPaginatedResponse(mockItems, 1, 3, 60),
});
renderItemList();
await waitFor(() => {
expect(screen.getByText(/\(page 1 of 3\)/)).toBeInTheDocument();
});
const nextButton = screen.getByLabelText('Next page');
fireEvent.click(nextButton);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('page=2'),
{ replace: true }
);
});
});
it('navigates to previous page', async () => {
mockedGetItems.mockResolvedValue({
data: createMockPaginatedResponse(mockItems, 2, 3, 60),
});
renderItemList('?page=2');
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
const prevButton = screen.getByLabelText('Previous page');
fireEvent.click(prevButton);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalled();
});
});
it('disables prev button on first page', async () => {
mockedGetItems.mockResolvedValue({
data: createMockPaginatedResponse(mockItems, 1, 3, 60),
});
renderItemList();
await waitFor(() => {
expect(screen.getByText(/\(page 1 of 3\)/)).toBeInTheDocument();
});
const prevButton = screen.getByLabelText('Previous page');
expect(prevButton).toBeDisabled();
});
it('disables next button on last page', async () => {
mockedGetItems.mockResolvedValue({
data: createMockPaginatedResponse(mockItems, 3, 3, 60),
});
renderItemList('?page=3');
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
const nextButton = screen.getByLabelText('Next page');
expect(nextButton).toBeDisabled();
});
it('scrolls to top on page change', async () => {
mockedGetItems.mockResolvedValue({
data: createMockPaginatedResponse(mockItems, 1, 3, 60),
});
renderItemList();
await waitFor(() => {
expect(screen.getByText(/\(page 1 of 3\)/)).toBeInTheDocument();
});
const nextButton = screen.getByLabelText('Next page');
fireEvent.click(nextButton);
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});
it('does not display pagination for single page', async () => {
renderItemList();
await waitFor(() => {
expect(screen.getByTestId('item-card-1')).toBeInTheDocument();
});
expect(screen.queryByLabelText('Previous page')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument();
});
});
describe('Auto-Apply User Address', () => {
it('applies user address coordinates when no location set', async () => {
const userWithAddress = {
...mockUser,
addresses: [
{
id: '1',
latitude: 40.7128,
longitude: -74.006,
city: 'New York',
state: 'NY',
},
],
};
renderItemList('', { user: userWithAddress as any });
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('lat=40.7128'),
{ replace: true }
);
});
});
it('does not override existing location filters', async () => {
const userWithAddress = {
...mockUser,
addresses: [
{
id: '1',
latitude: 40.7128,
longitude: -74.006,
},
],
};
renderItemList('?lat=37.7749&lng=-122.4194', { user: userWithAddress as any });
await waitFor(() => {
expect(mockedGetItems).toHaveBeenCalled();
});
// Should not have navigated to user's address
expect(mockNavigate).not.toHaveBeenCalledWith(
expect.stringContaining('lat=40.7128'),
expect.anything()
);
});
});
describe('View Toggle Buttons', () => {
it('hides view toggle when no items', async () => {
mockedGetItems.mockResolvedValue({
data: createMockPaginatedResponse([], 1, 1, 0),
});
renderItemList();
await waitFor(() => {
expect(screen.getByText('No items found')).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /list/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /map/i })).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,622 @@
/**
* Owning Page Tests
*
* Tests for the owner dashboard page showing listings,
* rental management, and condition checks.
*/
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 Owning from '../../pages/Owning';
import { useAuth } from '../../contexts/AuthContext';
import api, { rentalAPI, conditionCheckAPI } from '../../services/api';
import {
mockUser,
mockItemWithOwner,
createMockRental,
createMockItem,
createMockConditionCheck,
} from '../../mocks/handlers';
// Mock dependencies
vi.mock('../../contexts/AuthContext');
vi.mock('../../services/api', () => {
const defaultApi = {
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
};
return {
default: defaultApi,
rentalAPI: {
getListings: vi.fn(),
updateRentalStatus: vi.fn(),
},
conditionCheckAPI: {
getAvailableChecks: vi.fn(),
getBatchConditionChecks: vi.fn(),
},
};
});
vi.mock('../../services/uploadService', () => ({
getImageUrl: vi.fn((filename, variant) => `https://test-bucket.s3.amazonaws.com/${variant}/${filename}`),
}));
// Mock modal components
vi.mock('../../components/ReviewRenterModal', () => ({
default: ({ show, onClose, rental }: any) =>
show ? (
<div data-testid="review-renter-modal">
<span>Review Renter for {rental?.item?.name}</span>
<button onClick={onClose}>Close</button>
</div>
) : null,
}));
vi.mock('../../components/RentalCancellationModal', () => ({
default: ({ show, onHide, rental, onCancellationComplete }: any) =>
show ? (
<div data-testid="cancellation-modal">
<span>Cancel {rental?.item?.name}</span>
<button onClick={onHide}>Close</button>
<button onClick={() => onCancellationComplete({ ...rental, status: 'cancelled' })}>
Confirm Cancel
</button>
</div>
) : null,
}));
vi.mock('../../components/DeclineRentalModal', () => ({
default: ({ show, onHide, rental, onDeclineComplete }: any) =>
show ? (
<div data-testid="decline-modal">
<span>Decline {rental?.item?.name}</span>
<button onClick={onHide}>Close</button>
<button onClick={() => onDeclineComplete({ ...rental, status: 'declined' })}>
Confirm Decline
</button>
</div>
) : null,
}));
vi.mock('../../components/ConditionCheckModal', () => ({
default: ({ show, onHide, rentalId, checkType, onSuccess }: any) =>
show ? (
<div data-testid="condition-check-modal">
<span>Condition Check for rental {rentalId}</span>
<span>Type: {checkType}</span>
<button onClick={onHide}>Close</button>
<button onClick={onSuccess}>Submit</button>
</div>
) : null,
}));
vi.mock('../../components/ConditionCheckViewerModal', () => ({
default: ({ show, onHide, conditionCheck }: any) =>
show ? (
<div data-testid="condition-viewer-modal">
<span>View Check: {conditionCheck?.checkType}</span>
<button onClick={onHide}>Close</button>
</div>
) : null,
}));
vi.mock('../../components/ReturnStatusModal', () => ({
default: ({ show, onHide, rental, onReturnMarked }: any) =>
show ? (
<div data-testid="return-status-modal">
<span>Return Status for {rental?.item?.name}</span>
<button onClick={onHide}>Close</button>
<button onClick={() => onReturnMarked({ ...rental, status: 'completed' })}>
Mark Returned
</button>
</div>
) : null,
}));
vi.mock('../../components/PaymentFailedModal', () => ({
default: ({ show, onHide, paymentError, itemName }: any) =>
show ? (
<div data-testid="payment-failed-modal">
<span>Payment Failed for {itemName}</span>
<span>{paymentError?.message}</span>
<button onClick={onHide}>Close</button>
</div>
) : null,
}));
vi.mock('../../components/AuthenticationRequiredModal', () => ({
default: ({ rental, onClose }: any) => (
<div data-testid="auth-required-modal">
<span>Auth Required for {rental?.item?.name}</span>
<button onClick={onClose}>Close</button>
</div>
),
}));
const mockNavigate = vi.fn();
vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
return {
...actual,
useNavigate: () => mockNavigate,
};
});
const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>;
const mockedApiGet = api.get as MockedFunction<typeof api.get>;
const mockedApiPut = api.put as MockedFunction<typeof api.put>;
const mockedApiDelete = api.delete as MockedFunction<typeof api.delete>;
const mockedGetListings = rentalAPI.getListings as MockedFunction<typeof rentalAPI.getListings>;
const mockedUpdateRentalStatus = rentalAPI.updateRentalStatus as MockedFunction<typeof rentalAPI.updateRentalStatus>;
const mockedGetAvailableChecks = conditionCheckAPI.getAvailableChecks as MockedFunction<typeof conditionCheckAPI.getAvailableChecks>;
const mockedGetBatchConditionChecks = conditionCheckAPI.getBatchConditionChecks as MockedFunction<typeof conditionCheckAPI.getBatchConditionChecks>;
// Helper to render Owning page
const renderOwning = (authOverrides: Partial<ReturnType<typeof useAuth>> = {}) => {
const user = { ...mockUser, id: '2' }; // Same as mockItemWithOwner.ownerId
mockedUseAuth.mockReturnValue({
user: user as any,
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(
<MemoryRouter initialEntries={['/owning']}>
<Routes>
<Route path="/owning" element={<Owning />} />
</Routes>
</MemoryRouter>
);
};
describe('Owning', () => {
const mockListings = [
createMockItem({ id: '1', name: 'Camping Tent', ownerId: '2', isAvailable: true }),
createMockItem({ id: '2', name: 'Mountain Bike', ownerId: '2', isAvailable: false }),
];
const mockOwnerRentals = [
createMockRental({
id: '1',
status: 'pending',
displayStatus: 'pending',
item: mockItemWithOwner as any,
renter: mockUser as any,
}),
createMockRental({
id: '2',
status: 'confirmed',
displayStatus: 'active',
item: { ...mockItemWithOwner, name: 'Mountain Bike' } as any,
renter: mockUser as any,
}),
];
beforeEach(() => {
vi.clearAllMocks();
mockedApiGet.mockResolvedValue({ data: { items: mockListings } });
mockedGetListings.mockResolvedValue({ data: mockOwnerRentals });
mockedGetAvailableChecks.mockResolvedValue({ data: { availableChecks: [] } });
mockedGetBatchConditionChecks.mockResolvedValue({ data: { conditionChecks: [] } });
mockedUpdateRentalStatus.mockResolvedValue({ data: { paymentStatus: 'paid' } });
// Mock window.dispatchEvent
vi.spyOn(window, 'dispatchEvent').mockImplementation(() => true);
});
describe('Loading State', () => {
it('shows loading spinner while fetching data', () => {
mockedApiGet.mockImplementation(() => new Promise(() => {}));
renderOwning();
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Listings Display', () => {
it('fetches and displays user listings', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
});
// Mountain Bike appears in both the Rentals section (as an active rental)
// and the Listings section, so we use getAllByText
await waitFor(() => {
expect(screen.getAllByText('Mountain Bike').length).toBeGreaterThanOrEqual(1);
});
});
it('shows availability status badges', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
});
expect(screen.getByText('Available')).toBeInTheDocument();
expect(screen.getByText('Not Available')).toBeInTheDocument();
});
it('has edit button for each listing', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
});
const editLinks = screen.getAllByRole('link', { name: /edit/i });
expect(editLinks.length).toBeGreaterThanOrEqual(2);
});
it('has add new item link', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: /add new item/i })).toHaveAttribute('href', '/create-item');
});
});
describe('Empty Listings State', () => {
it('shows empty state when no listings', async () => {
mockedApiGet.mockResolvedValue({ data: { items: [] } });
mockedGetListings.mockResolvedValue({ data: [] });
renderOwning();
await waitFor(() => {
expect(screen.getByText(/haven't listed any items yet/i)).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: /list your first item/i })).toBeInTheDocument();
});
});
describe('Toggle Availability', () => {
it('toggles item availability', async () => {
mockedApiPut.mockResolvedValue({ data: {} });
renderOwning();
await waitFor(() => {
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
});
const toggleButtons = screen.getAllByRole('button', { name: /mark (un)?available/i });
fireEvent.click(toggleButtons[0]);
await waitFor(() => {
expect(mockedApiPut).toHaveBeenCalledWith(
'/items/1',
expect.objectContaining({ isAvailable: false })
);
});
});
});
describe('Delete Item', () => {
it('shows delete button for each listing', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
});
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
});
it('opens delete confirmation modal', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
});
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
fireEvent.click(deleteButtons[0]);
expect(screen.getByText('Delete Listing')).toBeInTheDocument();
expect(screen.getByText(/are you sure you want to delete/i)).toBeInTheDocument();
});
it('deletes item on confirmation', async () => {
mockedApiDelete.mockResolvedValue({ data: {} });
renderOwning();
await waitFor(() => {
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
});
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
fireEvent.click(deleteButtons[0]);
// Find and click confirm button in modal
const confirmButtons = screen.getAllByRole('button', { name: /delete/i });
const modalDeleteButton = confirmButtons[confirmButtons.length - 1];
fireEvent.click(modalDeleteButton);
await waitFor(() => {
expect(mockedApiDelete).toHaveBeenCalledWith('/items/1');
});
});
});
describe('Rental Requests', () => {
it('displays rental requests', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Rentals')).toBeInTheDocument();
});
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
it('shows renter information', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
// Find renter names
expect(screen.getAllByText(/Test User/i).length).toBeGreaterThan(0);
});
it('shows accept and decline buttons for pending requests', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /accept/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /decline/i })).toBeInTheDocument();
});
});
describe('Accept Rental', () => {
it('accepts rental and triggers payment', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const acceptButton = screen.getByRole('button', { name: /accept/i });
fireEvent.click(acceptButton);
await waitFor(() => {
expect(mockedUpdateRentalStatus).toHaveBeenCalledWith('1', 'confirmed');
});
});
it('shows processing state during accept', async () => {
mockedUpdateRentalStatus.mockImplementation(() => new Promise(() => {}));
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const acceptButton = screen.getByRole('button', { name: /accept/i });
fireEvent.click(acceptButton);
await waitFor(() => {
expect(screen.getByText('Confirming...')).toBeInTheDocument();
});
});
it('shows payment failed modal on payment error', async () => {
mockedUpdateRentalStatus.mockRejectedValue({
response: { status: 402, data: { error: 'payment_failed', message: 'Card declined' } },
});
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const acceptButton = screen.getByRole('button', { name: /accept/i });
fireEvent.click(acceptButton);
await waitFor(() => {
expect(screen.getByTestId('payment-failed-modal')).toBeInTheDocument();
});
});
it('shows auth required modal on 3DS requirement', async () => {
mockedUpdateRentalStatus.mockRejectedValue({
response: { data: { error: 'authentication_required' } },
});
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const acceptButton = screen.getByRole('button', { name: /accept/i });
fireEvent.click(acceptButton);
await waitFor(() => {
expect(screen.getByTestId('auth-required-modal')).toBeInTheDocument();
});
});
});
describe('Decline Rental', () => {
it('opens decline modal', async () => {
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const declineButton = screen.getByRole('button', { name: /decline/i });
fireEvent.click(declineButton);
expect(screen.getByTestId('decline-modal')).toBeInTheDocument();
});
});
describe('Cancel Rental', () => {
it('shows cancel button for confirmed rentals', async () => {
const confirmedRental = createMockRental({
id: '3',
status: 'confirmed',
displayStatus: 'confirmed',
item: mockItemWithOwner as any,
renter: mockUser as any,
});
mockedGetListings.mockResolvedValue({ data: [confirmedRental] });
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('opens cancellation modal', async () => {
const confirmedRental = createMockRental({
id: '3',
status: 'confirmed',
displayStatus: 'confirmed',
item: mockItemWithOwner as any,
renter: mockUser as any,
});
mockedGetListings.mockResolvedValue({ data: [confirmedRental] });
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(screen.getByTestId('cancellation-modal')).toBeInTheDocument();
});
});
describe('Complete Rental', () => {
it('shows complete button for active rentals', async () => {
renderOwning();
// Wait for both listings and rentals to load
await waitFor(() => {
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
});
// Wait for the Rentals section to be displayed with the Mountain Bike rental
await waitFor(() => {
expect(screen.getByText('Rentals')).toBeInTheDocument();
});
// The active rental with Mountain Bike should have a Complete button
await waitFor(() => {
expect(screen.getByRole('button', { name: /complete/i })).toBeInTheDocument();
});
});
it('opens return status modal', async () => {
renderOwning();
// Wait for both listings and rentals to load
await waitFor(() => {
expect(screen.getByText('Camping Tent')).toBeInTheDocument();
});
// Wait for the Rentals section to be displayed
await waitFor(() => {
expect(screen.getByText('Rentals')).toBeInTheDocument();
});
// Wait for the Complete button to appear
await waitFor(() => {
expect(screen.getByRole('button', { name: /complete/i })).toBeInTheDocument();
});
const completeButton = screen.getByRole('button', { name: /complete/i });
fireEvent.click(completeButton);
expect(screen.getByTestId('return-status-modal')).toBeInTheDocument();
});
});
describe('Condition Checks', () => {
it('displays pre-rental condition check button', async () => {
mockedGetAvailableChecks.mockResolvedValue({
data: { availableChecks: [{ rentalId: '1', checkType: 'pre_rental_owner' }] },
});
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /submit pre-rental condition/i })).toBeInTheDocument();
});
});
it('shows completed condition checks', async () => {
const conditionCheck = createMockConditionCheck({
rentalId: '1',
checkType: 'pre_rental_owner',
});
mockedGetBatchConditionChecks.mockResolvedValue({
data: { conditionChecks: [conditionCheck] },
});
renderOwning();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/pre-rental condition/i)).toBeInTheDocument();
});
});
});
describe('Error Handling', () => {
it('displays error on listing fetch failure', async () => {
mockedApiGet.mockRejectedValue({
response: { status: 500, data: { message: 'Server error' } },
});
renderOwning();
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/failed to get your listings/i)).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,559 @@
/**
* Renting Page Tests
*
* Tests for the renter dashboard page showing rentals,
* status management, and condition checks.
*/
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 Renting from '../../pages/Renting';
import { useAuth } from '../../contexts/AuthContext';
import { rentalAPI, conditionCheckAPI } from '../../services/api';
import {
mockUser,
mockItemWithOwner,
createMockRental,
createMockConditionCheck,
createMockAvailableChecks,
} from '../../mocks/handlers';
// Mock dependencies
vi.mock('../../contexts/AuthContext');
vi.mock('../../services/api', () => ({
rentalAPI: {
getRentals: vi.fn(),
},
conditionCheckAPI: {
getAvailableChecks: vi.fn(),
getBatchConditionChecks: vi.fn(),
},
}));
vi.mock('../../services/uploadService', () => ({
getImageUrl: vi.fn((filename, variant) => `https://test-bucket.s3.amazonaws.com/${variant}/${filename}`),
}));
// Mock modal components
vi.mock('../../components/ReviewModal', () => ({
default: ({ show, onClose, rental }: any) =>
show ? (
<div data-testid="review-modal">
<span>Review {rental?.item?.name}</span>
<button onClick={onClose}>Close</button>
</div>
) : null,
}));
vi.mock('../../components/RentalCancellationModal', () => ({
default: ({ show, onHide, rental, onCancellationComplete }: any) =>
show ? (
<div data-testid="cancellation-modal">
<span>Cancel {rental?.item?.name}</span>
<button onClick={onHide}>Close</button>
<button onClick={() => onCancellationComplete({ ...rental, status: 'cancelled' })}>
Confirm Cancel
</button>
</div>
) : null,
}));
vi.mock('../../components/ConditionCheckModal', () => ({
default: ({ show, onHide, rentalId, checkType, onSuccess }: any) =>
show ? (
<div data-testid="condition-check-modal">
<span>Condition Check for rental {rentalId}</span>
<span>Type: {checkType}</span>
<button onClick={onHide}>Close</button>
<button onClick={onSuccess}>Submit</button>
</div>
) : null,
}));
vi.mock('../../components/ConditionCheckViewerModal', () => ({
default: ({ show, onHide, conditionCheck }: any) =>
show ? (
<div data-testid="condition-viewer-modal">
<span>View Check: {conditionCheck?.checkType}</span>
<button onClick={onHide}>Close</button>
</div>
) : null,
}));
vi.mock('../../components/UpdatePaymentMethodModal', () => ({
default: ({ show, onHide, rentalId, onSuccess }: any) =>
show ? (
<div data-testid="update-payment-modal">
<span>Update Payment for {rentalId}</span>
<button onClick={onHide}>Close</button>
<button onClick={onSuccess}>Update</button>
</div>
) : null,
}));
const mockNavigate = vi.fn();
vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
return {
...actual,
useNavigate: () => mockNavigate,
};
});
const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>;
const mockedGetRentals = rentalAPI.getRentals as MockedFunction<typeof rentalAPI.getRentals>;
const mockedGetAvailableChecks = conditionCheckAPI.getAvailableChecks as MockedFunction<typeof conditionCheckAPI.getAvailableChecks>;
const mockedGetBatchConditionChecks = conditionCheckAPI.getBatchConditionChecks as MockedFunction<typeof conditionCheckAPI.getBatchConditionChecks>;
// Helper to render Renting page
const renderRenting = (authOverrides: Partial<ReturnType<typeof useAuth>> = {}) => {
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(
<MemoryRouter initialEntries={['/renting']}>
<Routes>
<Route path="/renting" element={<Renting />} />
</Routes>
</MemoryRouter>
);
};
describe('Renting', () => {
const mockOwner = {
id: '2',
firstName: 'Owner',
lastName: 'User',
email: 'owner@example.com',
};
const mockRentals = [
createMockRental({
id: '1',
status: 'pending',
displayStatus: 'pending',
item: mockItemWithOwner as any,
owner: mockOwner as any,
}),
createMockRental({
id: '2',
status: 'confirmed',
displayStatus: 'confirmed',
item: { ...mockItemWithOwner, name: 'Mountain Bike' } as any,
owner: mockOwner as any,
}),
createMockRental({
id: '3',
status: 'confirmed',
displayStatus: 'active',
item: { ...mockItemWithOwner, name: 'Kayak' } as any,
owner: mockOwner as any,
}),
];
beforeEach(() => {
vi.clearAllMocks();
mockedGetRentals.mockResolvedValue({ data: mockRentals });
mockedGetAvailableChecks.mockResolvedValue({ data: createMockAvailableChecks('1', []) });
mockedGetBatchConditionChecks.mockResolvedValue({ data: { conditionChecks: [] } });
});
describe('Loading State', () => {
it('shows loading spinner while fetching rentals', () => {
mockedGetRentals.mockImplementation(() => new Promise(() => {}));
renderRenting();
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Fetching and Displaying Rentals', () => {
it('fetches user rentals as renter', async () => {
renderRenting();
await waitFor(() => {
expect(mockedGetRentals).toHaveBeenCalled();
});
});
it('displays rentals in cards', async () => {
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
expect(screen.getByText('Mountain Bike')).toBeInTheDocument();
expect(screen.getByText('Kayak')).toBeInTheDocument();
});
});
it('displays rental details (dates, amount, owner)', async () => {
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
// Check total amounts are displayed
expect(screen.getAllByText(/\$25/)).toHaveLength(3);
});
it('shows rental status badges', async () => {
renderRenting();
await waitFor(() => {
expect(screen.getByText('Awaiting Owner Approval')).toBeInTheDocument();
expect(screen.getByText('Confirmed & Paid')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
});
});
});
describe('Empty State', () => {
it('shows empty state when no rentals', async () => {
mockedGetRentals.mockResolvedValue({ data: [] });
renderRenting();
await waitFor(() => {
expect(screen.getByText('No Active Rental Requests')).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: /browse items to rent/i })).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('displays error message on API failure', async () => {
mockedGetRentals.mockRejectedValue({
response: { data: { message: 'Failed to fetch rentals' } },
});
renderRenting();
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('Failed to fetch rentals')).toBeInTheDocument();
});
});
});
describe('Rental Actions', () => {
it('shows cancel button for pending rentals', async () => {
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
// Find cancel buttons
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
expect(cancelButtons.length).toBeGreaterThan(0);
});
it('opens cancellation modal on cancel click', async () => {
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
fireEvent.click(cancelButtons[0]);
expect(screen.getByTestId('cancellation-modal')).toBeInTheDocument();
});
it('shows review button for active rentals', async () => {
const activeRental = createMockRental({
id: '1',
status: 'confirmed',
displayStatus: 'active',
item: mockItemWithOwner as any,
});
mockedGetRentals.mockResolvedValue({ data: [activeRental] });
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /review/i })).toBeInTheDocument();
});
it('opens review modal on review click', async () => {
const activeRental = createMockRental({
id: '1',
status: 'confirmed',
displayStatus: 'active',
item: mockItemWithOwner as any,
});
mockedGetRentals.mockResolvedValue({ data: [activeRental] });
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const reviewButton = screen.getByRole('button', { name: /review/i });
fireEvent.click(reviewButton);
expect(screen.getByTestId('review-modal')).toBeInTheDocument();
});
});
describe('Payment Status Alerts', () => {
it('shows Complete Payment button for requires_action status', async () => {
const requiresActionRental = createMockRental({
id: '1',
status: 'pending',
displayStatus: 'pending',
paymentStatus: 'requires_action',
item: mockItemWithOwner as any,
});
mockedGetRentals.mockResolvedValue({ data: [requiresActionRental] });
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /complete payment/i })).toBeInTheDocument();
expect(screen.getByText(/payment authentication required/i)).toBeInTheDocument();
});
it('navigates to complete payment on button click', async () => {
const requiresActionRental = createMockRental({
id: '1',
status: 'pending',
displayStatus: 'pending',
paymentStatus: 'requires_action',
item: mockItemWithOwner as any,
});
mockedGetRentals.mockResolvedValue({ data: [requiresActionRental] });
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const completePaymentButton = screen.getByRole('button', { name: /complete payment/i });
fireEvent.click(completePaymentButton);
expect(mockNavigate).toHaveBeenCalledWith('/complete-payment/1');
});
it('shows Update Payment button for payment_failed status', async () => {
const failedPaymentRental = createMockRental({
id: '1',
status: 'pending',
displayStatus: 'pending',
paymentStatus: 'pending',
paymentFailedNotifiedAt: new Date().toISOString(),
item: mockItemWithOwner as any,
});
mockedGetRentals.mockResolvedValue({ data: [failedPaymentRental] });
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /update payment/i })).toBeInTheDocument();
});
it('opens update payment modal on button click', async () => {
const failedPaymentRental = createMockRental({
id: '1',
status: 'pending',
displayStatus: 'pending',
paymentStatus: 'pending',
paymentFailedNotifiedAt: new Date().toISOString(),
item: mockItemWithOwner as any,
});
mockedGetRentals.mockResolvedValue({ data: [failedPaymentRental] });
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
const updatePaymentButton = screen.getByRole('button', { name: /update payment/i });
fireEvent.click(updatePaymentButton);
expect(screen.getByTestId('update-payment-modal')).toBeInTheDocument();
});
});
describe('Condition Checks', () => {
it('displays condition check buttons when available', async () => {
const rental = createMockRental({
id: '1',
status: 'confirmed',
displayStatus: 'active',
item: mockItemWithOwner as any,
});
mockedGetRentals.mockResolvedValue({ data: [rental] });
mockedGetAvailableChecks.mockResolvedValue({
data: { availableChecks: [{ rentalId: '1', checkType: 'rental_start_renter' }] },
});
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /submit rental start condition/i })).toBeInTheDocument();
});
});
it('opens condition check modal', async () => {
const rental = createMockRental({
id: '1',
status: 'confirmed',
displayStatus: 'active',
item: mockItemWithOwner as any,
});
mockedGetRentals.mockResolvedValue({ data: [rental] });
mockedGetAvailableChecks.mockResolvedValue({
data: { availableChecks: [{ rentalId: '1', checkType: 'rental_start_renter' }] },
});
renderRenting();
await waitFor(() => {
expect(screen.getByRole('button', { name: /submit rental start condition/i })).toBeInTheDocument();
});
const checkButton = screen.getByRole('button', { name: /submit rental start condition/i });
fireEvent.click(checkButton);
expect(screen.getByTestId('condition-check-modal')).toBeInTheDocument();
});
it('shows completed condition checks', async () => {
const rental = createMockRental({
id: '1',
status: 'confirmed',
displayStatus: 'active',
item: mockItemWithOwner as any,
});
const conditionCheck = createMockConditionCheck({
rentalId: '1',
checkType: 'pre_rental_owner',
});
mockedGetRentals.mockResolvedValue({ data: [rental] });
mockedGetBatchConditionChecks.mockResolvedValue({
data: { conditionChecks: [conditionCheck] },
});
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/owner's pre-rental condition/i)).toBeInTheDocument();
});
});
it('opens condition check viewer modal', async () => {
const rental = createMockRental({
id: '1',
status: 'confirmed',
displayStatus: 'active',
item: mockItemWithOwner as any,
});
const conditionCheck = createMockConditionCheck({
rentalId: '1',
checkType: 'pre_rental_owner',
});
mockedGetRentals.mockResolvedValue({ data: [rental] });
mockedGetBatchConditionChecks.mockResolvedValue({
data: { conditionChecks: [conditionCheck] },
});
renderRenting();
await waitFor(() => {
expect(screen.getByText(/owner's pre-rental condition/i)).toBeInTheDocument();
});
const viewButton = screen.getByText(/owner's pre-rental condition/i);
fireEvent.click(viewButton);
expect(screen.getByTestId('condition-viewer-modal')).toBeInTheDocument();
});
});
describe('Navigation', () => {
it('navigates to owner profile on owner name click', async () => {
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
// Find owner name spans (multiple rentals may have owners)
const ownerElements = screen.getAllByText(/Owner User/);
fireEvent.click(ownerElements[0]);
expect(mockNavigate).toHaveBeenCalled();
});
it('links to item detail page', async () => {
renderRenting();
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
// The rental card should link to item detail
const itemLink = screen.getAllByRole('link')[0];
expect(itemLink).toHaveAttribute('href', expect.stringContaining('/items/'));
});
});
describe('Declined Rentals', () => {
it('shows decline reason for declined rentals', async () => {
const declinedRental = createMockRental({
id: '1',
status: 'declined',
displayStatus: 'declined',
declineReason: 'Item not available during requested dates',
item: mockItemWithOwner as any,
});
// Declined rentals are filtered out from active rentals view
mockedGetRentals.mockResolvedValue({ data: [declinedRental] });
renderRenting();
await waitFor(() => {
// Since declined rentals are filtered, we should see empty state
expect(screen.getByText('No Active Rental Requests')).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,431 @@
/**
* ResetPassword Page Tests
*
* Tests for the password reset page that validates reset tokens
* and allows users to set a new password.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, type MockedFunction } from 'vitest';
import { MemoryRouter, Routes, Route } from 'react-router';
import ResetPassword from '../../pages/ResetPassword';
import { useAuth } from '../../contexts/AuthContext';
import { authAPI } from '../../services/api';
// Mock dependencies
vi.mock('../../contexts/AuthContext');
vi.mock('../../services/api', () => ({
authAPI: {
verifyResetToken: vi.fn(),
resetPassword: vi.fn(),
},
}));
// Mock child components
vi.mock('../../components/PasswordInput', () => ({
default: ({ id, label, value, onChange, required }: any) => (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
type="password"
value={value}
onChange={onChange}
required={required}
data-testid={id}
/>
</div>
),
}));
vi.mock('../../components/PasswordStrengthMeter', () => ({
default: ({ password }: { password: string }) => (
<div data-testid="password-strength-meter">
Strength: {password.length >= 8 ? 'Strong' : 'Weak'}
</div>
),
}));
const mockNavigate = vi.fn();
const mockOpenAuthModal = vi.fn();
vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
return {
...actual,
useNavigate: () => mockNavigate,
};
});
const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>;
const mockedVerifyResetToken = authAPI.verifyResetToken as MockedFunction<typeof authAPI.verifyResetToken>;
const mockedResetPassword = authAPI.resetPassword as MockedFunction<typeof authAPI.resetPassword>;
// Helper to render ResetPassword with route params
const renderResetPassword = (searchParams: string = '') => {
mockedUseAuth.mockReturnValue({
user: null,
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: mockOpenAuthModal,
closeAuthModal: vi.fn(),
});
return render(
<MemoryRouter initialEntries={[`/reset-password${searchParams}`]}>
<Routes>
<Route path="/reset-password" element={<ResetPassword />} />
</Routes>
</MemoryRouter>
);
};
describe('ResetPassword', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedVerifyResetToken.mockResolvedValue({ data: { valid: true } });
mockedResetPassword.mockResolvedValue({ data: { success: true } });
});
describe('Initial Token Validation', () => {
it('shows validating state while checking token', () => {
// Make the token verification hang
mockedVerifyResetToken.mockImplementation(() => new Promise(() => {}));
renderResetPassword('?token=test-token');
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('Verifying Reset Link...')).toBeInTheDocument();
});
it('validates token on mount', async () => {
renderResetPassword('?token=valid-token-123');
await waitFor(() => {
expect(mockedVerifyResetToken).toHaveBeenCalledWith('valid-token-123');
});
});
it('shows password form for valid token', async () => {
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
expect(screen.getByText('Enter your new password below.')).toBeInTheDocument();
});
expect(screen.getByTestId('newPassword')).toBeInTheDocument();
expect(screen.getByTestId('confirmPassword')).toBeInTheDocument();
});
});
describe('Missing Token', () => {
it('displays error for missing token parameter', async () => {
renderResetPassword();
await waitFor(() => {
expect(screen.getByText('Invalid Reset Link')).toBeInTheDocument();
expect(screen.getByText('No reset token provided.')).toBeInTheDocument();
});
});
});
describe('Invalid Token Errors', () => {
it('displays error for expired token', async () => {
mockedVerifyResetToken.mockRejectedValue({
response: { data: { code: 'TOKEN_EXPIRED' } },
});
renderResetPassword('?token=expired-token');
await waitFor(() => {
expect(screen.getByText('Invalid Reset Link')).toBeInTheDocument();
expect(screen.getByText(/password reset link has expired/i)).toBeInTheDocument();
});
});
it('displays error for invalid token', async () => {
mockedVerifyResetToken.mockRejectedValue({
response: { data: { code: 'TOKEN_INVALID' } },
});
renderResetPassword('?token=invalid-token');
await waitFor(() => {
expect(screen.getByText('Invalid Reset Link')).toBeInTheDocument();
expect(screen.getByText(/invalid password reset link/i)).toBeInTheDocument();
});
});
it('displays generic error for unknown failures', async () => {
mockedVerifyResetToken.mockRejectedValue({
response: { data: { error: 'Something went wrong' } },
});
renderResetPassword('?token=problem-token');
await waitFor(() => {
expect(screen.getByText('Invalid Reset Link')).toBeInTheDocument();
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
});
});
describe('Password Form', () => {
it('validates password strength via PasswordStrengthMeter', async () => {
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
expect(screen.getByTestId('password-strength-meter')).toBeInTheDocument();
});
it('validates password confirmation matches', async () => {
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
const newPasswordInput = screen.getByTestId('newPassword');
const confirmPasswordInput = screen.getByTestId('confirmPassword');
const submitButton = screen.getByRole('button', { name: /reset password/i });
fireEvent.change(newPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'DifferentPassword' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Passwords do not match.')).toBeInTheDocument();
});
});
it('disables submit while processing', async () => {
// Make reset hang
mockedResetPassword.mockImplementation(() => new Promise(() => {}));
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
const newPasswordInput = screen.getByTestId('newPassword');
const confirmPasswordInput = screen.getByTestId('confirmPassword');
const submitButton = screen.getByRole('button', { name: /reset password/i });
fireEvent.change(newPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole('button', { name: /resetting password/i })).toBeDisabled();
});
});
it('disables submit when passwords are empty', async () => {
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
const submitButton = screen.getByRole('button', { name: /reset password/i });
expect(submitButton).toBeDisabled();
});
});
describe('Form Submission', () => {
it('submits form with valid data', async () => {
renderResetPassword('?token=valid-token-456');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
const newPasswordInput = screen.getByTestId('newPassword');
const confirmPasswordInput = screen.getByTestId('confirmPassword');
const submitButton = screen.getByRole('button', { name: /reset password/i });
fireEvent.change(newPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockedResetPassword).toHaveBeenCalledWith('valid-token-456', 'NewPassword123!');
});
});
it('shows success state on successful reset', async () => {
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
const newPasswordInput = screen.getByTestId('newPassword');
const confirmPasswordInput = screen.getByTestId('confirmPassword');
const submitButton = screen.getByRole('button', { name: /reset password/i });
fireEvent.change(newPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Password Reset Successfully!')).toBeInTheDocument();
expect(screen.getByText(/can now log in with your new password/i)).toBeInTheDocument();
});
});
it('shows validation errors from API', async () => {
mockedResetPassword.mockRejectedValue({
response: {
data: {
details: [
{ message: 'Password must be at least 8 characters' },
{ message: 'Password must contain a number' },
],
},
},
});
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
const newPasswordInput = screen.getByTestId('newPassword');
const confirmPasswordInput = screen.getByTestId('confirmPassword');
const submitButton = screen.getByRole('button', { name: /reset password/i });
fireEvent.change(newPasswordInput, { target: { value: 'weak' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'weak' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/password must be at least 8 characters.*password must contain a number/i)).toBeInTheDocument();
});
});
it('handles TOKEN_EXPIRED on submit', async () => {
mockedResetPassword.mockRejectedValue({
response: { data: { code: 'TOKEN_EXPIRED' } },
});
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
const newPasswordInput = screen.getByTestId('newPassword');
const confirmPasswordInput = screen.getByTestId('confirmPassword');
const submitButton = screen.getByRole('button', { name: /reset password/i });
fireEvent.change(newPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/reset link has expired/i)).toBeInTheDocument();
});
});
});
describe('Navigation and Actions', () => {
it('"Request New Link" button navigates home and opens auth modal', async () => {
mockedVerifyResetToken.mockRejectedValue({
response: { data: { code: 'TOKEN_EXPIRED' } },
});
renderResetPassword('?token=expired-token');
await waitFor(() => {
expect(screen.getByText('Invalid Reset Link')).toBeInTheDocument();
});
const requestNewLink = screen.getByRole('button', { name: /request new link/i });
fireEvent.click(requestNewLink);
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
expect(mockOpenAuthModal).toHaveBeenCalledWith('forgot-password');
});
it('has return to home link on invalid token', async () => {
mockedVerifyResetToken.mockRejectedValue({
response: { data: { code: 'TOKEN_INVALID' } },
});
renderResetPassword('?token=invalid-token');
await waitFor(() => {
expect(screen.getByText('Invalid Reset Link')).toBeInTheDocument();
});
const homeLink = screen.getByRole('link', { name: /return to home/i });
expect(homeLink).toHaveAttribute('href', '/');
});
it('redirects to login modal on success', async () => {
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
const newPasswordInput = screen.getByTestId('newPassword');
const confirmPasswordInput = screen.getByTestId('confirmPassword');
const submitButton = screen.getByRole('button', { name: /reset password/i });
fireEvent.change(newPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'NewPassword123!' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Password Reset Successfully!')).toBeInTheDocument();
});
const loginButton = screen.getByRole('button', { name: /log in now/i });
fireEvent.click(loginButton);
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
expect(mockOpenAuthModal).toHaveBeenCalledWith('login');
});
it('has return to home link in form view', async () => {
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
const homeLink = screen.getByRole('link', { name: /return to home/i });
expect(homeLink).toHaveAttribute('href', '/');
});
});
describe('StrictMode Prevention', () => {
it('prevents double token validation', async () => {
renderResetPassword('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
});
// Should only call once despite potential StrictMode double-render
expect(mockedVerifyResetToken).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,529 @@
/**
* VerifyEmail Page Tests
*
* Tests for the email verification page that handles both
* automatic token verification and manual code entry.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, type MockedFunction } from 'vitest';
import { MemoryRouter, Routes, Route } from 'react-router';
import VerifyEmail from '../../pages/VerifyEmail';
import { useAuth } from '../../contexts/AuthContext';
import { authAPI } from '../../services/api';
import { mockUser, mockUnverifiedUser } from '../../mocks/handlers';
// Mock dependencies
vi.mock('../../contexts/AuthContext');
vi.mock('../../services/api', () => ({
authAPI: {
verifyEmail: vi.fn(),
resendVerification: vi.fn(),
},
}));
const mockNavigate = vi.fn();
vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
return {
...actual,
useNavigate: () => mockNavigate,
};
});
const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>;
const mockedVerifyEmail = authAPI.verifyEmail as MockedFunction<typeof authAPI.verifyEmail>;
const mockedResendVerification = authAPI.resendVerification as MockedFunction<typeof authAPI.resendVerification>;
// Helper to render VerifyEmail with route params
const renderVerifyEmail = (searchParams: string = '', authOverrides: Partial<ReturnType<typeof useAuth>> = {}) => {
mockedUseAuth.mockReturnValue({
user: mockUnverifiedUser,
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(
<MemoryRouter initialEntries={[`/verify-email${searchParams}`]}>
<Routes>
<Route path="/verify-email" element={<VerifyEmail />} />
</Routes>
</MemoryRouter>
);
};
describe('VerifyEmail', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
mockedVerifyEmail.mockResolvedValue({ data: { success: true } });
mockedResendVerification.mockResolvedValue({ data: { success: true } });
});
afterEach(() => {
vi.useRealTimers();
});
describe('Initial Loading State', () => {
it('shows loading state while auth is initializing', async () => {
renderVerifyEmail('', { loading: true });
expect(screen.getByRole('status')).toBeInTheDocument();
// There are multiple "Loading..." elements - the spinner's visually-hidden text and the heading
expect(screen.getAllByText('Loading...').length).toBeGreaterThanOrEqual(1);
});
});
describe('Authentication Check', () => {
it('redirects unauthenticated users to login', async () => {
renderVerifyEmail('', { user: null });
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('/?login=true&redirect='),
{ replace: true }
);
});
});
it('redirects unauthenticated users with token to login with return URL', async () => {
renderVerifyEmail('?token=test-token-123', { user: null });
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('login=true'),
{ replace: true }
);
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('verify-email'),
{ replace: true }
);
});
});
});
describe('Auto-Verification with Token', () => {
it('auto-verifies when token present in URL', async () => {
renderVerifyEmail('?token=valid-token-123');
await waitFor(() => {
expect(mockedVerifyEmail).toHaveBeenCalledWith('valid-token-123');
});
});
it('shows success state after successful verification', async () => {
const mockCheckAuth = vi.fn();
renderVerifyEmail('?token=valid-token', { checkAuth: mockCheckAuth });
await waitFor(() => {
expect(screen.getByText('Email Verified Successfully!')).toBeInTheDocument();
});
expect(mockCheckAuth).toHaveBeenCalled();
});
it('shows success state immediately for already verified user', async () => {
renderVerifyEmail('', { user: mockUser });
await waitFor(() => {
expect(screen.getByText('Email Verified Successfully!')).toBeInTheDocument();
});
});
it('auto-redirects to home after successful verification', async () => {
renderVerifyEmail('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Email Verified Successfully!')).toBeInTheDocument();
});
// Advance timers to trigger auto-redirect (3 seconds)
await act(async () => {
vi.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
});
});
});
describe('Manual Code Entry', () => {
it('shows manual code entry form when no token in URL', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
expect(screen.getByText('Enter the 6-digit code sent to your email')).toBeInTheDocument();
});
// Check for 6 input fields
const inputs = screen.getAllByRole('textbox');
expect(inputs).toHaveLength(6);
});
it('handles 6-digit input with auto-focus', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const inputs = screen.getAllByRole('textbox');
// Type first digit using fireEvent
fireEvent.change(inputs[0], { target: { value: '1' } });
expect(inputs[0]).toHaveValue('1');
// Focus should auto-move to next input
// (Note: actual focus behavior may depend on DOM focus events)
});
it('filters non-numeric input', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const inputs = screen.getAllByRole('textbox');
// Try typing letters
fireEvent.change(inputs[0], { target: { value: 'a' } });
expect(inputs[0]).toHaveValue('');
// Try typing numbers
fireEvent.change(inputs[0], { target: { value: '5' } });
expect(inputs[0]).toHaveValue('5');
});
it('handles paste of 6-digit code', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const container = document.querySelector('.d-flex.justify-content-center.gap-2');
// Simulate paste event
const pasteEvent = new Event('paste', { bubbles: true, cancelable: true }) as any;
pasteEvent.clipboardData = {
getData: () => '123456',
};
fireEvent(container!, pasteEvent);
await waitFor(() => {
expect(mockedVerifyEmail).toHaveBeenCalledWith('123456');
});
});
it('submits manual code on button click', async () => {
// Make the verification hang to test the button state
mockedVerifyEmail.mockImplementation(() => new Promise(() => {}));
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const inputs = screen.getAllByRole('textbox');
// Fill in 5 digits (not 6 to avoid auto-submit)
fireEvent.change(inputs[0], { target: { value: '1' } });
fireEvent.change(inputs[1], { target: { value: '2' } });
fireEvent.change(inputs[2], { target: { value: '3' } });
fireEvent.change(inputs[3], { target: { value: '4' } });
fireEvent.change(inputs[4], { target: { value: '5' } });
// Button should be disabled with only 5 digits
const verifyButton = screen.getByRole('button', { name: /verify email/i });
expect(verifyButton).toBeDisabled();
// Now fill in the 6th digit - this will auto-submit
fireEvent.change(inputs[5], { target: { value: '6' } });
// The component auto-submits when 6 digits are entered
await waitFor(() => {
expect(mockedVerifyEmail).toHaveBeenCalledWith('123456');
});
});
it('disables verify button when code incomplete', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const verifyButton = screen.getByRole('button', { name: /verify email/i });
expect(verifyButton).toBeDisabled();
});
it('backspace moves focus to previous input', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const inputs = screen.getAllByRole('textbox');
// Fill first input and move to second
fireEvent.change(inputs[0], { target: { value: '1' } });
fireEvent.change(inputs[1], { target: { value: '2' } });
// Clear second input and press backspace
fireEvent.change(inputs[1], { target: { value: '' } });
fireEvent.keyDown(inputs[1], { key: 'Backspace' });
// The component handles this by focusing previous input
});
});
describe('Error Handling', () => {
it('displays EXPIRED error message', async () => {
mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'VERIFICATION_EXPIRED' } },
});
renderVerifyEmail('?token=expired-token');
await waitFor(() => {
expect(screen.getByText(/verification code has expired/i)).toBeInTheDocument();
});
});
it('displays INVALID error message', async () => {
mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'VERIFICATION_INVALID' } },
});
renderVerifyEmail('?token=invalid-token');
await waitFor(() => {
expect(screen.getByText(/code didn't match/i)).toBeInTheDocument();
});
});
it('displays TOO_MANY_ATTEMPTS error message', async () => {
mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'TOO_MANY_ATTEMPTS' } },
});
renderVerifyEmail('?token=blocked-token');
await waitFor(() => {
expect(screen.getByText(/too many attempts/i)).toBeInTheDocument();
});
});
it('displays ALREADY_VERIFIED error message', async () => {
mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'ALREADY_VERIFIED' } },
});
renderVerifyEmail('?token=already-verified-token');
await waitFor(() => {
expect(screen.getByText(/already verified/i)).toBeInTheDocument();
});
});
it('clears input on error', async () => {
mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'VERIFICATION_INVALID' } },
});
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const inputs = screen.getAllByRole('textbox');
// Fill all digits - this will auto-submit due to the component behavior
fireEvent.change(inputs[0], { target: { value: '1' } });
fireEvent.change(inputs[1], { target: { value: '2' } });
fireEvent.change(inputs[2], { target: { value: '3' } });
fireEvent.change(inputs[3], { target: { value: '4' } });
fireEvent.change(inputs[4], { target: { value: '5' } });
fireEvent.change(inputs[5], { target: { value: '6' } });
// Wait for error message to appear
await waitFor(() => {
expect(screen.getByText(/code didn't match/i)).toBeInTheDocument();
});
// Inputs should be cleared after error
await waitFor(() => {
const updatedInputs = screen.getAllByRole('textbox');
updatedInputs.forEach((input) => {
expect(input).toHaveValue('');
});
});
});
});
describe('Resend Verification', () => {
it('shows resend button', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /send code/i })).toBeInTheDocument();
});
it('starts 60-second cooldown after resend', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const resendButton = screen.getByRole('button', { name: /send code/i });
fireEvent.click(resendButton);
await waitFor(() => {
expect(screen.getByText(/resend in 60s/i)).toBeInTheDocument();
});
expect(mockedResendVerification).toHaveBeenCalled();
});
it('disables resend during cooldown', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const resendButton = screen.getByRole('button', { name: /send code/i });
fireEvent.click(resendButton);
await waitFor(() => {
expect(screen.getByText(/resend in 60s/i)).toBeInTheDocument();
});
const cooldownButton = screen.getByRole('button', { name: /resend in/i });
expect(cooldownButton).toBeDisabled();
});
it('shows success message after resend', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const resendButton = screen.getByRole('button', { name: /send code/i });
fireEvent.click(resendButton);
await waitFor(() => {
expect(screen.getByText(/new code sent/i)).toBeInTheDocument();
});
});
it('counts down timer', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const resendButton = screen.getByRole('button', { name: /send code/i });
fireEvent.click(resendButton);
await waitFor(() => {
expect(screen.getByText(/resend in 60s/i)).toBeInTheDocument();
});
// With shouldAdvanceTime: true, the timer will automatically count down
// Wait for the countdown to show a lower value
await waitFor(() => {
// Timer should have counted down from 60s to something less
const resendText = screen.getByRole('button', { name: /resend in \d+s/i }).textContent;
expect(resendText).toMatch(/Resend in [0-5][0-9]s/);
}, { timeout: 3000 });
});
it('handles resend error for already verified', async () => {
mockedResendVerification.mockRejectedValue({
response: { data: { code: 'ALREADY_VERIFIED' } },
});
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const resendButton = screen.getByRole('button', { name: /send code/i });
fireEvent.click(resendButton);
await waitFor(() => {
expect(screen.getByText(/already verified/i)).toBeInTheDocument();
});
});
it('handles rate limit error (429)', async () => {
mockedResendVerification.mockRejectedValue({
response: { status: 429 },
});
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const resendButton = screen.getByRole('button', { name: /send code/i });
fireEvent.click(resendButton);
await waitFor(() => {
expect(screen.getByText(/please wait before requesting/i)).toBeInTheDocument();
});
});
});
describe('Navigation', () => {
it('has return to home link', async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
});
const homeLink = screen.getByRole('link', { name: /return to home/i });
expect(homeLink).toHaveAttribute('href', '/');
});
it('has go to home link on success', async () => {
renderVerifyEmail('?token=valid-token');
await waitFor(() => {
expect(screen.getByText('Email Verified Successfully!')).toBeInTheDocument();
});
const homeLink = screen.getByRole('link', { name: /go to home page/i });
expect(homeLink).toHaveAttribute('href', '/');
});
});
});

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { MemoryRouter, MemoryRouterProps } from 'react-router';
interface RenderWithProvidersOptions extends Omit<RenderOptions, 'wrapper'> {
route?: string;
routerProps?: Omit<MemoryRouterProps, 'children'>;
}
/**
* Renders a component wrapped with necessary providers for testing.
*
* @param ui - The React element to render
* @param options - Configuration options including route and router props
* @returns The render result from @testing-library/react
*/
export const renderWithProviders = (
ui: React.ReactElement,
{ route = '/', routerProps, ...renderOptions }: RenderWithProvidersOptions = {}
) => {
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<MemoryRouter initialEntries={[route]} {...routerProps}>
{children}
</MemoryRouter>
);
};
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
};
/**
* Renders a component wrapped with MemoryRouter at a specific route.
* Useful for testing components that use useParams, useSearchParams, etc.
*
* @param ui - The React element to render
* @param route - The initial route to render at
* @returns The render result
*/
export const renderWithRouter = (
ui: React.ReactElement,
route: string = '/'
) => {
return renderWithProviders(ui, { route });
};
/**
* Creates a mock search params string from an object
*
* @param params - Object with key-value pairs for search params
* @returns A route string with search params
*/
export const createRouteWithParams = (
path: string,
params: Record<string, string>
): string => {
const searchParams = new URLSearchParams(params);
return `${path}?${searchParams.toString()}`;
};
export default renderWithProviders;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react"; import React, { useState, useCallback, useEffect } from "react";
import { loadConnectAndInitialize } from "@stripe/connect-js"; import { loadConnectAndInitialize } from "@stripe/connect-js";
import { import {
ConnectAccountOnboarding, ConnectAccountOnboarding,
@@ -86,7 +86,7 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
} }
}; };
const startOnboardingForExistingAccount = async () => { const startOnboardingForExistingAccount = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -98,14 +98,14 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
setError(err.message || "Failed to initialize onboarding"); setError(err.message || "Failed to initialize onboarding");
setLoading(false); setLoading(false);
} }
}; }, [initializeStripeConnect]);
// If user already has an account, initialize immediately // If user already has an account, initialize immediately
React.useEffect(() => { useEffect(() => {
if (hasExistingAccount && step === "onboarding" && !stripeConnectInstance) { if (hasExistingAccount && step === "onboarding" && !stripeConnectInstance) {
startOnboardingForExistingAccount(); startOnboardingForExistingAccount();
} }
}, [hasExistingAccount]); }, [hasExistingAccount, step, stripeConnectInstance, startOnboardingForExistingAccount]);
const handleStartSetup = () => { const handleStartSetup = () => {
createStripeAccount(); createStripeAccount();

View File

@@ -11,6 +11,7 @@ export const mockUser = {
lastName: 'User', lastName: 'User',
isVerified: true, isVerified: true,
role: 'user' as const, role: 'user' as const,
addresses: [],
}; };
export const mockUnverifiedUser = { export const mockUnverifiedUser = {
@@ -69,3 +70,165 @@ export const createMockError = (message: string, status: number, code?: string)
}; };
return error; return error;
}; };
// Extended mock user data for testing
export const mockAdminUser = {
...mockUser,
id: '3',
email: 'admin@example.com',
role: 'admin' as const,
};
// Mock Item factory with all fields
export const createMockItem = (overrides: Partial<typeof mockItem> = {}) => ({
...mockItem,
id: overrides.id || String(Math.floor(Math.random() * 1000)),
...overrides,
});
// Full mock item with owner details
export const mockItemWithOwner = {
...mockItem,
owner: {
id: '2',
firstName: 'Owner',
lastName: 'User',
email: 'owner@example.com',
},
pricePerHour: 10,
pricePerWeek: 150,
pricePerMonth: 500,
latitude: 37.7749,
longitude: -122.4194,
address1: '123 Test St',
city: 'Test City',
state: 'California',
zipCode: '94102',
availableAfter: '09:00',
availableBefore: '21:00',
specifyTimesPerDay: false,
weeklyTimes: null,
rules: 'Test rules',
imageFilenames: ['items/test-image-1.jpg', 'items/test-image-2.jpg'],
};
// Mock Rental factory with all fields
export const createMockRental = (overrides: Partial<typeof mockRental & {
item?: typeof mockItem;
renter?: typeof mockUser;
owner?: typeof mockUser;
displayStatus?: string;
paymentStatus?: string;
declineReason?: string;
refundAmount?: number;
intendedUse?: string;
}> = {}) => ({
...mockRental,
id: overrides.id || String(Math.floor(Math.random() * 1000)),
item: overrides.item || mockItemWithOwner,
renter: overrides.renter || mockUser,
owner: overrides.owner || { ...mockUser, id: '2' },
displayStatus: overrides.displayStatus || overrides.status || 'pending',
...overrides,
});
// Mock rentals with different statuses for testing
export const mockPendingRental = createMockRental({
id: '1',
status: 'pending',
displayStatus: 'pending',
paymentStatus: 'pending',
});
export const mockConfirmedRental = createMockRental({
id: '2',
status: 'confirmed',
displayStatus: 'confirmed',
paymentStatus: 'paid',
});
export const mockActiveRental = createMockRental({
id: '3',
status: 'confirmed',
displayStatus: 'active',
paymentStatus: 'paid',
});
export const mockCompletedRental = createMockRental({
id: '4',
status: 'completed',
displayStatus: 'completed',
paymentStatus: 'paid',
});
export const mockDeclinedRental = createMockRental({
id: '5',
status: 'declined',
displayStatus: 'declined',
declineReason: 'Item not available during requested period',
});
// Mock Condition Check factory
export const createMockConditionCheck = (overrides: {
id?: string;
rentalId?: string;
checkType?: string;
status?: string;
images?: string[];
notes?: string;
createdAt?: string;
} = {}) => ({
id: overrides.id || String(Math.floor(Math.random() * 1000)),
rentalId: overrides.rentalId || '1',
checkType: overrides.checkType || 'pre_rental_owner',
status: overrides.status || 'completed',
images: overrides.images || ['condition/check-1.jpg'],
notes: overrides.notes || 'Item in good condition',
createdAt: overrides.createdAt || new Date().toISOString(),
});
// Mock Address factory
export const createMockAddress = (overrides: {
id?: string;
address1?: string;
address2?: string;
city?: string;
state?: string;
zipCode?: string;
country?: string;
latitude?: number;
longitude?: number;
isPrimary?: boolean;
} = {}) => ({
id: overrides.id || String(Math.floor(Math.random() * 1000)),
address1: overrides.address1 || '123 Main St',
address2: overrides.address2 || '',
city: overrides.city || 'San Francisco',
state: overrides.state || 'CA',
zipCode: overrides.zipCode || '94102',
country: overrides.country || 'US',
latitude: overrides.latitude || 37.7749,
longitude: overrides.longitude || -122.4194,
isPrimary: overrides.isPrimary ?? true,
});
// Mock paginated response helper
export const createMockPaginatedResponse = <T>(
items: T[],
page = 1,
totalPages = 1,
totalItems?: number
) => ({
items,
page,
totalPages,
totalItems: totalItems ?? items.length,
});
// Mock available checks response
export const createMockAvailableChecks = (rentalId: string, checkTypes: string[] = ['pre_rental_owner']) => ({
availableChecks: checkTypes.map(checkType => ({
rentalId,
checkType,
})),
});

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useSearchParams, useNavigate } from "react-router"; import { useSearchParams, useNavigate } from "react-router";
import { Item } from "../types"; import { Item } from "../types";
import { itemAPI } from "../services/api"; import { itemAPI } from "../services/api";
@@ -21,6 +21,7 @@ const ItemList: React.FC = () => {
const [locationName, setLocationName] = useState(searchParams.get("locationName") || ""); const [locationName, setLocationName] = useState(searchParams.get("locationName") || "");
const locationCheckDone = useRef(false); const locationCheckDone = useRef(false);
const filterButtonRef = useRef<HTMLDivElement>(null); const filterButtonRef = useRef<HTMLDivElement>(null);
const isInitialMount = useRef(true);
const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get("page") || "1")); const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get("page") || "1"));
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0); const [totalItems, setTotalItems] = useState(0);
@@ -65,8 +66,12 @@ const ItemList: React.FC = () => {
fetchItems(); fetchItems();
}, [filters, currentPage]); }, [filters, currentPage]);
// Reset to page 1 when filters change // Reset to page 1 when filters change (but not on initial mount)
useEffect(() => { useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
setCurrentPage(1); setCurrentPage(1);
}, [filters.search, filters.city, filters.zipCode, filters.lat, filters.lng, filters.radius]); }, [filters.search, filters.city, filters.zipCode, filters.lat, filters.lng, filters.radius]);
@@ -215,7 +220,7 @@ const ItemList: React.FC = () => {
type="button" type="button"
className={`btn btn-outline-secondary ${hasActiveLocationFilter ? 'active' : ''}`} className={`btn btn-outline-secondary ${hasActiveLocationFilter ? 'active' : ''}`}
onClick={() => setShowFilterPanel(!showFilterPanel)} onClick={() => setShowFilterPanel(!showFilterPanel)}
data-filter-button data-testid="filter-button"
> >
<i className="bi bi-filter me-1 me-md-2 align-middle"></i> <i className="bi bi-filter me-1 me-md-2 align-middle"></i>
<span className="d-none d-md-inline">Filters</span> <span className="d-none d-md-inline">Filters</span>

View File

@@ -20,7 +20,7 @@ export default defineConfig(({ mode }) => {
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'], setupFiles: ['./src/setupTests.ts'],
include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}', 'src/**/__tests__/**/*.{js,jsx,ts,tsx}'], include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'lcov', 'html'], reporter: ['text', 'lcov', 'html'],