More frontend tests
This commit is contained in:
84
frontend/src/__mocks__/stripe.ts
Normal file
84
frontend/src/__mocks__/stripe.ts
Normal 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;
|
||||
@@ -253,5 +253,165 @@ describe('ItemCard', () => {
|
||||
|
||||
expect(screen.getByText('Free to Borrow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display first 2 pricing tiers when multiple available', () => {
|
||||
const item = createMockItem({
|
||||
pricePerHour: 5,
|
||||
pricePerDay: 25,
|
||||
pricePerWeek: 100,
|
||||
pricePerMonth: 300,
|
||||
});
|
||||
|
||||
renderWithRouter(<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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
388
frontend/src/__tests__/pages/CompletePayment.test.tsx
Normal file
388
frontend/src/__tests__/pages/CompletePayment.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
1096
frontend/src/__tests__/pages/CreateItem.test.tsx
Normal file
1096
frontend/src/__tests__/pages/CreateItem.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
919
frontend/src/__tests__/pages/EditItem.test.tsx
Normal file
919
frontend/src/__tests__/pages/EditItem.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
235
frontend/src/__tests__/pages/GoogleCallback.test.tsx
Normal file
235
frontend/src/__tests__/pages/GoogleCallback.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
505
frontend/src/__tests__/pages/ItemDetail.test.tsx
Normal file
505
frontend/src/__tests__/pages/ItemDetail.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
567
frontend/src/__tests__/pages/ItemList.test.tsx
Normal file
567
frontend/src/__tests__/pages/ItemList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
622
frontend/src/__tests__/pages/Owning.test.tsx
Normal file
622
frontend/src/__tests__/pages/Owning.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
559
frontend/src/__tests__/pages/Renting.test.tsx
Normal file
559
frontend/src/__tests__/pages/Renting.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
431
frontend/src/__tests__/pages/ResetPassword.test.tsx
Normal file
431
frontend/src/__tests__/pages/ResetPassword.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
529
frontend/src/__tests__/pages/VerifyEmail.test.tsx
Normal file
529
frontend/src/__tests__/pages/VerifyEmail.test.tsx
Normal 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', '/');
|
||||
});
|
||||
});
|
||||
});
|
||||
63
frontend/src/__tests__/utils/renderWithProviders.tsx
Normal file
63
frontend/src/__tests__/utils/renderWithProviders.tsx
Normal 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { loadConnectAndInitialize } from "@stripe/connect-js";
|
||||
import {
|
||||
ConnectAccountOnboarding,
|
||||
@@ -86,7 +86,7 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const startOnboardingForExistingAccount = async () => {
|
||||
const startOnboardingForExistingAccount = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -98,14 +98,14 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
||||
setError(err.message || "Failed to initialize onboarding");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [initializeStripeConnect]);
|
||||
|
||||
// If user already has an account, initialize immediately
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (hasExistingAccount && step === "onboarding" && !stripeConnectInstance) {
|
||||
startOnboardingForExistingAccount();
|
||||
}
|
||||
}, [hasExistingAccount]);
|
||||
}, [hasExistingAccount, step, stripeConnectInstance, startOnboardingForExistingAccount]);
|
||||
|
||||
const handleStartSetup = () => {
|
||||
createStripeAccount();
|
||||
|
||||
@@ -11,6 +11,7 @@ export const mockUser = {
|
||||
lastName: 'User',
|
||||
isVerified: true,
|
||||
role: 'user' as const,
|
||||
addresses: [],
|
||||
};
|
||||
|
||||
export const mockUnverifiedUser = {
|
||||
@@ -69,3 +70,165 @@ export const createMockError = (message: string, status: number, code?: string)
|
||||
};
|
||||
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,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -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 { Item } from "../types";
|
||||
import { itemAPI } from "../services/api";
|
||||
@@ -21,6 +21,7 @@ const ItemList: React.FC = () => {
|
||||
const [locationName, setLocationName] = useState(searchParams.get("locationName") || "");
|
||||
const locationCheckDone = useRef(false);
|
||||
const filterButtonRef = useRef<HTMLDivElement>(null);
|
||||
const isInitialMount = useRef(true);
|
||||
const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get("page") || "1"));
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
@@ -65,8 +66,12 @@ const ItemList: React.FC = () => {
|
||||
fetchItems();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
// Reset to page 1 when filters change (but not on initial mount)
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, [filters.search, filters.city, filters.zipCode, filters.lat, filters.lng, filters.radius]);
|
||||
|
||||
@@ -215,7 +220,7 @@ const ItemList: React.FC = () => {
|
||||
type="button"
|
||||
className={`btn btn-outline-secondary ${hasActiveLocationFilter ? 'active' : ''}`}
|
||||
onClick={() => setShowFilterPanel(!showFilterPanel)}
|
||||
data-filter-button
|
||||
data-testid="filter-button"
|
||||
>
|
||||
<i className="bi bi-filter me-1 me-md-2 align-middle"></i>
|
||||
<span className="d-none d-md-inline">Filters</span>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineConfig(({ mode }) => {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
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: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov', 'html'],
|
||||
|
||||
Reference in New Issue
Block a user