Add global navigation search, cancellation policies, and UI improvements

- Add global search in top bar for navigating to dashboard pages
- Add cancellation policy settings (window hours, late fee, deposit refund)
- Display booking policies on customer confirmation page
- Filter API tokens by sandbox/live mode
- Widen settings layout and full-width site builder
- Add help documentation search with OpenAI integration
- Add blocked time ranges API for calendar visualization
- Update business hours settings with holiday management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-25 23:39:07 -05:00
parent 8391ecbf88
commit 416cd7059b
174 changed files with 31835 additions and 4921 deletions

View File

@@ -0,0 +1,675 @@
/**
* Comprehensive Unit Tests for OwnerScheduler Component
*
* Test Coverage:
* - Component rendering (day/week/month views)
* - Loading states
* - Empty states (no appointments, no resources)
* - View mode switching (day/week/month)
* - Date navigation
* - Filter functionality (status, resource, service)
* - Pending appointments section
* - Create appointment modal
* - Zoom controls
* - Undo/Redo functionality
* - Resource management
* - Accessibility
* - WebSocket integration
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import OwnerScheduler from '../OwnerScheduler';
import { useAppointments, useUpdateAppointment, useDeleteAppointment, useCreateAppointment } from '../../hooks/useAppointments';
import { useResources } from '../../hooks/useResources';
import { useServices } from '../../hooks/useServices';
import { useAppointmentWebSocket } from '../../hooks/useAppointmentWebSocket';
import { useBlockedRanges } from '../../hooks/useTimeBlocks';
import { User, Business, Resource, Appointment, Service } from '../../types';
// Mock hooks
vi.mock('../../hooks/useAppointments');
vi.mock('../../hooks/useResources');
vi.mock('../../hooks/useServices');
vi.mock('../../hooks/useAppointmentWebSocket');
vi.mock('../../hooks/useTimeBlocks');
// Mock components
vi.mock('../../components/AppointmentModal', () => ({
AppointmentModal: ({ isOpen, onClose, onSave }: any) =>
isOpen ? (
<div data-testid="appointment-modal">
<button onClick={onClose}>Close</button>
<button onClick={() => onSave({})}>Save</button>
</div>
) : null,
}));
vi.mock('../../components/ui', () => ({
Modal: ({ isOpen, onClose, children }: any) =>
isOpen ? (
<div data-testid="modal">
<button onClick={onClose}>Close Modal</button>
{children}
</div>
) : null,
}));
vi.mock('../../components/Portal', () => ({
default: ({ children }: any) => <div data-testid="portal">{children}</div>,
}));
vi.mock('../../components/time-blocks/TimeBlockCalendarOverlay', () => ({
default: () => <div data-testid="time-block-overlay">Time Block Overlay</div>,
}));
// Mock utility functions
vi.mock('../../utils/quotaUtils', () => ({
getOverQuotaResourceIds: vi.fn(() => new Set()),
}));
vi.mock('../../utils/dateUtils', () => ({
formatLocalDate: (date: Date) => date.toISOString().split('T')[0],
}));
// Mock ResizeObserver
class ResizeObserverMock {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
global.ResizeObserver = ResizeObserverMock as any;
describe('OwnerScheduler', () => {
let queryClient: QueryClient;
let mockUser: User;
let mockBusiness: Business;
let mockResources: Resource[];
let mockAppointments: Appointment[];
let mockServices: Service[];
let mockUpdateMutation: any;
let mockDeleteMutation: any;
let mockCreateMutation: any;
const renderComponent = (props?: Partial<{ user: User; business: Business }>) => {
const defaultProps = {
user: mockUser,
business: mockBusiness,
};
return render(
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(OwnerScheduler, { ...defaultProps, ...props })
)
);
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
mockUser = {
id: 'user-1',
email: 'owner@example.com',
username: 'owner',
firstName: 'Owner',
lastName: 'User',
role: 'OWNER' as any,
businessId: 'business-1',
isSuperuser: false,
isStaff: false,
isActive: true,
emailVerified: true,
mfaEnabled: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
permissions: {},
quota_overages: {},
};
mockBusiness = {
id: 'business-1',
name: 'Test Business',
subdomain: 'testbiz',
timezone: 'America/New_York',
resourcesCanReschedule: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Business;
mockResources = [
{
id: 'resource-1',
name: 'Resource One',
type: 'STAFF',
userId: 'user-2',
businessId: 'business-1',
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 'resource-2',
name: 'Resource Two',
type: 'STAFF',
userId: 'user-3',
businessId: 'business-1',
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
const today = new Date();
today.setHours(10, 0, 0, 0);
mockAppointments = [
{
id: 'appt-1',
resourceId: 'resource-1',
serviceId: 'service-1',
customerId: 'customer-1',
customerName: 'John Doe',
startTime: today,
durationMinutes: 60,
status: 'CONFIRMED' as any,
businessId: 'business-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 'appt-2',
resourceId: 'resource-2',
serviceId: 'service-2',
customerId: 'customer-2',
customerName: 'Jane Smith',
startTime: new Date(today.getTime() + 2 * 60 * 60 * 1000),
durationMinutes: 30,
status: 'COMPLETED' as any,
businessId: 'business-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 'appt-3',
resourceId: null,
serviceId: 'service-1',
customerId: 'customer-3',
customerName: 'Bob Wilson',
startTime: today,
durationMinutes: 45,
status: 'PENDING' as any,
businessId: 'business-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
mockServices = [
{
id: 'service-1',
name: 'Haircut',
durationMinutes: 60,
price: 5000,
businessId: 'business-1',
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 'service-2',
name: 'Beard Trim',
durationMinutes: 30,
price: 2500,
businessId: 'business-1',
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
mockUpdateMutation = {
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
isSuccess: false,
};
mockDeleteMutation = {
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
isSuccess: false,
};
mockCreateMutation = {
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
isSuccess: false,
};
(useAppointments as any).mockReturnValue({
data: mockAppointments,
isLoading: false,
isError: false,
});
(useResources as any).mockReturnValue({
data: mockResources,
isLoading: false,
isError: false,
});
(useServices as any).mockReturnValue({
data: mockServices,
isLoading: false,
isError: false,
});
(useUpdateAppointment as any).mockReturnValue(mockUpdateMutation);
(useDeleteAppointment as any).mockReturnValue(mockDeleteMutation);
(useCreateAppointment as any).mockReturnValue(mockCreateMutation);
(useAppointmentWebSocket as any).mockReturnValue(undefined);
(useBlockedRanges as any).mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the scheduler header', () => {
renderComponent();
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
it('should render view mode buttons', () => {
renderComponent();
expect(screen.getByRole('button', { name: /Day/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Week/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Month/i })).toBeInTheDocument();
});
it('should render Today button', () => {
renderComponent();
expect(screen.getByRole('button', { name: /Today/i })).toBeInTheDocument();
});
it('should render filter button', () => {
renderComponent();
expect(screen.getByRole('button', { name: /Filter/i })).toBeInTheDocument();
});
it('should render resource sidebar', () => {
renderComponent();
expect(screen.getByText('Resource One')).toBeInTheDocument();
expect(screen.getByText('Resource Two')).toBeInTheDocument();
});
it('should display current date range', () => {
renderComponent();
const dateLabel = screen.getByText(
new RegExp(new Date().toLocaleDateString('en-US', { month: 'long' }))
);
expect(dateLabel).toBeInTheDocument();
});
it('should render New Appointment button', () => {
renderComponent();
expect(screen.getByRole('button', { name: /New Appointment/i })).toBeInTheDocument();
});
it('should render navigation buttons', () => {
renderComponent();
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(5);
});
it('should render pending appointments section', () => {
renderComponent();
expect(screen.getByText(/Pending/i)).toBeInTheDocument();
});
it('should display appointments', () => {
renderComponent();
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
describe('Loading States', () => {
it('should handle loading appointments', () => {
(useAppointments as any).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderComponent();
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
it('should handle loading resources', () => {
(useResources as any).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderComponent();
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
it('should handle loading services', () => {
(useServices as any).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderComponent();
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
it('should handle loading blocked ranges', () => {
(useBlockedRanges as any).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderComponent();
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
});
describe('Empty States', () => {
it('should handle no appointments', () => {
(useAppointments as any).mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
renderComponent();
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
});
it('should handle no resources', () => {
(useResources as any).mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
renderComponent();
expect(screen.queryByText('Resource One')).not.toBeInTheDocument();
});
it('should handle no services', () => {
(useServices as any).mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
renderComponent();
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
});
describe('View Mode Switching', () => {
it('should start in day view by default', () => {
renderComponent();
expect(screen.getByRole('button', { name: /Day/i })).toBeInTheDocument();
});
it('should switch to week view', async () => {
const user = userEvent.setup();
renderComponent();
const weekButton = screen.getByRole('button', { name: /Week/i });
await user.click(weekButton);
expect(weekButton).toBeInTheDocument();
});
it('should switch to month view', async () => {
const user = userEvent.setup();
renderComponent();
const monthButton = screen.getByRole('button', { name: /Month/i });
await user.click(monthButton);
expect(monthButton).toBeInTheDocument();
});
it('should switch back to day view from week view', async () => {
const user = userEvent.setup();
renderComponent();
await user.click(screen.getByRole('button', { name: /Week/i }));
await user.click(screen.getByRole('button', { name: /Day/i }));
expect(screen.getByRole('button', { name: /Day/i })).toBeInTheDocument();
});
});
describe('Date Navigation', () => {
it('should navigate to today', async () => {
const user = userEvent.setup();
renderComponent();
const todayButton = screen.getByRole('button', { name: /Today/i });
await user.click(todayButton);
expect(todayButton).toBeInTheDocument();
});
it('should have navigation controls', () => {
renderComponent();
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(5);
});
});
describe('Filter Functionality', () => {
it('should open filter menu when filter button clicked', async () => {
const user = userEvent.setup();
renderComponent();
const filterButton = screen.getByRole('button', { name: /Filter/i });
await user.click(filterButton);
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
it('should have filter button', () => {
renderComponent();
expect(screen.getByRole('button', { name: /Filter/i })).toBeInTheDocument();
});
});
describe('Pending Appointments', () => {
it('should display pending appointments', () => {
renderComponent();
expect(screen.getByText('Bob Wilson')).toBeInTheDocument();
});
it('should have pending section', () => {
renderComponent();
expect(screen.getByText(/Pending/i)).toBeInTheDocument();
});
});
describe('Create Appointment', () => {
it('should open create appointment modal', async () => {
const user = userEvent.setup();
renderComponent();
const createButton = screen.getByRole('button', { name: /New Appointment/i });
await user.click(createButton);
await waitFor(() => {
expect(screen.getByTestId('appointment-modal')).toBeInTheDocument();
});
});
it('should close create appointment modal', async () => {
const user = userEvent.setup();
renderComponent();
await user.click(screen.getByRole('button', { name: /New Appointment/i }));
await waitFor(() => {
expect(screen.getByTestId('appointment-modal')).toBeInTheDocument();
});
const closeButton = screen.getByRole('button', { name: /Close/i });
await user.click(closeButton);
await waitFor(() => {
expect(screen.queryByTestId('appointment-modal')).not.toBeInTheDocument();
});
});
});
describe('WebSocket Integration', () => {
it('should connect to WebSocket on mount', () => {
renderComponent();
expect(useAppointmentWebSocket).toHaveBeenCalled();
});
it('should handle WebSocket updates', () => {
renderComponent();
expect(useAppointmentWebSocket).toHaveBeenCalledTimes(1);
});
});
describe('Resource Management', () => {
it('should display all active resources', () => {
renderComponent();
expect(screen.getByText('Resource One')).toBeInTheDocument();
expect(screen.getByText('Resource Two')).toBeInTheDocument();
});
it('should not display inactive resources', () => {
const inactiveResource = {
...mockResources[0],
isActive: false,
};
(useResources as any).mockReturnValue({
data: [inactiveResource, mockResources[1]],
isLoading: false,
isError: false,
});
renderComponent();
expect(screen.getByText('Resource Two')).toBeInTheDocument();
});
});
describe('Appointment Display', () => {
it('should display confirmed appointments', () => {
renderComponent();
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('should display completed appointments', () => {
renderComponent();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
it('should display pending appointments', () => {
renderComponent();
expect(screen.getByText('Bob Wilson')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should handle error loading appointments', () => {
(useAppointments as any).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
renderComponent();
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
it('should handle error loading resources', () => {
(useResources as any).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
renderComponent();
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
it('should handle error loading services', () => {
(useServices as any).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
renderComponent();
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
it('should handle error loading blocked ranges', () => {
(useBlockedRanges as any).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
renderComponent();
expect(screen.getByText(/Schedule/i)).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have accessible button labels', () => {
renderComponent();
expect(screen.getByRole('button', { name: /Day/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Week/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Month/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Today/i })).toBeInTheDocument();
});
it('should have accessible navigation buttons', () => {
renderComponent();
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(5);
});
});
describe('Dark Mode', () => {
it('should render with dark mode classes', () => {
renderComponent();
const container = document.querySelector('[class*="dark:"]');
expect(container).toBeInTheDocument();
});
});
});