From 0105e765b3289ed2b8162b973389c2e30a0f9da1 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Thu, 20 Nov 2025 09:24:15 +0100 Subject: [PATCH] Add tests for auth storage logic and i18n routing configuration - Added comprehensive unit tests for `auth/storage` to handle SSR, E2E paths, storage method selection, and error handling. - Introduced tests for `i18n/routing` to validate locale configuration, navigation hooks, and link preservation. - Updated Jest coverage exclusions to include ` --- frontend/jest.config.js | 1 + frontend/tests/lib/auth/storage.test.ts | 100 +++++++++++++++++++++++ frontend/tests/lib/i18n/routing.test.ts | 38 +++++++++ frontend/tests/lib/i18n/routing.test.tsx | 39 +++++++++ 4 files changed, 178 insertions(+) create mode 100644 frontend/tests/lib/i18n/routing.test.ts create mode 100644 frontend/tests/lib/i18n/routing.test.tsx diff --git a/frontend/jest.config.js b/frontend/jest.config.js index b27cb26..d1a9b32 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -26,6 +26,7 @@ const customJestConfig = { '!src/**/*.stories.{js,jsx,ts,tsx}', '!src/**/__tests__/**', '!src/lib/api/generated/**', // Auto-generated API client - do not test + '!src/lib/api/client-config.ts', // Integration glue for generated client - covered by E2E '!src/lib/api/hooks/**', // React Query hooks - tested in E2E (require API mocking) '!src/**/*.old.{js,jsx,ts,tsx}', // Old implementation files '!src/components/ui/**', // shadcn/ui components - third-party, no need to test diff --git a/frontend/tests/lib/auth/storage.test.ts b/frontend/tests/lib/auth/storage.test.ts index 1b87d20..f2ed918 100644 --- a/frontend/tests/lib/auth/storage.test.ts +++ b/frontend/tests/lib/auth/storage.test.ts @@ -213,4 +213,104 @@ describe('Storage Module', () => { await expect(clearTokens()).resolves.not.toThrow(); }); }); + + describe('SSR and guards', () => { + const originalLocalStorage = global.localStorage; + const originalWindow = global.window; + + afterEach(() => { + // Restore globals + (global as any).window = originalWindow; + (global as any).localStorage = originalLocalStorage; + }); + + it('returns false on isStorageAvailable and null on getTokens when localStorage is unavailable', async () => { + // Simulate SSR/unavailable localStorage by shadowing the global getter + const descriptor = Object.getOwnPropertyDescriptor(global, 'localStorage'); + Object.defineProperty(global, 'localStorage', { value: undefined, configurable: true }); + + expect(isStorageAvailable()).toBe(false); + await expect(getTokens()).resolves.toBeNull(); + + // Restore descriptor + if (descriptor) Object.defineProperty(global, 'localStorage', descriptor); + }); + + it('saveTokens throws when localStorage is unavailable', async () => { + const descriptor = Object.getOwnPropertyDescriptor(global, 'localStorage'); + Object.defineProperty(global, 'localStorage', { value: undefined, configurable: true }); + + await expect( + saveTokens({ accessToken: 'a', refreshToken: 'r' }) + ).rejects.toThrow('localStorage not available - cannot save tokens'); + + if (descriptor) Object.defineProperty(global, 'localStorage', descriptor); + }); + }); + + describe('E2E mode path (skip encryption)', () => { + const originalFlag = (global.window as any).__PLAYWRIGHT_TEST__; + + beforeEach(() => { + (global.window as any).__PLAYWRIGHT_TEST__ = true; + localStorage.clear(); + clearEncryptionKey(); + }); + + afterEach(() => { + (global.window as any).__PLAYWRIGHT_TEST__ = originalFlag; + }); + + it('stores plain JSON and retrieves it when E2E flag is set', async () => { + const tokens = { accessToken: 'plainA', refreshToken: 'plainR' }; + await saveTokens(tokens); + + // Verify plain JSON persisted + const raw = localStorage.getItem('auth_tokens'); + expect(raw).toContain('plainA'); + + const retrieved = await getTokens(); + expect(retrieved).toEqual(tokens); + }); + }); + + describe('Storage method selection and cookie mode', () => { + it('falls back to localStorage when invalid method is stored', () => { + localStorage.setItem('auth_storage_method', 'invalid'); + expect(getStorageMethod()).toBe('localStorage'); + }); + + it('does not persist tokens when method is cookie (client-side no-op)', async () => { + setStorageMethod('cookie'); + expect(getStorageMethod()).toBe('cookie'); + + await saveTokens({ accessToken: 'A', refreshToken: 'R' }); + // Should be no-op for client-side + expect(localStorage.getItem('auth_tokens')).toBeNull(); + + const retrieved = await getTokens(); + expect(retrieved).toBeNull(); + + // clearTokens should not throw in cookie mode + await expect(clearTokens()).resolves.not.toThrow(); + }); + }); + + describe('setStorageMethod error path', () => { + it('logs an error if localStorage.setItem throws', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = jest.fn(() => { + throw new Error('boom'); + }); + + // Should not throw despite underlying error + expect(() => setStorageMethod('localStorage')).not.toThrow(); + + // Restore and verify log + Storage.prototype.setItem = originalSetItem; + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + }); }); diff --git a/frontend/tests/lib/i18n/routing.test.ts b/frontend/tests/lib/i18n/routing.test.ts new file mode 100644 index 0000000..f892960 --- /dev/null +++ b/frontend/tests/lib/i18n/routing.test.ts @@ -0,0 +1,38 @@ +/** + * Tests for i18n routing configuration + */ + +import { routing, Link, usePathname, useRouter } from '@/lib/i18n/routing'; +import { render, screen } from '@testing-library/react'; + +describe('i18n routing', () => { + it('exposes supported locales and defaultLocale', () => { + expect(routing.locales).toEqual(['en', 'it']); + expect(routing.defaultLocale).toBe('en'); + // Using "always" strategy for clarity + // @ts-expect-error - localePrefix not in typed export + expect(routing.localePrefix).toBe('always'); + }); + + it('provides Link wrapper that preserves href', () => { + render( + + About + + ); + const el = screen.getByTestId('test-link') as HTMLAnchorElement; + expect(el.tagName).toBe('A'); + expect(el.getAttribute('href')).toBe('/en/about'); + expect(screen.getByText('About')).toBeInTheDocument(); + }); + + it('provides navigation hooks', () => { + const pathname = usePathname(); + const router = useRouter(); + + expect(pathname).toBe('/en/test'); + expect(router).toEqual( + expect.objectContaining({ push: expect.any(Function), replace: expect.any(Function) }) + ); + }); +}); diff --git a/frontend/tests/lib/i18n/routing.test.tsx b/frontend/tests/lib/i18n/routing.test.tsx new file mode 100644 index 0000000..d906916 --- /dev/null +++ b/frontend/tests/lib/i18n/routing.test.tsx @@ -0,0 +1,39 @@ +/** + * Tests for i18n routing configuration + */ + +import { routing, Link, usePathname, useRouter, redirect } from '@/lib/i18n/routing'; +import { render, screen } from '@testing-library/react'; + +describe('i18n routing', () => { + it('exposes supported locales and defaultLocale', () => { + expect(routing.locales).toEqual(['en', 'it']); + expect(routing.defaultLocale).toBe('en'); + // Using "always" strategy for clarity (property exists in the config object) + // @ts-expect-error typed export may not include localePrefix + expect(routing.localePrefix).toBe('always'); + }); + + it('provides Link wrapper that preserves href and children', () => { + render( + + About + + ); + const el = screen.getByTestId('test-link') as HTMLAnchorElement; + expect(el.tagName).toBe('A'); + expect(el.getAttribute('href')).toBe('/en/about'); + expect(screen.getByText('About')).toBeInTheDocument(); + }); + + it('provides navigation hooks and redirect function', () => { + const pathname = usePathname(); + const router = useRouter(); + + expect(pathname).toBe('/en/test'); + expect(typeof redirect).toBe('function'); + expect(router).toEqual( + expect.objectContaining({ push: expect.any(Function), replace: expect.any(Function) }) + ); + }); +});