- 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>
631 lines
21 KiB
Plaintext
631 lines
21 KiB
Plaintext
/**
|
|
* Unit tests for HelpApiDocs component
|
|
*
|
|
* Tests cover:
|
|
* - Component rendering
|
|
* - Navigation sections display
|
|
* - Code examples in multiple languages
|
|
* - Token selector functionality
|
|
* - Section navigation and scroll behavior
|
|
* - No test tokens warning banner
|
|
* - Back button functionality
|
|
* - Language switcher in code blocks
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import { BrowserRouter } from 'react-router-dom';
|
|
import userEvent from '@testing-library/user-event';
|
|
import React from 'react';
|
|
import HelpApiDocs from '../HelpApiDocs';
|
|
|
|
// Mock the useTestTokensForDocs hook
|
|
const mockTestTokensData = [
|
|
{
|
|
id: 'token-1',
|
|
name: 'Test Token 1',
|
|
key_prefix: 'ss_test_abc123',
|
|
created_at: '2025-01-01T00:00:00Z',
|
|
},
|
|
{
|
|
id: 'token-2',
|
|
name: 'Test Token 2',
|
|
key_prefix: 'ss_test_def456',
|
|
created_at: '2025-01-02T00:00:00Z',
|
|
},
|
|
];
|
|
|
|
const mockUseTestTokensForDocs = vi.fn(() => ({
|
|
data: mockTestTokensData,
|
|
isLoading: false,
|
|
error: null,
|
|
}));
|
|
|
|
vi.mock('../../hooks/useApiTokens', () => ({
|
|
useTestTokensForDocs: mockUseTestTokensForDocs,
|
|
}));
|
|
|
|
// Mock react-i18next
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string) => {
|
|
const translations: Record<string, string> = {
|
|
'common.back': 'Back',
|
|
'help.api.title': 'API Documentation',
|
|
'help.api.noTestTokensFound': 'No test tokens found',
|
|
'help.api.noTestTokensDescription': 'Create a test API token to see interactive examples with your credentials.',
|
|
'help.api.createTestToken': 'Create Test Token',
|
|
'help.api.introduction': 'Introduction',
|
|
'help.api.authentication': 'Authentication',
|
|
'help.api.errors': 'Errors',
|
|
'help.api.rateLimits': 'Rate Limits',
|
|
'help.api.services': 'Services',
|
|
'help.api.resources': 'Resources',
|
|
'help.api.availability': 'Availability',
|
|
'help.api.appointments': 'Appointments',
|
|
'help.api.customers': 'Customers',
|
|
'help.api.webhooks': 'Webhooks',
|
|
'help.api.filtering': 'Filtering',
|
|
'help.api.listServices': 'List all services',
|
|
'help.api.retrieveService': 'Retrieve a service',
|
|
'help.api.checkAvailability': 'Check availability',
|
|
'help.api.createAppointment': 'Create an appointment',
|
|
'help.api.retrieveAppointment': 'Retrieve an appointment',
|
|
'help.api.updateAppointment': 'Update an appointment',
|
|
'help.api.cancelAppointment': 'Cancel an appointment',
|
|
'help.api.listAppointments': 'List all appointments',
|
|
'help.api.businessObject': 'The business object',
|
|
'help.api.serviceObject': 'The service object',
|
|
'help.api.resourceObject': 'The resource object',
|
|
'help.api.appointmentObject': 'The appointment object',
|
|
'help.api.customerObject': 'The customer object',
|
|
'help.api.createCustomer': 'Create a customer',
|
|
'help.api.retrieveCustomer': 'Retrieve a customer',
|
|
'help.api.updateCustomer': 'Update a customer',
|
|
'help.api.listCustomers': 'List all customers',
|
|
};
|
|
return translations[key] || key;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
// Mock useNavigate
|
|
const mockNavigate = vi.fn();
|
|
vi.mock('react-router-dom', async () => {
|
|
const actual = await vi.importActual('react-router-dom');
|
|
return {
|
|
...actual,
|
|
useNavigate: () => mockNavigate,
|
|
};
|
|
});
|
|
|
|
// Test wrapper with Router
|
|
const createWrapper = () => {
|
|
return ({ children }: { children: React.ReactNode }) => (
|
|
<BrowserRouter>{children}</BrowserRouter>
|
|
);
|
|
};
|
|
|
|
describe('HelpApiDocs', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset scroll position
|
|
window.scrollTo = vi.fn();
|
|
|
|
// Reset the mock to default behavior
|
|
mockUseTestTokensForDocs.mockReturnValue({
|
|
data: mockTestTokensData,
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('should render the API documentation page', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const heading = screen.getByText('API Documentation');
|
|
expect(heading).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render the back button', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const backButton = screen.getByRole('button', { name: /back/i });
|
|
expect(backButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render the page header', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const title = screen.getByText('API Documentation');
|
|
expect(title).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Navigation', () => {
|
|
it('should navigate back when back button is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const backButton = screen.getByRole('button', { name: /back/i });
|
|
await user.click(backButton);
|
|
|
|
expect(mockNavigate).toHaveBeenCalledWith(-1);
|
|
});
|
|
});
|
|
|
|
describe('Main Sections', () => {
|
|
it('should render introduction section', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('introduction');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render authentication section', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('authentication');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render errors section', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('errors');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render rate limits section', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('rate-limits');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render services section', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('list-services');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render appointments section', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('create-appointment');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render customers section', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('create-customer');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render webhooks section', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('webhook-events');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Token Selector', () => {
|
|
it('should render token selector when tokens are available', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const tokenSelector = screen.getByRole('combobox');
|
|
expect(tokenSelector).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display all available test tokens', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const tokenSelector = screen.getByRole('combobox');
|
|
const options = Array.from(tokenSelector.querySelectorAll('option'));
|
|
|
|
expect(options).toHaveLength(2);
|
|
expect(options[0]).toHaveTextContent('Test Token 1');
|
|
expect(options[1]).toHaveTextContent('Test Token 2');
|
|
});
|
|
|
|
it('should show token key prefix in selector', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const tokenSelector = screen.getByRole('combobox');
|
|
expect(tokenSelector).toHaveTextContent('ss_test_abc123');
|
|
});
|
|
|
|
it('should allow selecting a different token', async () => {
|
|
const user = userEvent.setup();
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const tokenSelector = screen.getByRole('combobox') as HTMLSelectElement;
|
|
|
|
await user.selectOptions(tokenSelector, 'token-2');
|
|
|
|
expect(tokenSelector.value).toBe('token-2');
|
|
});
|
|
|
|
it('should display key icon next to token selector', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Look for the Key icon (lucide-react renders as svg)
|
|
const keyIcon = container.querySelector('svg');
|
|
expect(keyIcon).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('No Test Tokens Warning', () => {
|
|
it('should show warning banner when no test tokens exist', async () => {
|
|
mockUseTestTokensForDocs.mockReturnValue({
|
|
data: [],
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const warning = screen.getByText('No test tokens found');
|
|
expect(warning).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should not show warning banner when tokens are loading', async () => {
|
|
mockUseTestTokensForDocs.mockReturnValue({
|
|
data: undefined,
|
|
isLoading: true,
|
|
error: null,
|
|
});
|
|
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const warning = screen.queryByText('No test tokens found');
|
|
expect(warning).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should not show warning banner when tokens exist', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const warning = screen.queryByText('No test tokens found');
|
|
expect(warning).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Code Examples', () => {
|
|
it('should render code blocks for API examples', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Code blocks typically have <pre> or <code> tags
|
|
const codeBlocks = container.querySelectorAll('pre');
|
|
expect(codeBlocks.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should display cURL examples by default', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Look for curl command in code blocks
|
|
const pageText = container.textContent || '';
|
|
expect(pageText).toContain('curl');
|
|
});
|
|
|
|
it('should include API token in code examples', async () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Wait for the component to load and use the token
|
|
await waitFor(() => {
|
|
const pageText = container.textContent || '';
|
|
expect(pageText).toContain('ss_test_abc123'); // The first token's prefix
|
|
});
|
|
});
|
|
|
|
it('should include sandbox URL in examples', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const pageText = container.textContent || '';
|
|
expect(pageText).toContain('sandbox.smoothschedule.com');
|
|
});
|
|
|
|
it('should render language selector tabs', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Look for language labels (cURL, Python, etc.)
|
|
const pageText = container.textContent || '';
|
|
expect(pageText).toContain('cURL');
|
|
});
|
|
|
|
it('should allow switching between code languages', async () => {
|
|
const user = userEvent.setup();
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Find Python language button (if visible)
|
|
// This is a simplified test - actual implementation may vary
|
|
const buttons = container.querySelectorAll('button');
|
|
const pythonButton = Array.from(buttons).find(btn =>
|
|
btn.textContent?.includes('Python')
|
|
);
|
|
|
|
if (pythonButton) {
|
|
await user.click(pythonButton);
|
|
|
|
// After clicking, should show Python code
|
|
await waitFor(() => {
|
|
const pageText = container.textContent || '';
|
|
expect(pageText).toContain('import requests');
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('HTTP Method Badges', () => {
|
|
it('should render GET method badges', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const pageText = container.textContent || '';
|
|
expect(pageText).toContain('GET');
|
|
});
|
|
|
|
it('should render POST method badges', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const pageText = container.textContent || '';
|
|
expect(pageText).toContain('POST');
|
|
});
|
|
|
|
it('should render PATCH method badges', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const pageText = container.textContent || '';
|
|
expect(pageText).toContain('PATCH');
|
|
});
|
|
|
|
it('should render DELETE method badges', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const pageText = container.textContent || '';
|
|
expect(pageText).toContain('DELETE');
|
|
});
|
|
});
|
|
|
|
describe('API Endpoints', () => {
|
|
it('should document the list services endpoint', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('list-services');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it('should document the create appointment endpoint', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('create-appointment');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it('should document the check availability endpoint', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const section = document.getElementById('check-availability');
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it('should document customer endpoints', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
expect(document.getElementById('create-customer')).toBeInTheDocument();
|
|
expect(document.getElementById('retrieve-customer')).toBeInTheDocument();
|
|
expect(document.getElementById('update-customer')).toBeInTheDocument();
|
|
expect(document.getElementById('list-customers')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should document webhook endpoints', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
expect(document.getElementById('webhook-events')).toBeInTheDocument();
|
|
expect(document.getElementById('create-webhook')).toBeInTheDocument();
|
|
expect(document.getElementById('list-webhooks')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Page Structure', () => {
|
|
it('should have a sticky header', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const header = container.querySelector('header');
|
|
expect(header).toHaveClass('sticky');
|
|
});
|
|
|
|
it('should render main content area', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const mainContent = container.querySelector('.min-h-screen');
|
|
expect(mainContent).toBeInTheDocument();
|
|
});
|
|
|
|
it('should apply dark mode classes', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const mainDiv = container.querySelector('.dark\\:bg-gray-900');
|
|
expect(mainDiv).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('should have semantic header element', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const header = container.querySelector('header');
|
|
expect(header).toBeInTheDocument();
|
|
});
|
|
|
|
it('should have accessible back button', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const backButton = screen.getByRole('button', { name: /back/i });
|
|
expect(backButton).toHaveAccessibleName();
|
|
});
|
|
|
|
it('should have accessible token selector', async () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Wait for the selector to be rendered
|
|
await waitFor(() => {
|
|
const selector = screen.getByRole('combobox');
|
|
expect(selector).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should use section elements for content sections', () => {
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const sections = container.querySelectorAll('section');
|
|
expect(sections.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('Internationalization', () => {
|
|
it('should translate page title', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const title = screen.getByText('API Documentation');
|
|
expect(title).toBeInTheDocument();
|
|
});
|
|
|
|
it('should translate back button text', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
const backButton = screen.getByRole('button', { name: /back/i });
|
|
expect(backButton).toHaveTextContent('Back');
|
|
});
|
|
|
|
it('should translate no tokens warning', async () => {
|
|
const { useTestTokensForDocs } = await import('../../hooks/useApiTokens');
|
|
vi.mocked(useTestTokensForDocs).mockReturnValue({
|
|
data: [],
|
|
isLoading: false,
|
|
error: null,
|
|
} as any);
|
|
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('No test tokens found')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Integration', () => {
|
|
it('should render complete API documentation page', async () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Check for key elements
|
|
expect(screen.getByText('API Documentation')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument();
|
|
|
|
// Wait for token selector to render
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(document.getElementById('introduction')).toBeInTheDocument();
|
|
expect(document.getElementById('authentication')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle token selection and update examples', async () => {
|
|
const user = userEvent.setup();
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Wait for token selector to render
|
|
const tokenSelector = await waitFor(() => {
|
|
return screen.getByRole('combobox') as HTMLSelectElement;
|
|
});
|
|
|
|
// Wait for initial token to appear
|
|
await waitFor(() => {
|
|
expect(container.textContent).toContain('ss_test_abc123');
|
|
});
|
|
|
|
// Select second token
|
|
await user.selectOptions(tokenSelector, 'token-2');
|
|
|
|
// Should now show second token in examples
|
|
await waitFor(() => {
|
|
expect(container.textContent).toContain('ss_test_def456');
|
|
});
|
|
});
|
|
|
|
it('should maintain structure with all sections present', () => {
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Verify all main sections exist
|
|
const sections = [
|
|
'introduction',
|
|
'authentication',
|
|
'errors',
|
|
'rate-limits',
|
|
'list-services',
|
|
'check-availability',
|
|
'create-appointment',
|
|
'create-customer',
|
|
'webhook-events',
|
|
];
|
|
|
|
sections.forEach(sectionId => {
|
|
const section = document.getElementById(sectionId);
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('should handle missing token data gracefully', async () => {
|
|
const { useTestTokensForDocs } = await import('../../hooks/useApiTokens');
|
|
vi.mocked(useTestTokensForDocs).mockReturnValue({
|
|
data: undefined,
|
|
isLoading: false,
|
|
error: null,
|
|
} as any);
|
|
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Should still render the page
|
|
expect(screen.getByText('API Documentation')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle token loading state', async () => {
|
|
const { useTestTokensForDocs } = await import('../../hooks/useApiTokens');
|
|
vi.mocked(useTestTokensForDocs).mockReturnValue({
|
|
data: undefined,
|
|
isLoading: true,
|
|
error: null,
|
|
} as any);
|
|
|
|
render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Should render without token selector
|
|
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should use default API key when no tokens available', async () => {
|
|
const { useTestTokensForDocs } = await import('../../hooks/useApiTokens');
|
|
vi.mocked(useTestTokensForDocs).mockReturnValue({
|
|
data: [],
|
|
isLoading: false,
|
|
error: null,
|
|
} as any);
|
|
|
|
const { container } = render(<HelpApiDocs />, { wrapper: createWrapper() });
|
|
|
|
// Should show default placeholder token
|
|
await waitFor(() => {
|
|
const pageText = container.textContent || '';
|
|
expect(pageText).toContain('ss_test_');
|
|
});
|
|
});
|
|
});
|
|
});
|