diff --git a/src/hooks/__tests__/useInternationalization.test.tsx b/src/hooks/__tests__/useInternationalization.test.tsx new file mode 100644 index 00000000..028b28a4 --- /dev/null +++ b/src/hooks/__tests__/useInternationalization.test.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { I18nProvider, useInternationalization } from '../useInternationalization'; + +const STORAGE_KEY = 'i18n:language'; + +vi.mock('@/lib/i18n/config', () => ({ + __esModule: true, + default: { + language: 'en', + changeLanguage: vi.fn(), + hasResourceBundle: vi.fn().mockReturnValue(true), + isInitialized: true, + }, + loadLocale: vi.fn().mockResolvedValue(undefined), + getHtmlDir: vi.fn().mockReturnValue('ltr'), +})); + +vi.mock('@/locales/translationManager', () => ({ + loadTranslations: vi.fn().mockResolvedValue({ common: { test: 'Test' } }), + getTranslation: vi.fn((_t: unknown, key: string) => key), + getMissingTranslations: vi.fn().mockReturnValue([]), +})); + +vi.mock('@/utils/i18nUtils', () => ({ + getCulturalPreferences: vi.fn().mockReturnValue({ + dateFormat: 'MM/dd/yyyy', + timeFormat: 'HH:mm', + numberFormat: 'en-US', + currency: 'USD', + currencySymbol: '$', + firstDayOfWeek: 0, + decimalSeparator: '.', + thousandsSeparator: ',', + direction: 'ltr', + }), + formatDate: vi.fn(), + formatRelativeTime: vi.fn(), + preloadDateFnsLocale: vi.fn(), + formatNumber: vi.fn(), + formatCurrency: vi.fn(), + formatPercentage: vi.fn(), + parseNumber: vi.fn(), + getTextDirection: vi + .fn() + .mockImplementation((lang: string) => (lang === 'ar' || lang === 'he' ? 'rtl' : 'ltr')), + isRTL: vi.fn().mockImplementation((lang: string) => lang === 'ar' || lang === 'he'), + formatFileSize: vi.fn(), + formatDuration: vi.fn(), +})); + +vi.mock('@/lib/logging', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +describe('useInternationalization', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const setup = (defaultLanguage = 'en') => + renderHook(() => useInternationalization(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + describe('localStorage persistence', () => { + it('restores saved language from localStorage on mount', async () => { + localStorage.setItem(STORAGE_KEY, 'es'); + + const { result } = setup(); + + await waitFor(() => { + expect(result.current.language).toBe('es'); + }); + }); + + it('persists language to localStorage when changeLanguage is called', async () => { + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.changeLanguage('es'); + }); + + expect(localStorage.getItem(STORAGE_KEY)).toBe('es'); + }); + + it('uses default language when no saved language exists', async () => { + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.language).toBe('en'); + }); + }); + + it('ignores invalid saved language and falls back to default', async () => { + localStorage.setItem(STORAGE_KEY, 'invalid_lang'); + + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.language).toBe('en'); + }); + }); + + it('survives re-mount simulating page reload', async () => { + const { result, unmount } = setup('en'); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.changeLanguage('es'); + }); + + expect(localStorage.getItem(STORAGE_KEY)).toBe('es'); + + unmount(); + + const { result: result2 } = setup('en'); + + await waitFor(() => { + expect(result2.current.language).toBe('es'); + }); + }); + + it('updates document.documentElement lang and dir for RTL language', async () => { + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.changeLanguage('ar'); + }); + + expect(document.documentElement.lang).toBe('ar'); + expect(document.documentElement.dir).toBe('rtl'); + }); + + it('sets document.cookie when language changes', async () => { + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.changeLanguage('es'); + }); + + expect(document.cookie).toMatch(/i18n:language=es/); + }); + + it('continues functioning when localStorage.setItem throws', async () => { + const setItemSpy = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new Error('Storage full'); + }); + + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.changeLanguage('es'); + }); + + expect(setItemSpy).toHaveBeenCalled(); + expect(result.current.language).toBe('es'); + + setItemSpy.mockRestore(); + }); + }); + + describe('fallback when used outside provider', () => { + it('returns default values', () => { + const { result } = renderHook(() => useInternationalization()); + + expect(result.current.language).toBe('en'); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('changeLanguage is a no-op outside provider', async () => { + const { result } = renderHook(() => useInternationalization()); + + await act(async () => { + await result.current.changeLanguage('es'); + }); + + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + }); +}); diff --git a/src/hooks/useInternationalization.tsx b/src/hooks/useInternationalization.tsx index 8a600388..adcf8a82 100644 --- a/src/hooks/useInternationalization.tsx +++ b/src/hooks/useInternationalization.tsx @@ -11,7 +11,7 @@ import { getTranslation, getMissingTranslations, } from '@/locales/translationManager'; -import { DEFAULT_LANGUAGE } from '@/locales/config'; +import { DEFAULT_LANGUAGE, getAvailableLanguages } from '@/locales/config'; import { getCulturalPreferences, formatDate as formatDateUtil, @@ -31,6 +31,8 @@ import { createLogger } from '@/lib/logging'; const logger = createLogger('use-internationalization'); +const I18N_LANGUAGE_STORAGE_KEY = 'i18n:language'; + interface I18nContextValue { language: LanguageCode; translations: Translations; @@ -131,8 +133,12 @@ export function I18nProvider({ // Load initial language from localStorage useEffect(() => { - const savedLanguage = localStorage.getItem('i18n:language') as LanguageCode | null; - if (savedLanguage && savedLanguage !== language) { + const savedLanguage = localStorage.getItem(I18N_LANGUAGE_STORAGE_KEY) as LanguageCode | null; + if ( + savedLanguage && + getAvailableLanguages().includes(savedLanguage) && + savedLanguage !== language + ) { setLanguage(savedLanguage); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -141,13 +147,18 @@ export function I18nProvider({ // Save language preference to localStorage + cookie (cookie enables SSR-side lang/dir) const changeLanguage = useCallback(async (newLanguage: LanguageCode) => { setLanguage(newLanguage); - localStorage.setItem('i18n:language', newLanguage); + + try { + localStorage.setItem(I18N_LANGUAGE_STORAGE_KEY, newLanguage); + } catch { + // localStorage may be unavailable (e.g. private browsing) — persist best-effort. + } // Persist to cookie so the server can read it on next request for SSR lang/dir. document.cookie = `i18n:language=${newLanguage};path=/;max-age=31536000;SameSite=Lax`; // Update and immediately for the current page visit. - const newDir = ['ar', 'he', 'fa', 'ur'].includes(newLanguage) ? 'rtl' : 'ltr'; + const newDir = isRTLUtil(newLanguage) ? 'rtl' : 'ltr'; document.documentElement.lang = newLanguage; document.documentElement.dir = newDir; }, []);