> = {}) => {
+ mockedUseAuth.mockReturnValue({
+ user: mockUser,
+ loading: false,
+ showAuthModal: false,
+ authModalMode: 'login',
+ login: vi.fn(),
+ register: vi.fn(),
+ googleLogin: vi.fn(),
+ logout: vi.fn(),
+ checkAuth: vi.fn(),
+ updateUser: vi.fn(),
+ openAuthModal: vi.fn(),
+ closeAuthModal: vi.fn(),
+ ...authOverrides,
+ });
+
+ return render(
+
+
+ } />
+
+
+ );
+};
+
+describe('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();
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/Owning.test.tsx b/frontend/src/__tests__/pages/Owning.test.tsx
new file mode 100644
index 0000000..1e6b41b
--- /dev/null
+++ b/frontend/src/__tests__/pages/Owning.test.tsx
@@ -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 ? (
+
+ Review Renter for {rental?.item?.name}
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/RentalCancellationModal', () => ({
+ default: ({ show, onHide, rental, onCancellationComplete }: any) =>
+ show ? (
+
+ Cancel {rental?.item?.name}
+
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/DeclineRentalModal', () => ({
+ default: ({ show, onHide, rental, onDeclineComplete }: any) =>
+ show ? (
+
+ Decline {rental?.item?.name}
+
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/ConditionCheckModal', () => ({
+ default: ({ show, onHide, rentalId, checkType, onSuccess }: any) =>
+ show ? (
+
+ Condition Check for rental {rentalId}
+ Type: {checkType}
+
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/ConditionCheckViewerModal', () => ({
+ default: ({ show, onHide, conditionCheck }: any) =>
+ show ? (
+
+ View Check: {conditionCheck?.checkType}
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/ReturnStatusModal', () => ({
+ default: ({ show, onHide, rental, onReturnMarked }: any) =>
+ show ? (
+
+ Return Status for {rental?.item?.name}
+
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/PaymentFailedModal', () => ({
+ default: ({ show, onHide, paymentError, itemName }: any) =>
+ show ? (
+
+ Payment Failed for {itemName}
+ {paymentError?.message}
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/AuthenticationRequiredModal', () => ({
+ default: ({ rental, onClose }: any) => (
+
+ Auth Required for {rental?.item?.name}
+
+
+ ),
+}));
+
+const mockNavigate = vi.fn();
+vi.mock('react-router', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+const mockedUseAuth = useAuth as MockedFunction;
+const mockedApiGet = api.get as MockedFunction;
+const mockedApiPut = api.put as MockedFunction;
+const mockedApiDelete = api.delete as MockedFunction;
+const mockedGetListings = rentalAPI.getListings as MockedFunction;
+const mockedUpdateRentalStatus = rentalAPI.updateRentalStatus as MockedFunction;
+const mockedGetAvailableChecks = conditionCheckAPI.getAvailableChecks as MockedFunction;
+const mockedGetBatchConditionChecks = conditionCheckAPI.getBatchConditionChecks as MockedFunction;
+
+// Helper to render Owning page
+const renderOwning = (authOverrides: Partial> = {}) => {
+ 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(
+
+
+ } />
+
+
+ );
+};
+
+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();
+ });
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/Renting.test.tsx b/frontend/src/__tests__/pages/Renting.test.tsx
new file mode 100644
index 0000000..a1d439f
--- /dev/null
+++ b/frontend/src/__tests__/pages/Renting.test.tsx
@@ -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 ? (
+
+ Review {rental?.item?.name}
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/RentalCancellationModal', () => ({
+ default: ({ show, onHide, rental, onCancellationComplete }: any) =>
+ show ? (
+
+ Cancel {rental?.item?.name}
+
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/ConditionCheckModal', () => ({
+ default: ({ show, onHide, rentalId, checkType, onSuccess }: any) =>
+ show ? (
+
+ Condition Check for rental {rentalId}
+ Type: {checkType}
+
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/ConditionCheckViewerModal', () => ({
+ default: ({ show, onHide, conditionCheck }: any) =>
+ show ? (
+
+ View Check: {conditionCheck?.checkType}
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/UpdatePaymentMethodModal', () => ({
+ default: ({ show, onHide, rentalId, onSuccess }: any) =>
+ show ? (
+
+ Update Payment for {rentalId}
+
+
+
+ ) : null,
+}));
+
+const mockNavigate = vi.fn();
+vi.mock('react-router', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+const mockedUseAuth = useAuth as MockedFunction;
+const mockedGetRentals = rentalAPI.getRentals as MockedFunction;
+const mockedGetAvailableChecks = conditionCheckAPI.getAvailableChecks as MockedFunction;
+const mockedGetBatchConditionChecks = conditionCheckAPI.getBatchConditionChecks as MockedFunction;
+
+// Helper to render Renting page
+const renderRenting = (authOverrides: Partial> = {}) => {
+ mockedUseAuth.mockReturnValue({
+ user: mockUser,
+ loading: false,
+ showAuthModal: false,
+ authModalMode: 'login',
+ login: vi.fn(),
+ register: vi.fn(),
+ googleLogin: vi.fn(),
+ logout: vi.fn(),
+ checkAuth: vi.fn(),
+ updateUser: vi.fn(),
+ openAuthModal: vi.fn(),
+ closeAuthModal: vi.fn(),
+ ...authOverrides,
+ });
+
+ return render(
+
+
+ } />
+
+
+ );
+};
+
+describe('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();
+ });
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/ResetPassword.test.tsx b/frontend/src/__tests__/pages/ResetPassword.test.tsx
new file mode 100644
index 0000000..75ac4ff
--- /dev/null
+++ b/frontend/src/__tests__/pages/ResetPassword.test.tsx
@@ -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) => (
+
+
+
+
+ ),
+}));
+
+vi.mock('../../components/PasswordStrengthMeter', () => ({
+ default: ({ password }: { password: string }) => (
+
+ Strength: {password.length >= 8 ? 'Strong' : 'Weak'}
+
+ ),
+}));
+
+const mockNavigate = vi.fn();
+const mockOpenAuthModal = vi.fn();
+
+vi.mock('react-router', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+const mockedUseAuth = useAuth as MockedFunction;
+const mockedVerifyResetToken = authAPI.verifyResetToken as MockedFunction;
+const mockedResetPassword = authAPI.resetPassword as MockedFunction;
+
+// 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(
+
+
+ } />
+
+
+ );
+};
+
+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);
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/VerifyEmail.test.tsx b/frontend/src/__tests__/pages/VerifyEmail.test.tsx
new file mode 100644
index 0000000..90a6a21
--- /dev/null
+++ b/frontend/src/__tests__/pages/VerifyEmail.test.tsx
@@ -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();
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+const mockedUseAuth = useAuth as MockedFunction;
+const mockedVerifyEmail = authAPI.verifyEmail as MockedFunction;
+const mockedResendVerification = authAPI.resendVerification as MockedFunction;
+
+// Helper to render VerifyEmail with route params
+const renderVerifyEmail = (searchParams: string = '', authOverrides: Partial> = {}) => {
+ 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(
+
+
+ } />
+
+
+ );
+};
+
+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', '/');
+ });
+ });
+});
diff --git a/frontend/src/__tests__/utils/renderWithProviders.tsx b/frontend/src/__tests__/utils/renderWithProviders.tsx
new file mode 100644
index 0000000..dbd5507
--- /dev/null
+++ b/frontend/src/__tests__/utils/renderWithProviders.tsx
@@ -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 {
+ route?: string;
+ routerProps?: Omit;
+}
+
+/**
+ * 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 (
+
+ {children}
+
+ );
+ };
+
+ 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 => {
+ const searchParams = new URLSearchParams(params);
+ return `${path}?${searchParams.toString()}`;
+};
+
+export default renderWithProviders;
diff --git a/frontend/src/components/StripeConnectOnboarding.tsx b/frontend/src/components/StripeConnectOnboarding.tsx
index f65a87c..6683307 100644
--- a/frontend/src/components/StripeConnectOnboarding.tsx
+++ b/frontend/src/components/StripeConnectOnboarding.tsx
@@ -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 = ({
}
};
- const startOnboardingForExistingAccount = async () => {
+ const startOnboardingForExistingAccount = useCallback(async () => {
setLoading(true);
setError(null);
@@ -98,14 +98,14 @@ const StripeConnectOnboarding: React.FC = ({
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();
diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts
index 9142240..2141ea4 100644
--- a/frontend/src/mocks/handlers.ts
+++ b/frontend/src/mocks/handlers.ts
@@ -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 = {}) => ({
+ ...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 = {}) => ({
+ ...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 = (
+ 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,
+ })),
+});
diff --git a/frontend/src/pages/ItemList.tsx b/frontend/src/pages/ItemList.tsx
index b5e31b9..1e6bc3b 100644
--- a/frontend/src/pages/ItemList.tsx
+++ b/frontend/src/pages/ItemList.tsx
@@ -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(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"
>
Filters
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 9faacf5..3ca3497 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -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'],