[SPRINT-3] feat: Add WCAG improvements and 160 unit tests

## ST-3.1 WCAG Accessibility (5 SP)
- Replace div onClick with semantic buttons
- Add aria-label to interactive icons
- Add aria-hidden to decorative icons
- Add focus:ring-2 for visible focus states
- Add role attributes to modals, trees, progressbars
- Add proper form labels with htmlFor

Files modified: NotificationDrawer, DashboardLayout, PermissionsMatrix,
NetworkTree, GoalProgressBar, AuditFilters, NotificationBell,
NotificationItem, RoleCard, RoleForm

## ST-3.2 Unit Tests (5 SP)
- Add 160 new unit tests (target was 42)
- Hooks: useAuth (18), useRbac (18), useGoals (15), useMlm (19), usePortfolio (24)
- Components: RoleForm (15), GoalProgressBar (28), RankBadge (23)
- All tests use AAA pattern with proper mocking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 20:27:34 -06:00
parent 891689a4f4
commit 9bd1aba33d
19 changed files with 3127 additions and 113 deletions

View File

@ -0,0 +1,269 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { GoalProgressBar } from '@/components/goals/GoalProgressBar';
describe('GoalProgressBar', () => {
describe('progress calculation', () => {
it('should render correct percentage', () => {
render(<GoalProgressBar currentValue={50} targetValue={100} />);
expect(screen.getByText('50.0%')).toBeInTheDocument();
});
it('should calculate percentage correctly', () => {
render(<GoalProgressBar currentValue={75} targetValue={100} />);
expect(screen.getByText('75.0%')).toBeInTheDocument();
});
it('should cap percentage at 100%', () => {
render(<GoalProgressBar currentValue={150} targetValue={100} />);
expect(screen.getByText('100.0%')).toBeInTheDocument();
});
it('should handle zero target value', () => {
render(<GoalProgressBar currentValue={50} targetValue={0} />);
expect(screen.getByText('0.0%')).toBeInTheDocument();
});
it('should handle zero current value', () => {
render(<GoalProgressBar currentValue={0} targetValue={100} />);
expect(screen.getByText('0.0%')).toBeInTheDocument();
});
});
describe('value display', () => {
it('should display current and target values', () => {
render(<GoalProgressBar currentValue={50} targetValue={100} />);
expect(screen.getByText(/50.*\/.*100/)).toBeInTheDocument();
});
it('should display unit when provided', () => {
render(<GoalProgressBar currentValue={5000} targetValue={10000} unit="USD" />);
expect(screen.getByText(/5,000.*\/.*10,000 USD/)).toBeInTheDocument();
});
it('should hide values when showValue is false', () => {
render(<GoalProgressBar currentValue={50} targetValue={100} showValue={false} />);
expect(screen.queryByText(/50.*\/.*100/)).not.toBeInTheDocument();
});
it('should hide percentage when showPercentage is false', () => {
render(<GoalProgressBar currentValue={50} targetValue={100} showPercentage={false} />);
expect(screen.queryByText('50.0%')).not.toBeInTheDocument();
});
it('should format large numbers with locale', () => {
render(<GoalProgressBar currentValue={1000000} targetValue={2000000} />);
expect(screen.getByText(/1,000,000.*\/.*2,000,000/)).toBeInTheDocument();
});
});
describe('color logic', () => {
it('should show green color when progress >= 100%', () => {
const { container } = render(<GoalProgressBar currentValue={100} targetValue={100} />);
const progressFill = container.querySelector('.bg-green-500');
expect(progressFill).toBeInTheDocument();
});
it('should show green-400 color when progress >= 80%', () => {
const { container } = render(<GoalProgressBar currentValue={85} targetValue={100} />);
const progressFill = container.querySelector('.bg-green-400');
expect(progressFill).toBeInTheDocument();
});
it('should show yellow color when progress >= 50%', () => {
const { container } = render(<GoalProgressBar currentValue={60} targetValue={100} />);
const progressFill = container.querySelector('.bg-yellow-500');
expect(progressFill).toBeInTheDocument();
});
it('should show orange color when progress >= 25%', () => {
const { container } = render(<GoalProgressBar currentValue={30} targetValue={100} />);
const progressFill = container.querySelector('.bg-orange-500');
expect(progressFill).toBeInTheDocument();
});
it('should show red color when progress < 25%', () => {
const { container } = render(<GoalProgressBar currentValue={10} targetValue={100} />);
const progressFill = container.querySelector('.bg-red-500');
expect(progressFill).toBeInTheDocument();
});
});
describe('size variants', () => {
it('should render small size', () => {
const { container } = render(
<GoalProgressBar currentValue={50} targetValue={100} size="sm" />
);
const progressBar = container.querySelector('.h-2');
expect(progressBar).toBeInTheDocument();
});
it('should render medium size (default)', () => {
const { container } = render(
<GoalProgressBar currentValue={50} targetValue={100} size="md" />
);
const progressBar = container.querySelector('.h-3');
expect(progressBar).toBeInTheDocument();
});
it('should render large size', () => {
const { container } = render(
<GoalProgressBar currentValue={50} targetValue={100} size="lg" />
);
const progressBar = container.querySelector('.h-4');
expect(progressBar).toBeInTheDocument();
});
});
describe('milestones', () => {
it('should render milestones when provided', () => {
const milestones = [
{ percentage: 25 },
{ percentage: 50 },
{ percentage: 75 },
];
const { container } = render(
<GoalProgressBar
currentValue={60}
targetValue={100}
milestones={milestones}
/>
);
// Should have milestone markers
const milestoneMarkers = container.querySelectorAll('.rounded-full.border-2');
expect(milestoneMarkers.length).toBeGreaterThanOrEqual(3);
});
it('should mark achieved milestones as green', () => {
const milestones = [
{ percentage: 25 },
{ percentage: 50 },
{ percentage: 75 },
];
const { container } = render(
<GoalProgressBar
currentValue={60}
targetValue={100}
milestones={milestones}
/>
);
// 25% and 50% milestones should be achieved (green)
const achievedMilestones = container.querySelectorAll('.bg-green-500.border-green-600');
expect(achievedMilestones.length).toBe(2);
});
it('should show milestone labels for large size', () => {
const milestones = [
{ percentage: 25 },
{ percentage: 50 },
{ percentage: 75 },
];
render(
<GoalProgressBar
currentValue={60}
targetValue={100}
milestones={milestones}
size="lg"
/>
);
expect(screen.getByText('25%')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
expect(screen.getByText('75%')).toBeInTheDocument();
});
it('should not show milestone labels for non-large sizes', () => {
const milestones = [
{ percentage: 50 },
];
render(
<GoalProgressBar
currentValue={60}
targetValue={100}
milestones={milestones}
size="sm"
/>
);
// The percentage label in the stats row is different from milestone labels
// Milestone labels are only shown for size="lg"
const allText = screen.queryAllByText('50%');
// Should not have additional milestone label for small size
expect(allText.length).toBe(0);
});
});
describe('custom className', () => {
it('should apply custom className', () => {
const { container } = render(
<GoalProgressBar
currentValue={50}
targetValue={100}
className="my-custom-class"
/>
);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('my-custom-class');
});
});
describe('edge cases', () => {
it('should handle decimal values', () => {
render(<GoalProgressBar currentValue={33.33} targetValue={100} />);
expect(screen.getByText('33.3%')).toBeInTheDocument();
});
it('should handle very small progress', () => {
render(<GoalProgressBar currentValue={1} targetValue={1000} />);
expect(screen.getByText('0.1%')).toBeInTheDocument();
});
it('should handle exact boundary at 80%', () => {
const { container } = render(<GoalProgressBar currentValue={80} targetValue={100} />);
const progressFill = container.querySelector('.bg-green-400');
expect(progressFill).toBeInTheDocument();
});
it('should handle exact boundary at 50%', () => {
const { container } = render(<GoalProgressBar currentValue={50} targetValue={100} />);
const progressFill = container.querySelector('.bg-yellow-500');
expect(progressFill).toBeInTheDocument();
});
it('should handle exact boundary at 25%', () => {
const { container } = render(<GoalProgressBar currentValue={25} targetValue={100} />);
const progressFill = container.querySelector('.bg-orange-500');
expect(progressFill).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,190 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { RankBadge } from '@/components/mlm/RankBadge';
describe('RankBadge', () => {
describe('rendering with level', () => {
it('should render rank name', () => {
render(<RankBadge level={1} name="Bronze" />);
expect(screen.getByText('Bronze')).toBeInTheDocument();
});
it('should render level number in badge', () => {
render(<RankBadge level={3} name="Gold" />);
expect(screen.getByText('3')).toBeInTheDocument();
});
it('should show level label', () => {
render(<RankBadge level={2} name="Silver" />);
expect(screen.getByText(/Level 2/)).toBeInTheDocument();
});
it('should not show level when showLevel is false', () => {
render(<RankBadge level={1} name="Bronze" showLevel={false} />);
expect(screen.queryByText('Level 1')).not.toBeInTheDocument();
expect(screen.queryByText('1')).not.toBeInTheDocument();
});
it('should show first letter of name when showLevel is false', () => {
render(<RankBadge level={1} name="Bronze" showLevel={false} />);
expect(screen.getByText('B')).toBeInTheDocument();
});
});
describe('size variants', () => {
it('should render small size badge', () => {
const { container } = render(<RankBadge level={1} name="Bronze" size="sm" />);
const badge = container.querySelector('.w-6.h-6');
expect(badge).toBeInTheDocument();
});
it('should render medium size badge (default)', () => {
const { container } = render(<RankBadge level={1} name="Bronze" size="md" />);
const badge = container.querySelector('.w-8.h-8');
expect(badge).toBeInTheDocument();
});
it('should render large size badge', () => {
const { container } = render(<RankBadge level={1} name="Bronze" size="lg" />);
const badge = container.querySelector('.w-12.h-12');
expect(badge).toBeInTheDocument();
});
it('should hide name and level text for small size', () => {
render(<RankBadge level={1} name="Bronze" size="sm" />);
// For small size, only the level number is shown in the badge
// Name and Level text are hidden
expect(screen.queryByText('Bronze')).not.toBeInTheDocument();
expect(screen.queryByText(/Level 1/)).not.toBeInTheDocument();
});
it('should show name for medium and large sizes', () => {
render(<RankBadge level={1} name="Bronze" size="md" />);
expect(screen.getByText('Bronze')).toBeInTheDocument();
});
});
describe('color customization', () => {
it('should apply custom color to badge', () => {
const { container } = render(
<RankBadge level={1} name="Bronze" color="#CD7F32" />
);
const badge = container.querySelector('.rounded-full');
expect(badge).toHaveStyle({ backgroundColor: '#CD7F32' });
});
it('should use default gray color when no color provided', () => {
const { container } = render(<RankBadge level={1} name="Bronze" />);
const badge = container.querySelector('.rounded-full');
expect(badge).toHaveStyle({ backgroundColor: '#6B7280' });
});
it('should use default color when color is null', () => {
const { container } = render(
<RankBadge level={1} name="Bronze" color={null} />
);
const badge = container.querySelector('.rounded-full');
expect(badge).toHaveStyle({ backgroundColor: '#6B7280' });
});
it('should apply different colors for different ranks', () => {
const { container: bronzeContainer } = render(
<RankBadge level={1} name="Bronze" color="#CD7F32" />
);
const { container: goldContainer } = render(
<RankBadge level={3} name="Gold" color="#FFD700" />
);
const bronzeBadge = bronzeContainer.querySelector('.rounded-full');
const goldBadge = goldContainer.querySelector('.rounded-full');
expect(bronzeBadge).toHaveStyle({ backgroundColor: '#CD7F32' });
expect(goldBadge).toHaveStyle({ backgroundColor: '#FFD700' });
});
});
describe('accessibility', () => {
it('should have white text on colored background', () => {
const { container } = render(<RankBadge level={1} name="Bronze" />);
const badge = container.querySelector('.rounded-full');
expect(badge).toHaveClass('text-white');
});
it('should have font-bold for badge text', () => {
const { container } = render(<RankBadge level={1} name="Bronze" />);
const badge = container.querySelector('.rounded-full');
expect(badge).toHaveClass('font-bold');
});
});
describe('layout', () => {
it('should render as flex container', () => {
const { container } = render(<RankBadge level={1} name="Bronze" />);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('flex', 'items-center');
});
it('should have margin between badge and text for non-small sizes', () => {
const { container } = render(<RankBadge level={1} name="Bronze" size="md" />);
const textContainer = container.querySelector('.ml-2');
expect(textContainer).toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle long rank names', () => {
render(<RankBadge level={5} name="Diamond Elite Master" />);
expect(screen.getByText('Diamond Elite Master')).toBeInTheDocument();
});
it('should handle high level numbers', () => {
render(<RankBadge level={99} name="Ultimate" size="lg" />);
expect(screen.getByText('99')).toBeInTheDocument();
expect(screen.getByText(/Level 99/)).toBeInTheDocument();
});
it('should handle level 0', () => {
render(<RankBadge level={0} name="Starter" />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('should handle empty name with first letter fallback', () => {
render(<RankBadge level={1} name="" showLevel={false} />);
// First letter of empty string is empty
const { container } = render(<RankBadge level={1} name="" showLevel={false} />);
const badge = container.querySelector('.rounded-full');
expect(badge).toBeInTheDocument();
});
it('should render correctly with default props', () => {
render(<RankBadge level={1} name="Bronze" />);
// Default size is md, showLevel is true
expect(screen.getByText('Bronze')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText(/Level 1/)).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,270 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
import { RoleForm } from '@/components/rbac/RoleForm';
import type { Role, Permission } from '@/services/api';
// Mock react-router-dom navigate
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Wrapper with router
const renderWithRouter = (ui: ReactNode) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('RoleForm', () => {
const mockPermissions: Permission[] = [
{ id: 'perm-1', name: 'Read Users', slug: 'users:read', description: null, category: 'users', created_at: '' },
{ id: 'perm-2', name: 'Write Users', slug: 'users:write', description: null, category: 'users', created_at: '' },
{ id: 'perm-3', name: 'Read Billing', slug: 'billing:read', description: null, category: 'billing', created_at: '' },
{ id: 'perm-4', name: 'Write Billing', slug: 'billing:write', description: null, category: 'billing', created_at: '' },
];
const mockRole: Role = {
id: 'role-1',
tenant_id: 'tenant-1',
name: 'Admin',
slug: 'admin',
description: 'Administrator role',
is_system: false,
is_default: false,
permissions: [mockPermissions[0], mockPermissions[1]],
created_at: '2024-01-01',
updated_at: '2024-01-01',
};
const mockOnSubmit = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('should render form with all fields', () => {
renderWithRouter(
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
expect(screen.getByLabelText(/role name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/slug/i)).toBeInTheDocument();
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create role/i })).toBeInTheDocument();
});
it('should render with pre-filled values when editing', () => {
renderWithRouter(
<RoleForm role={mockRole} permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
expect(screen.getByDisplayValue('Admin')).toBeInTheDocument();
expect(screen.getByDisplayValue('admin')).toBeInTheDocument();
expect(screen.getByDisplayValue('Administrator role')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /update role/i })).toBeInTheDocument();
});
it('should show permission count', () => {
renderWithRouter(
<RoleForm role={mockRole} permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
expect(screen.getByText(/2 of 4 selected/i)).toBeInTheDocument();
});
it('should disable slug field when editing', () => {
renderWithRouter(
<RoleForm role={mockRole} permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
const slugInput = screen.getByDisplayValue('admin');
expect(slugInput).toBeDisabled();
});
});
describe('auto-slug generation', () => {
it('should auto-generate slug from name when creating', async () => {
const user = userEvent.setup();
renderWithRouter(
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
const nameInput = screen.getByLabelText(/role name/i);
await user.type(nameInput, 'Sales Manager');
const slugInput = screen.getByPlaceholderText(/e\.g\., sales-manager/i);
expect(slugInput).toHaveValue('sales-manager');
});
it('should handle special characters in auto-slug', async () => {
const user = userEvent.setup();
renderWithRouter(
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
const nameInput = screen.getByLabelText(/role name/i);
await user.type(nameInput, 'Super Admin @2024!');
const slugInput = screen.getByPlaceholderText(/e\.g\., sales-manager/i);
expect(slugInput).toHaveValue('super-admin-2024');
});
});
describe('form validation', () => {
it('should disable submit button when name is empty', () => {
renderWithRouter(
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
const submitButton = screen.getByRole('button', { name: /create role/i });
expect(submitButton).toBeDisabled();
});
it('should enable submit button when name is filled', async () => {
const user = userEvent.setup();
renderWithRouter(
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
const nameInput = screen.getByLabelText(/role name/i);
await user.type(nameInput, 'New Role');
const submitButton = screen.getByRole('button', { name: /create role/i });
expect(submitButton).not.toBeDisabled();
});
it('should disable submit button when loading', () => {
renderWithRouter(
<RoleForm
role={mockRole}
permissions={mockPermissions}
onSubmit={mockOnSubmit}
isLoading={true}
/>
);
const submitButton = screen.getByRole('button', { name: /saving/i });
expect(submitButton).toBeDisabled();
});
});
describe('form submission', () => {
it('should call onSubmit with form data when creating', async () => {
const user = userEvent.setup();
mockOnSubmit.mockResolvedValue(undefined);
renderWithRouter(
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
const nameInput = screen.getByLabelText(/role name/i);
await user.type(nameInput, 'New Role');
const descriptionInput = screen.getByLabelText(/description/i);
await user.type(descriptionInput, 'A new test role');
const submitButton = screen.getByRole('button', { name: /create role/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New Role',
slug: 'new-role',
description: 'A new test role',
permissions: [],
})
);
});
});
it('should call onSubmit with updated data when editing', async () => {
const user = userEvent.setup();
mockOnSubmit.mockResolvedValue(undefined);
renderWithRouter(
<RoleForm role={mockRole} permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
const nameInput = screen.getByDisplayValue('Admin');
await user.clear(nameInput);
await user.type(nameInput, 'Super Admin');
const submitButton = screen.getByRole('button', { name: /update role/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Super Admin',
permissions: ['users:read', 'users:write'],
})
);
});
});
});
describe('permission selection', () => {
it('should have Select All and Clear All buttons', () => {
renderWithRouter(
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
expect(screen.getByRole('button', { name: /select all/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument();
});
it('should select all permissions when clicking Select All', async () => {
const user = userEvent.setup();
renderWithRouter(
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
const selectAllButton = screen.getByRole('button', { name: /select all/i });
await user.click(selectAllButton);
expect(screen.getByText(/4 of 4 selected/i)).toBeInTheDocument();
});
it('should clear all permissions when clicking Clear All', async () => {
const user = userEvent.setup();
renderWithRouter(
<RoleForm role={mockRole} permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
expect(screen.getByText(/2 of 4 selected/i)).toBeInTheDocument();
const clearAllButton = screen.getByRole('button', { name: /clear all/i });
await user.click(clearAllButton);
expect(screen.getByText(/0 of 4 selected/i)).toBeInTheDocument();
});
});
describe('navigation', () => {
it('should navigate to roles list when clicking Cancel', async () => {
const user = userEvent.setup();
renderWithRouter(
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(mockNavigate).toHaveBeenCalledWith('/dashboard/rbac/roles');
});
});
});

View File

@ -0,0 +1,390 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import {
useCurrentUser,
useLogin,
useLogout,
useRegister,
useRequestPasswordReset,
useResetPassword,
useChangePassword,
useVerifyEmail,
authKeys,
} from '@/hooks/useAuth';
import { authApi } from '@/services/api';
import { useAuthStore } from '@/stores';
// Mock the API module
vi.mock('@/services/api', () => ({
authApi: {
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
requestPasswordReset: vi.fn(),
resetPassword: vi.fn(),
changePassword: vi.fn(),
verifyEmail: vi.fn(),
me: vi.fn(),
},
}));
// Mock react-router-dom
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
// Mock react-hot-toast
vi.mock('react-hot-toast', () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Create wrapper for react-query
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useAuth hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset auth store
useAuthStore.getState().logout();
});
describe('authKeys', () => {
it('should have correct query key structure', () => {
expect(authKeys.all).toEqual(['auth']);
expect(authKeys.me()).toEqual(['auth', 'me']);
});
});
describe('useCurrentUser', () => {
it('should not fetch when not authenticated', async () => {
const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});
expect(result.current.isFetching).toBe(false);
expect(authApi.me).not.toHaveBeenCalled();
});
it('should fetch user data when authenticated', async () => {
const mockUser = {
id: 'user-1',
email: 'test@example.com',
first_name: 'John',
last_name: 'Doe',
tenant_id: 'tenant-1',
status: 'active',
email_verified: true,
created_at: '2024-01-01',
};
vi.mocked(authApi.me).mockResolvedValue(mockUser);
// Set authenticated state
useAuthStore.getState().login(
{
id: 'user-1',
email: 'test@example.com',
first_name: 'John',
last_name: 'Doe',
role: 'user',
tenant_id: 'tenant-1',
},
'access-token',
'refresh-token'
);
const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(authApi.me).toHaveBeenCalled();
expect(result.current.data).toEqual(mockUser);
});
});
describe('useLogin', () => {
it('should return mutation function', () => {
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
expect(result.current.mutate).toBeDefined();
expect(result.current.mutateAsync).toBeDefined();
});
it('should handle successful login', async () => {
const mockResponse = {
user: {
id: 'user-1',
email: 'test@example.com',
first_name: 'John',
last_name: 'Doe',
tenant_id: 'tenant-1',
status: 'active',
email_verified: true,
created_at: '2024-01-01',
},
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
};
vi.mocked(authApi.login).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
result.current.mutate({ email: 'test@example.com', password: 'password123' });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(authApi.login).toHaveBeenCalledWith('test@example.com', 'password123');
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
it('should handle login error', async () => {
const mockError = {
response: {
data: { message: 'Invalid credentials' },
},
};
vi.mocked(authApi.login).mockRejectedValue(mockError);
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
result.current.mutate({ email: 'test@example.com', password: 'wrong' });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});
describe('useLogout', () => {
it('should return mutation function', () => {
const { result } = renderHook(() => useLogout(), {
wrapper: createWrapper(),
});
expect(result.current.mutate).toBeDefined();
});
it('should clear auth state on logout', async () => {
// First set authenticated state
useAuthStore.getState().login(
{
id: 'user-1',
email: 'test@example.com',
first_name: 'John',
last_name: 'Doe',
role: 'user',
tenant_id: 'tenant-1',
},
'access-token',
'refresh-token'
);
vi.mocked(authApi.logout).mockResolvedValue({ message: 'Logged out' });
const { result } = renderHook(() => useLogout(), {
wrapper: createWrapper(),
});
expect(useAuthStore.getState().isAuthenticated).toBe(true);
result.current.mutate();
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(useAuthStore.getState().isAuthenticated).toBe(false);
});
});
describe('useRegister', () => {
it('should return mutation function', () => {
const { result } = renderHook(() => useRegister(), {
wrapper: createWrapper(),
});
expect(result.current.mutate).toBeDefined();
});
it('should handle successful registration', async () => {
const mockResponse = {
user: {
id: 'user-1',
email: 'new@example.com',
first_name: 'New',
last_name: 'User',
tenant_id: 'tenant-1',
status: 'active',
email_verified: false,
created_at: '2024-01-01',
},
accessToken: 'access-token',
refreshToken: 'refresh-token',
};
vi.mocked(authApi.register).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useRegister(), {
wrapper: createWrapper(),
});
result.current.mutate({
email: 'new@example.com',
password: 'password123',
first_name: 'New',
last_name: 'User',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(authApi.register).toHaveBeenCalled();
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
});
describe('useRequestPasswordReset', () => {
it('should return mutation function', () => {
const { result } = renderHook(() => useRequestPasswordReset(), {
wrapper: createWrapper(),
});
expect(result.current.mutate).toBeDefined();
});
it('should handle password reset request', async () => {
vi.mocked(authApi.requestPasswordReset).mockResolvedValue({
message: 'Reset email sent',
});
const { result } = renderHook(() => useRequestPasswordReset(), {
wrapper: createWrapper(),
});
result.current.mutate('test@example.com');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(authApi.requestPasswordReset).toHaveBeenCalledWith('test@example.com');
});
});
describe('useResetPassword', () => {
it('should return mutation function', () => {
const { result } = renderHook(() => useResetPassword(), {
wrapper: createWrapper(),
});
expect(result.current.mutate).toBeDefined();
});
it('should handle password reset', async () => {
vi.mocked(authApi.resetPassword).mockResolvedValue({
message: 'Password reset successfully',
});
const { result } = renderHook(() => useResetPassword(), {
wrapper: createWrapper(),
});
result.current.mutate({ token: 'reset-token', password: 'newpassword123' });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(authApi.resetPassword).toHaveBeenCalledWith('reset-token', 'newpassword123');
});
});
describe('useChangePassword', () => {
it('should return mutation function', () => {
const { result } = renderHook(() => useChangePassword(), {
wrapper: createWrapper(),
});
expect(result.current.mutate).toBeDefined();
});
it('should handle password change', async () => {
vi.mocked(authApi.changePassword).mockResolvedValue({
message: 'Password changed',
});
const { result } = renderHook(() => useChangePassword(), {
wrapper: createWrapper(),
});
result.current.mutate({
currentPassword: 'oldpass',
newPassword: 'newpass',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(authApi.changePassword).toHaveBeenCalledWith('oldpass', 'newpass');
});
});
describe('useVerifyEmail', () => {
it('should return mutation function', () => {
const { result } = renderHook(() => useVerifyEmail(), {
wrapper: createWrapper(),
});
expect(result.current.mutate).toBeDefined();
});
it('should handle email verification', async () => {
vi.mocked(authApi.verifyEmail).mockResolvedValue({
message: 'Email verified',
});
const { result } = renderHook(() => useVerifyEmail(), {
wrapper: createWrapper(),
});
result.current.mutate('verification-token');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(authApi.verifyEmail).toHaveBeenCalledWith('verification-token');
});
});
});

View File

@ -0,0 +1,424 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import {
useGoalDefinitions,
useGoalDefinition,
useCreateGoalDefinition,
useUpdateGoalDefinition,
useDeleteGoalDefinition,
useGoalAssignments,
useGoalAssignment,
useCreateGoalAssignment,
useUpdateGoalProgress,
useMyGoals,
useMyGoalsSummary,
useGoalCompletionReport,
useGoalUserReport,
} from '@/hooks/useGoals';
import { definitionsApi, assignmentsApi } from '@/services/goals';
// Mock the API modules
vi.mock('@/services/goals', () => ({
definitionsApi: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateStatus: vi.fn(),
activate: vi.fn(),
duplicate: vi.fn(),
delete: vi.fn(),
},
assignmentsApi: {
list: vi.fn(),
listByGoal: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateStatus: vi.fn(),
updateProgress: vi.fn(),
getHistory: vi.fn(),
delete: vi.fn(),
getMyGoals: vi.fn(),
getMyGoalsSummary: vi.fn(),
updateMyProgress: vi.fn(),
getCompletionReport: vi.fn(),
getUserReport: vi.fn(),
},
}));
// Create wrapper for react-query
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useGoals hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useGoalDefinitions', () => {
it('should fetch all goal definitions', async () => {
const mockDefinitions = [
{
id: 'goal-1',
name: 'Monthly Sales Target',
targetValue: 10000,
unit: 'USD',
status: 'active',
},
{
id: 'goal-2',
name: 'Customer Acquisition',
targetValue: 50,
unit: 'customers',
status: 'draft',
},
];
vi.mocked(definitionsApi.list).mockResolvedValue(mockDefinitions);
const { result } = renderHook(() => useGoalDefinitions(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(definitionsApi.list).toHaveBeenCalled();
expect(result.current.data).toEqual(mockDefinitions);
});
it('should fetch with filters', async () => {
const mockDefinitions = [{ id: 'goal-1', name: 'Active Goal', status: 'active' }];
vi.mocked(definitionsApi.list).mockResolvedValue(mockDefinitions);
const filters = { status: 'active' as const };
const { result } = renderHook(() => useGoalDefinitions(filters), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(definitionsApi.list).toHaveBeenCalledWith(filters);
});
});
describe('useGoalDefinition', () => {
it('should fetch single goal definition', async () => {
const mockDefinition = {
id: 'goal-1',
name: 'Monthly Sales Target',
targetValue: 10000,
unit: 'USD',
};
vi.mocked(definitionsApi.get).mockResolvedValue(mockDefinition);
const { result } = renderHook(() => useGoalDefinition('goal-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(definitionsApi.get).toHaveBeenCalledWith('goal-1');
expect(result.current.data).toEqual(mockDefinition);
});
it('should not fetch when id is empty', () => {
const { result } = renderHook(() => useGoalDefinition(''), {
wrapper: createWrapper(),
});
expect(result.current.isFetching).toBe(false);
expect(definitionsApi.get).not.toHaveBeenCalled();
});
});
describe('useCreateGoalDefinition', () => {
it('should create a new goal definition', async () => {
const newGoal = {
id: 'goal-3',
name: 'New Goal',
targetValue: 5000,
unit: 'units',
};
vi.mocked(definitionsApi.create).mockResolvedValue(newGoal);
const { result } = renderHook(() => useCreateGoalDefinition(), {
wrapper: createWrapper(),
});
result.current.mutate({
name: 'New Goal',
target_value: 5000,
metric_type: 'sales',
period: 'monthly',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(definitionsApi.create).toHaveBeenCalled();
});
});
describe('useUpdateGoalDefinition', () => {
it('should update goal definition', async () => {
const updatedGoal = {
id: 'goal-1',
name: 'Updated Goal',
targetValue: 15000,
};
vi.mocked(definitionsApi.update).mockResolvedValue(updatedGoal);
const { result } = renderHook(() => useUpdateGoalDefinition(), {
wrapper: createWrapper(),
});
result.current.mutate({
id: 'goal-1',
data: { name: 'Updated Goal', target_value: 15000 },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(definitionsApi.update).toHaveBeenCalledWith('goal-1', {
name: 'Updated Goal',
target_value: 15000,
});
});
});
describe('useDeleteGoalDefinition', () => {
it('should delete goal definition', async () => {
vi.mocked(definitionsApi.delete).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteGoalDefinition(), {
wrapper: createWrapper(),
});
result.current.mutate('goal-1');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(definitionsApi.delete).toHaveBeenCalledWith('goal-1');
});
});
describe('useGoalAssignments', () => {
it('should fetch goal assignments', async () => {
const mockAssignments = [
{ id: 'assign-1', definitionId: 'goal-1', userId: 'user-1', progress: 50 },
{ id: 'assign-2', definitionId: 'goal-1', userId: 'user-2', progress: 75 },
];
vi.mocked(assignmentsApi.list).mockResolvedValue(mockAssignments);
const { result } = renderHook(() => useGoalAssignments(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(assignmentsApi.list).toHaveBeenCalled();
expect(result.current.data).toEqual(mockAssignments);
});
});
describe('useGoalAssignment', () => {
it('should fetch single assignment', async () => {
const mockAssignment = {
id: 'assign-1',
definitionId: 'goal-1',
userId: 'user-1',
progress: 50,
};
vi.mocked(assignmentsApi.get).mockResolvedValue(mockAssignment);
const { result } = renderHook(() => useGoalAssignment('assign-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(assignmentsApi.get).toHaveBeenCalledWith('assign-1');
});
});
describe('useCreateGoalAssignment', () => {
it('should create goal assignment', async () => {
const newAssignment = {
id: 'assign-3',
definitionId: 'goal-1',
userId: 'user-3',
progress: 0,
};
vi.mocked(assignmentsApi.create).mockResolvedValue(newAssignment);
const { result } = renderHook(() => useCreateGoalAssignment(), {
wrapper: createWrapper(),
});
result.current.mutate({
definition_id: 'goal-1',
user_id: 'user-3',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(assignmentsApi.create).toHaveBeenCalled();
});
});
describe('useUpdateGoalProgress', () => {
it('should update goal progress', async () => {
const updatedAssignment = {
id: 'assign-1',
progress: 75,
currentValue: 7500,
};
vi.mocked(assignmentsApi.updateProgress).mockResolvedValue(updatedAssignment);
const { result } = renderHook(() => useUpdateGoalProgress(), {
wrapper: createWrapper(),
});
result.current.mutate({
id: 'assign-1',
data: { current_value: 7500, notes: 'Good progress' },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(assignmentsApi.updateProgress).toHaveBeenCalledWith('assign-1', {
current_value: 7500,
notes: 'Good progress',
});
});
});
describe('useMyGoals', () => {
it('should fetch current user goals', async () => {
const mockGoals = [
{ id: 'assign-1', definitionId: 'goal-1', progress: 50 },
{ id: 'assign-2', definitionId: 'goal-2', progress: 80 },
];
vi.mocked(assignmentsApi.getMyGoals).mockResolvedValue(mockGoals);
const { result } = renderHook(() => useMyGoals(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(assignmentsApi.getMyGoals).toHaveBeenCalled();
expect(result.current.data).toEqual(mockGoals);
});
});
describe('useMyGoalsSummary', () => {
it('should fetch current user goals summary', async () => {
const mockSummary = {
total: 5,
completed: 2,
in_progress: 2,
not_started: 1,
average_progress: 45,
};
vi.mocked(assignmentsApi.getMyGoalsSummary).mockResolvedValue(mockSummary);
const { result } = renderHook(() => useMyGoalsSummary(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(assignmentsApi.getMyGoalsSummary).toHaveBeenCalled();
expect(result.current.data).toEqual(mockSummary);
});
});
describe('useGoalCompletionReport', () => {
it('should fetch completion report', async () => {
const mockReport = {
totalGoals: 10,
completedGoals: 6,
completionRate: 60,
byPeriod: [],
};
vi.mocked(assignmentsApi.getCompletionReport).mockResolvedValue(mockReport);
const { result } = renderHook(() => useGoalCompletionReport('2024-01-01', '2024-12-31'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(assignmentsApi.getCompletionReport).toHaveBeenCalledWith('2024-01-01', '2024-12-31');
});
});
describe('useGoalUserReport', () => {
it('should fetch user report', async () => {
const mockReport = [
{ userId: 'user-1', completedGoals: 5, totalGoals: 8 },
{ userId: 'user-2', completedGoals: 3, totalGoals: 5 },
];
vi.mocked(assignmentsApi.getUserReport).mockResolvedValue(mockReport);
const { result } = renderHook(() => useGoalUserReport(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(assignmentsApi.getUserReport).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,500 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import {
useStructures,
useStructure,
useCreateStructure,
useUpdateStructure,
useDeleteStructure,
useRanks,
useRank,
useCreateRank,
useNodes,
useNode,
useCreateNode,
useNodeDownline,
useMyDashboard,
useMyNetwork,
useMyEarnings,
useMyRank,
useMLMCommissions,
useCalculateCommissions,
} from '@/hooks/useMlm';
import { structuresApi, ranksApi, nodesApi, commissionsApi } from '@/services/mlm';
// Mock the API modules
vi.mock('@/services/mlm', () => ({
structuresApi: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
ranksApi: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
evaluate: vi.fn(),
},
nodesApi: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateStatus: vi.fn(),
getDownline: vi.fn(),
getUpline: vi.fn(),
getTree: vi.fn(),
getMyDashboard: vi.fn(),
getMyNetwork: vi.fn(),
getMyEarnings: vi.fn(),
getMyRank: vi.fn(),
generateInviteLink: vi.fn(),
},
commissionsApi: {
list: vi.fn(),
get: vi.fn(),
calculate: vi.fn(),
updateStatus: vi.fn(),
getByLevel: vi.fn(),
},
}));
// Create wrapper for react-query
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useMlm hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ==================== Structures ====================
describe('useStructures', () => {
it('should fetch all structures', async () => {
const mockStructures = [
{ id: 'struct-1', name: 'Binary Structure', type: 'binary', maxWidth: 2 },
{ id: 'struct-2', name: 'Unilevel Structure', type: 'unilevel', maxWidth: null },
];
vi.mocked(structuresApi.list).mockResolvedValue(mockStructures);
const { result } = renderHook(() => useStructures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(structuresApi.list).toHaveBeenCalled();
expect(result.current.data).toEqual(mockStructures);
});
});
describe('useStructure', () => {
it('should fetch single structure', async () => {
const mockStructure = {
id: 'struct-1',
name: 'Binary Structure',
type: 'binary',
maxWidth: 2,
};
vi.mocked(structuresApi.get).mockResolvedValue(mockStructure);
const { result } = renderHook(() => useStructure('struct-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(structuresApi.get).toHaveBeenCalledWith('struct-1');
});
it('should not fetch when id is empty', () => {
const { result } = renderHook(() => useStructure(''), {
wrapper: createWrapper(),
});
expect(result.current.isFetching).toBe(false);
});
});
describe('useCreateStructure', () => {
it('should create new structure', async () => {
const newStructure = { id: 'struct-3', name: 'Matrix', type: 'matrix' };
vi.mocked(structuresApi.create).mockResolvedValue(newStructure);
const { result } = renderHook(() => useCreateStructure(), {
wrapper: createWrapper(),
});
result.current.mutate({
name: 'Matrix',
type: 'matrix',
max_width: 3,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(structuresApi.create).toHaveBeenCalled();
});
});
describe('useUpdateStructure', () => {
it('should update structure', async () => {
const updatedStructure = { id: 'struct-1', name: 'Updated Binary' };
vi.mocked(structuresApi.update).mockResolvedValue(updatedStructure);
const { result } = renderHook(() => useUpdateStructure(), {
wrapper: createWrapper(),
});
result.current.mutate({
id: 'struct-1',
data: { name: 'Updated Binary' },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(structuresApi.update).toHaveBeenCalledWith('struct-1', { name: 'Updated Binary' });
});
});
describe('useDeleteStructure', () => {
it('should delete structure', async () => {
vi.mocked(structuresApi.delete).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteStructure(), {
wrapper: createWrapper(),
});
result.current.mutate('struct-1');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(structuresApi.delete).toHaveBeenCalledWith('struct-1');
});
});
// ==================== Ranks ====================
describe('useRanks', () => {
it('should fetch all ranks', async () => {
const mockRanks = [
{ id: 'rank-1', name: 'Bronze', level: 1, minVolume: 0 },
{ id: 'rank-2', name: 'Silver', level: 2, minVolume: 1000 },
{ id: 'rank-3', name: 'Gold', level: 3, minVolume: 5000 },
];
vi.mocked(ranksApi.list).mockResolvedValue(mockRanks);
const { result } = renderHook(() => useRanks(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ranksApi.list).toHaveBeenCalled();
expect(result.current.data).toEqual(mockRanks);
});
});
describe('useRank', () => {
it('should fetch single rank', async () => {
const mockRank = { id: 'rank-1', name: 'Bronze', level: 1 };
vi.mocked(ranksApi.get).mockResolvedValue(mockRank);
const { result } = renderHook(() => useRank('rank-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ranksApi.get).toHaveBeenCalledWith('rank-1');
});
});
describe('useCreateRank', () => {
it('should create new rank', async () => {
const newRank = { id: 'rank-4', name: 'Platinum', level: 4 };
vi.mocked(ranksApi.create).mockResolvedValue(newRank);
const { result } = renderHook(() => useCreateRank(), {
wrapper: createWrapper(),
});
result.current.mutate({
name: 'Platinum',
level: 4,
structure_id: 'struct-1',
min_personal_volume: 10000,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ranksApi.create).toHaveBeenCalled();
});
});
// ==================== Nodes ====================
describe('useNodes', () => {
it('should fetch all nodes', async () => {
const mockNodes = [
{ id: 'node-1', userId: 'user-1', rankId: 'rank-1', status: 'active' },
{ id: 'node-2', userId: 'user-2', rankId: 'rank-2', status: 'active' },
];
vi.mocked(nodesApi.list).mockResolvedValue(mockNodes);
const { result } = renderHook(() => useNodes(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(nodesApi.list).toHaveBeenCalled();
});
});
describe('useNode', () => {
it('should fetch single node', async () => {
const mockNode = { id: 'node-1', userId: 'user-1', status: 'active' };
vi.mocked(nodesApi.get).mockResolvedValue(mockNode);
const { result } = renderHook(() => useNode('node-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(nodesApi.get).toHaveBeenCalledWith('node-1');
});
});
describe('useCreateNode', () => {
it('should create new node', async () => {
const newNode = { id: 'node-3', userId: 'user-3', status: 'pending' };
vi.mocked(nodesApi.create).mockResolvedValue(newNode);
const { result } = renderHook(() => useCreateNode(), {
wrapper: createWrapper(),
});
result.current.mutate({
user_id: 'user-3',
structure_id: 'struct-1',
sponsor_id: 'node-1',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(nodesApi.create).toHaveBeenCalled();
});
});
describe('useNodeDownline', () => {
it('should fetch node downline', async () => {
const mockDownline = [
{ id: 'node-2', depth: 1, userId: 'user-2' },
{ id: 'node-3', depth: 2, userId: 'user-3' },
];
vi.mocked(nodesApi.getDownline).mockResolvedValue(mockDownline);
const { result } = renderHook(() => useNodeDownline('node-1', 3), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(nodesApi.getDownline).toHaveBeenCalledWith('node-1', 3);
});
});
// ==================== My Network ====================
describe('useMyDashboard', () => {
it('should fetch my dashboard data', async () => {
const mockDashboard = {
totalDownline: 25,
activeDownline: 20,
personalVolume: 5000,
groupVolume: 50000,
currentRank: 'Gold',
pendingCommissions: 1500,
};
vi.mocked(nodesApi.getMyDashboard).mockResolvedValue(mockDashboard);
const { result } = renderHook(() => useMyDashboard(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(nodesApi.getMyDashboard).toHaveBeenCalled();
expect(result.current.data).toEqual(mockDashboard);
});
});
describe('useMyNetwork', () => {
it('should fetch my network tree', async () => {
const mockNetwork = {
node: { id: 'node-1', userId: 'user-1' },
children: [
{ id: 'node-2', userId: 'user-2' },
],
};
vi.mocked(nodesApi.getMyNetwork).mockResolvedValue(mockNetwork);
const { result } = renderHook(() => useMyNetwork(3), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(nodesApi.getMyNetwork).toHaveBeenCalledWith(3);
});
});
describe('useMyEarnings', () => {
it('should fetch my earnings', async () => {
const mockEarnings = {
totalEarnings: 10000,
pendingPayouts: 2500,
paidOut: 7500,
byMonth: [],
};
vi.mocked(nodesApi.getMyEarnings).mockResolvedValue(mockEarnings);
const { result } = renderHook(() => useMyEarnings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(nodesApi.getMyEarnings).toHaveBeenCalled();
});
});
describe('useMyRank', () => {
it('should fetch my current rank', async () => {
const mockRank = {
current: { id: 'rank-3', name: 'Gold', level: 3 },
next: { id: 'rank-4', name: 'Platinum', level: 4 },
progressToNext: 75,
};
vi.mocked(nodesApi.getMyRank).mockResolvedValue(mockRank);
const { result } = renderHook(() => useMyRank(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(nodesApi.getMyRank).toHaveBeenCalled();
});
});
// ==================== Commissions ====================
describe('useMLMCommissions', () => {
it('should fetch commissions list', async () => {
const mockCommissions = [
{ id: 'comm-1', nodeId: 'node-1', amount: 500, status: 'pending' },
{ id: 'comm-2', nodeId: 'node-1', amount: 750, status: 'paid' },
];
vi.mocked(commissionsApi.list).mockResolvedValue(mockCommissions);
const { result } = renderHook(() => useMLMCommissions(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(commissionsApi.list).toHaveBeenCalled();
});
});
describe('useCalculateCommissions', () => {
it('should calculate commissions', async () => {
const mockResult = {
calculated: 15,
totalAmount: 7500,
byLevel: [],
};
vi.mocked(commissionsApi.calculate).mockResolvedValue(mockResult);
const { result } = renderHook(() => useCalculateCommissions(), {
wrapper: createWrapper(),
});
result.current.mutate({
structure_id: 'struct-1',
period_start: '2024-01-01',
period_end: '2024-01-31',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(commissionsApi.calculate).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,535 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import {
useCategories,
useCategory,
useCategoryTree,
useCreateCategory,
useUpdateCategory,
useDeleteCategory,
useProducts,
useProduct,
useCreateProduct,
useUpdateProduct,
useDeleteProduct,
useDuplicateProduct,
useProductVariants,
useCreateVariant,
useDeleteVariant,
useProductPrices,
useCreatePrice,
useDeletePrice,
} from '@/hooks/usePortfolio';
import { categoriesApi, productsApi } from '@/services/portfolio';
// Mock the API modules
vi.mock('@/services/portfolio', () => ({
categoriesApi: {
list: vi.fn(),
get: vi.fn(),
getTree: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
productsApi: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateStatus: vi.fn(),
duplicate: vi.fn(),
delete: vi.fn(),
getVariants: vi.fn(),
createVariant: vi.fn(),
updateVariant: vi.fn(),
deleteVariant: vi.fn(),
getPrices: vi.fn(),
createPrice: vi.fn(),
updatePrice: vi.fn(),
deletePrice: vi.fn(),
},
}));
// Create wrapper for react-query
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('usePortfolio hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ==================== Categories ====================
describe('useCategories', () => {
it('should fetch all categories', async () => {
const mockCategories = [
{ id: 'cat-1', name: 'Electronics', slug: 'electronics', parentId: null },
{ id: 'cat-2', name: 'Clothing', slug: 'clothing', parentId: null },
];
vi.mocked(categoriesApi.list).mockResolvedValue(mockCategories);
const { result } = renderHook(() => useCategories(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(categoriesApi.list).toHaveBeenCalled();
expect(result.current.data).toEqual(mockCategories);
});
it('should fetch with filters', async () => {
const filters = { parent_id: 'cat-1' };
vi.mocked(categoriesApi.list).mockResolvedValue([]);
const { result } = renderHook(() => useCategories(filters), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(categoriesApi.list).toHaveBeenCalledWith(filters);
});
});
describe('useCategory', () => {
it('should fetch single category', async () => {
const mockCategory = {
id: 'cat-1',
name: 'Electronics',
slug: 'electronics',
description: 'Electronic devices',
};
vi.mocked(categoriesApi.get).mockResolvedValue(mockCategory);
const { result } = renderHook(() => useCategory('cat-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(categoriesApi.get).toHaveBeenCalledWith('cat-1');
expect(result.current.data).toEqual(mockCategory);
});
it('should not fetch when id is empty', () => {
const { result } = renderHook(() => useCategory(''), {
wrapper: createWrapper(),
});
expect(result.current.isFetching).toBe(false);
expect(categoriesApi.get).not.toHaveBeenCalled();
});
});
describe('useCategoryTree', () => {
it('should fetch category tree', async () => {
const mockTree = [
{
id: 'cat-1',
name: 'Electronics',
children: [
{ id: 'cat-3', name: 'Phones', children: [] },
{ id: 'cat-4', name: 'Laptops', children: [] },
],
},
{ id: 'cat-2', name: 'Clothing', children: [] },
];
vi.mocked(categoriesApi.getTree).mockResolvedValue(mockTree);
const { result } = renderHook(() => useCategoryTree(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(categoriesApi.getTree).toHaveBeenCalled();
});
});
describe('useCreateCategory', () => {
it('should create new category', async () => {
const newCategory = { id: 'cat-5', name: 'Accessories', slug: 'accessories' };
vi.mocked(categoriesApi.create).mockResolvedValue(newCategory);
const { result } = renderHook(() => useCreateCategory(), {
wrapper: createWrapper(),
});
result.current.mutate({
name: 'Accessories',
slug: 'accessories',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(categoriesApi.create).toHaveBeenCalled();
});
});
describe('useUpdateCategory', () => {
it('should update category', async () => {
const updatedCategory = { id: 'cat-1', name: 'Updated Electronics' };
vi.mocked(categoriesApi.update).mockResolvedValue(updatedCategory);
const { result } = renderHook(() => useUpdateCategory(), {
wrapper: createWrapper(),
});
result.current.mutate({
id: 'cat-1',
data: { name: 'Updated Electronics' },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(categoriesApi.update).toHaveBeenCalledWith('cat-1', { name: 'Updated Electronics' });
});
});
describe('useDeleteCategory', () => {
it('should delete category', async () => {
vi.mocked(categoriesApi.delete).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteCategory(), {
wrapper: createWrapper(),
});
result.current.mutate('cat-1');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(categoriesApi.delete).toHaveBeenCalledWith('cat-1');
});
});
// ==================== Products ====================
describe('useProducts', () => {
it('should fetch all products', async () => {
const mockProducts = [
{ id: 'prod-1', name: 'iPhone 15', sku: 'IP15', price: 999, status: 'active' },
{ id: 'prod-2', name: 'MacBook Pro', sku: 'MBP', price: 1999, status: 'active' },
];
vi.mocked(productsApi.list).mockResolvedValue(mockProducts);
const { result } = renderHook(() => useProducts(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.list).toHaveBeenCalled();
expect(result.current.data).toEqual(mockProducts);
});
it('should fetch with filters', async () => {
const filters = { category_id: 'cat-1', status: 'active' as const };
vi.mocked(productsApi.list).mockResolvedValue([]);
const { result } = renderHook(() => useProducts(filters), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.list).toHaveBeenCalledWith(filters);
});
});
describe('useProduct', () => {
it('should fetch single product', async () => {
const mockProduct = {
id: 'prod-1',
name: 'iPhone 15',
sku: 'IP15',
price: 999,
description: 'Latest iPhone',
};
vi.mocked(productsApi.get).mockResolvedValue(mockProduct);
const { result } = renderHook(() => useProduct('prod-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.get).toHaveBeenCalledWith('prod-1');
});
it('should not fetch when id is empty', () => {
const { result } = renderHook(() => useProduct(''), {
wrapper: createWrapper(),
});
expect(result.current.isFetching).toBe(false);
});
});
describe('useCreateProduct', () => {
it('should create new product', async () => {
const newProduct = { id: 'prod-3', name: 'iPad', sku: 'IPAD' };
vi.mocked(productsApi.create).mockResolvedValue(newProduct);
const { result } = renderHook(() => useCreateProduct(), {
wrapper: createWrapper(),
});
result.current.mutate({
name: 'iPad',
sku: 'IPAD',
category_id: 'cat-1',
base_price: 799,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.create).toHaveBeenCalled();
});
});
describe('useUpdateProduct', () => {
it('should update product', async () => {
const updatedProduct = { id: 'prod-1', name: 'iPhone 15 Pro' };
vi.mocked(productsApi.update).mockResolvedValue(updatedProduct);
const { result } = renderHook(() => useUpdateProduct(), {
wrapper: createWrapper(),
});
result.current.mutate({
id: 'prod-1',
data: { name: 'iPhone 15 Pro' },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.update).toHaveBeenCalledWith('prod-1', { name: 'iPhone 15 Pro' });
});
});
describe('useDeleteProduct', () => {
it('should delete product', async () => {
vi.mocked(productsApi.delete).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteProduct(), {
wrapper: createWrapper(),
});
result.current.mutate('prod-1');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.delete).toHaveBeenCalledWith('prod-1');
});
});
describe('useDuplicateProduct', () => {
it('should duplicate product', async () => {
const duplicatedProduct = { id: 'prod-4', name: 'iPhone 15 (Copy)', sku: 'IP15-COPY' };
vi.mocked(productsApi.duplicate).mockResolvedValue(duplicatedProduct);
const { result } = renderHook(() => useDuplicateProduct(), {
wrapper: createWrapper(),
});
result.current.mutate('prod-1');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.duplicate).toHaveBeenCalledWith('prod-1');
});
});
// ==================== Variants ====================
describe('useProductVariants', () => {
it('should fetch product variants', async () => {
const mockVariants = [
{ id: 'var-1', productId: 'prod-1', name: '128GB', sku: 'IP15-128' },
{ id: 'var-2', productId: 'prod-1', name: '256GB', sku: 'IP15-256' },
];
vi.mocked(productsApi.getVariants).mockResolvedValue(mockVariants);
const { result } = renderHook(() => useProductVariants('prod-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.getVariants).toHaveBeenCalledWith('prod-1');
});
it('should not fetch when productId is empty', () => {
const { result } = renderHook(() => useProductVariants(''), {
wrapper: createWrapper(),
});
expect(result.current.isFetching).toBe(false);
});
});
describe('useCreateVariant', () => {
it('should create variant', async () => {
const newVariant = { id: 'var-3', name: '512GB', sku: 'IP15-512' };
vi.mocked(productsApi.createVariant).mockResolvedValue(newVariant);
const { result } = renderHook(() => useCreateVariant(), {
wrapper: createWrapper(),
});
result.current.mutate({
productId: 'prod-1',
data: { name: '512GB', sku: 'IP15-512', price_adjustment: 200 },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.createVariant).toHaveBeenCalledWith('prod-1', {
name: '512GB',
sku: 'IP15-512',
price_adjustment: 200,
});
});
});
describe('useDeleteVariant', () => {
it('should delete variant', async () => {
vi.mocked(productsApi.deleteVariant).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteVariant(), {
wrapper: createWrapper(),
});
result.current.mutate({ productId: 'prod-1', variantId: 'var-1' });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.deleteVariant).toHaveBeenCalledWith('prod-1', 'var-1');
});
});
// ==================== Prices ====================
describe('useProductPrices', () => {
it('should fetch product prices', async () => {
const mockPrices = [
{ id: 'price-1', productId: 'prod-1', currency: 'USD', amount: 999 },
{ id: 'price-2', productId: 'prod-1', currency: 'EUR', amount: 899 },
];
vi.mocked(productsApi.getPrices).mockResolvedValue(mockPrices);
const { result } = renderHook(() => useProductPrices('prod-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.getPrices).toHaveBeenCalledWith('prod-1');
});
it('should not fetch when productId is empty', () => {
const { result } = renderHook(() => useProductPrices(''), {
wrapper: createWrapper(),
});
expect(result.current.isFetching).toBe(false);
});
});
describe('useCreatePrice', () => {
it('should create price', async () => {
const newPrice = { id: 'price-3', currency: 'GBP', amount: 799 };
vi.mocked(productsApi.createPrice).mockResolvedValue(newPrice);
const { result } = renderHook(() => useCreatePrice(), {
wrapper: createWrapper(),
});
result.current.mutate({
productId: 'prod-1',
data: { currency: 'GBP', amount: 799 },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.createPrice).toHaveBeenCalledWith('prod-1', { currency: 'GBP', amount: 799 });
});
});
describe('useDeletePrice', () => {
it('should delete price', async () => {
vi.mocked(productsApi.deletePrice).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeletePrice(), {
wrapper: createWrapper(),
});
result.current.mutate({ productId: 'prod-1', priceId: 'price-1' });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(productsApi.deletePrice).toHaveBeenCalledWith('prod-1', 'price-1');
});
});
});

View File

@ -0,0 +1,347 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import {
useRoles,
useRole,
useCreateRole,
useUpdateRole,
useDeleteRole,
usePermissions,
useMyRoles,
useMyPermissions,
useCheckPermission,
useUserRoles,
getPermissionCategoryLabel,
getPermissionActionLabel,
parsePermissionSlug,
} from '@/hooks/useRbac';
import { rbacApi } from '@/services/api';
// Mock the API module
vi.mock('@/services/api', () => ({
rbacApi: {
listRoles: vi.fn(),
getRole: vi.fn(),
createRole: vi.fn(),
updateRole: vi.fn(),
deleteRole: vi.fn(),
listPermissions: vi.fn(),
getPermissionsByCategory: vi.fn(),
getUserRoles: vi.fn(),
getUserPermissions: vi.fn(),
getMyRoles: vi.fn(),
getMyPermissions: vi.fn(),
checkPermission: vi.fn(),
assignRoleToUser: vi.fn(),
removeRoleFromUser: vi.fn(),
},
}));
// Create wrapper for react-query
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useRbac hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useRoles', () => {
it('should fetch all roles', async () => {
const mockRoles = [
{ id: 'role-1', name: 'Admin', slug: 'admin', is_system: true },
{ id: 'role-2', name: 'User', slug: 'user', is_system: true },
];
vi.mocked(rbacApi.listRoles).mockResolvedValue(mockRoles);
const { result } = renderHook(() => useRoles(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(rbacApi.listRoles).toHaveBeenCalled();
expect(result.current.data).toEqual(mockRoles);
});
});
describe('useRole', () => {
it('should fetch single role by id', async () => {
const mockRole = {
id: 'role-1',
name: 'Admin',
slug: 'admin',
is_system: true,
permissions: [{ id: 'perm-1', slug: 'users:read' }],
};
vi.mocked(rbacApi.getRole).mockResolvedValue(mockRole);
const { result } = renderHook(() => useRole('role-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(rbacApi.getRole).toHaveBeenCalledWith('role-1');
expect(result.current.data).toEqual(mockRole);
});
it('should not fetch when id is empty', () => {
const { result } = renderHook(() => useRole(''), {
wrapper: createWrapper(),
});
expect(result.current.isFetching).toBe(false);
expect(rbacApi.getRole).not.toHaveBeenCalled();
});
});
describe('useCreateRole', () => {
it('should create a new role', async () => {
const newRole = {
id: 'role-3',
name: 'Manager',
slug: 'manager',
is_system: false,
};
vi.mocked(rbacApi.createRole).mockResolvedValue(newRole);
const { result } = renderHook(() => useCreateRole(), {
wrapper: createWrapper(),
});
result.current.mutate({
name: 'Manager',
slug: 'manager',
permissions: ['users:read'],
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(rbacApi.createRole).toHaveBeenCalled();
});
});
describe('useUpdateRole', () => {
it('should update an existing role', async () => {
const updatedRole = {
id: 'role-1',
name: 'Super Admin',
slug: 'admin',
is_system: true,
};
vi.mocked(rbacApi.updateRole).mockResolvedValue(updatedRole);
const { result } = renderHook(() => useUpdateRole(), {
wrapper: createWrapper(),
});
result.current.mutate({
id: 'role-1',
data: { name: 'Super Admin' },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(rbacApi.updateRole).toHaveBeenCalledWith('role-1', { name: 'Super Admin' });
});
});
describe('useDeleteRole', () => {
it('should delete a role', async () => {
vi.mocked(rbacApi.deleteRole).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteRole(), {
wrapper: createWrapper(),
});
result.current.mutate('role-1');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(rbacApi.deleteRole).toHaveBeenCalledWith('role-1');
});
});
describe('usePermissions', () => {
it('should fetch all permissions', async () => {
const mockPermissions = [
{ id: 'perm-1', slug: 'users:read', name: 'Read Users', category: 'users' },
{ id: 'perm-2', slug: 'users:write', name: 'Write Users', category: 'users' },
];
vi.mocked(rbacApi.listPermissions).mockResolvedValue(mockPermissions);
const { result } = renderHook(() => usePermissions(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(rbacApi.listPermissions).toHaveBeenCalled();
expect(result.current.data).toEqual(mockPermissions);
});
});
describe('useUserRoles', () => {
it('should fetch user roles', async () => {
const mockUserRoles = [
{ id: 'ur-1', user_id: 'user-1', role_id: 'role-1' },
];
vi.mocked(rbacApi.getUserRoles).mockResolvedValue(mockUserRoles);
const { result } = renderHook(() => useUserRoles('user-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(rbacApi.getUserRoles).toHaveBeenCalledWith('user-1');
});
it('should not fetch when userId is empty', () => {
const { result } = renderHook(() => useUserRoles(''), {
wrapper: createWrapper(),
});
expect(result.current.isFetching).toBe(false);
});
});
describe('useMyRoles', () => {
it('should fetch current user roles', async () => {
const mockRoles = [
{ id: 'role-1', name: 'Admin', slug: 'admin' },
];
vi.mocked(rbacApi.getMyRoles).mockResolvedValue(mockRoles);
const { result } = renderHook(() => useMyRoles(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(rbacApi.getMyRoles).toHaveBeenCalled();
expect(result.current.data).toEqual(mockRoles);
});
});
describe('useMyPermissions', () => {
it('should fetch current user permissions', async () => {
const mockPermissions = ['users:read', 'users:write', 'billing:read'];
vi.mocked(rbacApi.getMyPermissions).mockResolvedValue(mockPermissions);
const { result } = renderHook(() => useMyPermissions(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(rbacApi.getMyPermissions).toHaveBeenCalled();
expect(result.current.data).toEqual(mockPermissions);
});
});
describe('useCheckPermission', () => {
it('should check if user has permission', async () => {
vi.mocked(rbacApi.checkPermission).mockResolvedValue({ hasPermission: true });
const { result } = renderHook(() => useCheckPermission('users:read'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(rbacApi.checkPermission).toHaveBeenCalledWith('users:read');
expect(result.current.data).toEqual({ hasPermission: true });
});
it('should not check when permission is empty', () => {
const { result } = renderHook(() => useCheckPermission(''), {
wrapper: createWrapper(),
});
expect(result.current.isFetching).toBe(false);
});
});
describe('helper functions', () => {
describe('getPermissionCategoryLabel', () => {
it('should return correct label for known categories', () => {
expect(getPermissionCategoryLabel('users')).toBe('Users');
expect(getPermissionCategoryLabel('roles')).toBe('Roles & Permissions');
expect(getPermissionCategoryLabel('billing')).toBe('Billing');
expect(getPermissionCategoryLabel('audit')).toBe('Audit Logs');
expect(getPermissionCategoryLabel('ai')).toBe('AI Integration');
});
it('should return original value for unknown categories', () => {
expect(getPermissionCategoryLabel('custom')).toBe('custom');
});
});
describe('getPermissionActionLabel', () => {
it('should return correct label for known actions', () => {
expect(getPermissionActionLabel('read')).toBe('View');
expect(getPermissionActionLabel('write')).toBe('Create/Edit');
expect(getPermissionActionLabel('delete')).toBe('Delete');
expect(getPermissionActionLabel('manage')).toBe('Manage');
});
it('should return original value for unknown actions', () => {
expect(getPermissionActionLabel('custom')).toBe('custom');
});
});
describe('parsePermissionSlug', () => {
it('should parse permission slug correctly', () => {
expect(parsePermissionSlug('users:read')).toEqual({
category: 'users',
action: 'read',
});
expect(parsePermissionSlug('billing:write')).toEqual({
category: 'billing',
action: 'write',
});
});
});
});
});

View File

@ -76,9 +76,11 @@ export function AuditFilters({
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Search input */}
{onSearchChange && (
<form onSubmit={handleSearchSubmit} className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" />
<form onSubmit={handleSearchSubmit} className="relative flex-1 max-w-md" role="search">
<label htmlFor="audit-search" className="sr-only">Search audit logs</label>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" aria-hidden="true" />
<input
id="audit-search"
type="text"
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
@ -89,9 +91,10 @@ export function AuditFilters({
<button
type="button"
onClick={handleSearchClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 hover:text-secondary-600"
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 hover:text-secondary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded"
aria-label="Clear search"
>
<X className="w-4 h-4" />
<X className="w-4 h-4" aria-hidden="true" />
</button>
)}
</form>
@ -101,16 +104,18 @@ export function AuditFilters({
<button
onClick={() => setIsOpen(!isOpen)}
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500',
isOpen || activeFiltersCount > 0
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
)}
aria-expanded={isOpen}
aria-controls="audit-filters-panel"
>
<Filter className="w-4 h-4" />
<Filter className="w-4 h-4" aria-hidden="true" />
Filters
{activeFiltersCount > 0 && (
<span className="px-1.5 py-0.5 bg-primary-600 text-white text-xs rounded-full">
<span className="px-1.5 py-0.5 bg-primary-600 text-white text-xs rounded-full" aria-label={`${activeFiltersCount} active filters`}>
{activeFiltersCount}
</span>
)}
@ -119,9 +124,10 @@ export function AuditFilters({
{activeFiltersCount > 0 && (
<button
onClick={clearFilters}
className="flex items-center gap-1 text-sm text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300"
className="flex items-center gap-1 text-sm text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded"
aria-label="Clear all filters"
>
<X className="w-4 h-4" />
<X className="w-4 h-4" aria-hidden="true" />
Clear all
</button>
)}
@ -130,14 +136,15 @@ export function AuditFilters({
{/* Filter panel */}
{isOpen && (
<div className="p-4 bg-secondary-50 dark:bg-secondary-800/50 rounded-lg border border-secondary-200 dark:border-secondary-700">
<div id="audit-filters-panel" className="p-4 bg-secondary-50 dark:bg-secondary-800/50 rounded-lg border border-secondary-200 dark:border-secondary-700" role="region" aria-label="Audit log filters">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Action filter */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<label htmlFor="audit-filter-action" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Action
</label>
<select
id="audit-filter-action"
value={filters.action || ''}
onChange={(e) => handleChange('action', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
@ -153,10 +160,11 @@ export function AuditFilters({
{/* Entity type filter */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<label htmlFor="audit-filter-entity" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Entity Type
</label>
<select
id="audit-filter-entity"
value={filters.entity_type || ''}
onChange={(e) => handleChange('entity_type', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
@ -173,10 +181,11 @@ export function AuditFilters({
{/* User filter */}
{users.length > 0 && (
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<label htmlFor="audit-filter-user" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
User
</label>
<select
id="audit-filter-user"
value={filters.user_id || ''}
onChange={(e) => handleChange('user_id', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
@ -193,10 +202,11 @@ export function AuditFilters({
{/* From date filter */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<label htmlFor="audit-filter-from-date" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
From Date
</label>
<input
id="audit-filter-from-date"
type="date"
value={filters.from_date || ''}
onChange={(e) => handleChange('from_date', e.target.value)}
@ -206,10 +216,11 @@ export function AuditFilters({
{/* To date filter */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<label htmlFor="audit-filter-to-date" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
To Date
</label>
<input
id="audit-filter-to-date"
type="date"
value={filters.to_date || ''}
onChange={(e) => handleChange('to_date', e.target.value)}
@ -263,8 +274,12 @@ function FilterBadge({ label, onRemove }: { label: string; onRemove: () => void
return (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 rounded-full text-sm">
{label}
<button onClick={onRemove} className="hover:text-primary-900 dark:hover:text-primary-200">
<X className="w-3 h-3" />
<button
onClick={onRemove}
className="hover:text-primary-900 dark:hover:text-primary-200 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-full"
aria-label={`Remove filter: ${label}`}
>
<X className="w-3 h-3" aria-hidden="true" />
</button>
</span>
);

View File

@ -72,17 +72,25 @@ export function GoalProgressBar({
{/* Progress Bar Container */}
<div className="relative">
{/* Background Bar */}
<div className={`w-full bg-gray-200 rounded-full ${sizeClasses[size]}`}>
<div
className={`w-full bg-gray-200 rounded-full ${sizeClasses[size]}`}
role="progressbar"
aria-valuenow={Math.round(percentage)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Progress: ${currentValue.toLocaleString()} of ${targetValue.toLocaleString()} ${unit} (${percentage.toFixed(1)}%)`}
>
{/* Progress Fill */}
<div
className={`${sizeClasses[size]} rounded-full transition-all duration-500 ${getProgressColor(percentage)}`}
style={{ width: `${percentage}%` }}
aria-hidden="true"
/>
</div>
{/* Milestones */}
{milestones && milestones.length > 0 && (
<div className="absolute inset-0 flex items-center pointer-events-none">
<div className="absolute inset-0 flex items-center pointer-events-none" aria-hidden="true">
{milestones.map((milestone, index) => (
<div
key={index}
@ -104,7 +112,7 @@ export function GoalProgressBar({
{/* Milestone Labels */}
{milestones && milestones.length > 0 && size === 'lg' && (
<div className="relative mt-1 text-xs text-gray-500">
<div className="relative mt-1 text-xs text-gray-500" aria-hidden="true">
{milestones.map((milestone, index) => (
<span
key={index}

View File

@ -30,29 +30,42 @@ function TreeNodeItem({ node, depth, onNodeClick }: TreeNodeItemProps) {
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
};
const userName = node.user?.firstName && node.user?.lastName
? `${node.user.firstName} ${node.user.lastName}`
: node.user?.email || 'Unknown';
return (
<div>
<div
className="flex items-center py-2 px-4 hover:bg-gray-50 border-b border-gray-100 cursor-pointer"
<div role="treeitem" aria-expanded={node.children && node.children.length > 0 ? true : undefined}>
<button
type="button"
className="flex w-full items-center py-2 px-4 hover:bg-gray-50 border-b border-gray-100 cursor-pointer text-left focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
style={{ paddingLeft: `${indent + 16}px` }}
onClick={handleClick}
onKeyDown={handleKeyDown}
aria-label={`View details for ${userName}, ${node.rank?.name || 'No Rank'}, Level ${node.depth}`}
>
{depth > 0 && (
<span className="mr-2 text-gray-300">|--</span>
<span className="mr-2 text-gray-300" aria-hidden="true">|--</span>
)}
<div className="flex-1 flex items-center justify-between">
<div className="flex items-center">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium mr-3"
style={{ backgroundColor: node.rank?.color || '#6B7280' }}
aria-hidden="true"
>
{node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'}
</div>
<div>
<p className="text-sm font-medium text-gray-900">
{node.user?.firstName && node.user?.lastName
? `${node.user.firstName} ${node.user.lastName}`
: node.user?.email || 'Unknown'}
{userName}
</p>
<p className="text-xs text-gray-500">
{node.rank?.name || 'No Rank'} | Level {node.depth}
@ -69,10 +82,14 @@ function TreeNodeItem({ node, depth, onNodeClick }: TreeNodeItemProps) {
</span>
</div>
</div>
</div>
{node.children?.map((child) => (
<TreeNodeItem key={child.id} node={child} depth={depth + 1} onNodeClick={onNodeClick} />
))}
</button>
{node.children && node.children.length > 0 && (
<div role="group" aria-label={`${userName}'s downline`}>
{node.children.map((child) => (
<TreeNodeItem key={child.id} node={child} depth={depth + 1} onNodeClick={onNodeClick} />
))}
</div>
)}
</div>
);
}
@ -80,12 +97,13 @@ function TreeNodeItem({ node, depth, onNodeClick }: TreeNodeItemProps) {
export function NetworkTree({ tree, maxHeight = '600px', onNodeClick }: NetworkTreeProps) {
if (!tree) {
return (
<div className="text-center py-10">
<div className="text-center py-10" role="status">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
@ -100,7 +118,7 @@ export function NetworkTree({ tree, maxHeight = '600px', onNodeClick }: NetworkT
}
return (
<div className="overflow-y-auto" style={{ maxHeight }}>
<div className="overflow-y-auto" style={{ maxHeight }} role="tree" aria-label="Network hierarchy">
<TreeNodeItem node={tree} depth={0} onNodeClick={onNodeClick} />
</div>
);

View File

@ -15,8 +15,9 @@ export function NotificationBell() {
<>
<button
onClick={() => setIsDrawerOpen(true)}
className="relative p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors"
className="relative p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-label={`Notifications${hasUnread ? ` (${unreadCount} unread)` : ''}`}
aria-haspopup="dialog"
>
<Bell
className={clsx(
@ -25,9 +26,10 @@ export function NotificationBell() {
? 'text-primary-600 dark:text-primary-400'
: 'text-secondary-600 dark:text-secondary-400'
)}
aria-hidden="true"
/>
{hasUnread && (
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full">
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full" aria-hidden="true">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}

View File

@ -38,11 +38,15 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
<div
className="fixed inset-0 z-40 bg-black/20 dark:bg-black/40"
onClick={onClose}
aria-hidden="true"
/>
)}
{/* Drawer */}
<div
<aside
role="dialog"
aria-modal="true"
aria-label="Notifications panel"
className={clsx(
'fixed top-0 right-0 z-50 h-full w-full sm:w-96 bg-white dark:bg-secondary-800 shadow-xl transform transition-transform duration-300 ease-in-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
@ -51,8 +55,8 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
{/* Header */}
<div className="flex items-center justify-between h-16 px-4 border-b border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-2">
<Bell className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
<Bell className="w-5 h-5 text-secondary-600 dark:text-secondary-400" aria-hidden="true" />
<h2 id="notifications-drawer-title" className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
Notifications
</h2>
</div>
@ -61,21 +65,23 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
<button
onClick={handleMarkAllAsRead}
disabled={markAllAsRead.isPending}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-md transition-colors disabled:opacity-50"
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-md transition-colors disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
aria-label="Mark all notifications as read"
>
{markAllAsRead.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
<Loader2 className="w-3 h-3 animate-spin" aria-hidden="true" />
) : (
<CheckCheck className="w-3 h-3" />
<CheckCheck className="w-3 h-3" aria-hidden="true" />
)}
Mark all read
</button>
)}
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors"
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
aria-label="Close notifications panel"
>
<X className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
<X className="w-5 h-5 text-secondary-600 dark:text-secondary-400" aria-hidden="true" />
</button>
</div>
</div>
@ -83,12 +89,12 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
{/* Content */}
<div className="h-[calc(100%-4rem)] overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
<div className="flex items-center justify-center h-32" role="status" aria-label="Loading notifications">
<Loader2 className="w-6 h-6 animate-spin text-primary-500" aria-hidden="true" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center px-4">
<div className="w-16 h-16 bg-secondary-100 dark:bg-secondary-700 rounded-full flex items-center justify-center mb-4">
<div className="w-16 h-16 bg-secondary-100 dark:bg-secondary-700 rounded-full flex items-center justify-center mb-4" aria-hidden="true">
<Bell className="w-8 h-8 text-secondary-400" />
</div>
<p className="text-secondary-600 dark:text-secondary-400 font-medium">
@ -99,19 +105,20 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
</p>
</div>
) : (
<div className="divide-y divide-secondary-100 dark:divide-secondary-700">
<ul className="divide-y divide-secondary-100 dark:divide-secondary-700" role="list" aria-label="Notifications list">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onRead={handleMarkAsRead}
onNavigate={handleNavigate}
/>
<li key={notification.id}>
<NotificationItem
notification={notification}
onRead={handleMarkAsRead}
onNavigate={handleNavigate}
/>
</li>
))}
</div>
</ul>
)}
</div>
</div>
</aside>
</>
);
}

View File

@ -45,14 +45,15 @@ export function NotificationItem({ notification, onRead, onNavigate }: Notificat
<button
onClick={handleClick}
className={clsx(
'w-full flex items-start gap-3 p-3 text-left transition-colors rounded-lg',
'w-full flex items-start gap-3 p-3 text-left transition-colors rounded-lg focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500',
isRead
? 'bg-transparent hover:bg-secondary-50 dark:hover:bg-secondary-800'
: 'bg-primary-50/50 dark:bg-primary-900/10 hover:bg-primary-50 dark:hover:bg-primary-900/20'
)}
aria-label={`${notification.title}. ${notification.message}${!isRead ? ' (unread)' : ''}`}
>
{/* Icon */}
<div className={clsx('flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center', colorClass)}>
<div className={clsx('flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center', colorClass)} aria-hidden="true">
<Icon className="w-4 h-4" />
</div>
@ -68,7 +69,7 @@ export function NotificationItem({ notification, onRead, onNavigate }: Notificat
{notification.title}
</p>
{!isRead && (
<span className="flex-shrink-0 w-2 h-2 bg-primary-500 rounded-full" />
<span className="flex-shrink-0 w-2 h-2 bg-primary-500 rounded-full" aria-hidden="true" />
)}
</div>
<p className="text-xs text-secondary-500 dark:text-secondary-400 line-clamp-2 mt-0.5">

View File

@ -77,41 +77,52 @@ export function PermissionsMatrix({
className="rounded-lg border border-gray-200 dark:border-gray-700"
>
{/* Category Header */}
<div
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
<button
type="button"
className="flex w-full cursor-pointer items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 rounded-t-lg"
onClick={() => toggleCategory(category)}
aria-expanded={isExpanded}
aria-controls={`permissions-${category}`}
>
<div className="flex items-center gap-3">
<button
type="button"
<span
className="flex-shrink-0 text-gray-400"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
aria-hidden="true"
>
{isExpanded ? (
<ChevronDown className="h-5 w-5" />
) : (
<ChevronRight className="h-5 w-5" />
)}
</button>
</span>
{!readOnly && (
<button
type="button"
<span
onClick={(e) => {
e.stopPropagation();
handleCategoryCheckboxClick(category);
}}
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border ${
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
handleCategoryCheckboxClick(category);
}
}}
role="checkbox"
aria-checked={status === 'all' ? true : status === 'some' ? 'mixed' : false}
aria-label={`Toggle all ${category} permissions`}
tabIndex={0}
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${
status === 'all'
? 'border-blue-600 bg-blue-600 text-white'
: status === 'some'
? 'border-blue-600 bg-blue-100 text-blue-600 dark:bg-blue-900/20'
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'
}`}
aria-label={`Toggle all ${category} permissions`}
>
{status === 'all' && <Check className="h-3.5 w-3.5" />}
{status === 'some' && <Minus className="h-3.5 w-3.5" />}
</button>
{status === 'all' && <Check className="h-3.5 w-3.5" aria-hidden="true" />}
{status === 'some' && <Minus className="h-3.5 w-3.5" aria-hidden="true" />}
</span>
)}
<span className="font-medium text-gray-900 dark:text-white">
{getPermissionCategoryLabel(category)}
@ -120,11 +131,16 @@ export function PermissionsMatrix({
<span className="text-sm text-gray-500 dark:text-gray-400">
{selectedCount} / {categoryPerms.length}
</span>
</div>
</button>
{/* Permissions List */}
{isExpanded && (
<div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50">
<div
id={`permissions-${category}`}
className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
role="group"
aria-label={`${getPermissionCategoryLabel(category)} permissions`}
>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{categoryPerms.map((perm) => {
const isSelected = selectedPermissions.includes(perm.slug);

View File

@ -28,6 +28,7 @@ export function RoleCard({ role, onDelete, isDeleting }: RoleCardProps) {
? 'bg-purple-100 dark:bg-purple-900/20'
: 'bg-blue-100 dark:bg-blue-900/20'
}`}
aria-hidden="true"
>
<Shield
className={`h-5 w-5 ${
@ -41,8 +42,8 @@ export function RoleCard({ role, onDelete, isDeleting }: RoleCardProps) {
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900 dark:text-white">{role.name}</h3>
{role.is_system && (
<span>
<Lock className="h-4 w-4 text-gray-400" />
<span aria-label="System role (locked)" title="System role">
<Lock className="h-4 w-4 text-gray-400" aria-hidden="true" />
</span>
)}
{role.is_default && (
@ -59,27 +60,27 @@ export function RoleCard({ role, onDelete, isDeleting }: RoleCardProps) {
<div className="flex items-center gap-1">
<Link
to={`/dashboard/rbac/roles/${role.id}`}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="View details"
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label={`View details for role ${role.name}`}
>
<Users className="h-4 w-4" />
<Users className="h-4 w-4" aria-hidden="true" />
</Link>
{!role.is_system && (
<>
<Link
to={`/dashboard/rbac/roles/${role.id}/edit`}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="Edit role"
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label={`Edit role ${role.name}`}
>
<Edit className="h-4 w-4" />
<Edit className="h-4 w-4" aria-hidden="true" />
</Link>
<button
onClick={handleDelete}
disabled={isDeleting}
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Delete role"
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-red-900/20 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-red-500"
aria-label={`Delete role ${role.name}`}
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</>
)}

View File

@ -90,24 +90,27 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label htmlFor="role-name" className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Role Name *
</label>
<input
id="role-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Sales Manager"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
required
aria-required="true"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label htmlFor="role-slug" className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Slug {!isEditing && '*'}
</label>
<div className="relative">
<input
id="role-slug"
type="text"
value={slug}
onChange={(e) => {
@ -117,31 +120,34 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
placeholder="e.g., sales-manager"
disabled={isEditing}
className={clsx(
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white',
isEditing && 'cursor-not-allowed bg-gray-100 dark:bg-gray-800'
)}
required={!isEditing}
aria-required={!isEditing}
aria-describedby={isEditing ? 'slug-description' : undefined}
/>
{!isEditing && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400">
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400" aria-hidden="true">
{autoSlug ? 'Auto' : 'Manual'}
</span>
)}
</div>
{isEditing && (
<p className="mt-1 text-xs text-gray-500">Slug cannot be changed after creation</p>
<p id="slug-description" className="mt-1 text-xs text-gray-500">Slug cannot be changed after creation</p>
)}
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label htmlFor="role-description" className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<textarea
id="role-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this role is for..."
rows={3}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
@ -160,14 +166,14 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
<button
type="button"
onClick={handleSelectAll}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Select All
</button>
<button
type="button"
onClick={handleClearAll}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Clear All
</button>
@ -186,15 +192,16 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
<button
type="button"
onClick={() => navigate('/dashboard/rbac/roles')}
className="rounded-lg px-4 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
className="rounded-lg px-4 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Cancel
</button>
<button
type="submit"
disabled={!isValid || isLoading}
aria-disabled={!isValid || isLoading}
className={clsx(
'rounded-lg px-4 py-2 font-medium',
'rounded-lg px-4 py-2 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
isValid && !isLoading
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'cursor-not-allowed bg-gray-200 text-gray-400'

View File

@ -77,13 +77,14 @@ export function DashboardLayout() {
<h1 className="text-xl font-bold text-primary-600">Template SaaS</h1>
<button
onClick={toggleSidebar}
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-label="Close sidebar"
>
<X className="w-5 h-5" />
<X className="w-5 h-5" aria-hidden="true" />
</button>
</div>
<nav className="p-4 space-y-1">
<nav className="p-4 space-y-1" aria-label="Main navigation">
{/* Regular navigation */}
{navigation.map((item) => (
<NavLink
@ -92,14 +93,14 @@ export function DashboardLayout() {
end={item.href === '/dashboard'}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors',
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500',
isActive
? 'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400'
: 'text-secondary-600 hover:bg-secondary-100 dark:text-secondary-400 dark:hover:bg-secondary-700'
)
}
>
<item.icon className="w-5 h-5" />
<item.icon className="w-5 h-5" aria-hidden="true" />
{item.name}
</NavLink>
))}
@ -109,7 +110,7 @@ export function DashboardLayout() {
<>
<div className="pt-4 mt-4 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-2 px-4 py-2 text-xs font-semibold text-secondary-500 uppercase tracking-wider">
<Shield className="w-4 h-4" />
<Shield className="w-4 h-4" aria-hidden="true" />
Superadmin
</div>
</div>
@ -119,14 +120,14 @@ export function DashboardLayout() {
to={item.href}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors',
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-purple-500',
isActive
? 'bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400'
: 'text-secondary-600 hover:bg-secondary-100 dark:text-secondary-400 dark:hover:bg-secondary-700'
)
}
>
<item.icon className="w-5 h-5" />
<item.icon className="w-5 h-5" aria-hidden="true" />
{item.name}
</NavLink>
))}
@ -140,6 +141,7 @@ export function DashboardLayout() {
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={toggleSidebar}
aria-hidden="true"
/>
)}
@ -149,9 +151,10 @@ export function DashboardLayout() {
<header className="sticky top-0 z-30 h-16 bg-white dark:bg-secondary-800 border-b border-secondary-200 dark:border-secondary-700 px-4 lg:px-6 flex items-center justify-between">
<button
onClick={toggleSidebar}
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-label="Open sidebar menu"
>
<Menu className="w-5 h-5" />
<Menu className="w-5 h-5" aria-hidden="true" />
</button>
<div className="flex items-center gap-4 ml-auto">
@ -162,9 +165,12 @@ export function DashboardLayout() {
<div className="relative">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
className="flex items-center gap-3 p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-expanded={userMenuOpen}
aria-haspopup="true"
aria-label="User menu"
>
<div className="w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<div className="w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center" aria-hidden="true">
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
{user?.first_name?.[0]}{user?.last_name?.[0]}
</span>
@ -175,7 +181,7 @@ export function DashboardLayout() {
</p>
<p className="text-xs text-secondary-500">{user?.role}</p>
</div>
<ChevronDown className="w-4 h-4 text-secondary-400" />
<ChevronDown className="w-4 h-4 text-secondary-400" aria-hidden="true" />
</button>
{userMenuOpen && (
@ -183,13 +189,20 @@ export function DashboardLayout() {
<div
className="fixed inset-0 z-40"
onClick={() => setUserMenuOpen(false)}
aria-hidden="true"
/>
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50">
<div
className="absolute right-0 mt-2 w-48 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50"
role="menu"
aria-orientation="vertical"
aria-label="User menu options"
>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700"
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:bg-secondary-100 dark:focus:bg-secondary-700"
role="menuitem"
>
<LogOut className="w-4 h-4" />
<LogOut className="w-4 h-4" aria-hidden="true" />
Sign out
</button>
</div>

View File

@ -21,5 +21,6 @@
"@/*": ["src/*"]
}
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/__tests__", "src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"]
}