Set up comprehensive frontend testing infrastructure
- Install Jest for unit testing with React Testing Library - Install Playwright for end-to-end testing - Configure Jest with proper TypeScript support and module mapping - Create test setup files and utilities for both unit and e2e tests Components: * Jest configuration with coverage thresholds * Playwright configuration with browser automation * Unit tests for LoginForm, AuthContext, and useSocketIO hook * E2E tests for authentication, dashboard, and agents workflows * GitHub Actions workflow for automated testing * Mock data and API utilities for consistent testing * Test documentation with best practices Testing features: - Unit tests with 70% coverage threshold - E2E tests with API mocking and user journey testing - CI/CD integration for automated test runs - Cross-browser testing support with Playwright - Authentication system testing end-to-end 🚀 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
185
frontend/src/api/auth.ts
Normal file
185
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Types
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
name?: string; // For backward compatibility
|
||||
role?: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
is_verified: boolean;
|
||||
}
|
||||
|
||||
export interface APIKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
scopes: string[];
|
||||
created_at: string;
|
||||
last_used?: string;
|
||||
expires_at?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface CreateAPIKeyRequest {
|
||||
name: string;
|
||||
scopes: string[];
|
||||
expires_in_days?: number;
|
||||
}
|
||||
|
||||
export interface CreateAPIKeyResponse {
|
||||
api_key: APIKey;
|
||||
key: string; // Full key (only returned once)
|
||||
}
|
||||
|
||||
// API client
|
||||
const apiClient = axios.create({
|
||||
baseURL: process.env.VITE_API_BASE_URL || 'http://localhost:8087',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor for error handling
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear token and redirect to login
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API functions
|
||||
export const login = async (credentials: LoginRequest): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post('/api/auth/login', credentials);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const register = async (userData: RegisterRequest): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post('/api/auth/register', userData);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get('/api/auth/me');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await apiClient.post('/api/auth/logout');
|
||||
} finally {
|
||||
// Always clear local storage even if API call fails
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
};
|
||||
|
||||
export const refreshToken = async (): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post('/api/auth/refresh');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// API Key management
|
||||
export const getAPIKeys = async (): Promise<APIKey[]> => {
|
||||
const response = await apiClient.get('/api/auth/api-keys');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createAPIKey = async (data: CreateAPIKeyRequest): Promise<CreateAPIKeyResponse> => {
|
||||
const response = await apiClient.post('/api/auth/api-keys', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const revokeAPIKey = async (keyId: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/auth/api-keys/${keyId}`);
|
||||
};
|
||||
|
||||
export const updateAPIKey = async (keyId: string, data: Partial<CreateAPIKeyRequest>): Promise<APIKey> => {
|
||||
const response = await apiClient.put(`/api/auth/api-keys/${keyId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Password management
|
||||
export const changePassword = async (oldPassword: string, newPassword: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/change-password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
};
|
||||
|
||||
export const requestPasswordReset = async (email: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/forgot-password', { email });
|
||||
};
|
||||
|
||||
export const resetPassword = async (token: string, newPassword: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/reset-password', {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
});
|
||||
};
|
||||
|
||||
// Profile management
|
||||
export const updateProfile = async (data: Partial<User>): Promise<User> => {
|
||||
const response = await apiClient.put('/api/auth/profile', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const verifyEmail = async (token: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/verify-email', { token });
|
||||
};
|
||||
|
||||
export const resendVerificationEmail = async (): Promise<void> => {
|
||||
await apiClient.post('/api/auth/resend-verification');
|
||||
};
|
||||
|
||||
export default {
|
||||
login,
|
||||
register,
|
||||
getCurrentUser,
|
||||
logout,
|
||||
refreshToken,
|
||||
getAPIKeys,
|
||||
createAPIKey,
|
||||
revokeAPIKey,
|
||||
updateAPIKey,
|
||||
changePassword,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
updateProfile,
|
||||
verifyEmail,
|
||||
resendVerificationEmail,
|
||||
};
|
||||
140
frontend/src/components/auth/__tests__/LoginForm.test.tsx
Normal file
140
frontend/src/components/auth/__tests__/LoginForm.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, mockApiResponses } from '../../../test/utils';
|
||||
import LoginForm from '../LoginForm';
|
||||
import * as authApi from '../../../api/auth';
|
||||
|
||||
// Mock the auth API
|
||||
jest.mock('../../../api/auth');
|
||||
const mockAuthApi = authApi as jest.Mocked<typeof authApi>;
|
||||
|
||||
// Mock react-router-dom
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
describe('LoginForm', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders login form with email and password fields', () => {
|
||||
render(<LoginForm />);
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation errors for empty fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for invalid email format', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'invalid-email');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form with valid credentials', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockAuthApi.login.mockResolvedValue(mockApiResponses.auth.login);
|
||||
|
||||
render(<LoginForm />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthApi.login).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message on login failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockAuthApi.login.mockRejectedValue(new Error('Invalid credentials'));
|
||||
|
||||
render(<LoginForm />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'wrongpassword');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables submit button while loading', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Mock a slow API response
|
||||
mockAuthApi.login.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(mockApiResponses.auth.login), 1000))
|
||||
);
|
||||
|
||||
render(<LoginForm />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Check that button is disabled during loading
|
||||
expect(submitButton).toBeDisabled();
|
||||
expect(screen.getByText(/signing in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles password visibility', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm />);
|
||||
|
||||
const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement;
|
||||
const toggleButton = screen.getByRole('button', { name: /toggle password visibility/i });
|
||||
|
||||
// Initially password should be hidden
|
||||
expect(passwordInput.type).toBe('password');
|
||||
|
||||
// Click toggle to show password
|
||||
await user.click(toggleButton);
|
||||
expect(passwordInput.type).toBe('text');
|
||||
|
||||
// Click toggle again to hide password
|
||||
await user.click(toggleButton);
|
||||
expect(passwordInput.type).toBe('password');
|
||||
});
|
||||
});
|
||||
178
frontend/src/contexts/__tests__/AuthContext.test.tsx
Normal file
178
frontend/src/contexts/__tests__/AuthContext.test.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider, useAuth } from '../AuthContext';
|
||||
import * as authApi from '../../api/auth';
|
||||
import { mockApiResponses, mockUser } from '../../test/utils';
|
||||
|
||||
// Mock the auth API
|
||||
jest.mock('../../api/auth');
|
||||
const mockAuthApi = authApi as jest.Mocked<typeof authApi>;
|
||||
|
||||
// Mock localStorage
|
||||
const mockLocalStorage = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
});
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AuthContext', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockLocalStorage.getItem.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('initializes with no user when no token in localStorage', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('attempts to load user when token exists in localStorage', async () => {
|
||||
mockLocalStorage.getItem.mockReturnValue('mock-token');
|
||||
mockAuthApi.getCurrentUser.mockResolvedValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||
|
||||
// Initially loading
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
// Wait for the async operation to complete
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('clears user data when token is invalid', async () => {
|
||||
mockLocalStorage.getItem.mockReturnValue('invalid-token');
|
||||
mockAuthApi.getCurrentUser.mockRejectedValue(new Error('Unauthorized'));
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('token');
|
||||
});
|
||||
|
||||
it('logs in user successfully', async () => {
|
||||
mockAuthApi.login.mockResolvedValue(mockApiResponses.auth.login);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.login('test@example.com', 'password123');
|
||||
});
|
||||
|
||||
expect(mockAuthApi.login).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('token', 'mock-jwt-token');
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('throws error on login failure', async () => {
|
||||
const loginError = new Error('Invalid credentials');
|
||||
mockAuthApi.login.mockRejectedValue(loginError);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||
|
||||
await expect(act(async () => {
|
||||
await result.current.login('test@example.com', 'wrongpassword');
|
||||
})).rejects.toThrow('Invalid credentials');
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('logs out user successfully', async () => {
|
||||
// First set up an authenticated user
|
||||
mockLocalStorage.getItem.mockReturnValue('mock-token');
|
||||
mockAuthApi.getCurrentUser.mockResolvedValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
// Now logout
|
||||
await act(async () => {
|
||||
result.current.logout();
|
||||
});
|
||||
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('token');
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('updates user data', async () => {
|
||||
// First set up an authenticated user
|
||||
mockLocalStorage.getItem.mockReturnValue('mock-token');
|
||||
mockAuthApi.getCurrentUser.mockResolvedValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const updatedUser = { ...mockUser, full_name: 'Updated Name' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateUser(updatedUser);
|
||||
});
|
||||
|
||||
expect(result.current.user).toEqual(updatedUser);
|
||||
});
|
||||
|
||||
it('handles register functionality', async () => {
|
||||
const registerData = {
|
||||
email: 'newuser@example.com',
|
||||
password: 'password123',
|
||||
full_name: 'New User',
|
||||
username: 'newuser',
|
||||
};
|
||||
|
||||
mockAuthApi.register.mockResolvedValue(mockApiResponses.auth.login);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.register(registerData);
|
||||
});
|
||||
|
||||
expect(mockAuthApi.register).toHaveBeenCalledWith(registerData);
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
265
frontend/src/hooks/__tests__/useSocketIO.test.ts
Normal file
265
frontend/src/hooks/__tests__/useSocketIO.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useSocketIO } from '../useSocketIO';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
// Mock socket.io-client
|
||||
jest.mock('socket.io-client');
|
||||
const mockIo = io as jest.MockedFunction<typeof io>;
|
||||
|
||||
// Mock socket instance
|
||||
const mockSocket = {
|
||||
connected: false,
|
||||
on: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
onAny: jest.fn(),
|
||||
};
|
||||
|
||||
describe('useSocketIO', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSocket.connected = false;
|
||||
mockIo.mockReturnValue(mockSocket as any);
|
||||
});
|
||||
|
||||
it('initializes with disconnected state', () => {
|
||||
const { result } = renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: false,
|
||||
}));
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.connectionState).toBe('disconnected');
|
||||
expect(result.current.socket).toBe(null);
|
||||
expect(result.current.lastMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('auto-connects when autoConnect is true', () => {
|
||||
renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: true,
|
||||
}));
|
||||
|
||||
expect(mockIo).toHaveBeenCalledWith('http://localhost:8087', expect.objectContaining({
|
||||
transports: ['websocket', 'polling'],
|
||||
upgrade: true,
|
||||
rememberUpgrade: true,
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
timeout: 20000,
|
||||
forceNew: false,
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not auto-connect when autoConnect is false', () => {
|
||||
renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: false,
|
||||
}));
|
||||
|
||||
expect(mockIo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles connection events', () => {
|
||||
const onConnect = jest.fn();
|
||||
const onDisconnect = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
const { result } = renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: true,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onError,
|
||||
}));
|
||||
|
||||
// Simulate connection
|
||||
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')[1];
|
||||
act(() => {
|
||||
connectHandler();
|
||||
});
|
||||
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
expect(result.current.connectionState).toBe('connected');
|
||||
expect(onConnect).toHaveBeenCalled();
|
||||
|
||||
// Simulate disconnection
|
||||
const disconnectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'disconnect')[1];
|
||||
act(() => {
|
||||
disconnectHandler('transport close');
|
||||
});
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.connectionState).toBe('disconnected');
|
||||
expect(onDisconnect).toHaveBeenCalled();
|
||||
|
||||
// Simulate error
|
||||
const errorHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect_error')[1];
|
||||
act(() => {
|
||||
errorHandler(new Error('Connection failed'));
|
||||
});
|
||||
|
||||
expect(result.current.connectionState).toBe('error');
|
||||
expect(onError).toHaveBeenCalledWith(new Error('Connection failed'));
|
||||
});
|
||||
|
||||
it('handles message events', () => {
|
||||
const onMessage = jest.fn();
|
||||
|
||||
const { result } = renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: true,
|
||||
onMessage,
|
||||
}));
|
||||
|
||||
// Simulate generic message
|
||||
const anyHandler = mockSocket.onAny.mock.calls[0][0];
|
||||
act(() => {
|
||||
anyHandler('task_update', { id: 'task1', status: 'completed' });
|
||||
});
|
||||
|
||||
expect(result.current.lastMessage).toMatchObject({
|
||||
type: 'task_update',
|
||||
data: { id: 'task1', status: 'completed' },
|
||||
});
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'task_update',
|
||||
data: { id: 'task1', status: 'completed' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends messages when connected', () => {
|
||||
mockSocket.connected = true;
|
||||
|
||||
const { result } = renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: true,
|
||||
}));
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage('test_event', { data: 'test' });
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('test_event', { data: 'test' });
|
||||
});
|
||||
|
||||
it('does not send messages when not connected', () => {
|
||||
mockSocket.connected = false;
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
const { result } = renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: true,
|
||||
}));
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage('test_event', { data: 'test' });
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Socket.IO is not connected. Cannot send message:',
|
||||
{ event: 'test_event', data: { data: 'test' } }
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('joins and leaves rooms', () => {
|
||||
mockSocket.connected = true;
|
||||
|
||||
const { result } = renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: true,
|
||||
}));
|
||||
|
||||
act(() => {
|
||||
result.current.joinRoom('task_updates');
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('join_room', { room: 'task_updates' });
|
||||
|
||||
act(() => {
|
||||
result.current.leaveRoom('task_updates');
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('leave_room', { room: 'task_updates' });
|
||||
});
|
||||
|
||||
it('subscribes to events', () => {
|
||||
mockSocket.connected = true;
|
||||
|
||||
const { result } = renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: true,
|
||||
}));
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(['task_update', 'agent_status'], 'monitoring');
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe', {
|
||||
events: ['task_update', 'agent_status'],
|
||||
room: 'monitoring',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles manual connect and disconnect', () => {
|
||||
const { result } = renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: false,
|
||||
}));
|
||||
|
||||
// Manual connect
|
||||
act(() => {
|
||||
result.current.connect();
|
||||
});
|
||||
|
||||
expect(mockIo).toHaveBeenCalled();
|
||||
expect(result.current.connectionState).toBe('connecting');
|
||||
|
||||
// Manual disconnect
|
||||
act(() => {
|
||||
result.current.disconnect();
|
||||
});
|
||||
|
||||
expect(mockSocket.disconnect).toHaveBeenCalled();
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.connectionState).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('handles reconnection', () => {
|
||||
const { result } = renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: true,
|
||||
}));
|
||||
|
||||
// Simulate initial connection
|
||||
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')[1];
|
||||
act(() => {
|
||||
connectHandler();
|
||||
});
|
||||
|
||||
// Trigger reconnect
|
||||
act(() => {
|
||||
result.current.reconnect();
|
||||
});
|
||||
|
||||
expect(mockSocket.disconnect).toHaveBeenCalled();
|
||||
// Should attempt to reconnect after a short delay
|
||||
});
|
||||
|
||||
it('cleans up on unmount', () => {
|
||||
const { unmount } = renderHook(() => useSocketIO({
|
||||
url: 'http://localhost:8087',
|
||||
autoConnect: true,
|
||||
}));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockSocket.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
65
frontend/src/test/setup.ts
Normal file
65
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Mock environment variables
|
||||
process.env.VITE_API_BASE_URL = 'http://localhost:8087';
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = jest.fn().mockImplementation((callback) => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
trigger: (entries: any) => callback(entries),
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock scrollTo
|
||||
global.scrollTo = jest.fn();
|
||||
|
||||
// Mock console methods to reduce noise in tests
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
|
||||
console.error = (...args: any[]) => {
|
||||
// Suppress specific React warnings in tests
|
||||
const message = args[0];
|
||||
if (
|
||||
typeof message === 'string' &&
|
||||
(message.includes('Warning: ReactDOM.render is deprecated') ||
|
||||
message.includes('Warning: validateDOMNesting'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError.apply(console, args);
|
||||
};
|
||||
|
||||
console.warn = (...args: any[]) => {
|
||||
const message = args[0];
|
||||
if (
|
||||
typeof message === 'string' &&
|
||||
message.includes('componentWillReceiveProps has been renamed')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleWarn.apply(console, args);
|
||||
};
|
||||
120
frontend/src/test/utils.tsx
Normal file
120
frontend/src/test/utils.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
|
||||
// Create a custom render function that includes providers
|
||||
const AllProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const customRender = (
|
||||
ui: ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) => render(ui, { wrapper: AllProviders, ...options });
|
||||
|
||||
export * from '@testing-library/react';
|
||||
export { customRender as render };
|
||||
|
||||
// Mock data helpers
|
||||
export const mockUser = {
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
full_name: 'Test User',
|
||||
name: 'Test User',
|
||||
role: 'user',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
is_verified: true,
|
||||
};
|
||||
|
||||
export const mockAgent = {
|
||||
id: 'test-agent-1',
|
||||
name: 'Test Agent',
|
||||
endpoint: 'http://localhost:11434',
|
||||
model: 'llama3.1:8b',
|
||||
specialty: 'general_ai',
|
||||
max_concurrent: 2,
|
||||
current_tasks: 0,
|
||||
agent_type: 'ollama',
|
||||
status: 'online',
|
||||
last_heartbeat: Date.now(),
|
||||
};
|
||||
|
||||
export const mockTask = {
|
||||
id: 'task_1735589200_0',
|
||||
title: 'Test Task',
|
||||
description: 'A test task for unit testing',
|
||||
type: 'general_ai',
|
||||
priority: 3,
|
||||
status: 'pending',
|
||||
created_at: Date.now(),
|
||||
context: {
|
||||
objective: 'Test objective',
|
||||
requirements: ['Test requirement 1'],
|
||||
},
|
||||
};
|
||||
|
||||
export const mockWorkflow = {
|
||||
id: 'workflow_1735589200',
|
||||
name: 'Test Workflow',
|
||||
description: 'A test workflow',
|
||||
steps: [
|
||||
{
|
||||
name: 'Step 1',
|
||||
type: 'general_ai',
|
||||
agent_type: 'general_ai',
|
||||
inputs: { prompt: 'Test prompt' },
|
||||
outputs: ['result'],
|
||||
},
|
||||
],
|
||||
created_at: Date.now(),
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
// Mock API responses
|
||||
export const mockApiResponses = {
|
||||
auth: {
|
||||
login: {
|
||||
access_token: 'mock-jwt-token',
|
||||
token_type: 'bearer',
|
||||
user: mockUser,
|
||||
},
|
||||
me: mockUser,
|
||||
},
|
||||
agents: {
|
||||
list: [mockAgent],
|
||||
detail: mockAgent,
|
||||
},
|
||||
tasks: {
|
||||
list: [mockTask],
|
||||
detail: mockTask,
|
||||
},
|
||||
workflows: {
|
||||
list: [mockWorkflow],
|
||||
detail: mockWorkflow,
|
||||
},
|
||||
};
|
||||
|
||||
// Async utility for waiting for elements
|
||||
export const waitFor = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
Reference in New Issue
Block a user