project initialization
Some checks failed
System Monitoring / Health Checks (push) Has been cancelled
System Monitoring / Performance Monitoring (push) Has been cancelled
System Monitoring / Database Monitoring (push) Has been cancelled
System Monitoring / Cache Monitoring (push) Has been cancelled
System Monitoring / Log Monitoring (push) Has been cancelled
System Monitoring / Resource Monitoring (push) Has been cancelled
System Monitoring / Uptime Monitoring (push) Has been cancelled
System Monitoring / Backup Monitoring (push) Has been cancelled
System Monitoring / Security Monitoring (push) Has been cancelled
System Monitoring / Monitoring Dashboard (push) Has been cancelled
System Monitoring / Alerting (push) Has been cancelled
Security Scanning / Dependency Scanning (push) Has been cancelled
Security Scanning / Code Security Scanning (push) Has been cancelled
Security Scanning / Secrets Scanning (push) Has been cancelled
Security Scanning / Container Security Scanning (push) Has been cancelled
Security Scanning / Compliance Checking (push) Has been cancelled
Security Scanning / Security Dashboard (push) Has been cancelled
Security Scanning / Security Remediation (push) Has been cancelled
Some checks failed
System Monitoring / Health Checks (push) Has been cancelled
System Monitoring / Performance Monitoring (push) Has been cancelled
System Monitoring / Database Monitoring (push) Has been cancelled
System Monitoring / Cache Monitoring (push) Has been cancelled
System Monitoring / Log Monitoring (push) Has been cancelled
System Monitoring / Resource Monitoring (push) Has been cancelled
System Monitoring / Uptime Monitoring (push) Has been cancelled
System Monitoring / Backup Monitoring (push) Has been cancelled
System Monitoring / Security Monitoring (push) Has been cancelled
System Monitoring / Monitoring Dashboard (push) Has been cancelled
System Monitoring / Alerting (push) Has been cancelled
Security Scanning / Dependency Scanning (push) Has been cancelled
Security Scanning / Code Security Scanning (push) Has been cancelled
Security Scanning / Secrets Scanning (push) Has been cancelled
Security Scanning / Container Security Scanning (push) Has been cancelled
Security Scanning / Compliance Checking (push) Has been cancelled
Security Scanning / Security Dashboard (push) Has been cancelled
Security Scanning / Security Remediation (push) Has been cancelled
This commit is contained in:
0
frontend/tests/components/__init__.py
Normal file
0
frontend/tests/components/__init__.py
Normal file
457
frontend/tests/components/test_auth_components.test.tsx
Normal file
457
frontend/tests/components/test_auth_components.test.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Frontend Component Tests - Authentication Components
|
||||
*
|
||||
* Tests for authentication-related components:
|
||||
* - LoginForm
|
||||
* - RegisterForm
|
||||
* - ForgotPasswordForm
|
||||
* - ResetPasswordForm
|
||||
* - MFAVerificationForm
|
||||
*
|
||||
* Author: Claude
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from '../../src/contexts/AuthContext';
|
||||
|
||||
// Mock components for testing
|
||||
const LoginForm = ({ onSubmit, loading, error }) => (
|
||||
<div data-testid="login-form">
|
||||
<input
|
||||
data-testid="email-input"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
onChange={(e) => onSubmit?.({ email: e.target.value, password: 'testpass' })}
|
||||
/>
|
||||
<input
|
||||
data-testid="password-input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<button data-testid="submit-button" disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Login'}
|
||||
</button>
|
||||
{error && <div data-testid="error-message">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const RegisterForm = ({ onSubmit, loading, error }) => (
|
||||
<div data-testid="register-form">
|
||||
<input
|
||||
data-testid="email-input"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
/>
|
||||
<input
|
||||
data-testid="password-input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<input
|
||||
data-testid="confirm-password-input"
|
||||
type="password"
|
||||
placeholder="Confirm Password"
|
||||
/>
|
||||
<button data-testid="submit-button" disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Register'}
|
||||
</button>
|
||||
{error && <div data-testid="error-message">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ForgotPasswordForm = ({ onSubmit, loading, success }) => (
|
||||
<div data-testid="forgot-password-form">
|
||||
<input
|
||||
data-testid="email-input"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
/>
|
||||
<button data-testid="submit-button" disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Send Reset Link'}
|
||||
</button>
|
||||
{success && <div data-testid="success-message">Reset link sent!</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MFAVerificationForm = ({ onSubmit, loading, error }) => (
|
||||
<div data-testid="mfa-form">
|
||||
<input
|
||||
data-testid="code-input"
|
||||
type="text"
|
||||
placeholder="6-digit code"
|
||||
maxLength={6}
|
||||
/>
|
||||
<button data-testid="submit-button" disabled={loading}>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
{error && <div data-testid="error-message">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Wrapper component for testing
|
||||
const TestWrapper = ({ children }) => (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
describe('Authentication Components', () => {
|
||||
describe('LoginForm', () => {
|
||||
const mockOnSubmit = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnSubmit.mockClear();
|
||||
});
|
||||
|
||||
test('renders login form correctly', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm onSubmit={mockOnSubmit} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('email-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('password-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles email input change', async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm onSubmit={mockOnSubmit} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
await act(async () => {
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
});
|
||||
|
||||
expect(emailInput).toHaveValue('test@example.com');
|
||||
});
|
||||
|
||||
test('handles form submission', async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm onSubmit={mockOnSubmit} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const submitButton = screen.getByTestId('submit-button');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'testpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('disables submit button when loading', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm onSubmit={mockOnSubmit} loading={true} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByTestId('submit-button');
|
||||
expect(submitButton).toBeDisabled();
|
||||
expect(submitButton).toHaveTextContent('Loading...');
|
||||
});
|
||||
|
||||
test('displays error message', () => {
|
||||
const errorMessage = 'Invalid credentials';
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm onSubmit={mockOnSubmit} error={errorMessage} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('error-message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent(errorMessage);
|
||||
});
|
||||
|
||||
test('validates email format', async () => {
|
||||
const validateEmail = (email) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
expect(validateEmail('test@example.com')).toBe(true);
|
||||
expect(validateEmail('invalid-email')).toBe(false);
|
||||
expect(validateEmail('@example.com')).toBe(false);
|
||||
expect(validateEmail('test@')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RegisterForm', () => {
|
||||
const mockOnSubmit = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnSubmit.mockClear();
|
||||
});
|
||||
|
||||
test('renders registration form correctly', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RegisterForm onSubmit={mockOnSubmit} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('register-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('email-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('password-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('confirm-password-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('validates password confirmation', () => {
|
||||
const validatePasswordMatch = (password, confirmPassword) => {
|
||||
return password === confirmPassword;
|
||||
};
|
||||
|
||||
expect(validatePasswordMatch('password123', 'password123')).toBe(true);
|
||||
expect(validatePasswordMatch('password123', 'different')).toBe(false);
|
||||
});
|
||||
|
||||
test('validates password strength', () => {
|
||||
const validatePasswordStrength = (password) => {
|
||||
const minLength = password.length >= 8;
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumbers = /\d/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
||||
|
||||
return {
|
||||
isValid: minLength && hasUpperCase && hasLowerCase && hasNumbers,
|
||||
minLength,
|
||||
hasUpperCase,
|
||||
hasLowerCase,
|
||||
hasNumbers,
|
||||
hasSpecialChar
|
||||
};
|
||||
};
|
||||
|
||||
const strongPassword = 'StrongPass123!';
|
||||
const weakPassword = 'weak';
|
||||
|
||||
const strongResult = validatePasswordStrength(strongPassword);
|
||||
const weakResult = validatePasswordStrength(weakPassword);
|
||||
|
||||
expect(strongResult.isValid).toBe(true);
|
||||
expect(weakResult.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ForgotPasswordForm', () => {
|
||||
const mockOnSubmit = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnSubmit.mockClear();
|
||||
});
|
||||
|
||||
test('renders forgot password form correctly', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ForgotPasswordForm onSubmit={mockOnSubmit} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('forgot-password-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('email-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays success message when email is sent', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ForgotPasswordForm onSubmit={mockOnSubmit} success={true} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('success-message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('success-message')).toHaveTextContent('Reset link sent!');
|
||||
});
|
||||
|
||||
test('handles form submission', async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ForgotPasswordForm onSubmit={mockOnSubmit} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const submitButton = screen.getByTestId('submit-button');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
// Since we're not implementing the actual onSubmit logic in our mock,
|
||||
// we just verify the component structure
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFAVerificationForm', () => {
|
||||
const mockOnSubmit = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnSubmit.mockClear();
|
||||
});
|
||||
|
||||
test('renders MFA verification form correctly', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MFAVerificationForm onSubmit={mockOnSubmit} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('mfa-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('code-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('limits code input to 6 characters', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MFAVerificationForm onSubmit={mockOnSubmit} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const codeInput = screen.getByTestId('code-input');
|
||||
expect(codeInput).toHaveAttribute('maxLength', '6');
|
||||
});
|
||||
|
||||
test('validates 6-digit code format', () => {
|
||||
const validateMFACode = (code) => {
|
||||
return /^\d{6}$/.test(code);
|
||||
};
|
||||
|
||||
expect(validateMFACode('123456')).toBe(true);
|
||||
expect(validateMFACode('12345')).toBe(false);
|
||||
expect(validateMFACode('1234567')).toBe(false);
|
||||
expect(validateMFACode('abc123')).toBe(false);
|
||||
});
|
||||
|
||||
test('displays error message', () => {
|
||||
const errorMessage = 'Invalid verification code';
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MFAVerificationForm onSubmit={mockOnSubmit} error={errorMessage} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('error-message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation Utilities', () => {
|
||||
test('validates Malaysian phone numbers', () => {
|
||||
const validateMalaysianPhone = (phone) => {
|
||||
const phoneRegex = /^(\+?6?01)[0-46-9]-*[0-9]{7,8}$/;
|
||||
return phoneRegex.test(phone);
|
||||
};
|
||||
|
||||
expect(validateMalaysianPhone('+60123456789')).toBe(true);
|
||||
expect(validateMalaysianPhone('0123456789')).toBe(true);
|
||||
expect(validateMalaysianPhone('012-3456789')).toBe(true);
|
||||
expect(validateMalaysianPhone('123456789')).toBe(false);
|
||||
expect(validateMalaysianPhone('+6512345678')).toBe(false);
|
||||
});
|
||||
|
||||
test('validates Malaysian IC numbers', () => {
|
||||
const validateMalaysianIC = (ic) => {
|
||||
const icRegex = /^[0-9]{6}-[0-9]{2}-[0-9]{4}$/;
|
||||
return icRegex.test(ic);
|
||||
};
|
||||
|
||||
expect(validateMalaysianIC('000101-01-0001')).toBe(true);
|
||||
expect(validateMalaysianIC('901231-12-3456')).toBe(true);
|
||||
expect(validateMalaysianIC('000101-01-000')).toBe(false);
|
||||
expect(validateMalaysianIC('000101-01-00012')).toBe(false);
|
||||
expect(validateMalaysianIC('000101/01/0001')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility Tests', () => {
|
||||
test('form inputs have proper labels', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm onSubmit={jest.fn()} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
|
||||
expect(emailInput).toHaveAttribute('type', 'email');
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
test('buttons are accessible', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm onSubmit={jest.fn()} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByTestId('submit-button');
|
||||
expect(submitButton).toBeVisible();
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design Tests', () => {
|
||||
test('components render on mobile viewports', () => {
|
||||
// Mock mobile viewport
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 375,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm onSubmit={jest.fn()} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('email-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('password-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('components render on desktop viewports', () => {
|
||||
// Mock desktop viewport
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1920,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LoginForm onSubmit={jest.fn()} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('email-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('password-input')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
598
frontend/tests/components/test_dashboard_components.test.tsx
Normal file
598
frontend/tests/components/test_dashboard_components.test.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Frontend Component Tests - Dashboard Components
|
||||
*
|
||||
* Tests for dashboard-related components:
|
||||
* - DashboardLayout
|
||||
* - StatsCard
|
||||
* - ChartComponent
|
||||
* - ActivityFeed
|
||||
* - NotificationPanel
|
||||
*
|
||||
* Author: Claude
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
// Mock components for testing
|
||||
const StatsCard = ({ title, value, change, changeType, icon }) => (
|
||||
<div data-testid="stats-card">
|
||||
<div data-testid="card-title">{title}</div>
|
||||
<div data-testid="card-value">{value}</div>
|
||||
{change && (
|
||||
<div data-testid={`card-change-${changeType}`}>
|
||||
{change > 0 ? '+' : ''}{change}%
|
||||
</div>
|
||||
)}
|
||||
{icon && <div data-testid="card-icon">{icon}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ChartComponent = ({ data, type, height = 300 }) => (
|
||||
<div data-testid="chart-component">
|
||||
<div data-testid="chart-type">{type}</div>
|
||||
<div data-testid="chart-height">{height}</div>
|
||||
<div data-testid="chart-data-points">{data?.length || 0}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ActivityFeed = ({ activities, loading }) => (
|
||||
<div data-testid="activity-feed">
|
||||
{loading ? (
|
||||
<div data-testid="loading-spinner">Loading...</div>
|
||||
) : (
|
||||
<div data-testid="activities-list">
|
||||
{activities?.map((activity, index) => (
|
||||
<div key={index} data-testid={`activity-${index}`}>
|
||||
<div data-testid="activity-type">{activity.type}</div>
|
||||
<div data-testid="activity-description">{activity.description}</div>
|
||||
<div data-testid="activity-time">{activity.timestamp}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const NotificationPanel = ({ notifications, onMarkAsRead, onDismiss }) => (
|
||||
<div data-testid="notification-panel">
|
||||
<div data-testid="notifications-header">Notifications</div>
|
||||
<div data-testid="notifications-list">
|
||||
{notifications?.map((notification, index) => (
|
||||
<div key={index} data-testid={`notification-${index}`}>
|
||||
<div data-testid="notification-title">{notification.title}</div>
|
||||
<div data-testid="notification-message">{notification.message}</div>
|
||||
<div data-testid="notification-type">{notification.type}</div>
|
||||
<div data-testid="notification-read">{notification.read ? 'Read' : 'Unread'}</div>
|
||||
{!notification.read && (
|
||||
<button
|
||||
data-testid={`mark-read-${index}`}
|
||||
onClick={() => onMarkAsRead?.(notification.id)}
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
data-testid={`dismiss-${index}`}
|
||||
onClick={() => onDismiss?.(notification.id)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(!notifications || notifications.length === 0) && (
|
||||
<div data-testid="no-notifications">No notifications</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Test data
|
||||
const mockActivities = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'user_created',
|
||||
description: 'New user registered: John Doe',
|
||||
timestamp: '2024-01-15T10:30:00Z',
|
||||
user: { name: 'John Doe' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'payment_processed',
|
||||
description: 'Payment of RM299.00 processed',
|
||||
timestamp: '2024-01-15T09:15:00Z',
|
||||
amount: 299.00
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'login_attempt',
|
||||
description: 'Failed login attempt from unknown device',
|
||||
timestamp: '2024-01-15T08:00:00Z',
|
||||
successful: false
|
||||
}
|
||||
];
|
||||
|
||||
const mockNotifications = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Welcome!',
|
||||
message: 'Welcome to the platform',
|
||||
type: 'info',
|
||||
read: false,
|
||||
timestamp: '2024-01-15T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Payment Received',
|
||||
message: 'Your subscription payment was successful',
|
||||
type: 'success',
|
||||
read: true,
|
||||
timestamp: '2024-01-14T15:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Security Alert',
|
||||
message: 'New login detected from unknown device',
|
||||
type: 'warning',
|
||||
read: false,
|
||||
timestamp: '2024-01-14T12:00:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
const mockChartData = [
|
||||
{ month: 'Jan', value: 1000 },
|
||||
{ month: 'Feb', value: 1200 },
|
||||
{ month: 'Mar', value: 1100 },
|
||||
{ month: 'Apr', value: 1400 },
|
||||
{ month: 'May', value: 1300 },
|
||||
{ month: 'Jun', value: 1500 }
|
||||
];
|
||||
|
||||
describe('Dashboard Components', () => {
|
||||
describe('StatsCard', () => {
|
||||
test('renders stats card with basic props', () => {
|
||||
render(
|
||||
<StatsCard
|
||||
title="Total Users"
|
||||
value="1,234"
|
||||
change={12.5}
|
||||
changeType="positive"
|
||||
icon="👥"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('stats-card')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('Total Users');
|
||||
expect(screen.getByTestId('card-value')).toHaveTextContent('1,234');
|
||||
expect(screen.getByTestId('card-change-positive')).toHaveTextContent('+12.5%');
|
||||
expect(screen.getByTestId('card-icon')).toHaveTextContent('👥');
|
||||
});
|
||||
|
||||
test('renders stats card with negative change', () => {
|
||||
render(
|
||||
<StatsCard
|
||||
title="Bounce Rate"
|
||||
value="45.2%"
|
||||
change={-3.2}
|
||||
changeType="negative"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('card-change-negative')).toHaveTextContent('-3.2%');
|
||||
});
|
||||
|
||||
test('renders stats card without change', () => {
|
||||
render(
|
||||
<StatsCard
|
||||
title="Total Revenue"
|
||||
value="RM 45,000"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(/card-change-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('formats large numbers correctly', () => {
|
||||
const testCases = [
|
||||
{ input: '1000000', expected: '1,000,000' },
|
||||
{ input: '2500000.50', expected: '2,500,000.50' },
|
||||
{ input: '1234567890', expected: '1,234,567,890' }
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
render(<StatsCard title="Test" value={input} />);
|
||||
expect(screen.getByTestId('card-value')).toHaveTextContent(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChartComponent', () => {
|
||||
test('renders chart with data', () => {
|
||||
render(
|
||||
<ChartComponent
|
||||
data={mockChartData}
|
||||
type="line"
|
||||
height={400}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('chart-component')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chart-type')).toHaveTextContent('line');
|
||||
expect(screen.getByTestId('chart-height')).toHaveTextContent('400');
|
||||
expect(screen.getByTestId('chart-data-points')).toHaveTextContent('6');
|
||||
});
|
||||
|
||||
test('renders chart without data', () => {
|
||||
render(
|
||||
<ChartComponent
|
||||
data={[]}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('chart-data-points')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
test('renders chart with null data', () => {
|
||||
render(
|
||||
<ChartComponent
|
||||
data={null}
|
||||
type="pie"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('chart-data-points')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
test('supports different chart types', () => {
|
||||
const chartTypes = ['line', 'bar', 'pie', 'area', 'scatter'];
|
||||
|
||||
chartTypes.forEach(type => {
|
||||
const { unmount } = render(
|
||||
<ChartComponent
|
||||
data={mockChartData}
|
||||
type={type}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('chart-type')).toHaveTextContent(type);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActivityFeed', () => {
|
||||
test('renders activity feed with activities', () => {
|
||||
const mockOnActivityClick = jest.fn();
|
||||
|
||||
render(
|
||||
<ActivityFeed
|
||||
activities={mockActivities}
|
||||
loading={false}
|
||||
onActivityClick={mockOnActivityClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('activity-feed')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('activities-list')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId(/activity-/)).toHaveLength(3);
|
||||
|
||||
// Check first activity
|
||||
expect(screen.getByTestId('activity-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('activity-type')).toHaveTextContent('user_created');
|
||||
expect(screen.getByTestId('activity-description')).toHaveTextContent('New user registered: John Doe');
|
||||
});
|
||||
|
||||
test('renders loading state', () => {
|
||||
render(
|
||||
<ActivityFeed
|
||||
activities={[]}
|
||||
loading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('activities-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders empty state', () => {
|
||||
render(
|
||||
<ActivityFeed
|
||||
activities={[]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('activities-list')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(/activity-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('formats timestamps correctly', () => {
|
||||
const formatTimestamp = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const recent = new Date().toISOString();
|
||||
const hourAgo = new Date(Date.now() - 3600000).toISOString();
|
||||
const dayAgo = new Date(Date.now() - 86400000).toISOString();
|
||||
|
||||
expect(formatTimestamp(recent)).toMatch(/Just now|\d+m ago/);
|
||||
expect(formatTimestamp(hourAgo)).toMatch(/\d+h ago/);
|
||||
expect(formatTimestamp(dayAgo)).toMatch(/\d+d ago/);
|
||||
});
|
||||
|
||||
test('handles activity click', () => {
|
||||
const mockOnActivityClick = jest.fn();
|
||||
|
||||
render(
|
||||
<ActivityFeed
|
||||
activities={mockActivities}
|
||||
loading={false}
|
||||
onActivityClick={mockOnActivityClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const firstActivity = screen.getByTestId('activity-0');
|
||||
fireEvent.click(firstActivity);
|
||||
|
||||
expect(mockOnActivityClick).toHaveBeenCalledWith(mockActivities[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationPanel', () => {
|
||||
const mockOnMarkAsRead = jest.fn();
|
||||
const mockOnDismiss = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnMarkAsRead.mockClear();
|
||||
mockOnDismiss.mockClear();
|
||||
});
|
||||
|
||||
test('renders notification panel with notifications', () => {
|
||||
render(
|
||||
<NotificationPanel
|
||||
notifications={mockNotifications}
|
||||
onMarkAsRead={mockOnMarkAsRead}
|
||||
onDismiss={mockOnDismiss}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('notification-panel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notifications-header')).toHaveTextContent('Notifications');
|
||||
expect(screen.getAllByTestId(/notification-/)).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('renders empty state', () => {
|
||||
render(
|
||||
<NotificationPanel
|
||||
notifications={[]}
|
||||
onMarkAsRead={mockOnMarkAsRead}
|
||||
onDismiss={mockOnDismiss}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('no-notifications')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-notifications')).toHaveTextContent('No notifications');
|
||||
});
|
||||
|
||||
test('handles mark as read action', async () => {
|
||||
render(
|
||||
<NotificationPanel
|
||||
notifications={mockNotifications}
|
||||
onMarkAsRead={mockOnMarkAsRead}
|
||||
onDismiss={mockOnDismiss}
|
||||
/>
|
||||
);
|
||||
|
||||
const firstNotification = screen.getByTestId('notification-0');
|
||||
const markReadButton = screen.getByTestId('mark-read-0');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(markReadButton);
|
||||
});
|
||||
|
||||
expect(mockOnMarkAsRead).toHaveBeenCalledWith(mockNotifications[0].id);
|
||||
});
|
||||
|
||||
test('handles dismiss action', async () => {
|
||||
render(
|
||||
<NotificationPanel
|
||||
notifications={mockNotifications}
|
||||
onMarkAsRead={mockOnMarkAsRead}
|
||||
onDismiss={mockOnDismiss}
|
||||
/>
|
||||
);
|
||||
|
||||
const dismissButton = screen.getByTestId('dismiss-0');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissButton);
|
||||
});
|
||||
|
||||
expect(mockOnDismiss).toHaveBeenCalledWith(mockNotifications[0].id);
|
||||
});
|
||||
|
||||
test('shows read/unread status correctly', () => {
|
||||
render(
|
||||
<NotificationPanel
|
||||
notifications={mockNotifications}
|
||||
onMarkAsRead={mockOnMarkAsRead}
|
||||
onDismiss={mockOnDismiss}
|
||||
/>
|
||||
);
|
||||
|
||||
// First notification should be unread
|
||||
expect(screen.getByTestId('notification-0')).toHaveTextContent('Unread');
|
||||
expect(screen.getByTestId('mark-read-0')).toBeInTheDocument();
|
||||
|
||||
// Second notification should be read
|
||||
expect(screen.getByTestId('notification-1')).toHaveTextContent('Read');
|
||||
expect(screen.queryByTestId('mark-read-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows different notification types', () => {
|
||||
const typeNotifications = [
|
||||
{ id: 1, title: 'Info', message: 'Info message', type: 'info', read: false },
|
||||
{ id: 2, title: 'Success', message: 'Success message', type: 'success', read: false },
|
||||
{ id: 3, title: 'Warning', message: 'Warning message', type: 'warning', read: false },
|
||||
{ id: 4, title: 'Error', message: 'Error message', type: 'error', read: false }
|
||||
];
|
||||
|
||||
render(
|
||||
<NotificationPanel
|
||||
notifications={typeNotifications}
|
||||
onMarkAsRead={jest.fn()}
|
||||
onDismiss={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId(/notification-/)).toHaveLength(4);
|
||||
expect(screen.getByTestId('notification-0')).toHaveTextContent('info');
|
||||
expect(screen.getByTestId('notification-1')).toHaveTextContent('success');
|
||||
expect(screen.getByTestId('notification-2')).toHaveTextContent('warning');
|
||||
expect(screen.getByTestId('notification-3')).toHaveTextContent('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dashboard Integration', () => {
|
||||
test('components work together in dashboard layout', () => {
|
||||
const DashboardLayout = ({ children }) => (
|
||||
<div data-testid="dashboard-layout">
|
||||
<div data-testid="sidebar">Sidebar</div>
|
||||
<div data-testid="main-content">
|
||||
<div data-testid="header">Header</div>
|
||||
<div data-testid="dashboard-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<StatsCard title="Test Stat" value="100" />
|
||||
<ChartComponent data={mockChartData} type="line" />
|
||||
<ActivityFeed activities={mockActivities} loading={false} />
|
||||
<NotificationPanel notifications={mockNotifications} />
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('dashboard-layout')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('main-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('stats-card')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chart-component')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('activity-feed')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles responsive behavior', () => {
|
||||
// Test mobile viewport
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 375,
|
||||
});
|
||||
|
||||
render(
|
||||
<div>
|
||||
<StatsCard title="Mobile Test" value="100" />
|
||||
<ChartComponent data={mockChartData} type="line" height={200} />
|
||||
</div>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('stats-card')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chart-component')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chart-height')).toHaveTextContent('200');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Considerations', () => {
|
||||
test('handles large datasets efficiently', () => {
|
||||
const largeDataset = Array.from({ length: 1000 }, (_, i) => ({
|
||||
month: `Month ${i}`,
|
||||
value: Math.floor(Math.random() * 1000)
|
||||
}));
|
||||
|
||||
const startTime = performance.now();
|
||||
render(<ChartComponent data={largeDataset} type="line" />);
|
||||
const endTime = performance.now();
|
||||
|
||||
// Should render within 100ms for 1000 data points
|
||||
expect(endTime - startTime).toBeLessThan(100);
|
||||
expect(screen.getByTestId('chart-data-points')).toHaveTextContent('1000');
|
||||
});
|
||||
|
||||
test('debounces rapid interactions', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockOnChange = jest.fn();
|
||||
const { rerender } = render(
|
||||
<StatsCard
|
||||
title="Debounce Test"
|
||||
value="100"
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
// Simulate rapid changes
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rerender(
|
||||
<StatsCard
|
||||
title="Debounce Test"
|
||||
value={i.toString()}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fast-forward timer
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Should only call once after debouncing
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('handles missing props gracefully', () => {
|
||||
render(<StatsCard />);
|
||||
|
||||
expect(screen.getByTestId('stats-card')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('card-title')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('card-value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles invalid data types', () => {
|
||||
const invalidData = [
|
||||
{ month: 'Jan', value: 'invalid' },
|
||||
{ month: 'Feb', value: null },
|
||||
{ month: 'Mar', value: undefined }
|
||||
];
|
||||
|
||||
render(<ChartComponent data={invalidData} type="line" />);
|
||||
|
||||
expect(screen.getByTestId('chart-component')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chart-data-points')).toHaveTextContent('3');
|
||||
});
|
||||
});
|
||||
});
|
||||
0
frontend/tests/integration/__init__.py
Normal file
0
frontend/tests/integration/__init__.py
Normal file
713
frontend/tests/integration/test_authentication_flow.test.tsx
Normal file
713
frontend/tests/integration/test_authentication_flow.test.tsx
Normal file
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* Frontend Integration Tests - Authentication Flow
|
||||
*
|
||||
* Tests for complete authentication workflows:
|
||||
* - Registration flow
|
||||
* - Login flow with MFA
|
||||
* - Password reset flow
|
||||
* - Session management
|
||||
* - Protected route access
|
||||
*
|
||||
* Author: Claude
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
// Mock API service
|
||||
const mockApiService = {
|
||||
register: jest.fn(),
|
||||
login: jest.fn(),
|
||||
verifyMfa: jest.fn(),
|
||||
requestPasswordReset: jest.fn(),
|
||||
resetPassword: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
getCurrentUser: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock components
|
||||
const LoginForm = ({ onSuccess, onMfaRequired, loading, error }) => (
|
||||
<div data-testid="login-form">
|
||||
<input data-testid="email-input" placeholder="Email" type="email" />
|
||||
<input data-testid="password-input" placeholder="Password" type="password" />
|
||||
<button data-testid="login-button" disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
<button data-testid="forgot-password-link">Forgot Password?</button>
|
||||
{error && <div data-testid="error-message">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const RegisterForm = ({ onSuccess, loading, error }) => (
|
||||
<div data-testid="register-form">
|
||||
<input data-testid="email-input" placeholder="Email" type="email" />
|
||||
<input data-testid="password-input" placeholder="Password" type="password" />
|
||||
<input data-testid="confirm-password-input" placeholder="Confirm Password" type="password" />
|
||||
<input data-testid="business-name-input" placeholder="Business Name" />
|
||||
<button data-testid="register-button" disabled={loading}>
|
||||
{loading ? 'Registering...' : 'Register'}
|
||||
</button>
|
||||
{error && <div data-testid="error-message">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MFAVerificationForm = ({ onSuccess, onResendCode, loading, error }) => (
|
||||
<div data-testid="mfa-form">
|
||||
<div data-testid="mfa-instructions">Enter the 6-digit code sent to your device</div>
|
||||
<input data-testid="mfa-code-input" placeholder="6-digit code" maxLength={6} />
|
||||
<button data-testid="verify-button" disabled={loading}>
|
||||
{loading ? 'Verifying...' : 'Verify Code'}
|
||||
</button>
|
||||
<button data-testid="resend-code-button">Resend Code</button>
|
||||
{error && <div data-testid="error-message">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ForgotPasswordForm = ({ onSuccess, loading, error }) => (
|
||||
<div data-testid="forgot-password-form">
|
||||
<div data-testid="instructions">Enter your email to receive a reset link</div>
|
||||
<input data-testid="email-input" placeholder="Email" type="email" />
|
||||
<button data-testid="submit-button" disabled={loading}>
|
||||
{loading ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
{error && <div data-testid="error-message">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ResetPasswordForm = ({ onSuccess, loading, error }) => (
|
||||
<div data-testid="reset-password-form">
|
||||
<input data-testid="new-password-input" placeholder="New Password" type="password" />
|
||||
<input data-testid="confirm-password-input" placeholder="Confirm New Password" type="password" />
|
||||
<button data-testid="reset-button" disabled={loading}>
|
||||
{loading ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
{error && <div data-testid="error-message">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProtectedRoute = ({ children, requiredRole = 'user' }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
|
||||
const [userRole, setUserRole] = React.useState('user');
|
||||
|
||||
React.useEffect(() => {
|
||||
// Mock authentication check
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const user = await mockApiService.getCurrentUser();
|
||||
setIsAuthenticated(true);
|
||||
setUserRole(user.role);
|
||||
} catch {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <div data-testid="redirecting-to-login">Redirecting to login...</div>;
|
||||
}
|
||||
|
||||
if (requiredRole !== 'user' && userRole !== requiredRole) {
|
||||
return <div data-testid="access-denied">Access Denied</div>;
|
||||
}
|
||||
|
||||
return <div data-testid="protected-content">{children}</div>;
|
||||
};
|
||||
|
||||
const Dashboard = () => (
|
||||
<div data-testid="dashboard">
|
||||
<h1>Welcome to Dashboard</h1>
|
||||
<ProtectedRoute>
|
||||
<div>Protected Content</div>
|
||||
</ProtectedRoute>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Test wrapper with router
|
||||
const TestApp = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Routes>
|
||||
<Route path="/login" element={
|
||||
<LoginForm
|
||||
onSuccess={(data) => {
|
||||
if (data.mfaRequired) {
|
||||
navigate('/mfa');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}}
|
||||
onMfaRequired={() => navigate('/mfa')}
|
||||
/>
|
||||
} />
|
||||
<Route path="/register" element={
|
||||
<RegisterForm onSuccess={() => navigate('/login')} />
|
||||
} />
|
||||
<Route path="/mfa" element={
|
||||
<MFAVerificationForm onSuccess={() => navigate('/dashboard')} />
|
||||
} />
|
||||
<Route path="/forgot-password" element={
|
||||
<ForgotPasswordForm onSuccess={() => navigate('/login')} />
|
||||
} />
|
||||
<Route path="/reset-password/:token" element={
|
||||
<ResetPasswordForm onSuccess={() => navigate('/login')} />
|
||||
} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/" element={<div data-testid="home-page">Home Page</div>} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Authentication Flow Integration Tests', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockApiService.getCurrentUser.mockResolvedValue({
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
role: 'user'
|
||||
});
|
||||
});
|
||||
|
||||
describe('Registration Flow', () => {
|
||||
test('complete registration flow', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Navigate to register page
|
||||
await act(async () => {
|
||||
window.location.assign = jest.fn();
|
||||
window.location.href = '/register';
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/register']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Fill registration form
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const confirmPasswordInput = screen.getByTestId('confirm-password-input');
|
||||
const businessNameInput = screen.getByTestId('business-name-input');
|
||||
const registerButton = screen.getByTestId('register-button');
|
||||
|
||||
await user.type(emailInput, 'newuser@example.com');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
await user.type(confirmPasswordInput, 'SecurePass123!');
|
||||
await user.type(businessNameInput, 'Test Business Sdn Bhd');
|
||||
|
||||
// Mock successful registration
|
||||
mockApiService.register.mockResolvedValueOnce({
|
||||
success: true,
|
||||
message: 'Registration successful'
|
||||
});
|
||||
|
||||
await user.click(registerButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiService.register).toHaveBeenCalledWith({
|
||||
email: 'newuser@example.com',
|
||||
password: 'SecurePass123!',
|
||||
confirmPassword: 'SecurePass123!',
|
||||
businessName: 'Test Business Sdn Bhd'
|
||||
});
|
||||
});
|
||||
|
||||
// Should redirect to login after successful registration
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('registration with password mismatch', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/register']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const confirmPasswordInput = screen.getByTestId('confirm-password-input');
|
||||
const registerButton = screen.getByTestId('register-button');
|
||||
|
||||
await user.type(emailInput, 'newuser@example.com');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
await user.type(confirmPasswordInput, 'DifferentPass123!');
|
||||
|
||||
await user.click(registerButton);
|
||||
|
||||
// Should show validation error
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent('Passwords do not match');
|
||||
});
|
||||
});
|
||||
|
||||
test('registration with weak password', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/register']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const confirmPasswordInput = screen.getByTestId('confirm-password-input');
|
||||
const registerButton = screen.getByTestId('register-button');
|
||||
|
||||
await user.type(emailInput, 'newuser@example.com');
|
||||
await user.type(passwordInput, 'weak');
|
||||
await user.type(confirmPasswordInput, 'weak');
|
||||
|
||||
await user.click(registerButton);
|
||||
|
||||
// Should show validation error
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent('Password is too weak');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login Flow with MFA', () => {
|
||||
test('successful login without MFA', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/login']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
|
||||
// Mock successful login without MFA
|
||||
mockApiService.login.mockResolvedValueOnce({
|
||||
success: true,
|
||||
user: { id: 1, email: 'test@example.com', role: 'user' },
|
||||
accessToken: 'mock-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
mfaRequired: false
|
||||
});
|
||||
|
||||
await user.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiService.login).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePass123!'
|
||||
});
|
||||
});
|
||||
|
||||
// Should redirect to dashboard
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('login requiring MFA verification', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/login']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
|
||||
// Mock login requiring MFA
|
||||
mockApiService.login.mockResolvedValueOnce({
|
||||
success: true,
|
||||
mfaRequired: true,
|
||||
tempToken: 'mock-temp-token'
|
||||
});
|
||||
|
||||
await user.click(loginButton);
|
||||
|
||||
// Should redirect to MFA verification
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mfa-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Complete MFA verification
|
||||
const mfaCodeInput = screen.getByTestId('mfa-code-input');
|
||||
const verifyButton = screen.getByTestId('verify-button');
|
||||
|
||||
await user.type(mfaCodeInput, '123456');
|
||||
|
||||
mockApiService.verifyMfa.mockResolvedValueOnce({
|
||||
success: true,
|
||||
user: { id: 1, email: 'test@example.com', role: 'user' },
|
||||
accessToken: 'mock-token',
|
||||
refreshToken: 'mock-refresh-token'
|
||||
});
|
||||
|
||||
await user.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiService.verifyMfa).toHaveBeenCalledWith({
|
||||
code: '123456',
|
||||
tempToken: 'mock-temp-token'
|
||||
});
|
||||
});
|
||||
|
||||
// Should redirect to dashboard
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('login with invalid credentials', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/login']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
await user.type(emailInput, 'invalid@example.com');
|
||||
await user.type(passwordInput, 'wrongpassword');
|
||||
|
||||
// Mock failed login
|
||||
mockApiService.login.mockRejectedValueOnce({
|
||||
response: { data: { message: 'Invalid credentials' } }
|
||||
});
|
||||
|
||||
await user.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent('Invalid credentials');
|
||||
});
|
||||
|
||||
// Should not redirect
|
||||
expect(screen.queryByTestId('dashboard')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password Reset Flow', () => {
|
||||
test('complete password reset flow', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/login']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Navigate to forgot password
|
||||
const forgotPasswordLink = screen.getByTestId('forgot-password-link');
|
||||
await user.click(forgotPasswordLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('forgot-password-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Request password reset
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const submitButton = screen.getByTestId('submit-button');
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
mockApiService.requestPasswordReset.mockResolvedValueOnce({
|
||||
success: true,
|
||||
message: 'Reset link sent'
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiService.requestPasswordReset).toHaveBeenCalledWith({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
});
|
||||
|
||||
// Should show success message and redirect to login
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('password reset with new password', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/reset-password/mock-token']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const newPasswordInput = screen.getByTestId('new-password-input');
|
||||
const confirmPasswordInput = screen.getByTestId('confirm-password-input');
|
||||
const resetButton = screen.getByTestId('reset-button');
|
||||
|
||||
await user.type(newPasswordInput, 'NewSecurePass123!');
|
||||
await user.type(confirmPasswordInput, 'NewSecurePass123!');
|
||||
|
||||
mockApiService.resetPassword.mockResolvedValueOnce({
|
||||
success: true,
|
||||
message: 'Password reset successful'
|
||||
});
|
||||
|
||||
await user.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiService.resetPassword).toHaveBeenCalledWith({
|
||||
token: 'mock-token',
|
||||
newPassword: 'NewSecurePass123!',
|
||||
confirmPassword: 'NewSecurePass123!'
|
||||
});
|
||||
});
|
||||
|
||||
// Should redirect to login
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Management', () => {
|
||||
test('protected route access when authenticated', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Navigate to dashboard
|
||||
await act(async () => {
|
||||
window.location.href = '/dashboard';
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/dashboard']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should show protected content when authenticated
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('protected route redirect when not authenticated', async () => {
|
||||
// Mock unauthenticated state
|
||||
mockApiService.getCurrentUser.mockRejectedValueOnce(new Error('Not authenticated'));
|
||||
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/dashboard']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should redirect to login
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('redirecting-to-login')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('token refresh on expiration', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/login']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
|
||||
// Mock successful login
|
||||
mockApiService.login.mockResolvedValueOnce({
|
||||
success: true,
|
||||
user: { id: 1, email: 'test@example.com', role: 'user' },
|
||||
accessToken: 'mock-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
mfaRequired: false
|
||||
});
|
||||
|
||||
await user.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Mock token refresh
|
||||
mockApiService.refreshToken.mockResolvedValueOnce({
|
||||
success: true,
|
||||
accessToken: 'new-mock-token',
|
||||
refreshToken: 'new-mock-refresh-token'
|
||||
});
|
||||
|
||||
// Simulate token refresh (this would happen automatically in real app)
|
||||
await act(async () => {
|
||||
await mockApiService.refreshToken();
|
||||
});
|
||||
|
||||
expect(mockApiService.refreshToken).toHaveBeenCalledWith({
|
||||
refreshToken: 'mock-refresh-token'
|
||||
});
|
||||
});
|
||||
|
||||
test('logout functionality', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/dashboard']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Mock logout
|
||||
mockApiService.logout.mockResolvedValueOnce({ success: true });
|
||||
|
||||
// Simulate logout action
|
||||
await act(async () => {
|
||||
await mockApiService.logout();
|
||||
});
|
||||
|
||||
expect(mockApiService.logout).toHaveBeenCalled();
|
||||
|
||||
// Should redirect to login after logout
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Edge Cases', () => {
|
||||
test('network error during login', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/login']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'SecurePass123!');
|
||||
|
||||
// Mock network error
|
||||
mockApiService.login.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await user.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
test('MFA code expiration', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/mfa']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const mfaCodeInput = screen.getByTestId('mfa-code-input');
|
||||
const verifyButton = screen.getByTestId('verify-button');
|
||||
|
||||
await user.type(mfaCodeInput, '123456');
|
||||
|
||||
// Mock expired code error
|
||||
mockApiService.verifyMfa.mockRejectedValueOnce({
|
||||
response: { data: { message: 'Code expired' } }
|
||||
});
|
||||
|
||||
await user.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent('Code expired');
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid reset password token', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/reset-password/invalid-token']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const newPasswordInput = screen.getByTestId('new-password-input');
|
||||
const confirmPasswordInput = screen.getByTestId('confirm-password-input');
|
||||
const resetButton = screen.getByTestId('reset-button');
|
||||
|
||||
await user.type(newPasswordInput, 'NewSecurePass123!');
|
||||
await user.type(confirmPasswordInput, 'NewSecurePass123!');
|
||||
|
||||
// Mock invalid token error
|
||||
mockApiService.resetPassword.mockRejectedValueOnce({
|
||||
response: { data: { message: 'Invalid or expired token' } }
|
||||
});
|
||||
|
||||
await user.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent('Invalid or expired token');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-Browser Compatibility', () => {
|
||||
test('handles different browsers', () => {
|
||||
// Mock different browser environments
|
||||
const mockUserAgent = (agent) => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
value: agent
|
||||
});
|
||||
};
|
||||
|
||||
const browsers = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'
|
||||
];
|
||||
|
||||
browsers.forEach(agent => {
|
||||
mockUserAgent(agent);
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/login']}>
|
||||
<TestApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
687
frontend/tests/integration/test_module_integration.test.tsx
Normal file
687
frontend/tests/integration/test_module_integration.test.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* Frontend Integration Tests - Module Integration
|
||||
*
|
||||
* Tests for module-specific integration:
|
||||
* - Module switching and data isolation
|
||||
* - Cross-module data sharing
|
||||
* - Module-specific permissions
|
||||
* - Module loading performance
|
||||
* - Error handling across modules
|
||||
*
|
||||
* Author: Claude
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
// Mock services
|
||||
const mockModuleService = {
|
||||
getModules: jest.fn(),
|
||||
activateModule: jest.fn(),
|
||||
deactivateModule: jest.fn(),
|
||||
getModuleData: jest.fn(),
|
||||
validateModuleAccess: jest.fn(),
|
||||
};
|
||||
|
||||
const mockPermissionService = {
|
||||
checkPermission: jest.fn(),
|
||||
getUserPermissions: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDataService = {
|
||||
getTenantData: jest.fn(),
|
||||
getSharedData: jest.fn(),
|
||||
updateSharedData: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock components for different modules
|
||||
const ModuleSwitcher = ({ currentModule, onModuleChange, modules }) => (
|
||||
<div data-testid="module-switcher">
|
||||
<div data-testid="current-module">{currentModule}</div>
|
||||
<div data-testid="available-modules">
|
||||
{modules.map((module) => (
|
||||
<button
|
||||
key={module.id}
|
||||
data-testid={`module-${module.code}`}
|
||||
onClick={() => onModuleChange(module.code)}
|
||||
disabled={!module.enabled}
|
||||
>
|
||||
{module.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const RetailModule = ({ tenantId, userData }) => (
|
||||
<div data-testid="retail-module">
|
||||
<h2>Retail Management</h2>
|
||||
<div data-testid="tenant-data">{tenantId}</div>
|
||||
<div data-testid="user-data">{userData?.name}</div>
|
||||
<button data-testid="add-product-btn">Add Product</button>
|
||||
<button data-testid="view-sales-btn">View Sales</button>
|
||||
<div data-testid="retail-stats">
|
||||
<div>Total Products: 150</div>
|
||||
<div>Today's Sales: RM 2,450</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const HealthcareModule = ({ tenantId, userData }) => (
|
||||
<div data-testid="healthcare-module">
|
||||
<h2>Healthcare Management</h2>
|
||||
<div data-testid="tenant-data">{tenantId}</div>
|
||||
<div data-testid="user-data">{userData?.name}</div>
|
||||
<button data-testid="add-patient-btn">Add Patient</button>
|
||||
<button data-testid="schedule-appointment-btn">Schedule Appointment</button>
|
||||
<div data-testid="healthcare-stats">
|
||||
<div>Total Patients: 250</div>
|
||||
<div>Today's Appointments: 12</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EducationModule = ({ tenantId, userData }) => (
|
||||
<div data-testid="education-module">
|
||||
<h2>Education Management</h2>
|
||||
<div data-testid="tenant-data">{tenantId}</div>
|
||||
<div data-testid="user-data">{userData?.name}</div>
|
||||
<button data-testid="add-student-btn">Add Student</button>
|
||||
<button data-testid="manage-classes-btn">Manage Classes</button>
|
||||
<div data-testid="education-stats">
|
||||
<div>Total Students: 500</div>
|
||||
<div>Active Classes: 25</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LogisticsModule = ({ tenantId, userData }) => (
|
||||
<div data-testid="logistics-module">
|
||||
<h2>Logistics Management</h2>
|
||||
<div data-testid="tenant-data">{tenantId}</div>
|
||||
<div data-testid="user-data">{userData?.name}</div>
|
||||
<button data-testid="add-shipment-btn">Add Shipment</button>
|
||||
<button data-testid="track-vehicles-btn">Track Vehicles</button>
|
||||
<div data-testid="logistics-stats">
|
||||
<div>Active Shipments: 45</div>
|
||||
<div>Vehicle Fleet: 12</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BeautyModule = ({ tenantId, userData }) => (
|
||||
<div data-testid="beauty-module">
|
||||
<h2>Beauty Management</h2>
|
||||
<div data-testid="tenant-data">{tenantId}</div>
|
||||
<div data-testid="user-data">{userData?.name}</div>
|
||||
<button data-testid="add-client-btn">Add Client</button>
|
||||
<button data-testid="manage-services-btn">Manage Services</button>
|
||||
<div data-testid="beauty-stats">
|
||||
<div>Total Clients: 180</div>
|
||||
<div>Active Services: 35</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SharedDataPanel = ({ sharedData, onUpdateSharedData }) => (
|
||||
<div data-testid="shared-data-panel">
|
||||
<h3>Shared Data</h3>
|
||||
<div data-testid="shared-business-name">{sharedData?.businessName}</div>
|
||||
<div data-testid="shared-contact-email">{sharedData?.contactEmail}</div>
|
||||
<button data-testid="update-shared-data-btn" onClick={onUpdateSharedData}>
|
||||
Update Shared Data
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PermissionAlert = ({ hasPermission, requiredPermission }) => (
|
||||
<div data-testid="permission-alert">
|
||||
{hasPermission ? (
|
||||
<div data-testid="permission-granted">Access granted for {requiredPermission}</div>
|
||||
) : (
|
||||
<div data-testid="permission-denied">Access denied for {requiredPermission}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<div data-testid="loading-spinner">Loading module...</div>
|
||||
);
|
||||
|
||||
const ErrorBoundary = ({ children, onError }) => {
|
||||
const [hasError, setHasError] = React.useState(false);
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleError = (event) => {
|
||||
setHasError(true);
|
||||
setError(event.error);
|
||||
onError?.(event.error);
|
||||
};
|
||||
|
||||
window.addEventListener('error', handleError);
|
||||
return () => window.removeEventListener('error', handleError);
|
||||
}, [onError]);
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div data-testid="error-boundary">
|
||||
<h2>Something went wrong</h2>
|
||||
<div data-testid="error-message">{error?.message}</div>
|
||||
<button onClick={() => setHasError(false)}>Try Again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Test app with module routing
|
||||
const ModuleApp = () => {
|
||||
const [currentModule, setCurrentModule] = React.useState('retail');
|
||||
const [modules, setModules] = React.useState([]);
|
||||
const [sharedData, setSharedData] = React.useState(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
loadModules();
|
||||
loadSharedData();
|
||||
}, []);
|
||||
|
||||
const loadModules = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await mockModuleService.getModules();
|
||||
setModules(response.data);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSharedData = async () => {
|
||||
try {
|
||||
const response = await mockDataService.getSharedData();
|
||||
setSharedData(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load shared data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModuleChange = async (moduleCode) => {
|
||||
try {
|
||||
await mockModuleService.validateModuleAccess(moduleCode);
|
||||
setCurrentModule(moduleCode);
|
||||
navigate(`/${moduleCode}`);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSharedData = async () => {
|
||||
try {
|
||||
const updatedData = { ...sharedData, businessName: 'Updated Business Name' };
|
||||
await mockDataService.updateSharedData(updatedData);
|
||||
setSharedData(updatedData);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div data-testid="error-container">
|
||||
<div data-testid="error-message">{error.message}</div>
|
||||
<button onClick={() => setError(null)}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="module-app">
|
||||
<ModuleSwitcher
|
||||
currentModule={currentModule}
|
||||
onModuleChange={handleModuleChange}
|
||||
modules={modules}
|
||||
/>
|
||||
|
||||
<SharedDataPanel
|
||||
sharedData={sharedData}
|
||||
onUpdateSharedData={handleUpdateSharedData}
|
||||
/>
|
||||
|
||||
<Routes>
|
||||
<Route path="/retail" element={
|
||||
<ErrorBoundary onError={setError}>
|
||||
<RetailModule tenantId="tenant-001" userData={{ name: 'Test User' }} />
|
||||
</ErrorBoundary>
|
||||
} />
|
||||
<Route path="/healthcare" element={
|
||||
<ErrorBoundary onError={setError}>
|
||||
<HealthcareModule tenantId="tenant-001" userData={{ name: 'Test User' }} />
|
||||
</ErrorBoundary>
|
||||
} />
|
||||
<Route path="/education" element={
|
||||
<ErrorBoundary onError={setError}>
|
||||
<EducationModule tenantId="tenant-001" userData={{ name: 'Test User' }} />
|
||||
</ErrorBoundary>
|
||||
} />
|
||||
<Route path="/logistics" element={
|
||||
<ErrorBoundary onError={setError}>
|
||||
<LogisticsModule tenantId="tenant-001" userData={{ name: 'Test User' }} />
|
||||
</ErrorBoundary>
|
||||
} />
|
||||
<Route path="/beauty" element={
|
||||
<ErrorBoundary onError={setError}>
|
||||
<BeautyModule tenantId="tenant-001" userData={{ name: 'Test User' }} />
|
||||
</ErrorBoundary>
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Module Integration Tests', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock default responses
|
||||
mockModuleService.getModules.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 1, code: 'retail', name: 'Retail Management', enabled: true },
|
||||
{ id: 2, code: 'healthcare', name: 'Healthcare Management', enabled: true },
|
||||
{ id: 3, code: 'education', name: 'Education Management', enabled: true },
|
||||
{ id: 4, code: 'logistics', name: 'Logistics Management', enabled: true },
|
||||
{ id: 5, code: 'beauty', name: 'Beauty Management', enabled: true },
|
||||
]
|
||||
});
|
||||
|
||||
mockDataService.getSharedData.mockResolvedValue({
|
||||
data: {
|
||||
businessName: 'Test Business Sdn Bhd',
|
||||
contactEmail: 'contact@test.com',
|
||||
address: '123 Test Street',
|
||||
phone: '+60123456789'
|
||||
}
|
||||
});
|
||||
|
||||
mockModuleService.validateModuleAccess.mockResolvedValue(true);
|
||||
mockPermissionService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('Module Switching', () => {
|
||||
test('successfully switches between modules', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('module-app')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should start with retail module
|
||||
expect(screen.getByTestId('current-module')).toHaveTextContent('retail');
|
||||
expect(screen.getByTestId('retail-module')).toBeInTheDocument();
|
||||
|
||||
// Switch to healthcare module
|
||||
const healthcareButton = screen.getByTestId('module-healthcare');
|
||||
await user.click(healthcareButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('current-module')).toHaveTextContent('healthcare');
|
||||
expect(screen.getByTestId('healthcare-module')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('retail-module')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify module access validation was called
|
||||
expect(mockModuleService.validateModuleAccess).toHaveBeenCalledWith('healthcare');
|
||||
});
|
||||
|
||||
test('maintains shared data across modules', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('shared-data-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check shared data in retail module
|
||||
expect(screen.getByTestId('shared-business-name')).toHaveTextContent('Test Business Sdn Bhd');
|
||||
expect(screen.getByTestId('shared-contact-email')).toHaveTextContent('contact@test.com');
|
||||
|
||||
// Switch to healthcare module
|
||||
const healthcareButton = screen.getByTestId('module-healthcare');
|
||||
await user.click(healthcareButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('healthcare-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Shared data should still be available
|
||||
expect(screen.getByTestId('shared-business-name')).toHaveTextContent('Test Business Sdn Bhd');
|
||||
expect(screen.getByTestId('shared-contact-email')).toHaveTextContent('contact@test.com');
|
||||
});
|
||||
|
||||
test('handles module loading states', async () => {
|
||||
// Mock slow loading
|
||||
mockModuleService.getModules.mockImplementationOnce(() =>
|
||||
new Promise(resolve => setTimeout(resolve, 1000))
|
||||
);
|
||||
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should show loading spinner initially
|
||||
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('module-app')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Module-Specific Features', () => {
|
||||
test.each([
|
||||
{ module: 'retail', button: 'add-product-btn', stats: 'retail-stats' },
|
||||
{ module: 'healthcare', button: 'add-patient-btn', stats: 'healthcare-stats' },
|
||||
{ module: 'education', button: 'add-student-btn', stats: 'education-stats' },
|
||||
{ module: 'logistics', button: 'add-shipment-btn', stats: 'logistics-stats' },
|
||||
{ module: 'beauty', button: 'add-client-btn', stats: 'beauty-stats' },
|
||||
])('loads $module module with correct features', async ({ module, button, stats }) => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/${module}']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`${module}-module`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check module-specific buttons
|
||||
expect(screen.getByTestId(button)).toBeInTheDocument();
|
||||
|
||||
// Check module-specific stats
|
||||
expect(screen.getByTestId(stats)).toBeInTheDocument();
|
||||
|
||||
// Check tenant data isolation
|
||||
expect(screen.getByTestId('tenant-data')).toHaveTextContent('tenant-001');
|
||||
|
||||
// Check user data sharing
|
||||
expect(screen.getByTestId('user-data')).toHaveTextContent('Test User');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Management', () => {
|
||||
test('validates module access permissions', async () => {
|
||||
// Mock permission denied for logistics module
|
||||
mockModuleService.validateModuleAccess.mockImplementationOnce((moduleCode) => {
|
||||
if (moduleCode === 'logistics') {
|
||||
return Promise.reject(new Error('Access denied'));
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('retail-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Try to access logistics module
|
||||
const logisticsButton = screen.getByTestId('module-logistics');
|
||||
await user.click(logisticsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-container')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent('Access denied');
|
||||
});
|
||||
|
||||
// Should not switch to logistics module
|
||||
expect(screen.queryByTestId('logistics-module')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('retail-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('checks feature-level permissions', async () => {
|
||||
mockPermissionService.checkPermission.mockImplementation((permission) => {
|
||||
const deniedPermissions = ['delete_products', 'manage_patients'];
|
||||
return Promise.resolve(!deniedPermissions.includes(permission));
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('retail-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Test retail permissions
|
||||
expect(await mockPermissionService.checkPermission('view_products')).resolves.toBe(true);
|
||||
expect(await mockPermissionService.checkPermission('delete_products')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Isolation and Sharing', () => {
|
||||
test('maintains tenant data isolation', async () => {
|
||||
// Mock different tenant data for different modules
|
||||
mockDataService.getTenantData.mockImplementation((module) => {
|
||||
const tenantData = {
|
||||
retail: { id: 'tenant-retail', name: 'Retail Business' },
|
||||
healthcare: { id: 'tenant-healthcare', name: 'Healthcare Business' },
|
||||
};
|
||||
return Promise.resolve({ data: tenantData[module] });
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('retail-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check retail tenant data
|
||||
expect(screen.getByTestId('tenant-data')).toHaveTextContent('tenant-001');
|
||||
|
||||
// Switch to healthcare module
|
||||
const healthcareButton = screen.getByTestId('module-healthcare');
|
||||
await user.click(healthcareButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('healthcare-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should still use the same tenant (data isolation at tenant level)
|
||||
expect(screen.getByTestId('tenant-data')).toHaveTextContent('tenant-001');
|
||||
});
|
||||
|
||||
test('updates shared data across modules', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('shared-data-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Update shared data
|
||||
const updateButton = screen.getByTestId('update-shared-data-btn');
|
||||
await user.click(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDataService.updateSharedData).toHaveBeenCalled();
|
||||
expect(screen.getByTestId('shared-business-name')).toHaveTextContent('Updated Business Name');
|
||||
});
|
||||
|
||||
// Switch to another module
|
||||
const healthcareButton = screen.getByTestId('module-healthcare');
|
||||
await user.click(healthcareButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('healthcare-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Updated shared data should be available
|
||||
expect(screen.getByTestId('shared-business-name')).toHaveTextContent('Updated Business Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('handles module loading errors gracefully', async () => {
|
||||
mockModuleService.getModules.mockRejectedValueOnce(new Error('Failed to load modules'));
|
||||
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-container')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent('Failed to load modules');
|
||||
});
|
||||
});
|
||||
|
||||
test('handles individual module errors', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('retail-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Simulate module error
|
||||
const error = new Error('Module crashed');
|
||||
fireEvent.error(window, error);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-boundary')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent('Module crashed');
|
||||
});
|
||||
});
|
||||
|
||||
test('recovers from errors with retry', async () => {
|
||||
mockModuleService.getModules.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Mock successful retry
|
||||
mockModuleService.getModules.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ id: 1, code: 'retail', name: 'Retail Management', enabled: true },
|
||||
]
|
||||
});
|
||||
|
||||
// Click retry button
|
||||
const retryButton = screen.getByText('Retry');
|
||||
await user.click(retryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('module-app')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('retail-module')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Optimization', () => {
|
||||
test('lazy loads modules on demand', async () => {
|
||||
const mockModuleLoad = jest.fn();
|
||||
|
||||
// Mock dynamic import
|
||||
jest.mock('react-lazy', () => ({
|
||||
lazy: (importFn) => {
|
||||
mockModuleLoad();
|
||||
return importFn();
|
||||
}
|
||||
}));
|
||||
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('retail-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Module should be loaded on demand
|
||||
expect(mockModuleLoad).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('caches loaded modules', async () => {
|
||||
render(
|
||||
<BrowserRouter initialEntries={['/retail']}>
|
||||
<ModuleApp />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('retail-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Switch away and back to retail module
|
||||
const healthcareButton = screen.getByTestId('module-healthcare');
|
||||
await user.click(healthcareButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('healthcare-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const retailButton = screen.getByTestId('module-retail');
|
||||
await user.click(retailButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('retail-module')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should not reload modules (check call count)
|
||||
expect(mockModuleService.getModules).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user