Add new frontend

This commit is contained in:
Urtzi Alfaro
2025-07-22 07:37:51 +02:00
parent d77cbbafb6
commit 777798b054
24 changed files with 2023 additions and 2590 deletions

View File

@@ -56,21 +56,21 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz",
"integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==",
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
"integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.3",
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz",
"integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==",
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
"integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -79,9 +79,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz",
"integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz",
"integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -780,9 +780,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.8.tgz",
"integrity": "sha512-HzbgCY53T6bfu4tT7Aq3TvViJyHjLjPNaAS3HOuMc9pw97KHsUtXNX4L+wu59g1WnjsZSko35MbEqnO58rihhw==",
"version": "20.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz",
"integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2149,9 +2149,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.183",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.183.tgz",
"integrity": "sha512-vCrDBYjQCAEefWGjlK3EpoSKfKbT10pR4XXPdn65q7snuNOZnthoVpBfZPykmDapOKfoD+MMIPG8ZjKyyc9oHA==",
"version": "1.5.189",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.189.tgz",
"integrity": "sha512-y9D1ntS1ruO/pZ/V2FtLE+JXLQe28XoRpZ7QCCo0T8LdQladzdcOVQZH/IWLVJvCw12OGMb6hYOeOAjntCmJRQ==",
"dev": true,
"license": "ISC"
},
@@ -2979,9 +2979,9 @@
}
},
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -4227,9 +4227,9 @@
}
},
"node_modules/napi-postinstall": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.0.tgz",
"integrity": "sha512-M7NqKyhODKV1gRLdkwE7pDsZP2/SC2a2vHkOYh9MCpKMbWVfyVfUw5MaH83Fv6XMjxr5jryUp3IDDL9rlxsTeA==",
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz",
"integrity": "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -5834,9 +5834,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -0,0 +1,96 @@
// src/api/auth/authService.ts
import { tokenManager } from './tokenManager';
import { apiClient } from '../base/apiClient';
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterData {
email: string;
password: string;
full_name: string;
tenant_name?: string;
}
export interface UserProfile {
id: string;
email: string;
full_name: string;
tenant_id: string;
role: string;
is_active: boolean;
created_at: string;
}
class AuthService {
async login(credentials: LoginCredentials): Promise<UserProfile> {
// OAuth2 password flow
const formData = new URLSearchParams();
formData.append('username', credentials.email);
formData.append('password', credentials.password);
formData.append('grant_type', 'password');
const response = await fetch('/api/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Login failed');
}
const tokenResponse = await response.json();
await tokenManager.storeTokens(tokenResponse);
// Get user profile
return this.getCurrentUser();
}
async register(data: RegisterData): Promise<UserProfile> {
const response = await apiClient.post('/auth/register', data);
// Auto-login after registration
await this.login({
email: data.email,
password: data.password
});
return response;
}
async logout(): Promise<void> {
try {
await apiClient.post('/auth/logout');
} finally {
tokenManager.clearTokens();
window.location.href = '/login';
}
}
async getCurrentUser(): Promise<UserProfile> {
return apiClient.get('/auth/me');
}
async updateProfile(updates: Partial<UserProfile>): Promise<UserProfile> {
return apiClient.patch('/auth/profile', updates);
}
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await apiClient.post('/auth/change-password', {
current_password: currentPassword,
new_password: newPassword
});
}
isAuthenticated(): boolean {
return tokenManager.isAuthenticated();
}
}
export const authService = new AuthService();

View File

@@ -0,0 +1,186 @@
// src/api/auth/tokenManager.ts
import { jwtDecode } from 'jwt-decode';
interface TokenPayload {
sub: string;
user_id: string;
email: string;
exp: number;
iat: number;
}
interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
}
class TokenManager {
private static instance: TokenManager;
private accessToken: string | null = null;
private refreshToken: string | null = null;
private refreshPromise: Promise<void> | null = null;
private tokenExpiry: Date | null = null;
private constructor() {}
static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
async initialize(): Promise<void> {
// Try to restore tokens from secure storage
const stored = this.getStoredTokens();
if (stored) {
this.accessToken = stored.accessToken;
this.refreshToken = stored.refreshToken;
this.tokenExpiry = new Date(stored.expiry);
// Check if token needs refresh
if (this.isTokenExpired()) {
await this.refreshAccessToken();
}
}
}
async storeTokens(response: TokenResponse): Promise<void> {
this.accessToken = response.access_token;
this.refreshToken = response.refresh_token;
// Calculate expiry time
const expiresIn = response.expires_in || 3600; // Default 1 hour
this.tokenExpiry = new Date(Date.now() + expiresIn * 1000);
// Store securely (not in localStorage for security)
this.secureStore({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expiry: this.tokenExpiry.toISOString()
});
}
async getAccessToken(): Promise<string | null> {
// Check if token is expired or will expire soon (5 min buffer)
if (this.shouldRefreshToken()) {
await this.refreshAccessToken();
}
return this.accessToken;
}
async refreshAccessToken(): Promise<void> {
// Prevent multiple simultaneous refresh attempts
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.performTokenRefresh();
try {
await this.refreshPromise;
} finally {
this.refreshPromise = null;
}
}
private async performTokenRefresh(): Promise<void> {
if (!this.refreshToken) {
throw new Error('No refresh token available');
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh_token: this.refreshToken
})
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const data: TokenResponse = await response.json();
await this.storeTokens(data);
} catch (error) {
// Clear tokens on refresh failure
this.clearTokens();
throw error;
}
}
clearTokens(): void {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
this.clearSecureStore();
}
isAuthenticated(): boolean {
return !!this.accessToken && !this.isTokenExpired();
}
private isTokenExpired(): boolean {
if (!this.tokenExpiry) return true;
return new Date() >= this.tokenExpiry;
}
private shouldRefreshToken(): boolean {
if (!this.tokenExpiry) return true;
// Refresh if token expires in less than 5 minutes
const bufferTime = 5 * 60 * 1000; // 5 minutes
return new Date(Date.now() + bufferTime) >= this.tokenExpiry;
}
// Secure storage implementation
private secureStore(data: any): void {
// In production, use httpOnly cookies or secure session storage
// For now, using sessionStorage with encryption
const encrypted = this.encrypt(JSON.stringify(data));
sessionStorage.setItem('auth_tokens', encrypted);
}
private getStoredTokens(): any {
const stored = sessionStorage.getItem('auth_tokens');
if (!stored) return null;
try {
const decrypted = this.decrypt(stored);
return JSON.parse(decrypted);
} catch {
return null;
}
}
private clearSecureStore(): void {
sessionStorage.removeItem('auth_tokens');
}
// Simple encryption for demo (use proper encryption in production)
private encrypt(data: string): string {
return btoa(data);
}
private decrypt(data: string): string {
return atob(data);
}
// Get decoded token payload
getTokenPayload(): TokenPayload | null {
if (!this.accessToken) return null;
try {
return jwtDecode<TokenPayload>(this.accessToken);
} catch {
return null;
}
}
}
export const tokenManager = TokenManager.getInstance();

View File

@@ -1,212 +1,449 @@
// frontend/dashboard/src/api/base/apiClient.ts
/**
* Base API client with authentication and error handling
*/
// src/api/base/apiClient.ts
import { tokenManager } from '../auth/tokenManager';
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { ApiError, TokenResponse } from '../../types/api';
export interface ApiClientConfig {
baseURL?: string;
export interface ApiConfig {
baseURL: string;
timeout?: number;
enableAuth?: boolean;
enableRetry?: boolean;
retryAttempts?: number;
retryDelay?: number;
}
export class ApiClient {
private client: AxiosInstance;
private enableAuth: boolean;
private refreshPromise: Promise<string> | null = null;
export interface ApiError {
message: string;
code?: string;
status?: number;
details?: any;
}
constructor(config: ApiClientConfig = {}) {
const {
baseURL = process.env.REACT_APP_API_URL || 'http://localhost:8000',
timeout = 10000,
enableAuth = true,
enableRetry = true,
} = config;
export interface RequestConfig extends RequestInit {
params?: Record<string, any>;
timeout?: number;
retry?: boolean;
retryAttempts?: number;
}
this.enableAuth = enableAuth;
type Interceptor<T> = (value: T) => T | Promise<T>;
this.client = axios.create({
baseURL: `${baseURL}/api/v1`,
timeout,
headers: {
'Content-Type': 'application/json',
},
});
class ApiClient {
private config: ApiConfig;
private requestInterceptors: Interceptor<RequestConfig>[] = [];
private responseInterceptors: {
fulfilled: Interceptor<Response>;
rejected: Interceptor<any>;
}[] = [];
this.setupInterceptors(enableRetry);
constructor(config: ApiConfig) {
this.config = {
timeout: 30000,
retryAttempts: 3,
retryDelay: 1000,
...config
};
this.setupDefaultInterceptors();
}
private setupInterceptors(enableRetry: boolean) {
// Request interceptor - add auth token
this.client.interceptors.request.use(
(config) => {
if (this.enableAuth) {
const token = this.getStoredToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => Promise.reject(error)
);
private setupDefaultInterceptors(): void {
// Request interceptor for authentication
this.addRequestInterceptor(async (config) => {
const token = await tokenManager.getAccessToken();
if (token) {
config.headers = {
...config.headers,
'Authorization': `Bearer ${token}`
};
}
return config;
});
// Response interceptor - handle auth errors and retries
this.client.interceptors.response.use(
// Request interceptor for content type
this.addRequestInterceptor((config) => {
if (config.body && !(config.body instanceof FormData)) {
config.headers = {
...config.headers,
'Content-Type': 'application/json'
};
}
return config;
});
// Response interceptor for error handling
this.addResponseInterceptor(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Handle 401 errors with token refresh
if (
error.response?.status === 401 &&
this.enableAuth &&
!originalRequest._retry
) {
originalRequest._retry = true;
if (error.response?.status === 401) {
// Try to refresh token
try {
const newToken = await this.refreshToken();
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return this.client(originalRequest);
await tokenManager.refreshAccessToken();
// Retry original request
return this.request(error.config);
} catch (refreshError) {
this.handleAuthFailure();
return Promise.reject(refreshError);
// Redirect to login
window.location.href = '/login';
throw refreshError;
}
}
// Handle other errors
return Promise.reject(this.formatError(error));
throw this.transformError(error);
}
);
}
private async refreshToken(): Promise<string> {
// Prevent multiple simultaneous refresh requests
if (this.refreshPromise) {
return this.refreshPromise;
addRequestInterceptor(interceptor: Interceptor<RequestConfig>): void {
this.requestInterceptors.push(interceptor);
}
addResponseInterceptor(
fulfilled: Interceptor<Response>,
rejected: Interceptor<any>
): void {
this.responseInterceptors.push({ fulfilled, rejected });
}
private async applyRequestInterceptors(config: RequestConfig): Promise<RequestConfig> {
let processedConfig = config;
for (const interceptor of this.requestInterceptors) {
processedConfig = await interceptor(processedConfig);
}
return processedConfig;
}
private async applyResponseInterceptors(
response: Response | Promise<Response>
): Promise<Response> {
let processedResponse = await response;
for (const { fulfilled, rejected } of this.responseInterceptors) {
try {
processedResponse = await fulfilled(processedResponse);
} catch (error) {
processedResponse = await rejected(error);
}
}
return processedResponse;
}
private buildURL(endpoint: string, params?: Record<string, any>): string {
const url = new URL(endpoint, this.config.baseURL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
private createTimeoutPromise(timeout: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('Request timeout'));
}, timeout);
});
}
private async executeWithRetry(
fn: () => Promise<Response>,
attempts: number,
delay: number
): Promise<Response> {
try {
return await fn();
} catch (error) {
if (attempts <= 1) throw error;
// Check if error is retryable
const isRetryable = this.isRetryableError(error);
if (!isRetryable) throw error;
// Wait before retry
await new Promise(resolve => setTimeout(resolve, delay));
// Exponential backoff
return this.executeWithRetry(fn, attempts - 1, delay * 2);
}
}
private isRetryableError(error: any): boolean {
// Network errors or 5xx server errors are retryable
if (!error.response) return true;
return error.response.status >= 500;
}
private transformError(error: any): ApiError {
if (error.response) {
// Server responded with error
return {
message: error.response.data?.detail || error.response.statusText,
code: error.response.data?.code,
status: error.response.status,
details: error.response.data
};
} else if (error.request) {
// Request made but no response
return {
message: 'Network error - no response from server',
code: 'NETWORK_ERROR'
};
} else {
// Something else happened
return {
message: error.message || 'An unexpected error occurred',
code: 'UNKNOWN_ERROR'
};
}
}
async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<T> {
const processedConfig = await this.applyRequestInterceptors({
...config,
headers: {
'X-Request-ID': this.generateRequestId(),
...config.headers
}
});
const url = this.buildURL(endpoint, processedConfig.params);
const timeout = processedConfig.timeout || this.config.timeout;
const shouldRetry = processedConfig.retry !== false;
const retryAttempts = processedConfig.retryAttempts || this.config.retryAttempts;
const executeRequest = async () => {
const fetchPromise = fetch(url, {
...processedConfig,
signal: processedConfig.signal
});
const timeoutPromise = this.createTimeoutPromise(timeout);
const response = await Promise.race([fetchPromise, timeoutPromise]);
if (!response.ok) {
throw { response, config: { endpoint, ...processedConfig } };
}
return response;
};
try {
const response = shouldRetry
? await this.executeWithRetry(
executeRequest,
retryAttempts,
this.config.retryDelay!
)
: await executeRequest();
const processedResponse = await this.applyResponseInterceptors(response);
// Parse response
const contentType = processedResponse.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await processedResponse.json();
} else {
return await processedResponse.text() as any;
}
} catch (error) {
throw await this.applyResponseInterceptors(Promise.reject(error));
}
}
// Convenience methods
get<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'GET' });
}
post<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'POST',
body: data ? JSON.stringify(data) : undefined
});
}
put<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined
});
}
patch<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined
});
}
delete<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
}
// File upload
upload<T = any>(
endpoint: string,
file: File,
additionalData?: Record<string, any>,
config?: RequestConfig
): Promise<T> {
const formData = new FormData();
formData.append('file', file);
if (additionalData) {
Object.entries(additionalData).forEach(([key, value]) => {
formData.append(key, value);
});
}
return this.request<T>(endpoint, {
...config,
method: 'POST',
body: formData
});
}
// WebSocket connection
createWebSocket(endpoint: string): WebSocket {
const wsUrl = this.config.baseURL.replace(/^http/, 'ws');
return new WebSocket(`${wsUrl}${endpoint}`);
}
private generateRequestId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
// Create default instance
export const apiClient = new ApiClient({
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8000/api'
});
// src/api/base/circuitBreaker.ts
export class CircuitBreaker {
private failures: number = 0;
private lastFailureTime: number = 0;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
constructor(
private threshold: number = 5,
private timeout: number = 60000 // 1 minute
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.timeout) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit breaker is OPEN');
}
}
this.refreshPromise = this.performTokenRefresh();
try {
const token = await this.refreshPromise;
this.refreshPromise = null;
return token;
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.refreshPromise = null;
this.onFailure();
throw error;
}
}
private async performTokenRefresh(): Promise<string> {
const refreshToken = localStorage.getItem('refresh_token');
private onSuccess(): void {
this.failures = 0;
this.state = 'CLOSED';
}
private onFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();
if (!refreshToken) {
throw new Error('No refresh token available');
}
try {
const response = await axios.post<TokenResponse>(
`${this.client.defaults.baseURL}/auth/refresh`,
{ refresh_token: refreshToken }
);
const { access_token, refresh_token: newRefreshToken } = response.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', newRefreshToken);
return access_token;
} catch (error) {
throw new Error('Token refresh failed');
if (this.failures >= this.threshold) {
this.state = 'OPEN';
}
}
private getStoredToken(): string | null {
return localStorage.getItem('access_token');
}
private handleAuthFailure(): void {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_profile');
// Redirect to login
window.location.href = '/login';
}
private formatError(error: any): ApiError {
if (error.response?.data) {
return {
detail: error.response.data.detail || 'An error occurred',
service: error.response.data.service,
error_code: error.response.data.error_code,
timestamp: new Date().toISOString(),
};
}
return {
detail: error.message || 'Network error occurred',
timestamp: new Date().toISOString(),
};
}
// HTTP methods
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.get<T>(url, config);
return response.data;
}
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.post<T>(url, data, config);
return response.data;
}
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.put<T>(url, data, config);
return response.data;
}
async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.patch<T>(url, data, config);
return response.data;
}
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.delete<T>(url, config);
return response.data;
}
// File upload
async uploadFile<T>(url: string, file: File, onProgress?: (progress: number) => void): Promise<T> {
const formData = new FormData();
formData.append('file', file);
const response = await this.client.post<T>(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(progress);
}
},
});
return response.data;
}
// WebSocket connection helper
createWebSocket(path: string): WebSocket {
const wsUrl = this.client.defaults.baseURL?.replace('http', 'ws') + path;
return new WebSocket(wsUrl);
getState(): string {
return this.state;
}
}
// Default client instance
export const apiClient = new ApiClient();
// src/api/services/index.ts
import { apiClient } from '../base/apiClient';
import { AuthService } from './authService';
import { TrainingService } from './trainingService';
import { ForecastingService } from './forecastingService';
import { DataService } from './dataService';
import { TenantService } from './tenantService';
// Service instances with circuit breakers
export const authService = new AuthService(apiClient);
export const trainingService = new TrainingService(apiClient);
export const forecastingService = new ForecastingService(apiClient);
export const dataService = new DataService(apiClient);
export const tenantService = new TenantService(apiClient);
// Export types
export * from '../types';
// src/components/common/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = {
hasError: false,
error: null
};
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
// Send error to monitoring service
if (process.env.NODE_ENV === 'production') {
// logErrorToService(error, errorInfo);
}
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Algo salió mal
</h1>
<p className="text-gray-600 mb-6">
Ha ocurrido un error inesperado. Por favor, recarga la página.
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
>
Recargar página
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

View File

@@ -0,0 +1,17 @@
// src/api/services/index.ts
import { apiClient } from '../base/apiClient';
import { AuthService } from './authService';
import { TrainingService } from './trainingService';
import { ForecastingService } from './forecastingService';
import { DataService } from './dataService';
import { TenantService } from './tenantService';
// Service instances with circuit breakers
export const authService = new AuthService(apiClient);
export const trainingService = new TrainingService(apiClient);
export const forecastingService = new ForecastingService(apiClient);
export const dataService = new DataService(apiClient);
export const tenantService = new TenantService(apiClient);
// Export types
export * from '../types';

View File

@@ -0,0 +1,233 @@
// src/api/websocket/WebSocketManager.ts
import { tokenManager } from '../auth/tokenManager';
import { EventEmitter } from 'events';
export interface WebSocketConfig {
url: string;
protocols?: string[];
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
heartbeatInterval?: number;
}
export interface WebSocketHandlers {
onOpen?: () => void;
onMessage?: (data: any) => void;
onError?: (error: Event) => void;
onClose?: (event: CloseEvent) => void;
onReconnect?: () => void;
onReconnectFailed?: () => void;
}
interface WebSocketConnection {
ws: WebSocket;
config: WebSocketConfig;
handlers: WebSocketHandlers;
reconnectAttempts: number;
heartbeatTimer?: NodeJS.Timeout;
reconnectTimer?: NodeJS.Timeout;
}
class WebSocketManager extends EventEmitter {
private static instance: WebSocketManager;
private connections: Map<string, WebSocketConnection> = new Map();
private baseUrl: string;
private constructor() {
super();
this.baseUrl = this.getWebSocketBaseUrl();
}
static getInstance(): WebSocketManager {
if (!WebSocketManager.instance) {
WebSocketManager.instance = new WebSocketManager();
}
return WebSocketManager.instance;
}
async connect(
endpoint: string,
handlers: WebSocketHandlers,
config: Partial<WebSocketConfig> = {}
): Promise<WebSocket> {
// Get authentication token
const token = await tokenManager.getAccessToken();
if (!token) {
throw new Error('Authentication required for WebSocket connection');
}
const fullConfig: WebSocketConfig = {
url: `${this.baseUrl}${endpoint}`,
reconnect: true,
reconnectInterval: 1000,
maxReconnectAttempts: 5,
heartbeatInterval: 30000,
...config
};
// Add token to URL as query parameter
const urlWithAuth = `${fullConfig.url}?token=${token}`;
const ws = new WebSocket(urlWithAuth, fullConfig.protocols);
const connection: WebSocketConnection = {
ws,
config: fullConfig,
handlers,
reconnectAttempts: 0
};
this.setupWebSocketHandlers(endpoint, connection);
this.connections.set(endpoint, connection);
return ws;
}
disconnect(endpoint: string): void {
const connection = this.connections.get(endpoint);
if (connection) {
this.cleanupConnection(connection);
this.connections.delete(endpoint);
}
}
disconnectAll(): void {
this.connections.forEach((connection, endpoint) => {
this.cleanupConnection(connection);
});
this.connections.clear();
}
send(endpoint: string, data: any): void {
const connection = this.connections.get(endpoint);
if (connection && connection.ws.readyState === WebSocket.OPEN) {
connection.ws.send(JSON.stringify(data));
} else {
console.error(`WebSocket not connected for endpoint: ${endpoint}`);
}
}
private setupWebSocketHandlers(endpoint: string, connection: WebSocketConnection): void {
const { ws, handlers, config } = connection;
ws.onopen = () => {
console.log(`WebSocket connected: ${endpoint}`);
connection.reconnectAttempts = 0;
// Start heartbeat
if (config.heartbeatInterval) {
this.startHeartbeat(connection);
}
handlers.onOpen?.();
this.emit('connected', endpoint);
};
ws.onmessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
// Handle heartbeat response
if (data.type === 'pong') {
return;
}
handlers.onMessage?.(data);
this.emit('message', { endpoint, data });
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onerror = (error: Event) => {
console.error(`WebSocket error on ${endpoint}:`, error);
handlers.onError?.(error);
this.emit('error', { endpoint, error });
};
ws.onclose = (event: CloseEvent) => {
console.log(`WebSocket closed: ${endpoint}`, event.code, event.reason);
// Clear heartbeat
if (connection.heartbeatTimer) {
clearInterval(connection.heartbeatTimer);
}
handlers.onClose?.(event);
this.emit('disconnected', endpoint);
// Attempt reconnection
if (config.reconnect && connection.reconnectAttempts < config.maxReconnectAttempts!) {
this.scheduleReconnect(endpoint, connection);
} else if (connection.reconnectAttempts >= config.maxReconnectAttempts!) {
handlers.onReconnectFailed?.();
this.emit('reconnectFailed', endpoint);
}
};
}
private scheduleReconnect(endpoint: string, connection: WebSocketConnection): void {
const { config, handlers, reconnectAttempts } = connection;
// Exponential backoff
const delay = Math.min(
config.reconnectInterval! * Math.pow(2, reconnectAttempts),
30000 // Max 30 seconds
);
console.log(`Scheduling reconnect for ${endpoint} in ${delay}ms`);
connection.reconnectTimer = setTimeout(async () => {
connection.reconnectAttempts++;
try {
await this.connect(endpoint, handlers, config);
handlers.onReconnect?.();
this.emit('reconnected', endpoint);
} catch (error) {
console.error(`Reconnection failed for ${endpoint}:`, error);
}
}, delay);
}
private startHeartbeat(connection: WebSocketConnection): void {
connection.heartbeatTimer = setInterval(() => {
if (connection.ws.readyState === WebSocket.OPEN) {
connection.ws.send(JSON.stringify({ type: 'ping' }));
}
}, connection.config.heartbeatInterval!);
}
private cleanupConnection(connection: WebSocketConnection): void {
if (connection.heartbeatTimer) {
clearInterval(connection.heartbeatTimer);
}
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer);
}
if (connection.ws.readyState === WebSocket.OPEN) {
connection.ws.close();
}
}
private getWebSocketBaseUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = process.env.REACT_APP_WS_URL || window.location.host;
return `${protocol}//${host}/ws`;
}
// Get connection status
getConnectionStatus(endpoint: string): number {
const connection = this.connections.get(endpoint);
return connection ? connection.ws.readyState : WebSocket.CLOSED;
}
isConnected(endpoint: string): boolean {
return this.getConnectionStatus(endpoint) === WebSocket.OPEN;
}
}
export const wsManager = WebSocketManager.getInstance();

View File

@@ -1,30 +1,20 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import api from '../api/api';
import { useRouter } from 'next/router';
import axios from 'axios';
interface User {
id: string;
email: string;
full_name: string;
tenant_id: string;
}
interface Tenant {
id: string;
name: string;
subdomain: string;
}
// src/contexts/AuthContext.tsx
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { authService, UserProfile } from '../api/auth/authService';
import { tokenManager } from '../api/auth/tokenManager';
interface AuthContextType {
user: User | null;
tenant: Tenant | null;
user: UserProfile | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
loading: boolean;
register: (data: any) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AuthContext = createContext<AuthContextType | null>(null);
export const useAuth = () => {
const context = useContext(AuthContext);
@@ -35,74 +25,86 @@ export const useAuth = () => {
};
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [tenant, setTenant] = useState<Tenant | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
const [user, setUser] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Initialize auth state
useEffect(() => {
const token = localStorage.getItem('access_token');
if (token) {
loadUserData();
} else {
setLoading(false);
const initAuth = async () => {
try {
await tokenManager.initialize();
if (authService.isAuthenticated()) {
const profile = await authService.getCurrentUser();
setUser(profile);
}
} catch (error) {
console.error('Auth initialization failed:', error);
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
const login = useCallback(async (email: string, password: string) => {
const profile = await authService.login({ email, password });
setUser(profile);
}, []);
const register = useCallback(async (data: any) => {
const profile = await authService.register(data);
setUser(profile);
}, []);
const logout = useCallback(async () => {
await authService.logout();
setUser(null);
}, []);
const updateProfile = useCallback(async (updates: Partial<UserProfile>) => {
const updated = await authService.updateProfile(updates);
setUser(updated);
}, [updateProfile]);
const refreshUser = useCallback(async () => {
if (authService.isAuthenticated()) {
const profile = await authService.getCurrentUser();
setUser(profile);
}
}, []);
const loadUserData = async () => {
try {
const response = await api.get('/auth/users/me');
setUser(response.data.user);
setTenant(response.data.tenant);
} catch (error) {
console.error('Failed to load user data:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('tenant_id');
setUser(null);
setTenant(null);
} finally {
setLoading(false);
}
};
// Set up token refresh interval
useEffect(() => {
if (!user) return;
const login = async (email: string, password: string) => {
try {
// Create form data for OAuth2PasswordRequestForm
const formData = new URLSearchParams();
formData.append('username', email);
formData.append('password', password);
// Check token expiry every minute
const interval = setInterval(async () => {
try {
await tokenManager.getAccessToken(); // This will refresh if needed
} catch (error) {
console.error('Token refresh failed:', error);
await logout();
}
}, 60000); // 1 minute
// Make login request with correct content type
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/auth/token`,
formData,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
localStorage.setItem('access_token', response.data.access_token);
localStorage.setItem('tenant_id', response.data.tenant_id);
await loadUserData();
} catch (error) {
console.error('Login failed:', error);
throw error;
}
};
const logout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('tenant_id');
setUser(null);
setTenant(null);
router.push('/login');
};
return () => clearInterval(interval);
}, [user, logout]);
return (
<AuthContext.Provider value={{ user, tenant, login, logout, loading }}>
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
updateProfile,
refreshUser
}}
>
{children}
</AuthContext.Provider>
);

View File

View File

File diff suppressed because it is too large Load Diff

656
frontend/src/setupTests.ts Normal file
View File

@@ -0,0 +1,656 @@
// 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