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 && (
-
@@ -101,16 +104,18 @@ export function AuditFilters({