Add new frontend - fix 12
This commit is contained in:
@@ -320,5 +320,5 @@ class ApiClient {
|
||||
|
||||
// FIXED: Create default instance with correct base URL (removed /api suffix)
|
||||
export const apiClient = new ApiClient({
|
||||
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8000'
|
||||
baseURL: process.env.FRONTEND_API_URL || 'http://localhost:8000/api/v1'
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/api/services/AuthService.ts
|
||||
// src/api/services/AuthService.ts - UPDATED with missing methods
|
||||
import { apiClient } from '../base/apiClient';
|
||||
import { tokenManager } from '../auth/tokenManager';
|
||||
import {
|
||||
ApiResponse,
|
||||
LoginRequest,
|
||||
@@ -9,6 +10,27 @@ import {
|
||||
} from '../types/api';
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
* Check if user is authenticated (has valid token)
|
||||
* Note: This is a synchronous check using the tokenManager's isAuthenticated method
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
try {
|
||||
return tokenManager.isAuthenticated();
|
||||
} catch (error) {
|
||||
console.error('Error checking authentication status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
*/
|
||||
async getCurrentUser(): Promise<UserProfile> {
|
||||
const response = await apiClient.get<ApiResponse<UserProfile>>('/users/me');
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
* User login
|
||||
*/
|
||||
@@ -17,7 +39,12 @@ export class AuthService {
|
||||
'/auth/login',
|
||||
credentials
|
||||
);
|
||||
return response.data!;
|
||||
|
||||
// Store tokens after successful login
|
||||
const tokenData = response.data!;
|
||||
await tokenManager.storeTokens(tokenData);
|
||||
|
||||
return tokenData;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,11 +70,10 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
* Get current user profile (alias for getCurrentUser)
|
||||
*/
|
||||
async getProfile(): Promise<UserProfile> {
|
||||
const response = await apiClient.get<ApiResponse<UserProfile>>('/users/me');
|
||||
return response.data!;
|
||||
return this.getCurrentUser();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +138,14 @@ export class AuthService {
|
||||
* Logout (invalidate tokens)
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('Logout API call failed:', error);
|
||||
} finally {
|
||||
// Always clear tokens regardless of API call success
|
||||
tokenManager.clearTokens();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
*/
|
||||
|
||||
// Import all service classes
|
||||
export { AuthService, authService } from './authService';
|
||||
export { DataService, dataService } from './dataService';
|
||||
export { TrainingService, trainingService } from './trainingService';
|
||||
export { ForecastingService, forecastingService } from './forecastingService';
|
||||
export { NotificationService, notificationService } from './notificationService';
|
||||
export { TenantService, tenantService } from './tenantService';
|
||||
import { AuthService, authService } from './authService';
|
||||
import { DataService, dataService } from './dataService';
|
||||
import { TrainingService, trainingService } from './trainingService';
|
||||
import { ForecastingService, forecastingService } from './forecastingService';
|
||||
import { NotificationService, notificationService } from './notificationService';
|
||||
import { TenantService, tenantService } from './tenantService';
|
||||
|
||||
// Import base API client for custom implementations
|
||||
export { apiClient } from '../base/apiClient';
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
// frontend/src/contexts/AuthContext.tsx - UPDATED TO USE NEW REGISTRATION FLOW
|
||||
// frontend/src/contexts/AuthContext.tsx - FIXED VERSION
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { authService } from '../api/services/authService';
|
||||
import { tokenManager } from '../api/auth/tokenManager';
|
||||
|
||||
import {
|
||||
UserProfile
|
||||
} from '@/api/services';
|
||||
|
||||
import api from '@/api/services';
|
||||
UserProfile,
|
||||
RegisterRequest,
|
||||
api
|
||||
} from '../api/services';
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserProfile | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>; // SIMPLIFIED - no longer needs auto-login
|
||||
register: (data: RegisterRequest) => Promise<void>; // FIXED: Use RegisterRequest
|
||||
logout: () => Promise<void>;
|
||||
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
@@ -50,6 +49,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
full_name: tokenUser.full_name,
|
||||
is_active: true,
|
||||
is_verified: tokenUser.is_verified,
|
||||
role: 'user', // Default role
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
created_at: '', // Will be filled by API call
|
||||
});
|
||||
}
|
||||
@@ -78,7 +80,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const profile = await authService.login({ email, password });
|
||||
// Login and store tokens
|
||||
const tokenResponse = await authService.login({ email, password });
|
||||
|
||||
// After login, get user profile
|
||||
const profile = await api.auth.getCurrentUser();
|
||||
setUser(profile);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
@@ -88,10 +94,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: RegisterData) => {
|
||||
const register = useCallback(async (data: RegisterRequest) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// NEW: Registration now handles tokens internally - no auto-login needed!
|
||||
const profile = await authService.register(data);
|
||||
setUser(profile);
|
||||
} catch (error) {
|
||||
@@ -194,3 +199,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Export the RegisterRequest type for use in components
|
||||
export type { RegisterRequest };
|
||||
@@ -1,656 +0,0 @@
|
||||
// src/setupTests.ts
|
||||
import '@testing-library/jest-dom';
|
||||
import { server } from './mocks/server';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// Establish API mocking before all tests
|
||||
beforeAll(() => server.listen());
|
||||
|
||||
// Reset any request handlers added during tests
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Clean up after tests
|
||||
afterAll(() => server.close());
|
||||
|
||||
// Mock WebSocket
|
||||
global.WebSocket = jest.fn().mockImplementation(() => ({
|
||||
send: jest.fn(),
|
||||
close: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
readyState: 1
|
||||
}));
|
||||
|
||||
// src/mocks/server.ts
|
||||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
|
||||
// src/mocks/handlers/index.ts
|
||||
import { rest } from 'msw';
|
||||
import { authHandlers } from './auth';
|
||||
import { trainingHandlers } from './training';
|
||||
import { dataHandlers } from './data';
|
||||
|
||||
export const handlers = [
|
||||
...authHandlers,
|
||||
...trainingHandlers,
|
||||
...dataHandlers
|
||||
];
|
||||
|
||||
// src/mocks/handlers/auth.ts
|
||||
import { rest } from 'msw';
|
||||
|
||||
export const authHandlers = [
|
||||
rest.post('/api/auth/token', (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
access_token: 'mock-access-token',
|
||||
refresh_token: 'mock-refresh-token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600
|
||||
})
|
||||
);
|
||||
}),
|
||||
|
||||
rest.get('/api/auth/me', (req, res, ctx) => {
|
||||
const token = req.headers.get('Authorization');
|
||||
|
||||
if (!token || token !== 'Bearer mock-access-token') {
|
||||
return res(ctx.status(401), ctx.json({ detail: 'Unauthorized' }));
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
id: '123',
|
||||
email: 'test@bakery.com',
|
||||
full_name: 'Test User',
|
||||
tenant_id: 'tenant-123',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
})
|
||||
);
|
||||
}),
|
||||
|
||||
rest.post('/api/auth/logout', (req, res, ctx) => {
|
||||
return res(ctx.status(204));
|
||||
})
|
||||
];
|
||||
|
||||
// src/mocks/handlers/training.ts
|
||||
import { rest } from 'msw';
|
||||
|
||||
export const trainingHandlers = [
|
||||
rest.post('/api/training/train', (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
job_id: 'job-123',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
current_step: 'Initializing',
|
||||
total_steps: 5,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
);
|
||||
}),
|
||||
|
||||
rest.get('/api/training/status/:jobId', (req, res, ctx) => {
|
||||
const { jobId } = req.params;
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
job_id: jobId,
|
||||
status: 'running',
|
||||
progress: 45,
|
||||
current_step: 'Training models',
|
||||
total_steps: 5,
|
||||
estimated_time_remaining: 120
|
||||
})
|
||||
);
|
||||
})
|
||||
];
|
||||
|
||||
// src/__tests__/unit/api/tokenManager.test.ts
|
||||
import { tokenManager } from '../../../api/auth/tokenManager';
|
||||
|
||||
describe('TokenManager', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
test('should store tokens securely', async () => {
|
||||
const tokenResponse = {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600
|
||||
};
|
||||
|
||||
await tokenManager.storeTokens(tokenResponse);
|
||||
const accessToken = await tokenManager.getAccessToken();
|
||||
|
||||
expect(accessToken).toBe('test-access-token');
|
||||
});
|
||||
|
||||
test('should refresh token when expired', async () => {
|
||||
const expiredToken = {
|
||||
access_token: 'expired-token',
|
||||
refresh_token: 'refresh-token',
|
||||
token_type: 'bearer',
|
||||
expires_in: -1 // Already expired
|
||||
};
|
||||
|
||||
await tokenManager.storeTokens(expiredToken);
|
||||
|
||||
// Mock refresh endpoint
|
||||
global.fetch = jest.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600
|
||||
})
|
||||
});
|
||||
|
||||
const accessToken = await tokenManager.getAccessToken();
|
||||
expect(accessToken).toBe('new-access-token');
|
||||
});
|
||||
|
||||
test('should clear tokens on logout', () => {
|
||||
tokenManager.clearTokens();
|
||||
expect(tokenManager.isAuthenticated()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// src/__tests__/unit/hooks/useWebSocket.test.tsx
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useWebSocket } from '../../../hooks/useWebSocket';
|
||||
|
||||
describe('useWebSocket', () => {
|
||||
test('should connect to WebSocket', async () => {
|
||||
const onMessage = jest.fn();
|
||||
const onConnect = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({
|
||||
endpoint: '/test',
|
||||
onMessage,
|
||||
onConnect
|
||||
})
|
||||
);
|
||||
|
||||
// Wait for connection
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle reconnection', async () => {
|
||||
const onReconnect = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({
|
||||
endpoint: '/test',
|
||||
onMessage: jest.fn(),
|
||||
onReconnect
|
||||
})
|
||||
);
|
||||
|
||||
// Simulate disconnect and reconnect
|
||||
act(() => {
|
||||
result.current.disconnect();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
expect(onReconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// src/__tests__/integration/AuthFlow.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from '../../../contexts/AuthContext';
|
||||
import { LoginPage } from '../../../pages/LoginPage';
|
||||
import { Dashboard } from '../../../pages/Dashboard/Dashboard';
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthProvider>{component}</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Authentication Flow', () => {
|
||||
test('should login and redirect to dashboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
// Fill login form
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@bakery.com');
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123');
|
||||
|
||||
// Submit form
|
||||
await user.click(screen.getByRole('button', { name: /login/i }));
|
||||
|
||||
// Wait for redirect
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle login errors', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock failed login
|
||||
server.use(
|
||||
rest.post('/api/auth/token', (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(401),
|
||||
ctx.json({ detail: 'Invalid credentials' })
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'wrong@email.com');
|
||||
await user.type(screen.getByLabelText(/password/i), 'wrongpass');
|
||||
await user.click(screen.getByRole('button', { name: /login/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// src/__tests__/integration/Dashboard.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Dashboard } from '../../../pages/Dashboard/Dashboard';
|
||||
import { AuthProvider } from '../../../contexts/AuthContext';
|
||||
|
||||
const renderDashboard = () => {
|
||||
return render(
|
||||
<AuthProvider>
|
||||
<Dashboard />
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Dashboard Integration', () => {
|
||||
test('should load and display dashboard data', async () => {
|
||||
renderDashboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/bakery forecast dashboard/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check stats cards
|
||||
expect(screen.getByText(/total sales/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/total revenue/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/last training/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/forecast accuracy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should start training process', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /start training/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click training button
|
||||
await user.click(screen.getByRole('button', { name: /start training/i }));
|
||||
|
||||
// Check progress card appears
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/training progress/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle file upload', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
const file = new File(['sales,data'], 'sales.csv', { type: 'text/csv' });
|
||||
const input = screen.getByLabelText(/upload sales data/i);
|
||||
|
||||
await user.upload(input, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/upload successful/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// cypress/e2e/user-workflows.cy.ts
|
||||
describe('End-to-End User Workflows', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
it('should complete full forecasting workflow', () => {
|
||||
// Login
|
||||
cy.get('[data-cy=email-input]').type('test@bakery.com');
|
||||
cy.get('[data-cy=password-input]').type('password123');
|
||||
cy.get('[data-cy=login-button]').click();
|
||||
|
||||
// Wait for dashboard
|
||||
cy.url().should('include', '/dashboard');
|
||||
cy.contains('Bakery Forecast Dashboard').should('be.visible');
|
||||
|
||||
// Upload sales data
|
||||
cy.get('[data-cy=upload-button]').click();
|
||||
cy.get('input[type=file]').selectFile({
|
||||
contents: Cypress.Buffer.from('product,quantity,date\nPan,100,2024-01-01'),
|
||||
fileName: 'sales.csv',
|
||||
mimeType: 'text/csv'
|
||||
});
|
||||
|
||||
// Wait for upload confirmation
|
||||
cy.contains('Upload Successful').should('be.visible');
|
||||
|
||||
// Start training
|
||||
cy.get('[data-cy=train-button]').click();
|
||||
cy.contains('Training Progress').should('be.visible');
|
||||
|
||||
// Verify real-time updates
|
||||
cy.get('[data-cy=progress-bar]', { timeout: 10000 })
|
||||
.should('have.attr', 'aria-valuenow')
|
||||
.and('not.equal', '0');
|
||||
|
||||
// Wait for completion
|
||||
cy.contains('Training Complete', { timeout: 60000 }).should('be.visible');
|
||||
|
||||
// Verify forecasts are displayed
|
||||
cy.get('[data-cy=forecast-chart]').should('have.length.at.least', 1);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
// Login with invalid credentials
|
||||
cy.get('[data-cy=email-input]').type('invalid@email.com');
|
||||
cy.get('[data-cy=password-input]').type('wrongpassword');
|
||||
cy.get('[data-cy=login-button]').click();
|
||||
|
||||
// Verify error message
|
||||
cy.contains('Invalid credentials').should('be.visible');
|
||||
|
||||
// Login with valid credentials
|
||||
cy.get('[data-cy=email-input]').clear().type('test@bakery.com');
|
||||
cy.get('[data-cy=password-input]').clear().type('password123');
|
||||
cy.get('[data-cy=login-button]').click();
|
||||
|
||||
// Simulate network error during training
|
||||
cy.intercept('POST', '/api/training/train', { statusCode: 500 }).as('trainingError');
|
||||
cy.get('[data-cy=train-button]').click();
|
||||
cy.wait('@trainingError');
|
||||
|
||||
// Verify error notification
|
||||
cy.contains('Failed to start training').should('be.visible');
|
||||
});
|
||||
|
||||
it('should maintain session across tabs', () => {
|
||||
// Login in first tab
|
||||
cy.get('[data-cy=email-input]').type('test@bakery.com');
|
||||
cy.get('[data-cy=password-input]').type('password123');
|
||||
cy.get('[data-cy=login-button]').click();
|
||||
|
||||
// Open new tab (simulated)
|
||||
cy.window().then((win) => {
|
||||
cy.stub(win, 'open').as('newTab');
|
||||
});
|
||||
|
||||
// Verify session persists
|
||||
cy.reload();
|
||||
cy.url().should('include', '/dashboard');
|
||||
cy.contains('Bakery Forecast Dashboard').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
// cypress/support/commands.ts
|
||||
Cypress.Commands.add('login', (email: string, password: string) => {
|
||||
cy.visit('/login');
|
||||
cy.get('[data-cy=email-input]').type(email);
|
||||
cy.get('[data-cy=password-input]').type(password);
|
||||
cy.get('[data-cy=login-button]').click();
|
||||
cy.url().should('include', '/dashboard');
|
||||
});
|
||||
|
||||
Cypress.Commands.add('mockWebSocket', () => {
|
||||
cy.window().then((win) => {
|
||||
win.WebSocket = class MockWebSocket {
|
||||
constructor(url: string) {
|
||||
setTimeout(() => {
|
||||
this.onopen?.({} as Event);
|
||||
}, 100);
|
||||
}
|
||||
send = cy.stub();
|
||||
close = cy.stub();
|
||||
onopen?: (event: Event) => void;
|
||||
onmessage?: (event: MessageEvent) => void;
|
||||
onerror?: (event: Event) => void;
|
||||
onclose?: (event: CloseEvent) => void;
|
||||
} as any;
|
||||
});
|
||||
});
|
||||
|
||||
// src/__tests__/performance/Dashboard.perf.test.tsx
|
||||
import { render } from '@testing-library/react';
|
||||
import { Dashboard } from '../../../pages/Dashboard/Dashboard';
|
||||
import { AuthProvider } from '../../../contexts/AuthContext';
|
||||
|
||||
describe('Dashboard Performance', () => {
|
||||
test('should render within performance budget', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Dashboard />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
const endTime = performance.now();
|
||||
const renderTime = endTime - startTime;
|
||||
|
||||
// Should render within 100ms
|
||||
expect(renderTime).toBeLessThan(100);
|
||||
});
|
||||
|
||||
test('should not cause memory leaks', async () => {
|
||||
const initialMemory = (performance as any).memory?.usedJSHeapSize;
|
||||
|
||||
// Render and unmount multiple times
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const { unmount } = render(
|
||||
<AuthProvider>
|
||||
<Dashboard />
|
||||
</AuthProvider>
|
||||
);
|
||||
unmount();
|
||||
}
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
|
||||
const finalMemory = (performance as any).memory?.usedJSHeapSize;
|
||||
|
||||
// Memory should not increase significantly
|
||||
if (initialMemory && finalMemory) {
|
||||
const memoryIncrease = finalMemory - initialMemory;
|
||||
expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024); // 10MB threshold
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// jest.config.js
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*): '<rootDir>/src/$1',
|
||||
'\\.(css|less|scss|sass): 'identity-obj-proxy',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx): 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/mocks/**',
|
||||
'!src/setupTests.ts',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// cypress.config.ts
|
||||
import { defineConfig } from 'cypress';
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:3000',
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
video: true,
|
||||
screenshotOnRunFailure: true,
|
||||
defaultCommandTimeout: 10000,
|
||||
requestTimeout: 10000,
|
||||
responseTimeout: 10000,
|
||||
setupNodeEvents(on, config) {
|
||||
// Performance testing
|
||||
on('task', {
|
||||
measurePerformance: () => {
|
||||
return {
|
||||
memory: process.memoryUsage(),
|
||||
cpu: process.cpuUsage(),
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
component: {
|
||||
devServer: {
|
||||
framework: 'react',
|
||||
bundler: 'webpack',
|
||||
},
|
||||
specPattern: 'src/**/*.cy.{ts,tsx}',
|
||||
},
|
||||
});
|
||||
|
||||
// package.json (test scripts)
|
||||
{
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:e2e": "cypress run",
|
||||
"test:e2e:open": "cypress open",
|
||||
"test:integration": "jest --testMatch='**/*.integration.test.{ts,tsx}'",
|
||||
"test:unit": "jest --testMatch='**/*.unit.test.{ts,tsx}'",
|
||||
"test:perf": "jest --testMatch='**/*.perf.test.{ts,tsx}'",
|
||||
"test:all": "npm run test:unit && npm run test:integration && npm run test:e2e"
|
||||
}
|
||||
}
|
||||
|
||||
// .github/workflows/test.yml
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
|
||||
integration-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run integration tests
|
||||
run: npm run test:integration
|
||||
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Start application
|
||||
run: |
|
||||
npm run build
|
||||
npm run start &
|
||||
npx wait-on http://localhost:3000
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
|
||||
- name: Upload test videos
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: cypress-videos
|
||||
path: cypress/videos
|
||||
Reference in New Issue
Block a user