import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; // Mock dependencies vi.mock('../../api/auth', () => ({ login: vi.fn(), logout: vi.fn(), getCurrentUser: vi.fn(), masquerade: vi.fn(), stopMasquerade: vi.fn(), })); vi.mock('../../utils/cookies', () => ({ getCookie: vi.fn(), setCookie: vi.fn(), deleteCookie: vi.fn(), })); vi.mock('../../utils/domain', () => ({ getBaseDomain: vi.fn(() => 'lvh.me'), buildSubdomainUrl: vi.fn((subdomain, path) => `http://${subdomain}.lvh.me:5173${path || '/'}`), })); import { useAuth, useCurrentUser, useLogin, useLogout, useIsAuthenticated, useMasquerade, useStopMasquerade, } from '../useAuth'; import * as authApi from '../../api/auth'; import * as cookies from '../../utils/cookies'; // Create a wrapper with QueryClientProvider const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return function Wrapper({ children }: { children: React.ReactNode }) { return React.createElement( QueryClientProvider, { client: queryClient }, children ); }; }; describe('useAuth hooks', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); }); describe('useAuth', () => { it('provides setTokens function', () => { const { result } = renderHook(() => useAuth(), { wrapper: createWrapper(), }); expect(result.current.setTokens).toBeDefined(); expect(typeof result.current.setTokens).toBe('function'); }); it('setTokens calls setCookie for both tokens', () => { const { result } = renderHook(() => useAuth(), { wrapper: createWrapper(), }); result.current.setTokens('access-123', 'refresh-456'); expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'access-123', 7); expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'refresh-456', 7); }); }); describe('useCurrentUser', () => { it('returns null when no token exists', async () => { vi.mocked(cookies.getCookie).mockReturnValue(null); const { result } = renderHook(() => useCurrentUser(), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.data).toBeNull(); expect(authApi.getCurrentUser).not.toHaveBeenCalled(); }); it('fetches user when token exists', async () => { const mockUser = { id: 1, email: 'test@example.com', role: 'owner', first_name: 'Test', last_name: 'User', }; vi.mocked(cookies.getCookie).mockReturnValue('valid-token'); vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser as authApi.User); const { result } = renderHook(() => useCurrentUser(), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.data).toEqual(mockUser); expect(authApi.getCurrentUser).toHaveBeenCalled(); }); it('returns null when getCurrentUser fails', async () => { vi.mocked(cookies.getCookie).mockReturnValue('invalid-token'); vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Unauthorized')); const { result } = renderHook(() => useCurrentUser(), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.data).toBeNull(); }); }); describe('useLogin', () => { it('stores tokens in cookies on success', async () => { const mockResponse = { access: 'access-token', refresh: 'refresh-token', user: { id: 1, email: 'test@example.com', role: 'owner', first_name: 'Test', last_name: 'User', }, }; vi.mocked(authApi.login).mockResolvedValue(mockResponse as authApi.LoginResponse); const { result } = renderHook(() => useLogin(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync({ email: 'test@example.com', password: 'password123', }); }); expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'access-token', 7); expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'refresh-token', 7); }); it('clears masquerade stack on login', async () => { window.localStorage.setItem('masquerade_stack', JSON.stringify([{ user_pk: 1 }])); const mockResponse = { access: 'access-token', refresh: 'refresh-token', user: { id: 1, email: 'test@example.com', role: 'owner', }, }; vi.mocked(authApi.login).mockResolvedValue(mockResponse as authApi.LoginResponse); const { result } = renderHook(() => useLogin(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync({ email: 'test@example.com', password: 'password123', }); }); // After login, masquerade_stack should be removed expect(window.localStorage.getItem('masquerade_stack')).toBeFalsy(); }); }); describe('useLogout', () => { it('clears tokens and masquerade stack', async () => { window.localStorage.setItem('masquerade_stack', JSON.stringify([{ user_pk: 1 }])); vi.mocked(authApi.logout).mockResolvedValue(undefined); // Mock window.location const originalLocation = window.location; Object.defineProperty(window, 'location', { value: { ...originalLocation, href: '', protocol: 'http:', port: '5173' }, writable: true, }); const { result } = renderHook(() => useLogout(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync(); }); expect(cookies.deleteCookie).toHaveBeenCalledWith('access_token'); expect(cookies.deleteCookie).toHaveBeenCalledWith('refresh_token'); // After logout, masquerade_stack should be removed expect(window.localStorage.getItem('masquerade_stack')).toBeFalsy(); // Restore window.location Object.defineProperty(window, 'location', { value: originalLocation, writable: true, }); }); }); describe('useIsAuthenticated', () => { it('returns false when no user', async () => { vi.mocked(cookies.getCookie).mockReturnValue(null); const { result } = renderHook(() => useIsAuthenticated(), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current).toBe(false); }); }); it('returns true when user exists', async () => { const mockUser = { id: 1, email: 'test@example.com', role: 'owner', }; vi.mocked(cookies.getCookie).mockReturnValue('valid-token'); vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser as authApi.User); const { result } = renderHook(() => useIsAuthenticated(), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current).toBe(true); }); }); }); describe('useMasquerade', () => { let originalLocation: Location; let originalFetch: typeof fetch; beforeEach(() => { originalLocation = window.location; originalFetch = global.fetch; // Mock window.location Object.defineProperty(window, 'location', { value: { ...originalLocation, href: 'http://platform.lvh.me:5173/', hostname: 'platform.lvh.me', protocol: 'http:', port: '5173', reload: vi.fn(), }, writable: true, }); // Mock fetch for logout API call global.fetch = vi.fn().mockResolvedValue({ ok: true }); }); afterEach(() => { Object.defineProperty(window, 'location', { value: originalLocation, writable: true, }); global.fetch = originalFetch; }); it('calls masquerade API with user_pk and current stack', async () => { const mockResponse = { access: 'new-access-token', refresh: 'new-refresh-token', user: { id: 2, email: 'staff@example.com', role: 'staff', business_subdomain: 'demo', }, masquerade_stack: [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }], }; vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useMasquerade(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync(2); }); expect(authApi.masquerade).toHaveBeenCalledWith(2, []); }); it('passes existing masquerade stack to API', async () => { const existingStack = [{ user_pk: 1, access: 'old-access', refresh: 'old-refresh' }]; localStorage.setItem('masquerade_stack', JSON.stringify(existingStack)); const mockResponse = { access: 'new-access-token', refresh: 'new-refresh-token', user: { id: 3, email: 'staff@example.com', role: 'staff', business_subdomain: 'demo', }, masquerade_stack: [...existingStack, { user_pk: 2, access: 'mid-access', refresh: 'mid-refresh' }], }; vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useMasquerade(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync(3); }); expect(authApi.masquerade).toHaveBeenCalledWith(3, existingStack); }); it('stores masquerade stack in localStorage on success', async () => { const mockStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }]; const mockResponse = { access: 'new-access-token', refresh: 'new-refresh-token', user: { id: 2, email: 'staff@example.com', role: 'staff', business_subdomain: 'demo', }, masquerade_stack: mockStack, }; vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useMasquerade(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync(2); }); expect(localStorage.getItem('masquerade_stack')).toEqual(JSON.stringify(mockStack)); }); it('redirects to platform subdomain for platform users', async () => { // Set current hostname to something else to trigger redirect Object.defineProperty(window, 'location', { value: { ...window.location, hostname: 'demo.lvh.me', // Different from platform href: 'http://demo.lvh.me:5173/', }, writable: true, }); const mockResponse = { access: 'new-access-token', refresh: 'new-refresh-token', user: { id: 1, email: 'admin@example.com', role: 'superuser', }, masquerade_stack: [], }; vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useMasquerade(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync(1); }); // Should have called fetch to clear session expect(global.fetch).toHaveBeenCalled(); }); it('sets cookies when no redirect is needed', async () => { // Set current hostname to match the target Object.defineProperty(window, 'location', { value: { hostname: 'demo.lvh.me', href: 'http://demo.lvh.me:5173/', protocol: 'http:', port: '5173', reload: vi.fn(), }, writable: true, }); const mockResponse = { access: 'new-access-token', refresh: 'new-refresh-token', user: { id: 2, email: 'staff@example.com', role: 'staff', business_subdomain: 'demo', }, masquerade_stack: [], }; vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useMasquerade(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync(2); }); expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'new-access-token', 7); expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'new-refresh-token', 7); }); }); describe('useStopMasquerade', () => { let originalLocation: Location; let originalFetch: typeof fetch; beforeEach(() => { originalLocation = window.location; originalFetch = global.fetch; Object.defineProperty(window, 'location', { value: { ...originalLocation, href: 'http://demo.lvh.me:5173/', hostname: 'demo.lvh.me', protocol: 'http:', port: '5173', reload: vi.fn(), }, writable: true, }); global.fetch = vi.fn().mockResolvedValue({ ok: true }); }); afterEach(() => { Object.defineProperty(window, 'location', { value: originalLocation, writable: true, }); global.fetch = originalFetch; }); it('throws error when no masquerade stack exists', async () => { localStorage.removeItem('masquerade_stack'); const { result } = renderHook(() => useStopMasquerade(), { wrapper: createWrapper(), }); let error: Error | undefined; await act(async () => { try { await result.current.mutateAsync(); } catch (e) { error = e as Error; } }); expect(error?.message).toBe('No masquerading session to stop'); }); it('calls stopMasquerade API with current stack', async () => { const existingStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }]; localStorage.setItem('masquerade_stack', JSON.stringify(existingStack)); const mockResponse = { access: 'restored-access-token', refresh: 'restored-refresh-token', user: { id: 1, email: 'admin@example.com', role: 'superuser', }, masquerade_stack: [], }; vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useStopMasquerade(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync(); }); expect(authApi.stopMasquerade).toHaveBeenCalledWith(existingStack); }); it('clears masquerade stack when returning to original user', async () => { const existingStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }]; localStorage.setItem('masquerade_stack', JSON.stringify(existingStack)); const mockResponse = { access: 'restored-access-token', refresh: 'restored-refresh-token', user: { id: 1, email: 'admin@example.com', role: 'superuser', }, masquerade_stack: [], // Empty stack means back to original }; vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useStopMasquerade(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync(); }); expect(localStorage.getItem('masquerade_stack')).toBeNull(); }); it('keeps stack when still masquerading after stop', async () => { const deepStack = [ { user_pk: 1, access: 'level1-access', refresh: 'level1-refresh' }, { user_pk: 2, access: 'level2-access', refresh: 'level2-refresh' }, ]; localStorage.setItem('masquerade_stack', JSON.stringify(deepStack)); const remainingStack = [{ user_pk: 1, access: 'level1-access', refresh: 'level1-refresh' }]; const mockResponse = { access: 'level2-access-token', refresh: 'level2-refresh-token', user: { id: 2, email: 'manager@example.com', role: 'manager', business_subdomain: 'demo', }, masquerade_stack: remainingStack, }; vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useStopMasquerade(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync(); }); expect(localStorage.getItem('masquerade_stack')).toEqual(JSON.stringify(remainingStack)); }); it('sets cookies when no redirect is needed', async () => { const existingStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }]; localStorage.setItem('masquerade_stack', JSON.stringify(existingStack)); // Set hostname to match target subdomain Object.defineProperty(window, 'location', { value: { hostname: 'demo.lvh.me', href: 'http://demo.lvh.me:5173/', protocol: 'http:', port: '5173', reload: vi.fn(), }, writable: true, }); const mockResponse = { access: 'restored-access-token', refresh: 'restored-refresh-token', user: { id: 2, email: 'owner@example.com', role: 'owner', business_subdomain: 'demo', }, masquerade_stack: [], }; vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useStopMasquerade(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync(); }); expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'restored-access-token', 7); expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'restored-refresh-token', 7); }); }); });