- Add can_edit_staff and can_edit_customers dangerous permissions - Move Site Builder, Services, Locations, Time Blocks, Payments to Settings permissions - Link Edit Others' Schedules and Edit Own Schedule permissions - Add permission checks to StaffViewSet (partial_update, toggle_active, verify_email) - Add permission checks to CustomerViewSet (update, partial_update, verify_email) - Fix CustomerViewSet permission key mismatch (can_access_customers) - Hide Edit/Verify buttons on Staff and Customers pages without permission - Make dangerous permissions section more visually distinct (darker red) - Fix StaffDashboard links to use correct paths (/dashboard/my-schedule) - Disable settings sub-permissions when Access Settings is unchecked 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen } from '@testing-library/react';
|
|
import React from 'react';
|
|
import { CustomerPreview } from '../CustomerPreview';
|
|
import { Service, Business } from '../../../types';
|
|
|
|
// Mock dependencies
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({ t: (key: string) => key }),
|
|
}));
|
|
|
|
// Mock Lucide icons
|
|
vi.mock('lucide-react', () => ({
|
|
Clock: () => <span data-testid="icon-clock" />,
|
|
DollarSign: () => <span data-testid="icon-dollar-sign" />,
|
|
Image: () => <span data-testid="icon-image" />,
|
|
CheckCircle2: () => <span data-testid="icon-check-circle" />,
|
|
AlertCircle: () => <span data-testid="icon-alert-circle" />,
|
|
}));
|
|
|
|
// Mock Badge component
|
|
vi.mock('../../ui/Badge', () => ({
|
|
default: ({ children, variant, size }: any) =>
|
|
<span data-testid={`badge-${variant}`} data-size={size}>{children}</span>,
|
|
}));
|
|
|
|
describe('CustomerPreview', () => {
|
|
const mockBusiness: Business = {
|
|
id: 'business-1',
|
|
name: 'Test Business',
|
|
primaryColor: '#2563eb',
|
|
secondaryColor: '#0ea5e9',
|
|
} as Business;
|
|
|
|
const mockService: Service = {
|
|
id: '1',
|
|
name: 'Haircut',
|
|
description: 'Professional haircut service',
|
|
price: 50,
|
|
durationMinutes: 60,
|
|
photos: [],
|
|
category: { id: 'cat1', name: 'Hair Services' },
|
|
variable_pricing: false,
|
|
} as Service;
|
|
|
|
const mockServiceWithPhoto: Service = {
|
|
...mockService,
|
|
photos: ['https://example.com/photo.jpg'],
|
|
};
|
|
|
|
const mockServiceWithDeposit: Service = {
|
|
...mockService,
|
|
deposit_amount: 10,
|
|
};
|
|
|
|
const mockServiceVariablePricing: Service = {
|
|
...mockService,
|
|
variable_pricing: true,
|
|
deposit_amount: 25,
|
|
};
|
|
|
|
const defaultProps = {
|
|
service: mockService,
|
|
business: mockBusiness,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('renders customer preview heading', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByText('Customer Preview')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows live preview badge', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByTestId('badge-info')).toBeInTheDocument();
|
|
expect(screen.getByText('Live Preview')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays service name', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByText('Haircut')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays service description', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByText('Professional haircut service')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays service category', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByText('Hair Services')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays duration', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByText('60 mins')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows clock icon for duration', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByTestId('icon-clock')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays price', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByText('50')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows dollar sign icon for price', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByTestId('icon-dollar-sign')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows image icon when no photos', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByTestId('icon-image')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays photo when available', () => {
|
|
const props = { ...defaultProps, service: mockServiceWithPhoto };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
const img = document.querySelector('img');
|
|
expect(img).toBeInTheDocument();
|
|
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
|
expect(img).toHaveAttribute('alt', 'Haircut');
|
|
});
|
|
|
|
it('displays deposit requirement when set', () => {
|
|
const props = { ...defaultProps, service: mockServiceWithDeposit };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText(/Deposit required:/)).toBeInTheDocument();
|
|
expect(screen.getByText((content, element) => {
|
|
return element?.textContent === 'Deposit required: $10';
|
|
})).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show deposit when not required', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.queryByText(/Deposit required:/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows variable pricing badge', () => {
|
|
const props = { ...defaultProps, service: mockServiceVariablePricing };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText('Variable')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows "Price varies" text for variable pricing', () => {
|
|
const props = { ...defaultProps, service: mockServiceVariablePricing };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText('Price varies')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows deposit for variable pricing services', () => {
|
|
const props = { ...defaultProps, service: mockServiceVariablePricing };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText(/Deposit required:/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays info alert about preview', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
expect(screen.getByTestId('icon-alert-circle')).toBeInTheDocument();
|
|
expect(screen.getByText(/This is how your service will appear to customers/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles null service gracefully', () => {
|
|
const props = { ...defaultProps, service: null };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText('New Service')).toBeInTheDocument();
|
|
expect(screen.getByText('Service description will appear here...')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays default category when not set', () => {
|
|
const serviceWithoutCategory = { ...mockService, category: undefined };
|
|
const props = { ...defaultProps, service: serviceWithoutCategory };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText('General')).toBeInTheDocument();
|
|
});
|
|
|
|
it('uses preview data when provided', () => {
|
|
const previewData = {
|
|
name: 'Custom Name',
|
|
description: 'Custom Description',
|
|
price: 75,
|
|
durationMinutes: 90,
|
|
};
|
|
const props = { ...defaultProps, previewData };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText('Custom Name')).toBeInTheDocument();
|
|
expect(screen.getByText('Custom Description')).toBeInTheDocument();
|
|
expect(screen.getByText('75')).toBeInTheDocument();
|
|
expect(screen.getByText('90 mins')).toBeInTheDocument();
|
|
});
|
|
|
|
it('preview data overrides service data', () => {
|
|
const previewData = { name: 'Preview Name' };
|
|
const props = { ...defaultProps, previewData };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
// Should show preview data instead of service data
|
|
expect(screen.getByText('Preview Name')).toBeInTheDocument();
|
|
expect(screen.queryByText('Haircut')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('formats price correctly', () => {
|
|
const props = { ...defaultProps, service: { ...mockService, price: 123 } };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText('123')).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles zero price', () => {
|
|
const props = { ...defaultProps, service: { ...mockService, price: 0 } };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText('0')).toBeInTheDocument();
|
|
});
|
|
|
|
it('applies business colors to gradient', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
|
|
// The gradient uses business colors in the style attribute
|
|
const gradientDiv = document.querySelector('[style*="gradient"]');
|
|
expect(gradientDiv).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays deposit with correct formatting', () => {
|
|
const props = { ...defaultProps, service: { ...mockService, deposit_amount: 15 } };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText((content, element) => {
|
|
return element?.textContent === 'Deposit required: $15';
|
|
})).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows default duration when not set', () => {
|
|
const serviceWithoutDuration = { ...mockService, durationMinutes: undefined };
|
|
const props = { ...defaultProps, service: serviceWithoutDuration };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByText('30 mins')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays border styling to indicate selected preview', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
|
|
const card = document.querySelector('.border-brand-600');
|
|
expect(card).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays ring styling', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
|
|
const card = document.querySelector('.ring-brand-600');
|
|
expect(card).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles preview data with partial updates', () => {
|
|
const previewData = { price: 99 };
|
|
const props = { ...defaultProps, previewData };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
// Name should still be from service
|
|
expect(screen.getByText('Haircut')).toBeInTheDocument();
|
|
// Price should be from previewData
|
|
expect(screen.getByText('99')).toBeInTheDocument();
|
|
});
|
|
|
|
it('merges photos from preview data', () => {
|
|
const previewData = { photos: ['https://preview.com/new.jpg'] };
|
|
const props = { ...defaultProps, previewData };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
const img = document.querySelector('img');
|
|
expect(img).toHaveAttribute('src', 'https://preview.com/new.jpg');
|
|
});
|
|
|
|
it('handles empty photos array', () => {
|
|
const props = { ...defaultProps, service: { ...mockService, photos: [] } };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
expect(screen.getByTestId('icon-image')).toBeInTheDocument();
|
|
});
|
|
|
|
it('uses first photo only for cover', () => {
|
|
const serviceWithMultiplePhotos = {
|
|
...mockService,
|
|
photos: ['https://first.com/1.jpg', 'https://second.com/2.jpg'],
|
|
};
|
|
const props = { ...defaultProps, service: serviceWithMultiplePhotos };
|
|
render(React.createElement(CustomerPreview, props));
|
|
|
|
const img = document.querySelector('img');
|
|
expect(img).toHaveAttribute('src', 'https://first.com/1.jpg');
|
|
});
|
|
|
|
it('displays horizontal card layout', () => {
|
|
render(React.createElement(CustomerPreview, defaultProps));
|
|
|
|
const cardContainer = document.querySelector('.flex.h-full');
|
|
expect(cardContainer).toBeInTheDocument();
|
|
});
|
|
});
|