- 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>
638 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|