feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
869
frontend/src/pages/customer/__tests__/BookingPage.test.tsx
Normal file
869
frontend/src/pages/customer/__tests__/BookingPage.test.tsx
Normal file
@@ -0,0 +1,869 @@
|
||||
/**
|
||||
* Unit tests for BookingPage component
|
||||
*
|
||||
* Tests all booking functionality including:
|
||||
* - Service selection and rendering
|
||||
* - Date/time picker interaction
|
||||
* - Multi-step booking flow
|
||||
* - Booking confirmation
|
||||
* - Loading states
|
||||
* - Error states
|
||||
* - Complete user flows
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import React, { type ReactNode } from 'react';
|
||||
import BookingPage from '../BookingPage';
|
||||
import { useServices } from '../../../hooks/useServices';
|
||||
import { User, Business, Service } from '../../../types';
|
||||
|
||||
// Mock the useServices hook
|
||||
vi.mock('../../../hooks/useServices', () => ({
|
||||
useServices: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons to avoid rendering issues in tests
|
||||
vi.mock('lucide-react', () => ({
|
||||
Check: () => <div data-testid="check-icon">Check</div>,
|
||||
ChevronLeft: () => <div data-testid="chevron-left-icon">ChevronLeft</div>,
|
||||
Calendar: () => <div data-testid="calendar-icon">Calendar</div>,
|
||||
Clock: () => <div data-testid="clock-icon">Clock</div>,
|
||||
AlertTriangle: () => <div data-testid="alert-icon">AlertTriangle</div>,
|
||||
CreditCard: () => <div data-testid="credit-card-icon">CreditCard</div>,
|
||||
Loader2: () => <div data-testid="loader-icon">Loader2</div>,
|
||||
}));
|
||||
|
||||
// Test data factories
|
||||
const createMockUser = (overrides?: Partial<User>): User => ({
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'customer',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockBusiness = (overrides?: Partial<Business>): Business => ({
|
||||
id: '1',
|
||||
name: 'Test Business',
|
||||
subdomain: 'testbiz',
|
||||
primaryColor: '#3B82F6',
|
||||
secondaryColor: '#10B981',
|
||||
whitelabelEnabled: false,
|
||||
paymentsEnabled: true,
|
||||
requirePaymentMethodToBook: false,
|
||||
cancellationWindowHours: 24,
|
||||
lateCancellationFeePercent: 50,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockService = (overrides?: Partial<Service>): Service => ({
|
||||
id: '1',
|
||||
name: 'Haircut',
|
||||
durationMinutes: 60,
|
||||
price: 50.0,
|
||||
description: 'Professional haircut service',
|
||||
displayOrder: 0,
|
||||
photos: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Test wrapper with all necessary providers
|
||||
const createWrapper = (queryClient: QueryClient, user: User, business: Business) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/book']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/book"
|
||||
element={
|
||||
<div>
|
||||
{React.cloneElement(children as React.ReactElement, {
|
||||
// Simulate useOutletContext
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom render function with context
|
||||
const renderBookingPage = (user: User, business: Business, queryClient: QueryClient) => {
|
||||
// Mock useOutletContext by wrapping the component
|
||||
const BookingPageWithContext = () => {
|
||||
// Simulate the outlet context
|
||||
const context = { user, business };
|
||||
|
||||
// Pass context through a wrapper component
|
||||
return React.createElement(BookingPage, { ...context } as any);
|
||||
};
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<BookingPageWithContext />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('BookingPage', () => {
|
||||
let queryClient: QueryClient;
|
||||
let mockUser: User;
|
||||
let mockBusiness: Business;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
mockUser = createMockUser();
|
||||
mockBusiness = createMockBusiness();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
describe('Service Selection (Step 1)', () => {
|
||||
it('should render loading state while fetching services', () => {
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByTestId('loader-icon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render empty state when no services available', () => {
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('No services available for booking at this time.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render list of available services', () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
createMockService({ id: '2', name: 'Hair Color', price: 120, durationMinutes: 120 }),
|
||||
createMockService({ id: '3', name: 'Styling', price: 40, durationMinutes: 45 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('Haircut')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hair Color')).toBeInTheDocument();
|
||||
expect(screen.getByText('Styling')).toBeInTheDocument();
|
||||
expect(screen.getByText('$50.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('$120.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('$40.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display service details including duration and description', () => {
|
||||
const mockServices = [
|
||||
createMockService({
|
||||
id: '1',
|
||||
name: 'Deep Tissue Massage',
|
||||
price: 90,
|
||||
durationMinutes: 90,
|
||||
description: 'Relaxing full-body massage',
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('Deep Tissue Massage')).toBeInTheDocument();
|
||||
expect(screen.getByText(/90 min.*Relaxing full-body massage/)).toBeInTheDocument();
|
||||
expect(screen.getByText('$90.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should advance to step 2 when a service is selected', async () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
const serviceButton = screen.getByRole('button', { name: /Haircut/i });
|
||||
fireEvent.click(serviceButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show correct subtitle for step 1', () => {
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('Pick from our list of available services.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time Selection (Step 2)', () => {
|
||||
beforeEach(() => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should display available time slots', async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Select a service first
|
||||
const serviceButton = screen.getByRole('button', { name: /Haircut/i });
|
||||
fireEvent.click(serviceButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that time slots are displayed
|
||||
const timeButtons = screen.getAllByRole('button');
|
||||
// Should have multiple time slot buttons
|
||||
expect(timeButtons.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('should show subtitle with current date', async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Select a service first
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const todayDate = new Date().toLocaleDateString();
|
||||
expect(screen.getByText(new RegExp(`Available times for ${todayDate}`, 'i'))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should advance to step 3 when a time is selected', async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Select a service
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select first available time slot
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
|
||||
if (timeButtons.length > 0) {
|
||||
fireEvent.click(timeButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 3: Confirm Details')).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should show back button on step 2', async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Select a service
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chevron-left-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should go back to step 1 when back button is clicked', async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Select a service
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click back button
|
||||
const backButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.querySelector('[data-testid="chevron-left-icon"]')
|
||||
);
|
||||
|
||||
if (backButton) {
|
||||
fireEvent.click(backButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Booking Confirmation (Step 3)', () => {
|
||||
beforeEach(() => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const navigateToStep3 = async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Step 1: Select service
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Step 2: Select time
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
|
||||
if (timeButtons.length > 0) {
|
||||
fireEvent.click(timeButtons[0]);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 3: Confirm Details')).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it('should display booking confirmation details', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
expect(screen.getByText('Confirm Your Booking')).toBeInTheDocument();
|
||||
expect(screen.getByText(/You are booking/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Haircut/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('calendar-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show confirm appointment button', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /Confirm Appointment/i });
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show subtitle with review instructions', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
expect(screen.getByText('Please review your appointment details below.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show back button on step 3', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
expect(screen.getByTestId('chevron-left-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should go back to step 2 when back button is clicked', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
// Click back button
|
||||
const backButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.querySelector('[data-testid="chevron-left-icon"]')
|
||||
);
|
||||
|
||||
if (backButton) {
|
||||
fireEvent.click(backButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should advance to step 4 when confirm button is clicked', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /Confirm Appointment/i });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Booking Confirmed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Appointment Booked!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Booking Success (Step 4)', () => {
|
||||
beforeEach(() => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const navigateToStep4 = async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Step 1: Select service
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Step 2: Select time
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
|
||||
if (timeButtons.length > 0) {
|
||||
fireEvent.click(timeButtons[0]);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 3: Confirm Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Step 3: Confirm
|
||||
fireEvent.click(screen.getByRole('button', { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Booking Confirmed')).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it('should display success message with check icon', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
expect(screen.getByText('Appointment Booked!')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('check-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show booking confirmation details', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
expect(screen.getByText(/Your appointment for/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Haircut/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/is confirmed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show confirmation email message', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
expect(screen.getByText("We've sent a confirmation to your email.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Go to Dashboard" link', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
const dashboardLink = screen.getByRole('link', { name: /Go to Dashboard/i });
|
||||
expect(dashboardLink).toBeInTheDocument();
|
||||
expect(dashboardLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('should show "Book Another" button', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
const bookAnotherButton = screen.getByRole('button', { name: /Book Another/i });
|
||||
expect(bookAnotherButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reset flow when "Book Another" is clicked', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
const bookAnotherButton = screen.getByRole('button', { name: /Book Another/i });
|
||||
fireEvent.click(bookAnotherButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show back button on step 4', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
const backButtons = screen.queryAllByTestId('chevron-left-icon');
|
||||
expect(backButtons.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete User Flow', () => {
|
||||
it('should complete entire booking flow from service selection to confirmation', async () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Massage Therapy', price: 80, durationMinutes: 90 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Step 1: User sees and selects service
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
expect(screen.getByText('Massage Therapy')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Massage Therapy/i }));
|
||||
|
||||
// Step 2: User sees and selects time
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
fireEvent.click(timeButtons[0]);
|
||||
|
||||
// Step 3: User confirms booking
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 3: Confirm Details')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Massage Therapy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Confirm Appointment/i }));
|
||||
|
||||
// Step 4: User sees success message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Booking Confirmed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Appointment Booked!')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Massage Therapy/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow user to navigate backward through steps', async () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Go to step 2
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Go back to step 1
|
||||
const backButton1 = screen.getAllByRole('button').find(btn =>
|
||||
btn.querySelector('[data-testid="chevron-left-icon"]')
|
||||
);
|
||||
if (backButton1) fireEvent.click(backButton1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Go to step 2 again
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Go to step 3
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
if (timeButtons.length > 0) fireEvent.click(timeButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 3: Confirm Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Go back to step 2
|
||||
const backButton2 = screen.getAllByRole('button').find(btn =>
|
||||
btn.querySelector('[data-testid="chevron-left-icon"]')
|
||||
);
|
||||
if (backButton2) fireEvent.click(backButton2);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle service with zero price', () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Free Consultation', price: 0, durationMinutes: 30 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('$0.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle service with long name', () => {
|
||||
const mockServices = [
|
||||
createMockService({
|
||||
id: '1',
|
||||
name: 'Very Long Service Name That Could Potentially Break The Layout',
|
||||
price: 100,
|
||||
durationMinutes: 120,
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('Very Long Service Name That Could Potentially Break The Layout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle service with long description', () => {
|
||||
const longDescription = 'A'.repeat(200);
|
||||
const mockServices = [
|
||||
createMockService({
|
||||
id: '1',
|
||||
name: 'Service',
|
||||
price: 50,
|
||||
durationMinutes: 60,
|
||||
description: longDescription,
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText(new RegExp(longDescription))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle multiple services with same price', () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Service A', price: 50, durationMinutes: 60 }),
|
||||
createMockService({ id: '2', name: 'Service B', price: 50, durationMinutes: 45 }),
|
||||
createMockService({ id: '3', name: 'Service C', price: 50, durationMinutes: 30 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
const priceElements = screen.getAllByText('$50.00');
|
||||
expect(priceElements.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle rapid step navigation', async () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Rapidly click through steps
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
if (timeButtons.length > 0) {
|
||||
fireEvent.click(timeButtons[0]);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const confirmButton = screen.queryByRole('button', { name: /Confirm Appointment/i });
|
||||
if (confirmButton) {
|
||||
fireEvent.click(confirmButton);
|
||||
}
|
||||
});
|
||||
|
||||
// Should end up at success page
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Appointment Booked!')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
const heading = screen.getByText('Step 1: Select a Service');
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have clickable service buttons', () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
const serviceButton = screen.getByRole('button', { name: /Haircut/i });
|
||||
expect(serviceButton).toBeInTheDocument();
|
||||
expect(serviceButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should have navigable link in success step', async () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Navigate to success page
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
if (timeButtons.length > 0) fireEvent.click(timeButtons[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const confirmButton = screen.queryByRole('button', { name: /Confirm Appointment/i });
|
||||
if (confirmButton) fireEvent.click(confirmButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const dashboardLink = screen.queryByRole('link', { name: /Go to Dashboard/i });
|
||||
expect(dashboardLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user