diff --git a/.gitignore b/.gitignore
index 8a2b8af..e03a68e 100755
--- a/.gitignore
+++ b/.gitignore
@@ -174,6 +174,7 @@ htmlcov/
.nox/
.coverage
.coverage.*
+coverage.json
.cache
nosetests.xml
coverage.xml
diff --git a/frontend/tests/components/auth/AuthGuard.test.tsx b/frontend/tests/components/auth/AuthGuard.test.tsx
new file mode 100644
index 0000000..e0d554f
--- /dev/null
+++ b/frontend/tests/components/auth/AuthGuard.test.tsx
@@ -0,0 +1,359 @@
+/**
+ * Tests for AuthGuard component
+ * Security-critical: Route protection and access control
+ */
+
+import { render, screen, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { AuthGuard } from '@/components/auth/AuthGuard';
+
+// Mock Next.js navigation
+const mockPush = jest.fn();
+const mockPathname = '/protected';
+
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+ usePathname: () => mockPathname,
+}));
+
+// Mock auth store
+let mockAuthState: {
+ isAuthenticated: boolean;
+ isLoading: boolean;
+ user: any;
+} = {
+ isAuthenticated: false,
+ isLoading: false,
+ user: null,
+};
+
+jest.mock('@/stores/authStore', () => ({
+ useAuthStore: () => mockAuthState,
+}));
+
+// Mock useMe hook
+let mockMeState: {
+ isLoading: boolean;
+ data: any;
+} = {
+ isLoading: false,
+ data: null,
+};
+
+jest.mock('@/lib/api/hooks/useAuth', () => ({
+ useMe: () => mockMeState,
+}));
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+};
+
+describe('AuthGuard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Reset to default unauthenticated state
+ mockAuthState = {
+ isAuthenticated: false,
+ isLoading: false,
+ user: null,
+ };
+ mockMeState = {
+ isLoading: false,
+ data: null,
+ };
+ });
+
+ describe('Loading States', () => {
+ it('shows loading spinner when auth is loading', () => {
+ mockAuthState = {
+ isAuthenticated: false,
+ isLoading: true,
+ user: null,
+ };
+
+ render(
+
+ Protected Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(screen.getByText(/loading/i)).toBeInTheDocument();
+ expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
+ });
+
+ it('shows loading spinner when user data is loading', () => {
+ mockAuthState = {
+ isAuthenticated: true,
+ isLoading: false,
+ user: null,
+ };
+ mockMeState = {
+ isLoading: true,
+ data: null,
+ };
+
+ render(
+
+ Protected Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(screen.getByText(/loading/i)).toBeInTheDocument();
+ });
+
+ it('shows custom fallback when provided', () => {
+ mockAuthState = {
+ isAuthenticated: false,
+ isLoading: true,
+ user: null,
+ };
+
+ render(
+ Please wait...}>
+ Protected Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(screen.getByText('Please wait...')).toBeInTheDocument();
+ // Default spinner should not be shown
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Authentication', () => {
+ it('redirects to login when not authenticated', async () => {
+ mockAuthState = {
+ isAuthenticated: false,
+ isLoading: false,
+ user: null,
+ };
+
+ render(
+
+ Protected Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith('/login?returnUrl=%2Fprotected');
+ });
+
+ expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
+ });
+
+ it('renders children when authenticated', () => {
+ mockAuthState = {
+ isAuthenticated: true,
+ isLoading: false,
+ user: {
+ id: '1',
+ email: 'user@example.com',
+ first_name: 'Test',
+ last_name: 'User',
+ is_active: true,
+ is_superuser: false,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ },
+ };
+
+ render(
+
+ Protected Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(screen.getByText('Protected Content')).toBeInTheDocument();
+ expect(mockPush).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Admin Access Control', () => {
+ it('renders children for admin user when requireAdmin is true', () => {
+ mockAuthState = {
+ isAuthenticated: true,
+ isLoading: false,
+ user: {
+ id: '1',
+ email: 'admin@example.com',
+ first_name: 'Admin',
+ last_name: 'User',
+ is_active: true,
+ is_superuser: true,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ },
+ };
+
+ render(
+
+ Admin Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(screen.getByText('Admin Content')).toBeInTheDocument();
+ expect(mockPush).not.toHaveBeenCalled();
+ });
+
+ it('redirects non-admin user when requireAdmin is true', async () => {
+ mockAuthState = {
+ isAuthenticated: true,
+ isLoading: false,
+ user: {
+ id: '1',
+ email: 'user@example.com',
+ first_name: 'Regular',
+ last_name: 'User',
+ is_active: true,
+ is_superuser: false,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ },
+ };
+
+ render(
+
+ Admin Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith('/');
+ });
+
+ expect(screen.queryByText('Admin Content')).not.toBeInTheDocument();
+ });
+
+ it('does not redirect regular user when requireAdmin is false', () => {
+ mockAuthState = {
+ isAuthenticated: true,
+ isLoading: false,
+ user: {
+ id: '1',
+ email: 'user@example.com',
+ first_name: 'Regular',
+ last_name: 'User',
+ is_active: true,
+ is_superuser: false,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ },
+ };
+
+ render(
+
+ User Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(screen.getByText('User Content')).toBeInTheDocument();
+ expect(mockPush).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Return URL Preservation', () => {
+ it('preserves current path in returnUrl when redirecting', async () => {
+ mockAuthState = {
+ isAuthenticated: false,
+ isLoading: false,
+ user: null,
+ };
+
+ render(
+
+ Protected Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(
+ expect.stringContaining('returnUrl=%2Fprotected')
+ );
+ });
+ });
+ });
+
+ describe('Integration with useMe', () => {
+ it('shows loading while useMe fetches user data', () => {
+ mockAuthState = {
+ isAuthenticated: true,
+ isLoading: false,
+ user: null,
+ };
+ mockMeState = {
+ isLoading: true,
+ data: null,
+ };
+
+ render(
+
+ Protected Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(screen.getByText(/loading/i)).toBeInTheDocument();
+ });
+
+ it('renders children after useMe completes', () => {
+ mockAuthState = {
+ isAuthenticated: true,
+ isLoading: false,
+ user: {
+ id: '1',
+ email: 'user@example.com',
+ first_name: 'Test',
+ last_name: 'User',
+ is_active: true,
+ is_superuser: false,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ },
+ };
+ mockMeState = {
+ isLoading: false,
+ data: {
+ id: '1',
+ email: 'user@example.com',
+ first_name: 'Test',
+ last_name: 'User',
+ is_active: true,
+ is_superuser: false,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ },
+ };
+
+ render(
+
+ Protected Content
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(screen.getByText('Protected Content')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/tests/lib/api/hooks/useAuth.test.tsx b/frontend/tests/lib/api/hooks/useAuth.test.tsx
new file mode 100644
index 0000000..3a962ad
--- /dev/null
+++ b/frontend/tests/lib/api/hooks/useAuth.test.tsx
@@ -0,0 +1,222 @@
+/**
+ * Tests for useAuth hooks
+ * Note: Full API integration tests require MSW (planned for Phase 9)
+ * These tests cover hook setup, types, and basic integration
+ */
+
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import {
+ useIsAuthenticated,
+ useCurrentUser,
+ useIsAdmin,
+} from '@/lib/api/hooks/useAuth';
+
+// Mock auth store
+let mockAuthState: {
+ isAuthenticated: boolean;
+ user: any;
+ accessToken: string | null;
+ refreshToken: string | null;
+} = {
+ isAuthenticated: false,
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+};
+
+jest.mock('@/stores/authStore', () => ({
+ useAuthStore: (selector?: (state: any) => any) => {
+ if (selector) {
+ return selector(mockAuthState);
+ }
+ return mockAuthState;
+ },
+}));
+
+// Mock router
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: jest.fn(),
+ }),
+}));
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+};
+
+describe('useAuth Hooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockAuthState = {
+ isAuthenticated: false,
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+ };
+ });
+
+ describe('useIsAuthenticated', () => {
+ it('returns false when not authenticated', () => {
+ mockAuthState = {
+ isAuthenticated: false,
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+ };
+
+ const { result } = renderHook(() => useIsAuthenticated(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns true when authenticated', () => {
+ mockAuthState = {
+ isAuthenticated: true,
+ user: {
+ id: '1',
+ email: 'user@example.com',
+ first_name: 'Test',
+ last_name: 'User',
+ is_active: true,
+ is_superuser: false,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ },
+ accessToken: 'test-token',
+ refreshToken: 'test-refresh',
+ };
+
+ const { result } = renderHook(() => useIsAuthenticated(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBe(true);
+ });
+ });
+
+ describe('useCurrentUser', () => {
+ it('returns null when not authenticated', () => {
+ mockAuthState = {
+ isAuthenticated: false,
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+ };
+
+ const { result } = renderHook(() => useCurrentUser(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBeNull();
+ });
+
+ it('returns user when authenticated', () => {
+ const mockUser = {
+ id: '1',
+ email: 'user@example.com',
+ first_name: 'Test',
+ last_name: 'User',
+ is_active: true,
+ is_superuser: false,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ };
+
+ mockAuthState = {
+ isAuthenticated: true,
+ user: mockUser,
+ accessToken: 'test-token',
+ refreshToken: 'test-refresh',
+ };
+
+ const { result } = renderHook(() => useCurrentUser(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toEqual(mockUser);
+ });
+ });
+
+ describe('useIsAdmin', () => {
+ it('returns false when not authenticated', () => {
+ mockAuthState = {
+ isAuthenticated: false,
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+ };
+
+ const { result } = renderHook(() => useIsAdmin(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false for regular user', () => {
+ mockAuthState = {
+ isAuthenticated: true,
+ user: {
+ id: '1',
+ email: 'user@example.com',
+ first_name: 'Test',
+ last_name: 'User',
+ is_active: true,
+ is_superuser: false,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ },
+ accessToken: 'test-token',
+ refreshToken: 'test-refresh',
+ };
+
+ const { result } = renderHook(() => useIsAdmin(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns true for admin user', () => {
+ mockAuthState = {
+ isAuthenticated: true,
+ user: {
+ id: '1',
+ email: 'admin@example.com',
+ first_name: 'Admin',
+ last_name: 'User',
+ is_active: true,
+ is_superuser: true,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ },
+ accessToken: 'test-token',
+ refreshToken: 'test-refresh',
+ };
+
+ const { result } = renderHook(() => useIsAdmin(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBe(true);
+ });
+ });
+
+ // Note: Mutation hooks (useLogin, useRegister, etc.) require MSW for full testing
+ // These will be tested in Phase 9 with proper API mocking
+ // For now, we've tested the convenience hooks which improve coverage significantly
+});