- 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>
398 lines
11 KiB
TypeScript
398 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } 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 { MemoryRouter, Route, Routes } from 'react-router-dom';
|
|
import React from 'react';
|
|
|
|
// Mock react-i18next
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, options?: any) => {
|
|
if (typeof options === 'object' && options.customerName) {
|
|
return `Contract for ${options.customerName}`;
|
|
}
|
|
return key;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
// Mock hooks
|
|
const mockUsePublicContract = vi.fn();
|
|
const mockUseSignContract = vi.fn();
|
|
|
|
vi.mock('../../hooks/useContracts', () => ({
|
|
usePublicContract: (token: string) => mockUsePublicContract(token),
|
|
useSignContract: () => mockUseSignContract(),
|
|
}));
|
|
|
|
import ContractSigning from '../ContractSigning';
|
|
|
|
describe('ContractSigning', () => {
|
|
const mockContractData = {
|
|
contract: {
|
|
id: '123',
|
|
content: '<h1>Contract Title</h1><p>Contract terms and conditions...</p>',
|
|
status: 'PENDING',
|
|
},
|
|
template: {
|
|
name: 'Service Agreement',
|
|
},
|
|
business: {
|
|
id: '1',
|
|
name: 'Test Business',
|
|
logo_url: 'https://example.com/logo.png',
|
|
},
|
|
customer: {
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
},
|
|
can_sign: true,
|
|
is_expired: false,
|
|
};
|
|
|
|
const mockSignature = {
|
|
signer_name: 'John Doe',
|
|
signer_email: 'john@example.com',
|
|
signed_at: '2024-01-15T10:30:00Z',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockUsePublicContract.mockReturnValue({
|
|
data: mockContractData,
|
|
isLoading: false,
|
|
error: null,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
mockUseSignContract.mockReturnValue({
|
|
mutateAsync: vi.fn().mockResolvedValue({}),
|
|
isPending: false,
|
|
isSuccess: false,
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
const renderComponent = (token = 'test-token') => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter initialEntries={[`/contracts/sign/${token}`]}>
|
|
<Routes>
|
|
<Route path="/contracts/sign/:token" element={<ContractSigning />} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</QueryClientProvider>
|
|
);
|
|
};
|
|
|
|
it('renders loading state', () => {
|
|
mockUsePublicContract.mockReturnValue({
|
|
data: null,
|
|
isLoading: true,
|
|
error: null,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
expect(screen.getByText('common.loading')).toBeInTheDocument();
|
|
const loader = document.querySelector('.animate-spin');
|
|
expect(loader).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders contract not found error', () => {
|
|
mockUsePublicContract.mockReturnValue({
|
|
data: null,
|
|
isLoading: false,
|
|
error: new Error('Not found'),
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
expect(screen.getByText('contracts.signing.notFound')).toBeInTheDocument();
|
|
expect(screen.getByText(/invalid or has expired/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders expired contract message', () => {
|
|
mockUsePublicContract.mockReturnValue({
|
|
data: { ...mockContractData, is_expired: true },
|
|
isLoading: false,
|
|
error: null,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
expect(screen.getByText('contracts.signing.expired')).toBeInTheDocument();
|
|
expect(screen.getByText(/can no longer be signed/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders contract signing form', () => {
|
|
renderComponent();
|
|
|
|
expect(screen.getByText('Test Business')).toBeInTheDocument();
|
|
expect(screen.getByText('Service Agreement')).toBeInTheDocument();
|
|
expect(screen.getByText('Contract for John Doe')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('Enter your full name')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders contract content', () => {
|
|
renderComponent();
|
|
|
|
const contractContent = document.querySelector('.prose');
|
|
expect(contractContent).toBeInTheDocument();
|
|
expect(contractContent?.innerHTML).toContain('Contract Title');
|
|
expect(contractContent?.innerHTML).toContain('Contract terms and conditions');
|
|
});
|
|
|
|
it('displays business logo when available', () => {
|
|
renderComponent();
|
|
|
|
const logo = screen.getByAltText('Test Business');
|
|
expect(logo).toBeInTheDocument();
|
|
expect(logo).toHaveAttribute('src', 'https://example.com/logo.png');
|
|
});
|
|
|
|
it('shows signature input and preview', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
|
await user.type(nameInput, 'John Doe');
|
|
|
|
expect(nameInput).toHaveValue('John Doe');
|
|
|
|
// Check for signature preview
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Signature Preview:')).toBeInTheDocument();
|
|
});
|
|
|
|
// Preview should show the typed name in cursive
|
|
const preview = document.querySelector('[style*="cursive"]');
|
|
expect(preview).toBeInTheDocument();
|
|
expect(preview?.textContent).toBe('John Doe');
|
|
});
|
|
|
|
it('requires all consent checkboxes', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
|
await user.type(nameInput, 'John Doe');
|
|
|
|
const signButton = screen.getByRole('button', { name: /sign contract/i });
|
|
|
|
// Button should be disabled without consent
|
|
expect(signButton).toBeDisabled();
|
|
|
|
// Check first checkbox
|
|
const checkbox1 = screen.getByLabelText(/have read and agree/i);
|
|
await user.click(checkbox1);
|
|
|
|
// Still disabled without second checkbox
|
|
expect(signButton).toBeDisabled();
|
|
|
|
// Check second checkbox
|
|
const checkbox2 = screen.getByLabelText(/consent to conduct business electronically/i);
|
|
await user.click(checkbox2);
|
|
|
|
// Now should be enabled
|
|
expect(signButton).toBeEnabled();
|
|
});
|
|
|
|
it('submits signature when all fields are valid', async () => {
|
|
const user = userEvent.setup();
|
|
const mockMutate = vi.fn().mockResolvedValue({});
|
|
mockUseSignContract.mockReturnValue({
|
|
mutateAsync: mockMutate,
|
|
isPending: false,
|
|
isSuccess: false,
|
|
isError: false,
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
// Fill name
|
|
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
|
await user.type(nameInput, 'John Doe');
|
|
|
|
// Check both checkboxes
|
|
const checkbox1 = screen.getByLabelText(/have read and agree/i);
|
|
const checkbox2 = screen.getByLabelText(/consent to conduct business electronically/i);
|
|
await user.click(checkbox1);
|
|
await user.click(checkbox2);
|
|
|
|
// Submit
|
|
const signButton = screen.getByRole('button', { name: /sign contract/i });
|
|
await user.click(signButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockMutate).toHaveBeenCalledWith({
|
|
token: 'test-token',
|
|
signer_name: 'John Doe',
|
|
consent_checkbox_checked: true,
|
|
electronic_consent_given: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('shows loading state while signing', async () => {
|
|
const user = userEvent.setup();
|
|
mockUseSignContract.mockReturnValue({
|
|
mutateAsync: vi.fn().mockImplementation(() => new Promise(() => {})),
|
|
isPending: true,
|
|
isSuccess: false,
|
|
isError: false,
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
expect(screen.getByRole('button', { name: /signing/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /signing/i })).toBeDisabled();
|
|
});
|
|
|
|
it('shows error message when signing fails', async () => {
|
|
const user = userEvent.setup();
|
|
mockUseSignContract.mockReturnValue({
|
|
mutateAsync: vi.fn().mockRejectedValue(new Error('Signing failed')),
|
|
isPending: false,
|
|
isSuccess: false,
|
|
isError: true,
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/failed to sign the contract/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('renders signed contract view after successful signing', () => {
|
|
mockUsePublicContract.mockReturnValue({
|
|
data: {
|
|
...mockContractData,
|
|
contract: { ...mockContractData.contract, status: 'SIGNED' },
|
|
signature: mockSignature,
|
|
},
|
|
isLoading: false,
|
|
error: null,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
expect(screen.getByText(/contract successfully signed/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /print contract/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays signature details in signed view', () => {
|
|
mockUsePublicContract.mockReturnValue({
|
|
data: {
|
|
...mockContractData,
|
|
contract: { ...mockContractData.contract, status: 'SIGNED' },
|
|
signature: mockSignature,
|
|
},
|
|
isLoading: false,
|
|
error: null,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
// Use getAllByText since "John Doe" and "john@example.com" appear multiple times
|
|
expect(screen.getAllByText('John Doe').length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('john@example.com').length).toBeGreaterThan(0);
|
|
|
|
// Check for "Signed" status badge
|
|
const signedBadges = screen.queryAllByText(/^signed$/i);
|
|
expect(signedBadges.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('handles print button click', async () => {
|
|
const user = userEvent.setup();
|
|
const mockPrint = vi.fn();
|
|
window.print = mockPrint;
|
|
|
|
mockUsePublicContract.mockReturnValue({
|
|
data: {
|
|
...mockContractData,
|
|
contract: { ...mockContractData.contract, status: 'SIGNED' },
|
|
signature: mockSignature,
|
|
},
|
|
isLoading: false,
|
|
error: null,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
const printButton = screen.getByRole('button', { name: /print contract/i });
|
|
await user.click(printButton);
|
|
|
|
expect(mockPrint).toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows legal compliance notice in signing form', () => {
|
|
renderComponent();
|
|
|
|
expect(screen.getByText(/ESIGN Act/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/UETA/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows electronic consent disclosure', () => {
|
|
renderComponent();
|
|
|
|
expect(screen.getByText(/conduct business electronically/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/right to receive documents in paper form/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('prevents signing when cannot sign', () => {
|
|
mockUsePublicContract.mockReturnValue({
|
|
data: {
|
|
...mockContractData,
|
|
can_sign: false,
|
|
},
|
|
isLoading: false,
|
|
error: null,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
// Should not show the signing form
|
|
expect(screen.queryByPlaceholderText('Enter your full name')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('validates name is not empty before enabling submit', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const checkbox1 = screen.getByLabelText(/have read and agree/i);
|
|
const checkbox2 = screen.getByLabelText(/consent to conduct business electronically/i);
|
|
await user.click(checkbox1);
|
|
await user.click(checkbox2);
|
|
|
|
const signButton = screen.getByRole('button', { name: /sign contract/i });
|
|
|
|
// Should be disabled with empty name
|
|
expect(signButton).toBeDisabled();
|
|
|
|
// Type name
|
|
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
|
await user.type(nameInput, 'John Doe');
|
|
|
|
// Should be enabled now
|
|
expect(signButton).toBeEnabled();
|
|
});
|
|
});
|