Files
smoothschedule/frontend/src/hooks/__tests__/useAuth.test.ts
poduck 8dc2248f1f feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities
- Add backend tests for webhooks, notifications, middleware, and edge cases
- Add ForgotPassword, NotFound, and ResetPassword pages
- Add migration for orphaned staff resources conversion
- Add coverage directory to gitignore (generated reports)
- Various bug fixes and improvements from previous work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:36:46 -05:00

638 lines
18 KiB
TypeScript

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);
});
});
});