Merge branch 'release-v2.1.4' of https://github.com/Worklenz/worklenz into feature/team-utilization

This commit is contained in:
Chamika J
2025-08-01 09:31:38 +05:30
31 changed files with 2799 additions and 251 deletions

View File

@@ -1,41 +0,0 @@
-- Test script to verify the sort order constraint fix
-- Test the helper function
SELECT get_sort_column_name('status'); -- Should return 'status_sort_order'
SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order'
SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order'
SELECT get_sort_column_name('members'); -- Should return 'member_sort_order'
SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default)
-- Test bulk update function (example - would need real project_id and task_ids)
/*
SELECT update_task_sort_orders_bulk(
'[
{"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"},
{"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"}
]'::json,
'status'
);
*/
-- Verify that sort_order constraint still exists and works
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_name = 'tasks_sort_order_unique';
-- Check that new sort order columns don't have unique constraints (which is correct)
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE kcu.table_name = 'tasks'
AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
AND tc.constraint_type = 'UNIQUE';

View File

@@ -1,30 +0,0 @@
-- Test script to validate the separate sort order implementation
-- Check if new columns exist
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'tasks'
AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
ORDER BY column_name;
-- Check if helper function exists
SELECT routine_name, routine_type
FROM information_schema.routines
WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change');
-- Sample test data to verify different sort orders work
-- (This would be run after the migrations)
/*
-- Test: Tasks should have different orders for different groupings
SELECT
id,
name,
sort_order,
status_sort_order,
priority_sort_order,
phase_sort_order,
member_sort_order
FROM tasks
WHERE project_id = '<test-project-id>'
ORDER BY status_sort_order;
*/

View File

@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
import TeamMembersController from "./team-members-controller";
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
import {updateUsers} from "../shared/paddle-requests";
import {statusExclude} from "../shared/constants";
import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
import {NotificationsService} from "../services/notifications/notifications.service";
export default class ProjectMembersController extends WorklenzControllerBase {
@@ -118,6 +118,17 @@ export default class ProjectMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
// if (subscriptionData.status === "trialing") break;
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
// if (subscriptionData.subscription_status === "active") {

View File

@@ -13,7 +13,7 @@ import { SocketEvents } from "../socket.io/events";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, getColor } from "../shared/utils";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants";
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
import { updateUsers } from "../shared/paddle-requests";
import { NotificationsService } from "../services/notifications/notifications.service";
@@ -141,6 +141,17 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
/**
* Checks subscription details and updates the user count if applicable.
* Sends a response if there is an issue with the subscription.
@@ -1081,6 +1092,18 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
const emailsToAdd = req.body.emails?.length || 1;
if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
// if (subscriptionData.status === "trialing") break;
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
if (subscriptionData.subscription_status === "active") {

View File

@@ -160,6 +160,9 @@ export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercas
// paddle status to exclude
export const statusExclude = ["past_due", "paused", "deleted"];
// Trial user team member limit
export const TRIAL_MEMBER_LIMIT = 10;
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
export const UNMAPPED = "Unmapped";

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,11 @@
"build": "vite build",
"dev-build": "vite build",
"serve": "vite preview",
"format": "prettier --write ."
"format": "prettier --write .",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
},
"dependencies": {
"@ant-design/colors": "^7.1.0",
@@ -77,7 +81,10 @@
"@types/react-dom": "19.0.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.21",
"jsdom": "^26.1.0",
"postcss": "^8.5.2",
"prettier-plugin-tailwindcss": "^0.6.13",
"rollup": "^4.40.2",

View File

@@ -15,7 +15,7 @@ class HubSpotManager {
* Load HubSpot script with dark mode support
*/
init() {
if (!this.isProduction) return;
// if (!this.isProduction) return;
const loadHubSpot = () => {
const script = document.createElement('script');
@@ -51,7 +51,8 @@ class HubSpotManager {
if (existingStyle) {
existingStyle.remove();
}
// Apply dark mode CSS if dark theme is active
if (isDark) {
this.injectDarkModeCSS();
}
@@ -122,3 +123,10 @@ document.addEventListener('DOMContentLoaded', () => {
// Make available globally for potential cleanup
window.HubSpotManager = hubspot;
});
// Add this style to ensure the chat widget uses the light color scheme
(function() {
var style = document.createElement('style');
style.innerHTML = '#hubspot-messages-iframe-container { color-scheme: light !important; }';
document.head.appendChild(style);
})();

View File

@@ -6,5 +6,12 @@
"reconnecting": "Jeni shkëputur nga serveri.",
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
"connection-restored": "U lidhët me serverin me sukses",
"cancel": "Anulo"
"cancel": "Anulo",
"update-available": "Worklenz u përditesua!",
"update-description": "Një version i ri i Worklenz është i disponueshëm me karakteristikat dhe përmirësimet më të fundit.",
"update-instruction": "Për eksperiencën më të mirë, ju lutemi rifreskoni faqen për të aplikuar ndryshimet e reja.",
"update-whats-new": "💡 <1>Çfarë ka të re:</1> Përmirësim i performancës, rregullime të gabimeve dhe eksperiencön e përmirësuar e përdoruesit",
"update-now": "Përditeso tani",
"update-later": "Më vonë",
"updating": "Duke u përditesuar..."
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Vom Server getrennt.",
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
"connection-restored": "Erfolgreich mit dem Server verbunden",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"update-available": "Worklenz aktualisiert!",
"update-description": "Eine neue Version von Worklenz ist verfügbar mit den neuesten Funktionen und Verbesserungen.",
"update-instruction": "Für die beste Erfahrung laden Sie bitte die Seite neu, um die neuen Änderungen zu übernehmen.",
"update-whats-new": "💡 <1>Was ist neu:</1> Verbesserte Leistung, Fehlerbehebungen und verbesserte Benutzererfahrung",
"update-now": "Jetzt aktualisieren",
"update-later": "Später",
"updating": "Wird aktualisiert..."
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Disconnected from server.",
"connection-lost": "Failed to connect to server. Please check your internet connection.",
"connection-restored": "Connected to server successfully",
"cancel": "Cancel"
"cancel": "Cancel",
"update-available": "Worklenz Updated!",
"update-description": "A new version of Worklenz is available with the latest features and improvements.",
"update-instruction": "To get the best experience, please reload the page to apply the new changes.",
"update-whats-new": "💡 <1>What's new:</1> Enhanced performance, bug fixes, and improved user experience",
"update-now": "Update Now",
"update-later": "Later",
"updating": "Updating..."
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Reconectando al servidor...",
"connection-lost": "Conexión perdida. Intentando reconectarse...",
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
"cancel": "Cancelar"
"cancel": "Cancelar",
"update-available": "¡Worklenz actualizado!",
"update-description": "Una nueva versión de Worklenz está disponible con las últimas funciones y mejoras.",
"update-instruction": "Para obtener la mejor experiencia, por favor recarga la página para aplicar los nuevos cambios.",
"update-whats-new": "💡 <1>Qué hay de nuevo:</1> Rendimiento mejorado, correcciones de errores y experiencia de usuario mejorada",
"update-now": "Actualizar ahora",
"update-later": "Más tarde",
"updating": "Actualizando..."
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Reconectando ao servidor...",
"connection-lost": "Conexão perdida. Tentando reconectar...",
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
"cancel": "Cancelar"
"cancel": "Cancelar",
"update-available": "Worklenz atualizado!",
"update-description": "Uma nova versão do Worklenz está disponível com os recursos e melhorias mais recentes.",
"update-instruction": "Para obter a melhor experiência, por favor recarregue a página para aplicar as novas mudanças.",
"update-whats-new": "💡 <1>O que há de novo:</1> Performance aprimorada, correções de bugs e experiência do usuário melhorada",
"update-now": "Atualizar agora",
"update-later": "Mais tarde",
"updating": "Atualizando..."
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "与服务器断开连接。",
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
"connection-restored": "成功连接到服务器",
"cancel": "取消"
"cancel": "取消",
"update-available": "Worklenz 已更新!",
"update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。",
"update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。",
"update-whats-new": "💡 <1>新增内容:</1>性能增强、错误修复和用户体验改善",
"update-now": "立即更新",
"update-later": "稍后",
"updating": "正在更新..."
}

View File

@@ -327,7 +327,13 @@ self.addEventListener('message', event => {
case 'GET_VERSION':
event.ports[0].postMessage({ version: CACHE_VERSION });
break;
case 'CHECK_FOR_UPDATES':
checkForUpdates().then((hasUpdates) => {
event.ports[0].postMessage({ hasUpdates });
});
break;
case 'CLEAR_CACHE':
clearAllCaches().then(() => {
event.ports[0].postMessage({ success: true });
@@ -352,6 +358,44 @@ async function clearAllCaches() {
console.log('Service Worker: All caches cleared');
}
async function checkForUpdates() {
try {
// Check if there's a new service worker available
const registration = await self.registration.update();
const hasNewWorker = registration.installing || registration.waiting;
if (hasNewWorker) {
console.log('Service Worker: New version detected');
return true;
}
// Also check if the main app files have been updated by trying to fetch index.html
// and comparing it with the cached version
try {
const cache = await caches.open(CACHE_NAMES.STATIC);
const cachedResponse = await cache.match('/');
const networkResponse = await fetch('/', { cache: 'no-cache' });
if (cachedResponse && networkResponse.ok) {
const cachedContent = await cachedResponse.text();
const networkContent = await networkResponse.text();
if (cachedContent !== networkContent) {
console.log('Service Worker: App content has changed');
return true;
}
}
} catch (error) {
console.log('Service Worker: Could not check for content updates', error);
}
return false;
} catch (error) {
console.error('Service Worker: Error checking for updates', error);
return false;
}
}
async function handleLogout() {
try {
// Clear all caches

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { default as UpdateNotification } from './UpdateNotification';
export { default as UpdateNotificationProvider } from './UpdateNotificationProvider';

View 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
};
}

View File

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

View File

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

View File

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

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

View 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();
});
});
});

View File

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

View 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);
};

View File

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

View File

@@ -0,0 +1,38 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
reporters: ['verbose'],
coverage: {
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
],
},
},
resolve: {
alias: [
{ find: '@', replacement: path.resolve(__dirname, './src') },
{ find: '@components', replacement: path.resolve(__dirname, './src/components') },
{ find: '@features', replacement: path.resolve(__dirname, './src/features') },
{ find: '@assets', replacement: path.resolve(__dirname, './src/assets') },
{ find: '@utils', replacement: path.resolve(__dirname, './src/utils') },
{ find: '@hooks', replacement: path.resolve(__dirname, './src/hooks') },
{ find: '@pages', replacement: path.resolve(__dirname, './src/pages') },
{ find: '@api', replacement: path.resolve(__dirname, './src/api') },
{ find: '@types', replacement: path.resolve(__dirname, './src/types') },
{ find: '@shared', replacement: path.resolve(__dirname, './src/shared') },
{ find: '@layouts', replacement: path.resolve(__dirname, './src/layouts') },
{ find: '@services', replacement: path.resolve(__dirname, './src/services') },
],
},
});