Add staff permission controls for editing staff and customers
- 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>
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user