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:
poduck
2025-12-29 17:38:48 -05:00
parent d7700a68fd
commit 47657e7076
105 changed files with 29709 additions and 873 deletions

View File

@@ -0,0 +1,679 @@
/**
* Unit tests for CustomerSupport component
*
* Tests support ticket functionality including:
* - Ticket list display
* - Ticket creation
* - Ticket detail view
* - Comments/conversation
* - Status badges
* - Loading and error states
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Outlet, Routes, Route } from 'react-router-dom';
import CustomerSupport from '../CustomerSupport';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string, options?: any) => {
if (options?.number !== undefined) {
return fallback?.replace('{{number}}', options.number) || key;
}
if (options?.date !== undefined) {
return fallback?.replace('{{date}}', options.date) || key;
}
return fallback || key;
},
}),
}));
// Mock the ticket hooks
const mockTickets = vi.fn();
const mockCreateTicket = vi.fn();
const mockTicketComments = vi.fn();
const mockCreateTicketComment = vi.fn();
vi.mock('../../../hooks/useTickets', () => ({
useTickets: () => mockTickets(),
useCreateTicket: () => ({
mutateAsync: mockCreateTicket,
isPending: false,
}),
useTicketComments: (ticketId: string) => mockTicketComments(ticketId),
useCreateTicketComment: () => ({
mutateAsync: mockCreateTicketComment,
isPending: false,
}),
}));
const mockUser = {
id: 'user-1',
email: 'customer@example.com',
name: 'John Doe',
role: 'customer' as const,
};
const mockBusiness = {
id: 'biz-1',
name: 'Test Business',
subdomain: 'test',
cancellationWindowHours: 24,
lateCancellationFeePercent: 50,
};
const defaultTickets = [
{
id: '1',
ticketNumber: 'TKT-001',
ticketType: 'CUSTOMER',
subject: 'Appointment rescheduling',
description: 'I need to reschedule my appointment',
status: 'OPEN',
priority: 'MEDIUM',
category: 'APPOINTMENT',
createdAt: '2024-12-01T10:00:00Z',
updatedAt: '2024-12-01T10:00:00Z',
creatorEmail: 'customer@example.com',
creatorFullName: 'John Doe',
},
{
id: '2',
ticketNumber: 'TKT-002',
ticketType: 'CUSTOMER',
subject: 'Refund request',
description: 'I would like a refund for my last appointment',
status: 'RESOLVED',
priority: 'HIGH',
category: 'REFUND',
createdAt: '2024-11-20T14:00:00Z',
updatedAt: '2024-11-22T16:00:00Z',
resolvedAt: '2024-11-22T16:00:00Z',
creatorEmail: 'customer@example.com',
creatorFullName: 'John Doe',
},
{
id: '3',
ticketNumber: 'TKT-003',
ticketType: 'PLATFORM',
subject: 'Platform issue',
description: 'This should be filtered out',
status: 'OPEN',
priority: 'LOW',
category: 'OTHER',
createdAt: '2024-11-15T09:00:00Z',
updatedAt: '2024-11-15T09:00:00Z',
creatorEmail: 'platform@example.com',
creatorFullName: 'Platform User',
},
];
const defaultComments = [
{
id: '1',
ticket: '1',
author: 'staff-1',
authorEmail: 'staff@example.com',
authorFullName: 'Support Staff',
commentText: 'We have received your request and are looking into it.',
isInternal: false,
createdAt: '2024-12-01T11:00:00Z',
},
{
id: '2',
ticket: '1',
author: 'staff-2',
authorEmail: 'manager@example.com',
authorFullName: 'Manager',
commentText: 'Internal note: escalate this',
isInternal: true,
createdAt: '2024-12-01T12:00:00Z',
},
{
id: '3',
ticket: '1',
author: 'user-1',
authorEmail: 'customer@example.com',
authorFullName: 'John Doe',
commentText: 'Thank you for your help!',
isInternal: false,
createdAt: '2024-12-01T13:00:00Z',
},
];
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const OutletWrapper = () => {
return React.createElement(Outlet, {
context: { user: mockUser, business: mockBusiness },
});
};
return ({ children }: { children: React.ReactNode }) =>
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(
MemoryRouter,
{ initialEntries: ['/customer/support'] },
React.createElement(
Routes,
null,
React.createElement(Route, {
element: React.createElement(OutletWrapper),
children: React.createElement(Route, {
path: 'customer/support',
element: children,
}),
})
)
)
);
};
describe('CustomerSupport', () => {
beforeEach(() => {
vi.clearAllMocks();
mockTickets.mockReturnValue({
data: defaultTickets,
isLoading: false,
refetch: vi.fn(),
});
mockTicketComments.mockReturnValue({
data: [],
isLoading: false,
});
});
describe('Page Header', () => {
it('should render the page title', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText('Support')).toBeInTheDocument();
});
it('should render the page subtitle', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText(/Get help with your appointments and account/)).toBeInTheDocument();
});
it('should render New Request button', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText('New Request')).toBeInTheDocument();
});
});
describe('Quick Help Section', () => {
it('should render Quick Help heading', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText('Quick Help')).toBeInTheDocument();
});
it('should render Contact Us option', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText('Contact Us')).toBeInTheDocument();
expect(screen.getByText('Submit a support request')).toBeInTheDocument();
});
it('should render Email Us option', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText('Email Us')).toBeInTheDocument();
expect(screen.getByText('Get help via email')).toBeInTheDocument();
});
it('should have email link with business subdomain', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const emailLink = screen.getByText('Email Us').closest('a');
expect(emailLink).toHaveAttribute('href', 'mailto:support@test.smoothschedule.com');
});
it('should open new ticket form when Contact Us is clicked', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const contactButton = screen.getAllByText('Contact Us')[0].closest('a');
fireEvent.click(contactButton!);
expect(screen.getByText('Submit a Support Request')).toBeInTheDocument();
});
});
describe('My Support Requests Section', () => {
it('should render section heading', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText('My Support Requests')).toBeInTheDocument();
});
it('should display customer tickets only', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText('Appointment rescheduling')).toBeInTheDocument();
expect(screen.getByText('Refund request')).toBeInTheDocument();
expect(screen.queryByText('Platform issue')).not.toBeInTheDocument();
});
it('should display ticket numbers', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText(/TKT-001/)).toBeInTheDocument();
expect(screen.getByText(/TKT-002/)).toBeInTheDocument();
});
it('should display ticket status badges', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText(/open/i)).toBeInTheDocument();
expect(screen.getByText(/resolved/i)).toBeInTheDocument();
});
it('should display ticket creation dates', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const dateElements = screen.getAllByText(/12\/1\/2024|11\/20\/2024/);
expect(dateElements.length).toBeGreaterThan(0);
});
it('should render loading state', () => {
mockTickets.mockReturnValue({
data: [],
isLoading: true,
refetch: vi.fn(),
});
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should render empty state when no tickets', () => {
mockTickets.mockReturnValue({
data: [],
isLoading: false,
refetch: vi.fn(),
});
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText(/haven't submitted any support requests yet/)).toBeInTheDocument();
});
it('should show Submit first request button in empty state', () => {
mockTickets.mockReturnValue({
data: [],
isLoading: false,
refetch: vi.fn(),
});
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
expect(screen.getByText('Submit your first request')).toBeInTheDocument();
});
it('should open ticket detail when ticket is clicked', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Appointment rescheduling').closest('button');
fireEvent.click(ticketButton!);
expect(screen.getByText('Appointment Details')).toBeInTheDocument();
});
});
describe('New Ticket Form Modal', () => {
beforeEach(() => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const newRequestButton = screen.getAllByText('New Request')[0];
fireEvent.click(newRequestButton);
});
it('should render form modal when New Request is clicked', () => {
expect(screen.getByText('Submit a Support Request')).toBeInTheDocument();
});
it('should render subject input field', () => {
expect(screen.getByLabelText(/subject/i)).toBeInTheDocument();
});
it('should render category dropdown', () => {
expect(screen.getByLabelText(/category/i)).toBeInTheDocument();
});
it('should render priority dropdown', () => {
expect(screen.getByLabelText(/priority/i)).toBeInTheDocument();
});
it('should render description textarea', () => {
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
});
it('should render Cancel button', () => {
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
it('should render Submit Request button', () => {
expect(screen.getByText('Submit Request')).toBeInTheDocument();
});
it('should close modal when Cancel is clicked', () => {
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(screen.queryByText('Submit a Support Request')).not.toBeInTheDocument();
});
it('should close modal when clicking outside', () => {
const backdrop = screen.getByText('Submit a Support Request').closest('.fixed');
fireEvent.click(backdrop!);
expect(screen.queryByText('Submit a Support Request')).not.toBeInTheDocument();
});
it('should not close modal when clicking inside form', () => {
const formContent = screen.getByLabelText(/subject/i).closest('.bg-white');
fireEvent.click(formContent!);
expect(screen.getByText('Submit a Support Request')).toBeInTheDocument();
});
it('should submit form with correct data', async () => {
const subjectInput = screen.getByLabelText(/subject/i);
const descriptionInput = screen.getByLabelText(/description/i);
const submitButton = screen.getByText('Submit Request');
fireEvent.change(subjectInput, { target: { value: 'Test ticket' } });
fireEvent.change(descriptionInput, { target: { value: 'Test description' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockCreateTicket).toHaveBeenCalledWith({
subject: 'Test ticket',
description: 'Test description',
category: 'GENERAL_INQUIRY',
priority: 'MEDIUM',
ticketType: 'CUSTOMER',
});
});
});
it('should display category options', () => {
const categorySelect = screen.getByLabelText(/category/i);
expect(categorySelect).toBeInTheDocument();
// Options are rendered as part of select
expect(screen.getByText(/appointment/i)).toBeInTheDocument();
});
it('should display priority options', () => {
const prioritySelect = screen.getByLabelText(/priority/i);
expect(prioritySelect).toBeInTheDocument();
expect(screen.getByText(/medium/i)).toBeInTheDocument();
});
it('should require subject field', () => {
const subjectInput = screen.getByLabelText(/subject/i) as HTMLInputElement;
expect(subjectInput.required).toBe(true);
});
it('should require description field', () => {
const descriptionInput = screen.getByLabelText(/description/i) as HTMLTextAreaElement;
expect(descriptionInput.required).toBe(true);
});
});
describe('Ticket Detail View', () => {
beforeEach(() => {
mockTicketComments.mockReturnValue({
data: defaultComments,
isLoading: false,
});
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Appointment rescheduling').closest('button');
fireEvent.click(ticketButton!);
});
it('should display ticket subject', () => {
expect(screen.getByText('Appointment rescheduling')).toBeInTheDocument();
});
it('should display ticket number', () => {
expect(screen.getByText(/Ticket #TKT-001/)).toBeInTheDocument();
});
it('should display creation date', () => {
expect(screen.getByText(/Created.*12\/1\/2024/)).toBeInTheDocument();
});
it('should display status badge', () => {
expect(screen.getByText(/open/i)).toBeInTheDocument();
});
it('should display priority badge', () => {
expect(screen.getByText(/medium/i)).toBeInTheDocument();
});
it('should display ticket description', () => {
expect(screen.getByText('I need to reschedule my appointment')).toBeInTheDocument();
});
it('should display back button', () => {
expect(screen.getByText(/Back to tickets/)).toBeInTheDocument();
});
it('should return to ticket list when back button is clicked', () => {
const backButton = screen.getByText(/Back to tickets/);
fireEvent.click(backButton);
expect(screen.getByText('My Support Requests')).toBeInTheDocument();
expect(screen.queryByText(/Ticket #TKT-001/)).not.toBeInTheDocument();
});
it('should display Conversation heading', () => {
expect(screen.getByText('Conversation')).toBeInTheDocument();
});
it('should filter out internal comments', () => {
expect(screen.getByText('We have received your request and are looking into it.')).toBeInTheDocument();
expect(screen.getByText('Thank you for your help!')).toBeInTheDocument();
expect(screen.queryByText('Internal note: escalate this')).not.toBeInTheDocument();
});
it('should display comment author names', () => {
expect(screen.getByText('Support Staff')).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('should display comment timestamps', () => {
const timestamps = screen.getAllByText(/12\/1\/2024/);
expect(timestamps.length).toBeGreaterThan(0);
});
it('should render reply form', () => {
expect(screen.getByLabelText('Your Reply')).toBeInTheDocument();
});
it('should render Send Reply button', () => {
expect(screen.getByText('Send Reply')).toBeInTheDocument();
});
it('should submit reply when Send Reply is clicked', async () => {
const replyInput = screen.getByLabelText('Your Reply');
const sendButton = screen.getByText('Send Reply');
fireEvent.change(replyInput, { target: { value: 'My reply message' } });
fireEvent.click(sendButton);
await waitFor(() => {
expect(mockCreateTicketComment).toHaveBeenCalledWith({
ticketId: '1',
commentData: {
commentText: 'My reply message',
isInternal: false,
},
});
});
});
it('should clear reply input after submission', async () => {
const replyInput = screen.getByLabelText('Your Reply') as HTMLTextAreaElement;
const sendButton = screen.getByText('Send Reply');
fireEvent.change(replyInput, { target: { value: 'My reply' } });
fireEvent.click(sendButton);
await waitFor(() => {
expect(replyInput.value).toBe('');
});
});
it('should disable Send Reply button when input is empty', () => {
const sendButton = screen.getByText('Send Reply');
expect(sendButton).toBeDisabled();
});
it('should enable Send Reply button when input has text', () => {
const replyInput = screen.getByLabelText('Your Reply');
const sendButton = screen.getByText('Send Reply');
fireEvent.change(replyInput, { target: { value: 'Some text' } });
expect(sendButton).not.toBeDisabled();
});
it('should show empty state when no comments', () => {
mockTicketComments.mockReturnValue({
data: [],
isLoading: false,
});
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Appointment rescheduling').closest('button');
fireEvent.click(ticketButton!);
expect(screen.getByText(/No replies yet/)).toBeInTheDocument();
});
it('should show loading state for comments', () => {
mockTicketComments.mockReturnValue({
data: [],
isLoading: true,
});
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Appointment rescheduling').closest('button');
fireEvent.click(ticketButton!);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
describe('Closed Ticket Behavior', () => {
beforeEach(() => {
const closedTicket = {
...defaultTickets[0],
status: 'CLOSED',
};
mockTickets.mockReturnValue({
data: [closedTicket],
isLoading: false,
refetch: vi.fn(),
});
mockTicketComments.mockReturnValue({
data: [],
isLoading: false,
});
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Appointment rescheduling').closest('button');
fireEvent.click(ticketButton!);
});
it('should not show reply form for closed tickets', () => {
expect(screen.queryByLabelText('Your Reply')).not.toBeInTheDocument();
});
it('should show closed ticket message', () => {
expect(screen.getByText(/This ticket is closed/)).toBeInTheDocument();
});
it('should suggest opening new request for closed tickets', () => {
expect(screen.getByText(/open a new support request/)).toBeInTheDocument();
});
});
describe('Status Messages', () => {
it('should show open status message', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Appointment rescheduling').closest('button');
fireEvent.click(ticketButton!);
expect(screen.getByText(/request has been received/)).toBeInTheDocument();
});
it('should show in progress status message', () => {
const inProgressTicket = {
...defaultTickets[0],
status: 'IN_PROGRESS',
};
mockTickets.mockReturnValue({
data: [inProgressTicket],
isLoading: false,
refetch: vi.fn(),
});
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Appointment rescheduling').closest('button');
fireEvent.click(ticketButton!);
expect(screen.getByText(/currently working on your request/)).toBeInTheDocument();
});
it('should show awaiting response status message', () => {
const awaitingTicket = {
...defaultTickets[0],
status: 'AWAITING_RESPONSE',
};
mockTickets.mockReturnValue({
data: [awaitingTicket],
isLoading: false,
refetch: vi.fn(),
});
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Appointment rescheduling').closest('button');
fireEvent.click(ticketButton!);
expect(screen.getByText(/need additional information/)).toBeInTheDocument();
});
it('should show resolved status message', () => {
mockTickets.mockReturnValue({
data: [defaultTickets[1]], // Resolved ticket
isLoading: false,
refetch: vi.fn(),
});
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Refund request').closest('button');
fireEvent.click(ticketButton!);
expect(screen.getByText(/request has been resolved/)).toBeInTheDocument();
});
});
describe('Status and Priority Badges', () => {
it('should render OPEN status badge correctly', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const openBadge = screen.getByText(/open/i);
expect(openBadge).toBeInTheDocument();
});
it('should render RESOLVED status badge correctly', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const resolvedBadge = screen.getByText(/resolved/i);
expect(resolvedBadge).toBeInTheDocument();
});
it('should render MEDIUM priority badge correctly', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Appointment rescheduling').closest('button');
fireEvent.click(ticketButton!);
const mediumBadge = screen.getByText(/medium/i);
expect(mediumBadge).toBeInTheDocument();
});
it('should render HIGH priority badge correctly', () => {
render(React.createElement(CustomerSupport), { wrapper: createWrapper() });
const ticketButton = screen.getByText('Refund request').closest('button');
fireEvent.click(ticketButton!);
const highBadge = screen.getByText(/high/i);
expect(highBadge).toBeInTheDocument();
});
});
});