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)
|
// FIXED: Create default instance with correct base URL (removed /api suffix)
|
||||||
export const apiClient = new ApiClient({
|
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 { apiClient } from '../base/apiClient';
|
||||||
|
import { tokenManager } from '../auth/tokenManager';
|
||||||
import {
|
import {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
@@ -9,6 +10,27 @@ import {
|
|||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
export class AuthService {
|
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
|
* User login
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +39,12 @@ export class AuthService {
|
|||||||
'/auth/login',
|
'/auth/login',
|
||||||
credentials
|
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> {
|
async getProfile(): Promise<UserProfile> {
|
||||||
const response = await apiClient.get<ApiResponse<UserProfile>>('/users/me');
|
return this.getCurrentUser();
|
||||||
return response.data!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,7 +138,14 @@ export class AuthService {
|
|||||||
* Logout (invalidate tokens)
|
* Logout (invalidate tokens)
|
||||||
*/
|
*/
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
await apiClient.post('/auth/logout');
|
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
|
// Import all service classes
|
||||||
export { AuthService, authService } from './authService';
|
import { AuthService, authService } from './authService';
|
||||||
export { DataService, dataService } from './dataService';
|
import { DataService, dataService } from './dataService';
|
||||||
export { TrainingService, trainingService } from './trainingService';
|
import { TrainingService, trainingService } from './trainingService';
|
||||||
export { ForecastingService, forecastingService } from './forecastingService';
|
import { ForecastingService, forecastingService } from './forecastingService';
|
||||||
export { NotificationService, notificationService } from './notificationService';
|
import { NotificationService, notificationService } from './notificationService';
|
||||||
export { TenantService, tenantService } from './tenantService';
|
import { TenantService, tenantService } from './tenantService';
|
||||||
|
|
||||||
// Import base API client for custom implementations
|
// Import base API client for custom implementations
|
||||||
export { apiClient } from '../base/apiClient';
|
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 React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||||
import { authService } from '../api/services/authService';
|
import { authService } from '../api/services/authService';
|
||||||
import { tokenManager } from '../api/auth/tokenManager';
|
import { tokenManager } from '../api/auth/tokenManager';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
UserProfile
|
UserProfile,
|
||||||
} from '@/api/services';
|
RegisterRequest,
|
||||||
|
api
|
||||||
import api from '@/api/services';
|
} from '../api/services';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: UserProfile | null;
|
user: UserProfile | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
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>;
|
logout: () => Promise<void>;
|
||||||
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
|
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
@@ -50,6 +49,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
full_name: tokenUser.full_name,
|
full_name: tokenUser.full_name,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_verified: tokenUser.is_verified,
|
is_verified: tokenUser.is_verified,
|
||||||
|
role: 'user', // Default role
|
||||||
|
language: 'es',
|
||||||
|
timezone: 'Europe/Madrid',
|
||||||
created_at: '', // Will be filled by API call
|
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) => {
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
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);
|
setUser(profile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsLoading(false);
|
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);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// NEW: Registration now handles tokens internally - no auto-login needed!
|
|
||||||
const profile = await authService.register(data);
|
const profile = await authService.register(data);
|
||||||
setUser(profile);
|
setUser(profile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -193,4 +198,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</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