- Updated test components (`PasswordResetConfirmForm`, `PasswordChangeForm`) to use i18n keys directly, ensuring accurate validation messages. - Refined translations in `it.json` to standardize format and content. - Replaced text-based labels with localized strings in `PasswordResetRequestForm` and `RegisterForm`. - Introduced `generateLocalizedMetadata` utility and updated layout metadata generation for locale-aware SEO. - Enhanced e2e tests with locale-prefixed routes and updated assertions for consistency. - Added comprehensive i18n documentation (`I18N.md`) for usage, architecture, and testing.
320 lines
12 KiB
JavaScript
320 lines
12 KiB
JavaScript
// Learn more: https://github.com/testing-library/jest-dom
|
|
import '@testing-library/jest-dom';
|
|
import 'whatwg-fetch'; // Polyfill fetch API
|
|
import { Crypto } from '@peculiar/webcrypto';
|
|
|
|
// Mock environment variables for tests
|
|
process.env.NEXT_PUBLIC_SITE_URL = 'http://localhost:3000';
|
|
process.env.NEXT_PUBLIC_API_BASE_URL = 'http://localhost:8000';
|
|
|
|
// Polyfill TransformStream for nock/msw
|
|
if (typeof global.TransformStream === 'undefined') {
|
|
const { TransformStream } = require('node:stream/web');
|
|
global.TransformStream = TransformStream;
|
|
}
|
|
|
|
// Mock window object
|
|
global.window = global.window || {};
|
|
|
|
// Mock BroadcastChannel for MSW
|
|
global.BroadcastChannel = class BroadcastChannel {
|
|
constructor(name) {
|
|
this.name = name;
|
|
}
|
|
postMessage() {}
|
|
close() {}
|
|
addEventListener() {}
|
|
removeEventListener() {}
|
|
};
|
|
|
|
// Mock IntersectionObserver for components that use viewport detection
|
|
global.IntersectionObserver = class IntersectionObserver {
|
|
constructor(callback) {
|
|
this.callback = callback;
|
|
}
|
|
observe() {
|
|
// Immediately trigger the callback with isIntersecting: true for tests
|
|
this.callback([{ isIntersecting: true }]);
|
|
}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
};
|
|
|
|
// Use real Web Crypto API polyfill for Node environment
|
|
const cryptoPolyfill = new Crypto();
|
|
|
|
// Store references before assignment
|
|
const subtleRef = cryptoPolyfill.subtle;
|
|
const getRandomValuesRef = cryptoPolyfill.getRandomValues.bind(cryptoPolyfill);
|
|
|
|
// Use Object.defineProperty to ensure properties aren't overridden
|
|
if (!global.crypto) {
|
|
global.crypto = {};
|
|
}
|
|
|
|
Object.defineProperty(global.crypto, 'subtle', {
|
|
value: subtleRef,
|
|
writable: false,
|
|
configurable: true,
|
|
enumerable: true,
|
|
});
|
|
|
|
Object.defineProperty(global.crypto, 'getRandomValues', {
|
|
value: getRandomValuesRef,
|
|
writable: false,
|
|
configurable: true,
|
|
enumerable: true,
|
|
});
|
|
|
|
// Mock TextEncoder/TextDecoder if not available
|
|
if (typeof TextEncoder === 'undefined') {
|
|
global.TextEncoder = class TextEncoder {
|
|
encode(str) {
|
|
const buf = Buffer.from(str, 'utf-8');
|
|
return new Uint8Array(buf);
|
|
}
|
|
};
|
|
}
|
|
|
|
if (typeof TextDecoder === 'undefined') {
|
|
global.TextDecoder = class TextDecoder {
|
|
decode(arr) {
|
|
return Buffer.from(arr).toString('utf-8');
|
|
}
|
|
};
|
|
}
|
|
|
|
// Mock localStorage (must be on global to satisfy typeof checks)
|
|
global.localStorage = {
|
|
getItem: jest.fn(),
|
|
setItem: jest.fn(),
|
|
removeItem: jest.fn(),
|
|
clear: jest.fn(),
|
|
length: 0,
|
|
key: jest.fn(),
|
|
};
|
|
|
|
// Mock sessionStorage (must be on global to satisfy typeof checks)
|
|
global.sessionStorage = {
|
|
getItem: jest.fn(),
|
|
setItem: jest.fn(),
|
|
removeItem: jest.fn(),
|
|
clear: jest.fn(),
|
|
length: 0,
|
|
key: jest.fn(),
|
|
};
|
|
|
|
// Suppress console logs during tests (unless VERBOSE=true)
|
|
const VERBOSE = process.env.VERBOSE === 'true';
|
|
|
|
if (!VERBOSE) {
|
|
global.console = {
|
|
...console,
|
|
log: jest.fn(),
|
|
debug: jest.fn(),
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
};
|
|
}
|
|
|
|
// Mock next-intl/server for server-side translations
|
|
jest.mock('next-intl/server', () => ({
|
|
getTranslations: jest.fn(async ({ locale: _locale, namespace: _namespace }) => {
|
|
return (key) => key;
|
|
}),
|
|
getMessages: jest.fn(async () => ({})),
|
|
}));
|
|
|
|
// Mock next-intl for all tests
|
|
jest.mock('next-intl', () => ({
|
|
useTranslations: (namespace) => {
|
|
// Return actual English translations for tests
|
|
const translations = {
|
|
auth: {
|
|
login: {
|
|
emailLabel: 'Email',
|
|
emailPlaceholder: 'Enter your email',
|
|
passwordLabel: 'Password',
|
|
passwordPlaceholder: 'Enter your password',
|
|
loginButton: 'Sign in',
|
|
loginButtonLoading: 'Signing in...',
|
|
forgotPassword: 'Forgot password?',
|
|
noAccount: "Don't have an account?",
|
|
registerLink: 'Sign up',
|
|
successMessage: 'Login successful',
|
|
},
|
|
register: {
|
|
firstNameLabel: 'First Name',
|
|
firstNamePlaceholder: 'Enter your first name',
|
|
lastNameLabel: 'Last Name',
|
|
lastNamePlaceholder: 'Enter your last name',
|
|
emailLabel: 'Email',
|
|
emailPlaceholder: 'Enter your email',
|
|
passwordLabel: 'Password',
|
|
passwordPlaceholder: 'Enter your password',
|
|
confirmPasswordLabel: 'Confirm Password',
|
|
confirmPasswordPlaceholder: 'Confirm your password',
|
|
registerButton: 'Create account',
|
|
registerButtonLoading: 'Creating account...',
|
|
hasAccount: 'Already have an account?',
|
|
loginLink: 'Sign in',
|
|
required: '*',
|
|
firstNameRequired: 'First name is required',
|
|
firstNameMinLength: 'First name must be at least 2 characters',
|
|
firstNameMaxLength: 'First name must not exceed 50 characters',
|
|
lastNameMaxLength: 'Last name must not exceed 50 characters',
|
|
passwordRequired: 'Password is required',
|
|
passwordMinLength: 'Password must be at least 8 characters',
|
|
passwordNumber: 'Password must contain at least one number',
|
|
passwordUppercase: 'Password must contain at least one uppercase letter',
|
|
confirmPasswordRequired: 'Please confirm your password',
|
|
passwordMismatch: 'Passwords do not match',
|
|
unexpectedError: 'An unexpected error occurred. Please try again.',
|
|
passwordRequirements: {
|
|
minLength: 'At least 8 characters',
|
|
hasNumber: 'Contains a number',
|
|
hasUppercase: 'Contains an uppercase letter',
|
|
},
|
|
},
|
|
passwordReset: {
|
|
emailLabel: 'Email',
|
|
emailPlaceholder: 'Enter your email',
|
|
sendResetLinkButton: 'Send reset link',
|
|
sendResetLinkButtonLoading: 'Sending...',
|
|
instructions:
|
|
'Enter your email address and we will send you a link to reset your password.',
|
|
successMessage:
|
|
'If an account exists with that email, you will receive a password reset link.',
|
|
unexpectedError: 'An unexpected error occurred. Please try again.',
|
|
backToLogin: 'Back to login',
|
|
rememberPassword: 'Remember your password?',
|
|
},
|
|
passwordResetConfirm: {
|
|
newPasswordLabel: 'New Password',
|
|
newPasswordPlaceholder: 'Enter your new password',
|
|
confirmPasswordLabel: 'Confirm Password',
|
|
confirmPasswordPlaceholder: 'Confirm your new password',
|
|
resetButton: 'Reset password',
|
|
resetButtonLoading: 'Resetting...',
|
|
instructions: 'Enter your new password below.',
|
|
successMessage: 'Your password has been successfully reset.',
|
|
backToLogin: 'Back to login',
|
|
rememberPassword: 'Remember your password?',
|
|
required: '*',
|
|
newPasswordRequired: 'New password is required',
|
|
newPasswordMinLength: 'Password must be at least 8 characters',
|
|
newPasswordNumber: 'Password must contain at least one number',
|
|
newPasswordUppercase: 'Password must contain at least one uppercase letter',
|
|
confirmPasswordRequired: 'Please confirm your password',
|
|
passwordMismatch: 'Passwords do not match',
|
|
unexpectedError: 'An unexpected error occurred. Please try again.',
|
|
passwordRequirements: {
|
|
minLength: 'At least 8 characters',
|
|
hasNumber: 'Contains a number',
|
|
hasUppercase: 'Contains an uppercase letter',
|
|
},
|
|
},
|
|
},
|
|
settings: {
|
|
password: {
|
|
title: 'Change Password',
|
|
subtitle: 'Update your password to keep your account secure',
|
|
currentPasswordLabel: 'Current Password',
|
|
currentPasswordPlaceholder: 'Enter your current password',
|
|
newPasswordLabel: 'New Password',
|
|
newPasswordPlaceholder: 'Enter your new password',
|
|
confirmPasswordLabel: 'Confirm New Password',
|
|
confirmPasswordPlaceholder: 'Confirm your new password',
|
|
updateButton: 'Update password',
|
|
updateButtonLoading: 'Updating...',
|
|
currentPasswordRequired: 'Current password is required',
|
|
newPasswordRequired: 'New password is required',
|
|
newPasswordMinLength: 'Password must be at least 8 characters',
|
|
newPasswordNumber: 'Password must contain at least one number',
|
|
newPasswordUppercase: 'Password must contain at least one uppercase letter',
|
|
newPasswordLowercase: 'Password must contain at least one lowercase letter',
|
|
newPasswordSpecial: 'Password must contain at least one special character',
|
|
confirmPasswordRequired: 'Please confirm your new password',
|
|
passwordMismatch: 'Passwords do not match',
|
|
unexpectedError: 'An unexpected error occurred. Please try again.',
|
|
passwordRequirements: {
|
|
minLength: 'At least 8 characters',
|
|
hasNumber: 'Contains a number',
|
|
hasUppercase: 'Contains an uppercase letter',
|
|
hasLowercase: 'Contains a lowercase letter',
|
|
hasSpecial: 'Contains a special character',
|
|
},
|
|
},
|
|
profile: {
|
|
title: 'Profile Settings',
|
|
subtitle: 'Manage your personal information',
|
|
firstNameLabel: 'First Name',
|
|
firstNamePlaceholder: 'Enter your first name',
|
|
lastNameLabel: 'Last Name',
|
|
lastNamePlaceholder: 'Enter your last name',
|
|
emailLabel: 'Email',
|
|
emailDescription: 'Email cannot be changed. Contact support if you need to update it.',
|
|
updateButton: 'Save changes',
|
|
updateButtonLoading: 'Saving...',
|
|
resetButton: 'Cancel',
|
|
firstNameRequired: 'First name is required',
|
|
firstNameMinLength: 'First name must be at least 2 characters',
|
|
firstNameMaxLength: 'First name must not exceed 50 characters',
|
|
lastNameMaxLength: 'Last name must not exceed 50 characters',
|
|
emailInvalid: 'Please enter a valid email address',
|
|
unexpectedError: 'An unexpected error occurred. Please try again.',
|
|
},
|
|
},
|
|
navigation: {
|
|
dashboard: 'Dashboard',
|
|
settings: 'Settings',
|
|
admin: 'Admin',
|
|
logout: 'Logout',
|
|
profile: 'Profile',
|
|
password: 'Password',
|
|
sessions: 'Sessions',
|
|
},
|
|
validation: {
|
|
required: 'This field is required',
|
|
email: 'Please enter a valid email address',
|
|
minLength: 'Must be at least 8 characters',
|
|
},
|
|
errors: {
|
|
validation: {
|
|
required: 'This field is required',
|
|
email: 'Please enter a valid email address',
|
|
passwordWeak: 'Password must contain at least one number and one uppercase letter',
|
|
},
|
|
},
|
|
};
|
|
|
|
// Helper to get nested value from object by dot notation
|
|
const get = (obj, path) => {
|
|
return path.split('.').reduce((acc, part) => acc?.[part], obj);
|
|
};
|
|
|
|
return (key) => {
|
|
const fullKey = namespace ? `${namespace}.${key}` : key;
|
|
return get(translations, fullKey) || key;
|
|
};
|
|
},
|
|
useLocale: () => 'en', // Default to English locale for tests
|
|
}));
|
|
|
|
// Reset storage mocks before each test
|
|
beforeEach(() => {
|
|
// Don't use clearAllMocks - it breaks the mocks
|
|
// Instead reset individual storage mock return values
|
|
if (global.localStorage && typeof global.localStorage.getItem.mockReset === 'function') {
|
|
global.localStorage.getItem.mockReset().mockReturnValue(null);
|
|
global.localStorage.setItem.mockReset();
|
|
global.localStorage.removeItem.mockReset();
|
|
}
|
|
if (global.sessionStorage && typeof global.sessionStorage.getItem.mockReset === 'function') {
|
|
global.sessionStorage.getItem.mockReset().mockReturnValue(null);
|
|
global.sessionStorage.setItem.mockReset();
|
|
global.sessionStorage.removeItem.mockReset();
|
|
}
|
|
});
|