diff --git a/src/__tests__/components/goals/GoalProgressBar.test.tsx b/src/__tests__/components/goals/GoalProgressBar.test.tsx new file mode 100644 index 0000000..4b17b00 --- /dev/null +++ b/src/__tests__/components/goals/GoalProgressBar.test.tsx @@ -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(); + + expect(screen.getByText('50.0%')).toBeInTheDocument(); + }); + + it('should calculate percentage correctly', () => { + render(); + + expect(screen.getByText('75.0%')).toBeInTheDocument(); + }); + + it('should cap percentage at 100%', () => { + render(); + + expect(screen.getByText('100.0%')).toBeInTheDocument(); + }); + + it('should handle zero target value', () => { + render(); + + expect(screen.getByText('0.0%')).toBeInTheDocument(); + }); + + it('should handle zero current value', () => { + render(); + + expect(screen.getByText('0.0%')).toBeInTheDocument(); + }); + }); + + describe('value display', () => { + it('should display current and target values', () => { + render(); + + expect(screen.getByText(/50.*\/.*100/)).toBeInTheDocument(); + }); + + it('should display unit when provided', () => { + render(); + + expect(screen.getByText(/5,000.*\/.*10,000 USD/)).toBeInTheDocument(); + }); + + it('should hide values when showValue is false', () => { + render(); + + expect(screen.queryByText(/50.*\/.*100/)).not.toBeInTheDocument(); + }); + + it('should hide percentage when showPercentage is false', () => { + render(); + + expect(screen.queryByText('50.0%')).not.toBeInTheDocument(); + }); + + it('should format large numbers with locale', () => { + render(); + + expect(screen.getByText(/1,000,000.*\/.*2,000,000/)).toBeInTheDocument(); + }); + }); + + describe('color logic', () => { + it('should show green color when progress >= 100%', () => { + const { container } = render(); + + const progressFill = container.querySelector('.bg-green-500'); + expect(progressFill).toBeInTheDocument(); + }); + + it('should show green-400 color when progress >= 80%', () => { + const { container } = render(); + + const progressFill = container.querySelector('.bg-green-400'); + expect(progressFill).toBeInTheDocument(); + }); + + it('should show yellow color when progress >= 50%', () => { + const { container } = render(); + + const progressFill = container.querySelector('.bg-yellow-500'); + expect(progressFill).toBeInTheDocument(); + }); + + it('should show orange color when progress >= 25%', () => { + const { container } = render(); + + const progressFill = container.querySelector('.bg-orange-500'); + expect(progressFill).toBeInTheDocument(); + }); + + it('should show red color when progress < 25%', () => { + const { container } = render(); + + const progressFill = container.querySelector('.bg-red-500'); + expect(progressFill).toBeInTheDocument(); + }); + }); + + describe('size variants', () => { + it('should render small size', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector('.h-2'); + expect(progressBar).toBeInTheDocument(); + }); + + it('should render medium size (default)', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector('.h-3'); + expect(progressBar).toBeInTheDocument(); + }); + + it('should render large size', () => { + const { container } = render( + + ); + + 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( + + ); + + // 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( + + ); + + // 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( + + ); + + 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( + + ); + + // 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( + + ); + + const wrapper = container.firstChild; + expect(wrapper).toHaveClass('my-custom-class'); + }); + }); + + describe('edge cases', () => { + it('should handle decimal values', () => { + render(); + + expect(screen.getByText('33.3%')).toBeInTheDocument(); + }); + + it('should handle very small progress', () => { + render(); + + expect(screen.getByText('0.1%')).toBeInTheDocument(); + }); + + it('should handle exact boundary at 80%', () => { + const { container } = render(); + + const progressFill = container.querySelector('.bg-green-400'); + expect(progressFill).toBeInTheDocument(); + }); + + it('should handle exact boundary at 50%', () => { + const { container } = render(); + + const progressFill = container.querySelector('.bg-yellow-500'); + expect(progressFill).toBeInTheDocument(); + }); + + it('should handle exact boundary at 25%', () => { + const { container } = render(); + + const progressFill = container.querySelector('.bg-orange-500'); + expect(progressFill).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/components/mlm/RankBadge.test.tsx b/src/__tests__/components/mlm/RankBadge.test.tsx new file mode 100644 index 0000000..65bc4b7 --- /dev/null +++ b/src/__tests__/components/mlm/RankBadge.test.tsx @@ -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(); + + expect(screen.getByText('Bronze')).toBeInTheDocument(); + }); + + it('should render level number in badge', () => { + render(); + + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('should show level label', () => { + render(); + + expect(screen.getByText(/Level 2/)).toBeInTheDocument(); + }); + + it('should not show level when showLevel is false', () => { + render(); + + 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(); + + expect(screen.getByText('B')).toBeInTheDocument(); + }); + }); + + describe('size variants', () => { + it('should render small size badge', () => { + const { container } = render(); + + const badge = container.querySelector('.w-6.h-6'); + expect(badge).toBeInTheDocument(); + }); + + it('should render medium size badge (default)', () => { + const { container } = render(); + + const badge = container.querySelector('.w-8.h-8'); + expect(badge).toBeInTheDocument(); + }); + + it('should render large size badge', () => { + const { container } = render(); + + const badge = container.querySelector('.w-12.h-12'); + expect(badge).toBeInTheDocument(); + }); + + it('should hide name and level text for small size', () => { + render(); + + // 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(); + + expect(screen.getByText('Bronze')).toBeInTheDocument(); + }); + }); + + describe('color customization', () => { + it('should apply custom color to badge', () => { + const { container } = render( + + ); + + const badge = container.querySelector('.rounded-full'); + expect(badge).toHaveStyle({ backgroundColor: '#CD7F32' }); + }); + + it('should use default gray color when no color provided', () => { + const { container } = render(); + + const badge = container.querySelector('.rounded-full'); + expect(badge).toHaveStyle({ backgroundColor: '#6B7280' }); + }); + + it('should use default color when color is null', () => { + const { container } = render( + + ); + + const badge = container.querySelector('.rounded-full'); + expect(badge).toHaveStyle({ backgroundColor: '#6B7280' }); + }); + + it('should apply different colors for different ranks', () => { + const { container: bronzeContainer } = render( + + ); + + const { container: goldContainer } = render( + + ); + + 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(); + + const badge = container.querySelector('.rounded-full'); + expect(badge).toHaveClass('text-white'); + }); + + it('should have font-bold for badge text', () => { + const { container } = render(); + + const badge = container.querySelector('.rounded-full'); + expect(badge).toHaveClass('font-bold'); + }); + }); + + describe('layout', () => { + it('should render as flex container', () => { + const { container } = render(); + + 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(); + + const textContainer = container.querySelector('.ml-2'); + expect(textContainer).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + it('should handle long rank names', () => { + render(); + + expect(screen.getByText('Diamond Elite Master')).toBeInTheDocument(); + }); + + it('should handle high level numbers', () => { + render(); + + expect(screen.getByText('99')).toBeInTheDocument(); + expect(screen.getByText(/Level 99/)).toBeInTheDocument(); + }); + + it('should handle level 0', () => { + render(); + + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('should handle empty name with first letter fallback', () => { + render(); + + // First letter of empty string is empty + const { container } = render(); + const badge = container.querySelector('.rounded-full'); + expect(badge).toBeInTheDocument(); + }); + + it('should render correctly with default props', () => { + render(); + + // Default size is md, showLevel is true + expect(screen.getByText('Bronze')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText(/Level 1/)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/components/rbac/RoleForm.test.tsx b/src/__tests__/components/rbac/RoleForm.test.tsx new file mode 100644 index 0000000..df41828 --- /dev/null +++ b/src/__tests__/components/rbac/RoleForm.test.tsx @@ -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({ui}); +}; + +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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.getByText(/2 of 4 selected/i)).toBeInTheDocument(); + }); + + it('should disable slug field when editing', () => { + renderWithRouter( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + expect(mockNavigate).toHaveBeenCalledWith('/dashboard/rbac/roles'); + }); + }); +}); diff --git a/src/__tests__/hooks/useAuth.test.tsx b/src/__tests__/hooks/useAuth.test.tsx new file mode 100644 index 0000000..229cef1 --- /dev/null +++ b/src/__tests__/hooks/useAuth.test.tsx @@ -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 }) => ( + {children} + ); +}; + +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'); + }); + }); +}); diff --git a/src/__tests__/hooks/useGoals.test.tsx b/src/__tests__/hooks/useGoals.test.tsx new file mode 100644 index 0000000..7bee243 --- /dev/null +++ b/src/__tests__/hooks/useGoals.test.tsx @@ -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 }) => ( + {children} + ); +}; + +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(); + }); + }); +}); diff --git a/src/__tests__/hooks/useMlm.test.tsx b/src/__tests__/hooks/useMlm.test.tsx new file mode 100644 index 0000000..b000a87 --- /dev/null +++ b/src/__tests__/hooks/useMlm.test.tsx @@ -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 }) => ( + {children} + ); +}; + +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(); + }); + }); +}); diff --git a/src/__tests__/hooks/usePortfolio.test.tsx b/src/__tests__/hooks/usePortfolio.test.tsx new file mode 100644 index 0000000..751ef49 --- /dev/null +++ b/src/__tests__/hooks/usePortfolio.test.tsx @@ -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 }) => ( + {children} + ); +}; + +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'); + }); + }); +}); diff --git a/src/__tests__/hooks/useRbac.test.tsx b/src/__tests__/hooks/useRbac.test.tsx new file mode 100644 index 0000000..6a735b8 --- /dev/null +++ b/src/__tests__/hooks/useRbac.test.tsx @@ -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 }) => ( + {children} + ); +}; + +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', + }); + }); + }); + }); +}); diff --git a/src/components/audit/AuditFilters.tsx b/src/components/audit/AuditFilters.tsx index 9171c70..d1e85be 100644 --- a/src/components/audit/AuditFilters.tsx +++ b/src/components/audit/AuditFilters.tsx @@ -76,9 +76,11 @@ export function AuditFilters({
{/* Search input */} {onSearchChange && ( -
- + + +