- 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>
230 lines
8.7 KiB
TypeScript
230 lines
8.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import React from 'react';
|
|
import { PaymentSection } from '../PaymentSection';
|
|
|
|
// Mock Lucide icons
|
|
vi.mock('lucide-react', () => ({
|
|
CreditCard: () => <span data-testid="icon-credit-card" />,
|
|
ShieldCheck: () => <span data-testid="icon-shield-check" />,
|
|
Lock: () => <span data-testid="icon-lock" />,
|
|
}));
|
|
|
|
describe('PaymentSection', () => {
|
|
const mockService = {
|
|
id: 1,
|
|
name: 'Haircut',
|
|
description: 'A professional haircut',
|
|
duration: 30,
|
|
price_cents: 2500,
|
|
photos: [],
|
|
deposit_amount_cents: 0,
|
|
};
|
|
|
|
const mockServiceWithDeposit = {
|
|
...mockService,
|
|
deposit_amount_cents: 1000,
|
|
};
|
|
|
|
const defaultProps = {
|
|
service: mockService,
|
|
onPaymentComplete: vi.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('renders payment form', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
expect(screen.getByText('Card Details')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('0000 0000 0000 0000')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('MM / YY')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('123')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays service total price', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
expect(screen.getByText('Service Total')).toBeInTheDocument();
|
|
const prices = screen.getAllByText('$25.00');
|
|
expect(prices.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('displays tax line item', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
expect(screen.getByText('Tax (Estimated)')).toBeInTheDocument();
|
|
expect(screen.getByText('$0.00')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays total amount', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
const totals = screen.getAllByText('$25.00');
|
|
expect(totals.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('formats card number input with spaces', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000') as HTMLInputElement;
|
|
|
|
fireEvent.change(cardInput, { target: { value: '4242424242424242' } });
|
|
expect(cardInput.value).toBe('4242 4242 4242 4242');
|
|
});
|
|
|
|
it('limits card number to 16 digits', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000') as HTMLInputElement;
|
|
|
|
fireEvent.change(cardInput, { target: { value: '42424242424242421234' } });
|
|
expect(cardInput.value).toBe('4242 4242 4242 4242');
|
|
});
|
|
|
|
it('removes non-digits from card input', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000') as HTMLInputElement;
|
|
|
|
fireEvent.change(cardInput, { target: { value: '4242-4242-4242-4242' } });
|
|
expect(cardInput.value).toBe('4242 4242 4242 4242');
|
|
});
|
|
|
|
it('handles expiry date input', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
const expiryInput = screen.getByPlaceholderText('MM / YY') as HTMLInputElement;
|
|
|
|
fireEvent.change(expiryInput, { target: { value: '12/25' } });
|
|
expect(expiryInput.value).toBe('12/25');
|
|
});
|
|
|
|
it('handles CVC input', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
const cvcInput = screen.getByPlaceholderText('123') as HTMLInputElement;
|
|
|
|
fireEvent.change(cvcInput, { target: { value: '123' } });
|
|
expect(cvcInput.value).toBe('123');
|
|
});
|
|
|
|
it('shows confirm booking button when no deposit', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
expect(screen.getByRole('button', { name: 'Confirm Booking' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows deposit amount button when deposit required', () => {
|
|
render(React.createElement(PaymentSection, { ...defaultProps, service: mockServiceWithDeposit }));
|
|
expect(screen.getByRole('button', { name: 'Pay $10.00 Deposit' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays deposit amount section when deposit required', () => {
|
|
render(React.createElement(PaymentSection, { ...defaultProps, service: mockServiceWithDeposit }));
|
|
expect(screen.getByText('Due Now (Deposit)')).toBeInTheDocument();
|
|
const depositAmounts = screen.getAllByText('$10.00');
|
|
expect(depositAmounts.length).toBeGreaterThan(0);
|
|
expect(screen.getByText('Due at appointment')).toBeInTheDocument();
|
|
expect(screen.getByText('$15.00')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays full payment message when no deposit', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
expect(screen.getByText(/Full payment will be collected at your appointment/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays deposit message when deposit required', () => {
|
|
render(React.createElement(PaymentSection, { ...defaultProps, service: mockServiceWithDeposit }));
|
|
expect(screen.getByText(/A deposit of/)).toBeInTheDocument();
|
|
expect(screen.getByText(/will be charged now/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows submit button text changes to processing', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
const submitButton = screen.getByRole('button', { name: 'Confirm Booking' });
|
|
expect(submitButton).not.toBeDisabled();
|
|
});
|
|
|
|
it('simulates payment processing timeout', async () => {
|
|
const onPaymentComplete = vi.fn();
|
|
render(React.createElement(PaymentSection, { ...defaultProps, onPaymentComplete }));
|
|
|
|
// The component uses setTimeout with 2000ms
|
|
// Just verify the timeout is reasonable
|
|
expect(onPaymentComplete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('displays security message', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
expect(screen.getByText(/Your payment is secure/)).toBeInTheDocument();
|
|
expect(screen.getByText(/We use Stripe to process your payment/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows shield check icon', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
expect(screen.getByTestId('icon-shield-check')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows credit card icon', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
expect(screen.getByTestId('icon-credit-card')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows lock icon for CVC field', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
expect(screen.getByTestId('icon-lock')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays payment summary section', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
expect(screen.getByText('Payment Summary')).toBeInTheDocument();
|
|
});
|
|
|
|
it('requires all form fields', () => {
|
|
const onPaymentComplete = vi.fn();
|
|
render(React.createElement(PaymentSection, { ...defaultProps, onPaymentComplete }));
|
|
|
|
const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000');
|
|
const expiryInput = screen.getByPlaceholderText('MM / YY');
|
|
const cvcInput = screen.getByPlaceholderText('123');
|
|
|
|
expect(cardInput).toHaveAttribute('required');
|
|
expect(expiryInput).toHaveAttribute('required');
|
|
expect(cvcInput).toHaveAttribute('required');
|
|
});
|
|
|
|
it('calculates deposit correctly', () => {
|
|
const service = { ...mockService, deposit_amount_cents: 500 };
|
|
render(React.createElement(PaymentSection, { ...defaultProps, service }));
|
|
|
|
const amounts = screen.getAllByText('$5.00');
|
|
expect(amounts.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('displays mock card icons', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
const mockCardIcons = document.querySelectorAll('.bg-gray-200.dark\\:bg-gray-600.rounded');
|
|
expect(mockCardIcons.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('handles large prices correctly', () => {
|
|
const expensiveService = { ...mockService, price_cents: 1000000 }; // $10,000
|
|
render(React.createElement(PaymentSection, { ...defaultProps, service: expensiveService }));
|
|
const prices = screen.getAllByText('$10000.00');
|
|
expect(prices.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('handles zero deposit', () => {
|
|
const service = { ...mockService, deposit_amount_cents: 0 };
|
|
render(React.createElement(PaymentSection, { ...defaultProps, service }));
|
|
expect(screen.queryByText('Due Now (Deposit)')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('has disabled state for button during processing', () => {
|
|
render(React.createElement(PaymentSection, defaultProps));
|
|
const submitButton = screen.getByRole('button', { name: 'Confirm Booking' });
|
|
|
|
// Initially enabled
|
|
expect(submitButton).not.toBeDisabled();
|
|
// Button will be disabled when processing state is true
|
|
});
|
|
});
|