From 7a7d34d7271db083f6153bcad02061274386586a Mon Sep 17 00:00:00 2001 From: nafsonig Date: Wed, 26 Aug 2026 16:08:52 +0100 Subject: [PATCH 1/4] implemented the auth.store.ts (Zustand) has no test coverage --- frontend/store/auth.store.test.ts | 197 ++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 frontend/store/auth.store.test.ts diff --git a/frontend/store/auth.store.test.ts b/frontend/store/auth.store.test.ts new file mode 100644 index 00000000..24e0fd08 --- /dev/null +++ b/frontend/store/auth.store.test.ts @@ -0,0 +1,197 @@ +import { useAuthStore } from './auth.store'; +import { authApi } from '@/lib/auth-api'; + +// Mock the authApi +jest.mock('@/lib/auth-api', () => ({ + authApi: { + login: jest.fn(), + register: jest.fn(), + logout: jest.fn(), + me: jest.fn(), + }, +})); + +const mockAuthUser = { + id: '1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + role: 'user', +}; + +const mockAuthResponse = { + user: mockAuthUser, + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', +}; + +describe('useAuthStore', () => { + // Reset store and mocks before each test + beforeEach(() => { + jest.clearAllMocks(); + useAuthStore.setState({ + user: null, + isLoading: false, + isAuthenticated: false, + }); + localStorage.clear(); + // Clear all cookies + document.cookie.split(';').forEach(cookie => { + document.cookie = cookie.replace(/^ +/, '').replace(/=.*/, `=;expires=${new Date(0).toUTCString()};path=/`); + }); + }); + + describe('login', () => { + it('should set user, isAuthenticated, and persist tokens to localStorage and cookies on successful login', async () => { + (authApi.login as jest.Mock).mockResolvedValue(mockAuthResponse); + + const { login } = useAuthStore.getState(); + await login({ email: 'test@example.com', password: 'password123' }); + + // Check store state + expect(useAuthStore.getState().user).toEqual(mockAuthUser); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(useAuthStore.getState().isLoading).toBe(false); + + // Check localStorage + expect(localStorage.getItem('accessToken')).toBe('test-access-token'); + expect(localStorage.getItem('refreshToken')).toBe('test-refresh-token'); + + // Check cookie + expect(document.cookie).toContain('accessToken=test-access-token'); + }); + + it('should set isLoading to true during login and reset to false even if login fails', async () => { + (authApi.login as jest.Mock).mockRejectedValue(new Error('Login failed')); + + const { login } = useAuthStore.getState(); + const loginPromise = login({ email: 'test@example.com', password: 'wrong' }); + + // Check that isLoading is true immediately after calling login + expect(useAuthStore.getState().isLoading).toBe(true); + + await loginPromise.catch(() => {}); + + // Check that isLoading is reset to false + expect(useAuthStore.getState().isLoading).toBe(false); + // State should remain unauthenticated + expect(useAuthStore.getState().user).toBeNull(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + }); + + describe('logout', () => { + it('should clear user, isAuthenticated, and remove tokens from localStorage and cookies', async () => { + // First set a logged-in state + useAuthStore.setState({ + user: mockAuthUser, + isAuthenticated: true, + }); + localStorage.setItem('accessToken', 'test-access-token'); + localStorage.setItem('refreshToken', 'test-refresh-token'); + document.cookie = 'accessToken=test-access-token; path=/'; + + (authApi.logout as jest.Mock).mockResolvedValue({}); + + const { logout } = useAuthStore.getState(); + await logout(); + + // Check store state is cleared + expect(useAuthStore.getState().user).toBeNull(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + + // Check localStorage is cleared + expect(localStorage.getItem('accessToken')).toBeNull(); + expect(localStorage.getItem('refreshToken')).toBeNull(); + + // Check cookie is cleared + expect(document.cookie).not.toContain('accessToken=test-access-token'); + }); + + it('should clear state and tokens even if the logout API call fails', async () => { + // First set a logged-in state + useAuthStore.setState({ + user: mockAuthUser, + isAuthenticated: true, + }); + localStorage.setItem('accessToken', 'test-access-token'); + localStorage.setItem('refreshToken', 'test-refresh-token'); + document.cookie = 'accessToken=test-access-token; path=/'; + + (authApi.logout as jest.Mock).mockRejectedValue(new Error('Logout failed')); + + const { logout } = useAuthStore.getState(); + await logout().catch(() => {}); + + // Check store state is cleared even if API fails + expect(useAuthStore.getState().user).toBeNull(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + + // Check localStorage and cookies are still cleared + expect(localStorage.getItem('accessToken')).toBeNull(); + expect(document.cookie).not.toContain('accessToken=test-access-token'); + }); + }); + + describe('loadUser (rehydration)', () => { + it('should load and set user state when there is a valid token in localStorage and me() succeeds', async () => { + // Set a token in localStorage to simulate a persisted session + localStorage.setItem('accessToken', 'valid-token'); + (authApi.me as jest.Mock).mockResolvedValue(mockAuthUser); + + const { loadUser } = useAuthStore.getState(); + const loadPromise = loadUser(); + + // Check isLoading is true during load + expect(useAuthStore.getState().isLoading).toBe(true); + + await loadPromise; + + // Check store is updated + expect(useAuthStore.getState().user).toEqual(mockAuthUser); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(useAuthStore.getState().isLoading).toBe(false); + // Token remains in localStorage + expect(localStorage.getItem('accessToken')).toBe('valid-token'); + }); + + it('should do nothing if there is no accessToken in localStorage', async () => { + // No token in localStorage + const { loadUser } = useAuthStore.getState(); + await loadUser(); + + // State remains unauthenticated + expect(useAuthStore.getState().user).toBeNull(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().isLoading).toBe(false); + // authApi.me is never called + expect(authApi.me).not.toHaveBeenCalled(); + }); + + it('should clear all state and tokens if me() fails (invalid/expired token)', async () => { + // Set an invalid/expired token in localStorage + localStorage.setItem('accessToken', 'expired-token'); + localStorage.setItem('refreshToken', 'old-refresh-token'); + document.cookie = 'accessToken=expired-token; path=/'; + + (authApi.me as jest.Mock).mockRejectedValue(new Error('Invalid token')); + + const { loadUser } = useAuthStore.getState(); + const loadPromise = loadUser(); + + expect(useAuthStore.getState().isLoading).toBe(true); + + await loadPromise; + + // Store state is cleared + expect(useAuthStore.getState().user).toBeNull(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().isLoading).toBe(false); + + // Tokens are removed from localStorage and cookie + expect(localStorage.getItem('accessToken')).toBeNull(); + expect(localStorage.getItem('refreshToken')).toBeNull(); + expect(document.cookie).not.toContain('accessToken=expired-token'); + }); + }); +}); \ No newline at end of file From 650afb2baefd7562805852ffd85bc2ffc62371f7 Mon Sep 17 00:00:00 2001 From: nafsonig Date: Wed, 26 Aug 2026 16:11:13 +0100 Subject: [PATCH 2/4] implemented the auth.store.ts (Zustand) has no test coverage --- frontend/hooks/useCommandPalette.test.ts | 124 ++++++++++++++++++++ frontend/hooks/useLocalStorageState.test.ts | 122 +++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 frontend/hooks/useCommandPalette.test.ts create mode 100644 frontend/hooks/useLocalStorageState.test.ts diff --git a/frontend/hooks/useCommandPalette.test.ts b/frontend/hooks/useCommandPalette.test.ts new file mode 100644 index 00000000..8e04d67f --- /dev/null +++ b/frontend/hooks/useCommandPalette.test.ts @@ -0,0 +1,124 @@ +import { renderHook, act } from '@testing-library/react'; +import { useCommandPalette } from './useCommandPalette'; + +describe('useCommandPalette', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize with isOpen as false', () => { + const { result } = renderHook(() => useCommandPalette()); + expect(result.current.isOpen).toBe(false); + }); + + it('should set isOpen to true when open() is called', () => { + const { result } = renderHook(() => useCommandPalette()); + + act(() => { + result.current.open(); + }); + + expect(result.current.isOpen).toBe(true); + }); + + it('should set isOpen to false when close() is called', () => { + const { result } = renderHook(() => useCommandPalette()); + + // First open it + act(() => { + result.current.open(); + }); + expect(result.current.isOpen).toBe(true); + + // Then close it + act(() => { + result.current.close(); + }); + + expect(result.current.isOpen).toBe(false); + }); + + it('should toggle isOpen state when toggle() is called', () => { + const { result } = renderHook(() => useCommandPalette()); + + // First toggle: false -> true + act(() => { + result.current.toggle(); + }); + expect(result.current.isOpen).toBe(true); + + // Second toggle: true -> false + act(() => { + result.current.toggle(); + }); + expect(result.current.isOpen).toBe(false); + }); + + it('should toggle the palette when Ctrl+K is pressed', () => { + renderHook(() => useCommandPalette()); + + // Dispatch Ctrl+K keydown event + const ctrlKEvent = new KeyboardEvent('keydown', { + ctrlKey: true, + key: 'k', + }); + const preventDefaultSpy = jest.spyOn(ctrlKEvent, 'preventDefault'); + document.dispatchEvent(ctrlKEvent); + + // Check that preventDefault was called and state toggled + expect(preventDefaultSpy).toHaveBeenCalled(); + const { result: { current: { isOpen } } } = renderHook(() => useCommandPalette()); + // Wait for state update + setTimeout(() => { + expect(isOpen).toBe(true); + }, 0); + }); + + it('should toggle the palette when Cmd+K (Mac) is pressed', () => { + renderHook(() => useCommandPalette()); + + // Dispatch Cmd+K keydown event (metaKey is Cmd on Mac) + const cmdKEvent = new KeyboardEvent('keydown', { + metaKey: true, + key: 'k', + }); + const preventDefaultSpy = jest.spyOn(cmdKEvent, 'preventDefault'); + document.dispatchEvent(cmdKEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should not toggle for other key combinations', () => { + const { result } = renderHook(() => useCommandPalette()); + expect(result.current.isOpen).toBe(false); + + // Dispatch unrelated keys + const randomEvent = new KeyboardEvent('keydown', { + ctrlKey: true, + key: 'l', + }); + document.dispatchEvent(randomEvent); + + const anotherEvent = new KeyboardEvent('keydown', { + key: 'k', + }); + document.dispatchEvent(anotherEvent); + + // State should remain false + expect(result.current.isOpen).toBe(false); + }); + + it('should remove the event listener when the component unmounts', () => { + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + const { unmount } = renderHook(() => useCommandPalette()); + + // Unmount the hook + unmount(); + + // Verify that the event listener was removed + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'keydown', + expect.any(Function) + ); + }); +}); \ No newline at end of file diff --git a/frontend/hooks/useLocalStorageState.test.ts b/frontend/hooks/useLocalStorageState.test.ts new file mode 100644 index 00000000..5352fd39 --- /dev/null +++ b/frontend/hooks/useLocalStorageState.test.ts @@ -0,0 +1,122 @@ +import { renderHook, act } from '@testing-library/react'; +import { useLocalStorageState } from './useLocalStorageState'; + +describe('useLocalStorageState', () => { + const testKey = 'test-key'; + const initialValue = 'initial-value'; + + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + }); + + it('should initialize with initial value when no stored value exists', () => { + const { result } = renderHook(() => useLocalStorageState(testKey, initialValue)); + + // Before hydration, it should still have initial value + expect(result.current.value).toBe(initialValue); + expect(result.current.isHydrated).toBe(false); + }); + + it('should hydrate and read existing value from localStorage', async () => { + const storedValue = 'stored-value'; + localStorage.setItem(testKey, JSON.stringify(storedValue)); + + const { result } = renderHook(() => useLocalStorageState(testKey, initialValue)); + + // Wait for hydration effect to run + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(result.current.isHydrated).toBe(true); + expect(result.current.value).toBe(storedValue); + }); + + it('should persist updated values to localStorage (write-through)', async () => { + const { result } = renderHook(() => useLocalStorageState(testKey, initialValue)); + + // Wait for hydration + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(result.current.isHydrated).toBe(true); + + // Update the value + const newValue = 'new-value'; + act(() => { + result.current.setValue(newValue); + }); + + // Wait for the effect to persist to localStorage + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Check that localStorage was updated + expect(localStorage.getItem(testKey)).toBe(JSON.stringify(newValue)); + expect(result.current.value).toBe(newValue); + }); + + it('should handle localStorage being unavailable (throws errors)', async () => { + // Mock localStorage.getItem to throw (simulates private browsing/disabled storage) + const originalGetItem = Storage.prototype.getItem; + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.getItem = jest.fn().mockImplementation(() => { + throw new Error('Storage unavailable'); + }); + Storage.prototype.setItem = jest.fn().mockImplementation(() => { + throw new Error('Storage unavailable'); + }); + + const { result } = renderHook(() => useLocalStorageState(testKey, initialValue)); + + // Wait for hydration + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Should still hydrate successfully, even if storage is unavailable + expect(result.current.isHydrated).toBe(true); + // Should keep the initial value since it couldn't read from storage + expect(result.current.value).toBe(initialValue); + + // Try to update the value - should not throw, and should update local state + const newValue = 'test-new-value'; + act(() => { + result.current.setValue(newValue); + }); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Local state should update even if storage fails + expect(result.current.value).toBe(newValue); + // The mocks were called (proves we tried to access storage) + expect(Storage.prototype.getItem).toHaveBeenCalled(); + expect(Storage.prototype.setItem).toHaveBeenCalled(); + + // Restore original methods + Storage.prototype.getItem = originalGetItem; + Storage.prototype.setItem = originalSetItem; + }); + + it('should maintain correct isHydrated state throughout lifecycle', async () => { + const { result, rerender } = renderHook(() => useLocalStorageState(testKey, initialValue)); + + // Initially, isHydrated is false + expect(result.current.isHydrated).toBe(false); + + // After hydration, it becomes true + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(result.current.isHydrated).toBe(true); + + // Rerender with different key + rerender(); + // Should stay hydrated + expect(result.current.isHydrated).toBe(true); + }); +}); \ No newline at end of file From a039034bb1ff6d3ca3558f933d2b8a29dba68c29 Mon Sep 17 00:00:00 2001 From: nafsonig Date: Wed, 26 Aug 2026 16:11:29 +0100 Subject: [PATCH 3/4] implemented the auth.store.ts (Zustand) has no test coverage --- frontend/hooks/useCommandPalette.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/hooks/useCommandPalette.test.ts b/frontend/hooks/useCommandPalette.test.ts index 8e04d67f..f1332472 100644 --- a/frontend/hooks/useCommandPalette.test.ts +++ b/frontend/hooks/useCommandPalette.test.ts @@ -55,7 +55,8 @@ describe('useCommandPalette', () => { }); it('should toggle the palette when Ctrl+K is pressed', () => { - renderHook(() => useCommandPalette()); + const { result } = renderHook(() => useCommandPalette()); + expect(result.current.isOpen).toBe(false); // Dispatch Ctrl+K keydown event const ctrlKEvent = new KeyboardEvent('keydown', { @@ -67,11 +68,11 @@ describe('useCommandPalette', () => { // Check that preventDefault was called and state toggled expect(preventDefaultSpy).toHaveBeenCalled(); - const { result: { current: { isOpen } } } = renderHook(() => useCommandPalette()); - // Wait for state update - setTimeout(() => { - expect(isOpen).toBe(true); - }, 0); + // Need to act to process the state update from the event listener + act(() => { + jest.runAllTimers(); + }); + expect(result.current.isOpen).toBe(true); }); it('should toggle the palette when Cmd+K (Mac) is pressed', () => { From fa259c8da474cbecfc95263b5fb81f339e4f62df Mon Sep 17 00:00:00 2001 From: nafsonig Date: Wed, 26 Aug 2026 16:11:42 +0100 Subject: [PATCH 4/4] implemented the auth.store.ts (Zustand) has no test coverage --- frontend/hooks/useCommandPalette.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/hooks/useCommandPalette.test.ts b/frontend/hooks/useCommandPalette.test.ts index f1332472..5e9de133 100644 --- a/frontend/hooks/useCommandPalette.test.ts +++ b/frontend/hooks/useCommandPalette.test.ts @@ -76,7 +76,8 @@ describe('useCommandPalette', () => { }); it('should toggle the palette when Cmd+K (Mac) is pressed', () => { - renderHook(() => useCommandPalette()); + const { result } = renderHook(() => useCommandPalette()); + expect(result.current.isOpen).toBe(false); // Dispatch Cmd+K keydown event (metaKey is Cmd on Mac) const cmdKEvent = new KeyboardEvent('keydown', { @@ -87,6 +88,10 @@ describe('useCommandPalette', () => { document.dispatchEvent(cmdKEvent); expect(preventDefaultSpy).toHaveBeenCalled(); + act(() => { + jest.runAllTimers(); + }); + expect(result.current.isOpen).toBe(true); }); it('should not toggle for other key combinations', () => {