diff --git a/frontend/src/api/base/apiClient.ts b/frontend/src/api/base/apiClient.ts index b076ec8e..d15c1a7e 100644 --- a/frontend/src/api/base/apiClient.ts +++ b/frontend/src/api/base/apiClient.ts @@ -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' }); \ No newline at end of file diff --git a/frontend/src/api/services/authService.ts b/frontend/src/api/services/authService.ts index 74d67d2b..8ea6e1ba 100644 --- a/frontend/src/api/services/authService.ts +++ b/frontend/src/api/services/authService.ts @@ -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 { + const response = await apiClient.get>('/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 { - const response = await apiClient.get>('/users/me'); - return response.data!; + return this.getCurrentUser(); } /** @@ -112,7 +138,14 @@ export class AuthService { * Logout (invalidate tokens) */ async logout(): Promise { - 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(); + } } /** diff --git a/frontend/src/api/services/index.ts b/frontend/src/api/services/index.ts index 61f98edb..869cb200 100644 --- a/frontend/src/api/services/index.ts +++ b/frontend/src/api/services/index.ts @@ -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'; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index d8c5674c..57c45945 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -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; - register: (data: RegisterData) => Promise; // SIMPLIFIED - no longer needs auto-login + register: (data: RegisterRequest) => Promise; // FIXED: Use RegisterRequest logout: () => Promise; updateProfile: (updates: Partial) => Promise; refreshUser: () => Promise; @@ -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} ); -}; \ No newline at end of file +}; + +// Export the RegisterRequest type for use in components +export type { RegisterRequest }; \ No newline at end of file diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts deleted file mode 100644 index 9146ba78..00000000 --- a/frontend/src/setupTests.ts +++ /dev/null @@ -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( - - {component} - - ); -}; - -describe('Authentication Flow', () => { - test('should login and redirect to dashboard', async () => { - const user = userEvent.setup(); - renderWithProviders(); - - // 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(); - - 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( - - - - ); -}; - -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( - - - - ); - - 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( - - - - ); - 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: ['/src/setupTests.ts'], - moduleNameMapper: { - '^@/(.*): '/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 \ No newline at end of file