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:
397
frontend/src/pages/__tests__/ContractSigning.test.tsx
Normal file
397
frontend/src/pages/__tests__/ContractSigning.test.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user