Add new frontend - fix 12

This commit is contained in:
Urtzi Alfaro
2025-07-22 19:40:12 +02:00
parent 6717ce7e0d
commit 5dffe39706
5 changed files with 65 additions and 680 deletions

View File

@@ -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'
});

View File

@@ -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> {
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();
}
}
/**

View File

@@ -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';

View File

@@ -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) {
@@ -193,4 +198,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
{children}
</AuthContext.Provider>
);
};
};
// Export the RegisterRequest type for use in components
export type { RegisterRequest };

View File

@@ -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