feat(trial-user-limits): implement trial member limit checks in project and team controllers

- Added TRIAL_MEMBER_LIMIT constant to enforce a maximum number of trial users in project and team member controllers.
- Implemented logic to check current trial members against the limit during user addition, providing appropriate responses for exceeding limits.
- Updated relevant controllers to utilize the new trial member limit functionality, enhancing subscription management for trial users.
- Enhanced error messaging to guide users on upgrading their subscription for additional members.
This commit is contained in:
Chamika J
2025-07-31 12:56:28 +05:30
parent 2bd6c19c13
commit 7635676289
14 changed files with 2334 additions and 163 deletions

View File

@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
import TeamMembersController from "./team-members-controller"; import TeamMembersController from "./team-members-controller";
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils"; import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
import {updateUsers} from "../shared/paddle-requests"; 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"; import {NotificationsService} from "../services/notifications/notifications.service";
export default class ProjectMembersController extends WorklenzControllerBase { 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.")); 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 (subscriptionData.status === "trialing") break;
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") { if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
// if (subscriptionData.subscription_status === "active") { // if (subscriptionData.subscription_status === "active") {

View File

@@ -13,7 +13,7 @@ import { SocketEvents } from "../socket.io/events";
import WorklenzControllerBase from "./worklenz-controller-base"; import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions"; import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, getColor } from "../shared/utils"; 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 { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
import { updateUsers } from "../shared/paddle-requests"; import { updateUsers } from "../shared/paddle-requests";
import { NotificationsService } from "../services/notifications/notifications.service"; 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.")); 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. * Checks subscription details and updates the user count if applicable.
* Sends a response if there is an issue with the subscription. * 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.")); 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.status === "trialing") break;
if (!subscriptionData.is_credit && !subscriptionData.is_custom) { if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
if (subscriptionData.subscription_status === "active") { 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 // paddle status to exclude
export const statusExclude = ["past_due", "paused", "deleted"]; 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 HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
export const UNMAPPED = "Unmapped"; export const UNMAPPED = "Unmapped";

File diff suppressed because it is too large Load Diff

View File

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

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

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