Merge branch 'release-v2.1.4' of https://github.com/Worklenz/worklenz into feature/team-utilization
This commit is contained in:
@@ -6,6 +6,7 @@ import i18next from 'i18next';
|
||||
// Components
|
||||
import ThemeWrapper from './features/theme/ThemeWrapper';
|
||||
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
|
||||
import { UpdateNotificationProvider } from './components/update-notification';
|
||||
|
||||
// Routes
|
||||
import router from './app/routes';
|
||||
@@ -208,14 +209,16 @@ const App: React.FC = memo(() => {
|
||||
return (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ThemeWrapper>
|
||||
<ModuleErrorBoundary>
|
||||
<RouterProvider
|
||||
router={router}
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
/>
|
||||
</ModuleErrorBoundary>
|
||||
<UpdateNotificationProvider>
|
||||
<ModuleErrorBoundary>
|
||||
<RouterProvider
|
||||
router={router}
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
/>
|
||||
</ModuleErrorBoundary>
|
||||
</UpdateNotificationProvider>
|
||||
</ThemeWrapper>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -10,3 +10,6 @@ export { default as LabelsSelector } from './LabelsSelector';
|
||||
export { default as Progress } from './Progress';
|
||||
export { default as Tag } from './Tag';
|
||||
export { default as Tooltip } from './Tooltip';
|
||||
|
||||
// Update Notification Components
|
||||
export * from './update-notification';
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Update Notification Component
|
||||
// Shows a notification when new build is available and provides update options
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Button, Space, Typography } from '@/shared/antd-imports';
|
||||
import { ReloadOutlined, CloseOutlined, DownloadOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface UpdateNotificationProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onUpdate
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||
const { hardReload } = useServiceWorker();
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
if (hardReload) {
|
||||
await hardReload();
|
||||
} else {
|
||||
// Fallback to regular reload
|
||||
window.location.reload();
|
||||
}
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Error during update:', error);
|
||||
// Fallback to regular reload
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLater = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<DownloadOutlined style={{ color: '#1890ff' }} />
|
||||
<Title level={4} style={{ margin: 0, color: '#1890ff' }}>
|
||||
{t('update-available')}
|
||||
</Title>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={handleLater}
|
||||
footer={null}
|
||||
centered
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
width={460}
|
||||
styles={{
|
||||
body: { padding: '20px 24px' }
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<Text style={{ fontSize: '16px', lineHeight: '1.6' }}>
|
||||
{t('update-description')}
|
||||
</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Text style={{ fontSize: '14px', color: '#8c8c8c' }}>
|
||||
{t('update-instruction')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<Text style={{ fontSize: '13px', color: '#389e0d' }}>
|
||||
{t('update-whats-new', {
|
||||
interpolation: { escapeValue: false }
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Space
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
size="middle"
|
||||
>
|
||||
<Button
|
||||
icon={<CloseOutlined />}
|
||||
onClick={handleLater}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t('update-later')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={isUpdating}
|
||||
onClick={handleUpdate}
|
||||
>
|
||||
{isUpdating ? t('updating') : t('update-now')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateNotification;
|
||||
@@ -0,0 +1,50 @@
|
||||
// Update Notification Provider
|
||||
// Provides global update notification management
|
||||
|
||||
import React from 'react';
|
||||
import { useUpdateChecker } from '../../hooks/useUpdateChecker';
|
||||
import UpdateNotification from './UpdateNotification';
|
||||
|
||||
interface UpdateNotificationProviderProps {
|
||||
children: React.ReactNode;
|
||||
checkInterval?: number;
|
||||
enableAutoCheck?: boolean;
|
||||
}
|
||||
|
||||
const UpdateNotificationProvider: React.FC<UpdateNotificationProviderProps> = ({
|
||||
children,
|
||||
checkInterval = 5 * 60 * 1000, // 5 minutes
|
||||
enableAutoCheck = true
|
||||
}) => {
|
||||
const {
|
||||
showUpdateNotification,
|
||||
setShowUpdateNotification,
|
||||
dismissUpdate
|
||||
} = useUpdateChecker({
|
||||
checkInterval,
|
||||
enableAutoCheck,
|
||||
showNotificationOnUpdate: true
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
dismissUpdate();
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
// The hardReload function in UpdateNotification will handle the actual update
|
||||
setShowUpdateNotification(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<UpdateNotification
|
||||
visible={showUpdateNotification}
|
||||
onClose={handleClose}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateNotificationProvider;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as UpdateNotification } from './UpdateNotification';
|
||||
export { default as UpdateNotificationProvider } from './UpdateNotificationProvider';
|
||||
141
worklenz-frontend/src/hooks/useUpdateChecker.ts
Normal file
141
worklenz-frontend/src/hooks/useUpdateChecker.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// Update Checker Hook
|
||||
// Periodically checks for app updates and manages update notifications
|
||||
|
||||
import React from 'react';
|
||||
import { useServiceWorker } from '../utils/serviceWorkerRegistration';
|
||||
|
||||
interface UseUpdateCheckerOptions {
|
||||
checkInterval?: number; // Check interval in milliseconds (default: 5 minutes)
|
||||
enableAutoCheck?: boolean; // Enable automatic checking (default: true)
|
||||
showNotificationOnUpdate?: boolean; // Show notification when update is found (default: true)
|
||||
}
|
||||
|
||||
interface UseUpdateCheckerReturn {
|
||||
hasUpdate: boolean;
|
||||
isChecking: boolean;
|
||||
lastChecked: Date | null;
|
||||
checkForUpdates: () => Promise<void>;
|
||||
dismissUpdate: () => void;
|
||||
showUpdateNotification: boolean;
|
||||
setShowUpdateNotification: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export function useUpdateChecker(options: UseUpdateCheckerOptions = {}): UseUpdateCheckerReturn {
|
||||
const {
|
||||
checkInterval = 5 * 60 * 1000, // 5 minutes
|
||||
enableAutoCheck = true,
|
||||
showNotificationOnUpdate = true
|
||||
} = options;
|
||||
|
||||
const { checkForUpdates: serviceWorkerCheckUpdates, swManager } = useServiceWorker();
|
||||
|
||||
const [hasUpdate, setHasUpdate] = React.useState(false);
|
||||
const [isChecking, setIsChecking] = React.useState(false);
|
||||
const [lastChecked, setLastChecked] = React.useState<Date | null>(null);
|
||||
const [showUpdateNotification, setShowUpdateNotification] = React.useState(false);
|
||||
const [updateDismissed, setUpdateDismissed] = React.useState(false);
|
||||
|
||||
// Check for updates function
|
||||
const checkForUpdates = React.useCallback(async () => {
|
||||
if (!serviceWorkerCheckUpdates || isChecking) return;
|
||||
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const hasUpdates = await serviceWorkerCheckUpdates();
|
||||
setHasUpdate(hasUpdates);
|
||||
setLastChecked(new Date());
|
||||
|
||||
// Show notification if update found and user hasn't dismissed it
|
||||
if (hasUpdates && showNotificationOnUpdate && !updateDismissed) {
|
||||
setShowUpdateNotification(true);
|
||||
}
|
||||
|
||||
console.log('Update check completed:', { hasUpdates });
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, [serviceWorkerCheckUpdates, isChecking, showNotificationOnUpdate, updateDismissed]);
|
||||
|
||||
// Dismiss update notification
|
||||
const dismissUpdate = React.useCallback(() => {
|
||||
setUpdateDismissed(true);
|
||||
setShowUpdateNotification(false);
|
||||
}, []);
|
||||
|
||||
// Set up automatic checking interval
|
||||
React.useEffect(() => {
|
||||
if (!enableAutoCheck || !swManager) return;
|
||||
|
||||
// Initial check after a short delay
|
||||
const initialTimeout = setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 10000); // 10 seconds after component mount
|
||||
|
||||
// Set up interval for periodic checks
|
||||
const intervalId = setInterval(() => {
|
||||
checkForUpdates();
|
||||
}, checkInterval);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initialTimeout);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [enableAutoCheck, swManager, checkInterval, checkForUpdates]);
|
||||
|
||||
// Listen for visibility change to check for updates when user returns to tab
|
||||
React.useEffect(() => {
|
||||
if (!enableAutoCheck) return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden && swManager) {
|
||||
// Check for updates when user returns to the tab
|
||||
setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 2000); // 2 second delay
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [enableAutoCheck, swManager, checkForUpdates]);
|
||||
|
||||
// Listen for focus events to check for updates
|
||||
React.useEffect(() => {
|
||||
if (!enableAutoCheck) return;
|
||||
|
||||
const handleFocus = () => {
|
||||
if (swManager && !isChecking) {
|
||||
// Check for updates when window regains focus
|
||||
setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 1000); // 1 second delay
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
};
|
||||
}, [enableAutoCheck, swManager, isChecking, checkForUpdates]);
|
||||
|
||||
// Reset dismissed state when new update is found
|
||||
React.useEffect(() => {
|
||||
if (hasUpdate && updateDismissed) {
|
||||
setUpdateDismissed(false);
|
||||
}
|
||||
}, [hasUpdate, updateDismissed]);
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
isChecking,
|
||||
lastChecked,
|
||||
checkForUpdates,
|
||||
dismissUpdate,
|
||||
showUpdateNotification,
|
||||
setShowUpdateNotification
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import AuthenticatingPage from '../AuthenticatingPage';
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/features/auth/authSlice', () => ({
|
||||
verifyAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/user/userSlice', () => ({
|
||||
setUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/session-helper', () => ({
|
||||
setSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/errorLogger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/shared/constants', () => ({
|
||||
WORKLENZ_REDIRECT_PROJ_KEY: 'worklenz_redirect_proj',
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dispatch
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||
useAppDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/auth-common': {
|
||||
authenticating: 'Authenticating...',
|
||||
gettingThingsReady: 'Getting things ready for you...',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test store
|
||||
const createTestStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
auth: (state = {}) => state,
|
||||
user: (state = {}) => state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
const store = createTestStore();
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AuthenticatingPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders loading state correctly', () => {
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Getting things ready for you...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('generic', { busy: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to login when authentication fails', async () => {
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ authenticated: false }),
|
||||
});
|
||||
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
// Run all pending timers
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
|
||||
it('redirects to setup when user setup is not completed', async () => {
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
setup_completed: false,
|
||||
};
|
||||
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: mockUser,
|
||||
}),
|
||||
});
|
||||
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
// Run all pending timers
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(setSession).toHaveBeenCalledWith(mockUser);
|
||||
expect(setUser).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/setup');
|
||||
});
|
||||
|
||||
it('redirects to home after successful authentication', async () => {
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
setup_completed: true,
|
||||
};
|
||||
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: mockUser,
|
||||
}),
|
||||
});
|
||||
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
// Run all pending timers
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(setSession).toHaveBeenCalledWith(mockUser);
|
||||
expect(setUser).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
|
||||
});
|
||||
|
||||
it('redirects to project when redirect key is present in localStorage', async () => {
|
||||
const projectId = 'test-project-123';
|
||||
localStorage.setItem('worklenz_redirect_proj', projectId);
|
||||
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
setup_completed: true,
|
||||
};
|
||||
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: mockUser,
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock window.location with a proper setter
|
||||
let hrefValue = '';
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
get href() {
|
||||
return hrefValue;
|
||||
},
|
||||
set href(value) {
|
||||
hrefValue = value;
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
// Run all pending timers
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(setSession).toHaveBeenCalledWith(mockUser);
|
||||
expect(setUser).toHaveBeenCalledWith(mockUser);
|
||||
expect(hrefValue).toBe(`/worklenz/projects/${projectId}?tab=tasks-list`);
|
||||
expect(localStorage.getItem('worklenz_redirect_proj')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles authentication errors and redirects to login', async () => {
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockRejectedValue(new Error('Authentication failed')),
|
||||
});
|
||||
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
// Run all pending timers
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import ForgotPasswordPage from '../ForgotPasswordPage';
|
||||
import { resetPassword, verifyAuthentication } from '@/features/auth/authSlice';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/features/auth/authSlice', () => ({
|
||||
resetPassword: vi.fn(),
|
||||
verifyAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/user/userSlice', () => ({
|
||||
setUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/session-helper', () => ({
|
||||
setSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/errorLogger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||
useMixpanelTracking: () => ({
|
||||
trackMixpanelEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||
useDocumentTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-responsive', () => ({
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dispatch
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||
useAppDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/forgot-password': {
|
||||
headerDescription: 'Enter your email to reset your password',
|
||||
emailRequired: 'Please input your email!',
|
||||
emailPlaceholder: 'Enter your email',
|
||||
resetPasswordButton: 'Reset Password',
|
||||
returnToLoginButton: 'Return to Login',
|
||||
orText: 'or',
|
||||
successTitle: 'Password Reset Email Sent',
|
||||
successMessage: 'Please check your email for instructions to reset your password.',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test store
|
||||
const createTestStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
auth: (state = {}) => state,
|
||||
user: (state = {}) => state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
const store = createTestStore();
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ForgotPasswordPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock URL search params
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders forgot password form correctly', () => {
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
expect(screen.getByText('Enter your email to reset your password')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Return to Login' })).toBeInTheDocument();
|
||||
expect(screen.getByText('or')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required email field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates email format', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'invalid-email');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form with valid email', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetPassword).toHaveBeenCalledWith('test@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success message after successful submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password Reset Email Sent')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please check your email for instructions to reset your password.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles submission errors gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockRejectedValue(new Error('Reset failed')),
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should not show success message
|
||||
expect(screen.queryByText('Password Reset Email Sent')).not.toBeInTheDocument();
|
||||
// Should still show the form
|
||||
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to login page when return button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const returnButton = screen.getByRole('button', { name: 'Return to Login' });
|
||||
await user.click(returnButton);
|
||||
|
||||
expect(returnButton.closest('a')).toHaveAttribute('href', '/auth/login');
|
||||
});
|
||||
|
||||
it('handles team parameter from URL', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?team=test-team-id' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
// Component should render normally even with team parameter
|
||||
expect(screen.getByText('Enter your email to reset your password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects authenticated users to home', async () => {
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: { id: '1', email: 'test@example.com' },
|
||||
}),
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not submit with empty email after trimming', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, ' '); // Only whitespace
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should not call resetPassword with empty string
|
||||
expect(resetPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import LoggingOutPage from '../LoggingOutPage';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import CacheCleanup from '@/utils/cache-cleanup';
|
||||
|
||||
// Mock dependencies
|
||||
const mockAuthService = {
|
||||
signOut: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuthService: () => mockAuthService,
|
||||
}));
|
||||
|
||||
vi.mock('@/api/auth/auth.api.service', () => ({
|
||||
authApiService: {
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/cache-cleanup', () => ({
|
||||
default: {
|
||||
clearAllCaches: vi.fn(),
|
||||
forceReload: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-responsive', () => ({
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/auth-common': {
|
||||
loggingOut: 'Logging Out...',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('LoggingOutPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Mock console.error to avoid noise in tests
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuthService: () => mockAuthService,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state correctly', () => {
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('performs complete logout sequence successfully', async () => {
|
||||
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApiService.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Fast-forward time to trigger the setTimeout
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles auth service signOut failure', async () => {
|
||||
mockAuthService.signOut.mockRejectedValue(new Error('SignOut failed'));
|
||||
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles backend logout failure', async () => {
|
||||
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||
(authApiService.logout as any).mockRejectedValue(new Error('Backend logout failed'));
|
||||
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApiService.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles cache cleanup failure', async () => {
|
||||
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||
(CacheCleanup.clearAllCaches as any).mockRejectedValue(new Error('Cache cleanup failed'));
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApiService.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers logout sequence immediately on mount', () => {
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows consistent loading UI throughout logout process', async () => {
|
||||
mockAuthService.signOut.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
(authApiService.logout as any).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
(CacheCleanup.clearAllCaches as any).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
// Should show loading state immediately
|
||||
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
|
||||
// Should continue showing loading state during the process
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls forceReload with correct path after timeout', async () => {
|
||||
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
// Wait for all async operations to complete
|
||||
await waitFor(() => {
|
||||
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Fast-forward exactly 1000ms
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles complete failure of all logout steps', async () => {
|
||||
mockAuthService.signOut.mockRejectedValue(new Error('SignOut failed'));
|
||||
(authApiService.logout as any).mockRejectedValue(new Error('Backend logout failed'));
|
||||
(CacheCleanup.clearAllCaches as any).mockRejectedValue(new Error('Cache cleanup failed'));
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
317
worklenz-frontend/src/pages/auth/__tests__/LoginPage.test.tsx
Normal file
317
worklenz-frontend/src/pages/auth/__tests__/LoginPage.test.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import LoginPage from '../LoginPage';
|
||||
import { login, verifyAuthentication } from '@/features/auth/authSlice';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/features/auth/authSlice', () => ({
|
||||
login: vi.fn(),
|
||||
verifyAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/user/userSlice', () => ({
|
||||
setUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/session-helper', () => ({
|
||||
setSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/errorLogger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||
useMixpanelTracking: () => ({
|
||||
trackMixpanelEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||
useDocumentTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuthService: () => ({
|
||||
getCurrentSession: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/alerts/alertService', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-responsive', () => ({
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dispatch
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||
useAppDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/login': {
|
||||
headerDescription: 'Sign in to your account',
|
||||
emailRequired: 'Please input your email!',
|
||||
passwordRequired: 'Please input your password!',
|
||||
emailPlaceholder: 'Email',
|
||||
passwordPlaceholder: 'Password',
|
||||
loginButton: 'Sign In',
|
||||
rememberMe: 'Remember me',
|
||||
forgotPasswordButton: 'Forgot password?',
|
||||
signInWithGoogleButton: 'Sign in with Google',
|
||||
orText: 'or',
|
||||
dontHaveAccountText: "Don't have an account?",
|
||||
signupButton: 'Sign up',
|
||||
successMessage: 'Login successful!',
|
||||
'validationMessages.email': 'Please enter a valid email!',
|
||||
'validationMessages.password': 'Password must be at least 8 characters!',
|
||||
'errorMessages.loginErrorTitle': 'Login Failed',
|
||||
'errorMessages.loginErrorMessage': 'Invalid email or password',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test store
|
||||
const createTestStore = (initialState: any = {}) => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
auth: (state = { isLoading: false, ...initialState.auth }) => state,
|
||||
user: (state = {}) => state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement, initialState: any = {}) => {
|
||||
const store = createTestStore(initialState);
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock environment variables
|
||||
vi.stubEnv('VITE_ENABLE_GOOGLE_LOGIN', 'true');
|
||||
vi.stubEnv('VITE_API_URL', 'http://localhost:3000');
|
||||
});
|
||||
|
||||
it('renders login form correctly', () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
expect(screen.getByText('Sign in to your account')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Remember me')).toBeInTheDocument();
|
||||
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Google login button when enabled', () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates email format', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Email');
|
||||
await user.type(emailInput, 'invalid-email');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please enter a valid email!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates password minimum length', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Email');
|
||||
const passwordInput = screen.getByPlaceholderText('Password');
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, '123');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password must be at least 8 characters!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form with valid credentials', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: { id: '1', email: 'test@example.com' },
|
||||
}),
|
||||
});
|
||||
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Email');
|
||||
const passwordInput = screen.getByPlaceholderText('Password');
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(login).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
remember: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during login', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />, { auth: { isLoading: true } });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||
expect(submitButton).toBeDisabled();
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles Google login click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const googleButton = screen.getByText('Sign in with Google');
|
||||
await user.click(googleButton);
|
||||
|
||||
expect(window.location.href).toBe('http://localhost:3000/secure/google');
|
||||
});
|
||||
|
||||
it('navigates to signup page', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const signupLink = screen.getByText('Sign up');
|
||||
await user.click(signupLink);
|
||||
|
||||
// Link navigation is handled by React Router, so we just check the element exists
|
||||
expect(signupLink.closest('a')).toHaveAttribute('href', '/auth/signup');
|
||||
});
|
||||
|
||||
it('navigates to forgot password page', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const forgotPasswordLink = screen.getByText('Forgot password?');
|
||||
await user.click(forgotPasswordLink);
|
||||
|
||||
expect(forgotPasswordLink.closest('a')).toHaveAttribute('href', '/auth/forgot-password');
|
||||
});
|
||||
|
||||
it('toggles remember me checkbox', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const rememberMeCheckbox = screen.getByRole('checkbox', { name: 'Remember me' });
|
||||
expect(rememberMeCheckbox).toBeChecked(); // Default is true
|
||||
|
||||
await user.click(rememberMeCheckbox);
|
||||
expect(rememberMeCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('redirects already authenticated users', async () => {
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: { id: '1', email: 'test@example.com', setup_completed: true },
|
||||
}),
|
||||
});
|
||||
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to setup for users with incomplete setup', async () => {
|
||||
const mockCurrentSession = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
setup_completed: false,
|
||||
};
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuthService: () => ({
|
||||
getCurrentSession: () => mockCurrentSession,
|
||||
}),
|
||||
}));
|
||||
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/setup');
|
||||
});
|
||||
});
|
||||
});
|
||||
359
worklenz-frontend/src/pages/auth/__tests__/SignupPage.test.tsx
Normal file
359
worklenz-frontend/src/pages/auth/__tests__/SignupPage.test.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import SignupPage from '../SignupPage';
|
||||
import { signUp } from '@/features/auth/authSlice';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/features/auth/authSlice', () => ({
|
||||
signUp: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/auth/auth.api.service', () => ({
|
||||
authApiService: {
|
||||
signUpCheck: vi.fn(),
|
||||
verifyRecaptchaToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||
useMixpanelTracking: () => ({
|
||||
trackMixpanelEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||
useDocumentTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/errorLogger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/alerts/alertService', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-responsive', () => ({
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dispatch
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||
useAppDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/signup': {
|
||||
headerDescription: 'Sign up to get started',
|
||||
nameLabel: 'Full Name',
|
||||
emailLabel: 'Email',
|
||||
passwordLabel: 'Password',
|
||||
nameRequired: 'Please input your name!',
|
||||
emailRequired: 'Please input your email!',
|
||||
passwordRequired: 'Please input your password!',
|
||||
nameMinCharacterRequired: 'Name must be at least 4 characters!',
|
||||
passwordMinCharacterRequired: 'Password must be at least 8 characters!',
|
||||
passwordMaxCharacterRequired: 'Password must be no more than 32 characters!',
|
||||
passwordPatternRequired: 'Password must contain uppercase, lowercase, number and special character!',
|
||||
namePlaceholder: 'Enter your full name',
|
||||
emailPlaceholder: 'Enter your email',
|
||||
strongPasswordPlaceholder: 'Enter a strong password',
|
||||
passwordGuideline: 'Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.',
|
||||
signupButton: 'Sign Up',
|
||||
signInWithGoogleButton: 'Sign up with Google',
|
||||
orText: 'or',
|
||||
alreadyHaveAccountText: 'Already have an account?',
|
||||
loginButton: 'Log in',
|
||||
bySigningUpText: 'By signing up, you agree to our',
|
||||
privacyPolicyLink: 'Privacy Policy',
|
||||
andText: 'and',
|
||||
termsOfUseLink: 'Terms of Use',
|
||||
reCAPTCHAVerificationError: 'reCAPTCHA Verification Failed',
|
||||
reCAPTCHAVerificationErrorMessage: 'Please try again',
|
||||
'passwordChecklist.minLength': 'At least 8 characters',
|
||||
'passwordChecklist.uppercase': 'One uppercase letter',
|
||||
'passwordChecklist.lowercase': 'One lowercase letter',
|
||||
'passwordChecklist.number': 'One number',
|
||||
'passwordChecklist.special': 'One special character',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test store
|
||||
const createTestStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
themeReducer: (state = { mode: 'light' }) => state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
const store = createTestStore();
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('SignupPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
// Mock environment variables
|
||||
vi.stubEnv('VITE_ENABLE_GOOGLE_LOGIN', 'true');
|
||||
vi.stubEnv('VITE_ENABLE_RECAPTCHA', 'false');
|
||||
vi.stubEnv('VITE_API_URL', 'http://localhost:3000');
|
||||
|
||||
// Mock URL search params
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders signup form correctly', () => {
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
expect(screen.getByText('Sign up to get started')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter your full name')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter a strong password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Google signup button when enabled', () => {
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
expect(screen.getByText('Sign up with Google')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your name!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates name minimum length', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
||||
await user.type(nameInput, 'Jo');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Name must be at least 4 characters!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates email format', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'invalid-email');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates password requirements', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||
await user.type(passwordInput, 'weak');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password must be at least 8 characters!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows password checklist when password field is focused', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||
await user.click(passwordInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates password checklist based on input', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||
await user.click(passwordInput);
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
|
||||
await waitFor(() => {
|
||||
// All checklist items should be visible and some should be checked
|
||||
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form with valid data', async () => {
|
||||
const user = userEvent.setup();
|
||||
(authApiService.signUpCheck as any).mockResolvedValue({ done: true });
|
||||
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||
|
||||
await user.type(nameInput, 'John Doe');
|
||||
await user.type(emailInput, 'john@example.com');
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApiService.signUpCheck).toHaveBeenCalledWith({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
password: 'Password123!',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles Google signup click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const googleButton = screen.getByText('Sign up with Google');
|
||||
await user.click(googleButton);
|
||||
|
||||
expect(window.location.href).toBe('http://localhost:3000/secure/google?');
|
||||
});
|
||||
|
||||
it('pre-fills form from URL parameters', () => {
|
||||
// Mock URLSearchParams
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?email=test@example.com&name=Test User' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter your full name') as HTMLInputElement;
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email') as HTMLInputElement;
|
||||
|
||||
expect(nameInput.value).toBe('Test User');
|
||||
expect(emailInput.value).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('shows terms of use and privacy policy links', () => {
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
expect(screen.getByText('Privacy Policy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Terms of Use')).toBeInTheDocument();
|
||||
|
||||
const privacyLink = screen.getByText('Privacy Policy').closest('a');
|
||||
const termsLink = screen.getByText('Terms of Use').closest('a');
|
||||
|
||||
expect(privacyLink).toHaveAttribute('href', 'https://worklenz.com/privacy/');
|
||||
expect(termsLink).toHaveAttribute('href', 'https://worklenz.com/terms/');
|
||||
});
|
||||
|
||||
it('navigates to login page', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const loginLink = screen.getByText('Log in');
|
||||
await user.click(loginLink);
|
||||
|
||||
expect(loginLink.closest('a')).toHaveAttribute('href', '/auth/login');
|
||||
});
|
||||
|
||||
it('shows loading state during signup', async () => {
|
||||
const user = userEvent.setup();
|
||||
(authApiService.signUpCheck as any).mockResolvedValue({ done: true });
|
||||
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||
|
||||
await user.type(nameInput, 'John Doe');
|
||||
await user.type(emailInput, 'john@example.com');
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import VerifyResetEmailPage from '../VerifyResetEmailPage';
|
||||
import { updatePassword } from '@/features/auth/authSlice';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/features/auth/authSlice', () => ({
|
||||
updatePassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/errorLogger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||
useMixpanelTracking: () => ({
|
||||
trackMixpanelEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||
useDocumentTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-responsive', () => ({
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dispatch
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||
useAppDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/verify-reset-email': {
|
||||
description: 'Enter your new password',
|
||||
passwordRequired: 'Please input your password!',
|
||||
confirmPasswordRequired: 'Please confirm your password!',
|
||||
placeholder: 'Enter new password',
|
||||
confirmPasswordPlaceholder: 'Confirm new password',
|
||||
resetPasswordButton: 'Reset Password',
|
||||
resendResetEmail: 'Resend Reset Email',
|
||||
orText: 'or',
|
||||
passwordMismatch: 'The two passwords do not match!',
|
||||
successTitle: 'Password Reset Successful',
|
||||
successMessage: 'Your password has been reset successfully.',
|
||||
'passwordChecklist.minLength': 'At least 8 characters',
|
||||
'passwordChecklist.uppercase': 'One uppercase letter',
|
||||
'passwordChecklist.lowercase': 'One lowercase letter',
|
||||
'passwordChecklist.number': 'One number',
|
||||
'passwordChecklist.special': 'One special character',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test store
|
||||
const createTestStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
themeReducer: (state = { mode: 'light' }) => state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement, route = '/verify-reset/test-hash/test-user') => {
|
||||
const store = createTestStore();
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('VerifyResetEmailPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders password reset form correctly', () => {
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
expect(screen.getByText('Enter your new password')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Confirm new password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Resend Reset Email' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows password checklist immediately', () => {
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required password fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please confirm your password!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates password confirmation match', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
await user.type(confirmPasswordInput, 'DifferentPassword123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('The two passwords do not match!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates password checklist based on input', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
|
||||
// All checklist items should be visible (this component shows them by default)
|
||||
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits form with valid matching passwords', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
await user.type(confirmPasswordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updatePassword).toHaveBeenCalledWith({
|
||||
hash: 'test-hash',
|
||||
user: 'test-user',
|
||||
password: 'Password123!',
|
||||
confirmPassword: 'Password123!',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success message after successful password reset', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
await user.type(confirmPasswordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password Reset Successful')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your password has been reset successfully.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
|
||||
});
|
||||
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
await user.type(confirmPasswordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles submission errors gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockRejectedValue(new Error('Reset failed')),
|
||||
});
|
||||
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
await user.type(confirmPasswordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should not show success message
|
||||
expect(screen.queryByText('Password Reset Successful')).not.toBeInTheDocument();
|
||||
// Should still show the form
|
||||
expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to forgot password page when resend button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const resendButton = screen.getByRole('button', { name: 'Resend Reset Email' });
|
||||
await user.click(resendButton);
|
||||
|
||||
expect(resendButton.closest('a')).toHaveAttribute('href', '/auth/forgot-password');
|
||||
});
|
||||
|
||||
it('prevents pasting in confirm password field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.click(confirmPasswordInput);
|
||||
|
||||
// Try to paste - should be prevented
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: new DataTransfer(),
|
||||
});
|
||||
|
||||
fireEvent(confirmPasswordInput, pasteEvent);
|
||||
|
||||
// The preventDefault should be called (we can't easily test this directly,
|
||||
// but we can ensure the input behavior remains consistent)
|
||||
expect(confirmPasswordInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not submit with empty passwords after trimming', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, ' '); // Only whitespace
|
||||
await user.type(confirmPasswordInput, ' '); // Only whitespace
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should not call updatePassword with empty strings
|
||||
expect(updatePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts hash and user from URL params', () => {
|
||||
renderWithProviders(<VerifyResetEmailPage />, '/verify-reset/my-hash/my-user');
|
||||
|
||||
// Component should render normally, indicating it received the params
|
||||
expect(screen.getByText('Enter your new password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
83
worklenz-frontend/src/test/setup.ts
Normal file
83
worklenz-frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock environment variables
|
||||
Object.defineProperty(process, 'env', {
|
||||
value: {
|
||||
NODE_ENV: 'test',
|
||||
VITE_API_URL: 'http://localhost:3000',
|
||||
VITE_ENABLE_GOOGLE_LOGIN: 'true',
|
||||
VITE_ENABLE_RECAPTCHA: 'false',
|
||||
VITE_RECAPTCHA_SITE_KEY: 'test-site-key',
|
||||
},
|
||||
});
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock window.getSelection
|
||||
Object.defineProperty(window, 'getSelection', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
rangeCount: 0,
|
||||
getRangeAt: vi.fn(),
|
||||
removeAllRanges: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal('localStorage', localStorageMock);
|
||||
|
||||
// Mock sessionStorage
|
||||
const sessionStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal('sessionStorage', sessionStorageMock);
|
||||
|
||||
// Suppress console warnings during tests
|
||||
const originalConsoleWarn = console.warn;
|
||||
console.warn = (...args) => {
|
||||
// Suppress specific warnings that are not relevant for tests
|
||||
if (
|
||||
args[0]?.includes?.('React Router Future Flag Warning') ||
|
||||
args[0]?.includes?.('validateDOMNesting')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleWarn(...args);
|
||||
};
|
||||
@@ -193,6 +193,17 @@ export class ServiceWorkerManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
async checkForUpdates(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.sendMessage('CHECK_FOR_UPDATES');
|
||||
return response.hasUpdates;
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Force update service worker
|
||||
async forceUpdate(): Promise<void> {
|
||||
if (!this.registration) return;
|
||||
@@ -207,6 +218,27 @@ export class ServiceWorkerManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Perform hard reload (clear cache and reload)
|
||||
async hardReload(): Promise<void> {
|
||||
try {
|
||||
// Clear all caches first
|
||||
await this.clearCache();
|
||||
|
||||
// Force update the service worker
|
||||
if (this.registration) {
|
||||
await this.registration.update();
|
||||
await this.sendMessage('SKIP_WAITING');
|
||||
}
|
||||
|
||||
// Perform hard reload by clearing browser cache
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to perform hard reload:', error);
|
||||
// Fallback to regular reload
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if app is running offline
|
||||
isOffline(): boolean {
|
||||
return !navigator.onLine;
|
||||
@@ -263,6 +295,8 @@ export function useServiceWorker() {
|
||||
swManager,
|
||||
clearCache: () => swManager?.clearCache(),
|
||||
forceUpdate: () => swManager?.forceUpdate(),
|
||||
hardReload: () => swManager?.hardReload(),
|
||||
checkForUpdates: () => swManager?.checkForUpdates(),
|
||||
getVersion: () => swManager?.getVersion(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user