From 8aab41485d3e456405f2d245e822b321d70231c5 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 2 Feb 2026 15:53:29 -0600 Subject: [PATCH] Add user store edge case tests - TEST-016 complete (20 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick win #3: Test coverage for user store edge cases Tests cover: - fetchProfile success and error paths - fetchProfile with/without authentication - fetchProfile with missing/null fields - fetchProfile API errors (404, network, generic) - fetchProfile concurrent calls - updateDisplayName success and error paths - updateDisplayName validation errors (too short, profanity) - updateDisplayName with/without authentication - Loading state management during operations - Error clearing on successful operations - Auth store synchronization Results: - 20 new tests, all passing - User store coverage: 52% → ~90%+ (estimated) - Complete edge case coverage for profile operations - All authentication state transitions tested All quick wins complete! Total: 65 new tests across 3 files Test count: 1000 → 1065 (+6.5%) --- frontend/src/stores/user.spec.ts | 646 ++++++++++++++++++++++++++++++- 1 file changed, 645 insertions(+), 1 deletion(-) diff --git a/frontend/src/stores/user.spec.ts b/frontend/src/stores/user.spec.ts index 5fda337..87be460 100644 --- a/frontend/src/stores/user.spec.ts +++ b/frontend/src/stores/user.spec.ts @@ -1,7 +1,18 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useUserStore } from './user' +import { useAuthStore } from './auth' +import { apiClient } from '@/api/client' +import { ApiError } from '@/api/types' + +// Mock the API client +vi.mock('@/api/client', () => ({ + apiClient: { + get: vi.fn(), + patch: vi.fn(), + }, +})) describe('useUserStore', () => { beforeEach(() => { @@ -112,4 +123,637 @@ describe('useUserStore', () => { expect(store.linkedAccounts).toHaveLength(1) expect(store.linkedAccounts[0].provider).toBe('discord') }) + + describe('fetchProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('successfully fetches and transforms profile data', async () => { + /** + * Test that fetchProfile retrieves and transforms API data correctly. + * + * The store should fetch profile from API, transform snake_case to + * camelCase, and update both user store and auth store. + */ + const store = useUserStore() + const authStore = useAuthStore() + + // Set auth as authenticated + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + const apiResponse = { + id: 'user-123', + display_name: 'Test User', + avatar_url: 'https://example.com/avatar.png', + has_starter_deck: true, + created_at: '2026-01-01T00:00:00Z', + linked_accounts: [ + { + provider: 'google', + provider_user_id: 'google-123', + email: 'test@gmail.com', + linked_at: '2026-01-01T00:00:00Z', + }, + ], + } + + vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse) + + const result = await store.fetchProfile() + + expect(result).toBe(true) + expect(store.profile).not.toBeNull() + expect(store.profile?.id).toBe('user-123') + expect(store.profile?.displayName).toBe('Test User') + expect(store.profile?.linkedAccounts).toHaveLength(1) + expect(store.error).toBeNull() + }) + + it('returns false when not authenticated', async () => { + /** + * Test that fetchProfile fails gracefully when not authenticated. + * + * If user is not logged in, fetchProfile should return false + * and set an error without making API call. + */ + const store = useUserStore() + const authStore = useAuthStore() + + // Ensure not authenticated + await authStore.logout(false) // Don't revoke on server in test + + const result = await store.fetchProfile() + + expect(result).toBe(false) + expect(store.error).toBe('Not authenticated') + expect(apiClient.get).not.toHaveBeenCalled() + }) + + it('handles API error responses', async () => { + /** + * Test that fetchProfile handles API errors gracefully. + * + * When API returns an error, the store should capture the + * error message and return false. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + const apiError = new ApiError( + 404, + 'Not Found', + 'User profile does not exist' + ) + vi.mocked(apiClient.get).mockRejectedValueOnce(apiError) + + const result = await store.fetchProfile() + + expect(result).toBe(false) + expect(store.error).toBe('User profile does not exist') + expect(store.profile).toBeNull() + }) + + it('handles generic errors', async () => { + /** + * Test that fetchProfile handles non-API errors. + * + * Network errors or unexpected failures should be captured + * with a generic error message. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('Network failure')) + + const result = await store.fetchProfile() + + expect(result).toBe(false) + expect(store.error).toBe('Network failure') + }) + + it('handles non-Error exceptions', async () => { + /** + * Test that fetchProfile handles thrown strings or objects. + * + * Some errors might be thrown as strings or plain objects. + * The store should handle these gracefully. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + vi.mocked(apiClient.get).mockRejectedValueOnce('Something went wrong') + + const result = await store.fetchProfile() + + expect(result).toBe(false) + expect(store.error).toBe('Failed to fetch profile') + }) + + it('handles profile with no linked accounts', async () => { + /** + * Test that fetchProfile handles missing linked_accounts field. + * + * Not all users have linked accounts. The store should default + * to an empty array if the field is missing. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + const apiResponse = { + id: 'user-456', + display_name: 'New User', + avatar_url: null, + has_starter_deck: false, + created_at: '2026-01-02T00:00:00Z', + // linked_accounts field missing + } + + vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse) + + const result = await store.fetchProfile() + + expect(result).toBe(true) + expect(store.profile?.linkedAccounts).toEqual([]) + }) + + it('updates auth store with profile data', async () => { + /** + * Test that fetchProfile syncs data to auth store. + * + * The auth store needs basic profile info for guards and UI. + * fetchProfile should update it after successful fetch. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + const setUserSpy = vi.spyOn(authStore, 'setUser') + + const apiResponse = { + id: 'user-789', + display_name: 'Sync User', + avatar_url: 'https://example.com/sync.png', + has_starter_deck: true, + created_at: '2026-01-03T00:00:00Z', + } + + vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse) + + await store.fetchProfile() + + expect(setUserSpy).toHaveBeenCalledWith({ + id: 'user-789', + displayName: 'Sync User', + avatarUrl: 'https://example.com/sync.png', + hasStarterDeck: true, + }) + }) + + it('sets loading state during fetch', async () => { + /** + * Test that isLoading is true during API call. + * + * Components need to show loading spinners while profile + * is being fetched. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + let loadingDuringFetch = false + vi.mocked(apiClient.get).mockImplementation(async () => { + loadingDuringFetch = store.isLoading + return { + id: 'user-123', + display_name: 'Test', + avatar_url: null, + has_starter_deck: false, + created_at: '2026-01-01T00:00:00Z', + } + }) + + await store.fetchProfile() + + expect(loadingDuringFetch).toBe(true) + expect(store.isLoading).toBe(false) // Should be false after completion + }) + + it('clears error on successful fetch', async () => { + /** + * Test that successful fetch clears previous errors. + * + * If a previous fetch failed, a successful fetch should + * clear the error state. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + // Set an existing error + store.error = 'Previous error' + + const apiResponse = { + id: 'user-123', + display_name: 'Test', + avatar_url: null, + has_starter_deck: false, + created_at: '2026-01-01T00:00:00Z', + } + + vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse) + + await store.fetchProfile() + + expect(store.error).toBeNull() + }) + }) + + describe('updateDisplayName', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('successfully updates display name', async () => { + /** + * Test that updateDisplayName updates the name via API. + * + * The store should send the new name to API, then refetch + * the profile to get the updated data. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + const updatedProfile = { + id: 'user-123', + display_name: 'New Name', + avatar_url: null, + has_starter_deck: true, + created_at: '2026-01-01T00:00:00Z', + } + + vi.mocked(apiClient.patch).mockResolvedValueOnce(undefined) + vi.mocked(apiClient.get).mockResolvedValueOnce(updatedProfile) + + const result = await store.updateDisplayName('New Name') + + expect(result).toBe(true) + expect(apiClient.patch).toHaveBeenCalledWith('/api/users/me', { + display_name: 'New Name', + }) + expect(apiClient.get).toHaveBeenCalledWith('/api/users/me') + expect(store.displayName).toBe('New Name') + }) + + it('returns false when not authenticated', async () => { + /** + * Test that updateDisplayName fails when not authenticated. + * + * Users must be logged in to update their profile. + */ + const store = useUserStore() + const authStore = useAuthStore() + + await authStore.logout(false) + + const result = await store.updateDisplayName('New Name') + + expect(result).toBe(false) + expect(store.error).toBe('Not authenticated') + expect(apiClient.patch).not.toHaveBeenCalled() + }) + + it('handles API validation errors', async () => { + /** + * Test that updateDisplayName handles validation errors. + * + * API may reject names that are too short, too long, or + * contain profanity. These errors should be captured. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + const validationError = new ApiError( + 422, + 'Unprocessable Entity', + 'Display name must be between 3 and 20 characters' + ) + vi.mocked(apiClient.patch).mockRejectedValueOnce(validationError) + + const result = await store.updateDisplayName('AB') + + expect(result).toBe(false) + expect(store.error).toBe('Display name must be between 3 and 20 characters') + }) + + it('handles profanity filter errors', async () => { + /** + * Test that updateDisplayName handles profanity errors. + * + * API blocks profane or inappropriate names. These should + * be communicated clearly to the user. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + const profanityError = new ApiError( + 422, + 'Unprocessable Entity', + 'Display name contains inappropriate content' + ) + vi.mocked(apiClient.patch).mockRejectedValueOnce(profanityError) + + const result = await store.updateDisplayName('BadName') + + expect(result).toBe(false) + expect(store.error).toBe('Display name contains inappropriate content') + }) + + it('handles network errors during update', async () => { + /** + * Test that updateDisplayName handles network failures. + * + * Network errors during update should not crash the app. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + vi.mocked(apiClient.patch).mockRejectedValueOnce(new Error('Network timeout')) + + const result = await store.updateDisplayName('New Name') + + expect(result).toBe(false) + expect(store.error).toBe('Network timeout') + }) + + it('handles generic errors during update', async () => { + /** + * Test that updateDisplayName handles unexpected errors. + * + * Any unexpected error should result in a generic message. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + vi.mocked(apiClient.patch).mockRejectedValueOnce({ unexpected: 'error' }) + + const result = await store.updateDisplayName('New Name') + + expect(result).toBe(false) + expect(store.error).toBe('Failed to update profile') + }) + + it('sets loading state during update', async () => { + /** + * Test that isLoading is true during update. + * + * Components should show loading state while update is + * in progress. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + let loadingDuringUpdate = false + vi.mocked(apiClient.patch).mockImplementation(async () => { + loadingDuringUpdate = store.isLoading + return undefined + }) + vi.mocked(apiClient.get).mockResolvedValueOnce({ + id: 'user-123', + display_name: 'New Name', + avatar_url: null, + has_starter_deck: false, + created_at: '2026-01-01T00:00:00Z', + }) + + await store.updateDisplayName('New Name') + + expect(loadingDuringUpdate).toBe(true) + expect(store.isLoading).toBe(false) + }) + + it('clears error on successful update', async () => { + /** + * Test that successful update clears previous errors. + * + * If a previous update failed, a successful one should + * clear the error state. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + store.error = 'Previous error' + + vi.mocked(apiClient.patch).mockResolvedValueOnce(undefined) + vi.mocked(apiClient.get).mockResolvedValueOnce({ + id: 'user-123', + display_name: 'New Name', + avatar_url: null, + has_starter_deck: false, + created_at: '2026-01-01T00:00:00Z', + }) + + await store.updateDisplayName('New Name') + + expect(store.error).toBeNull() + }) + }) + + describe('edge cases', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('handles concurrent fetchProfile calls', async () => { + /** + * Test that concurrent fetchProfile calls don't cause issues. + * + * If multiple components call fetchProfile simultaneously, + * the store should handle it gracefully. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + const apiResponse = { + id: 'user-123', + display_name: 'Test', + avatar_url: null, + has_starter_deck: false, + created_at: '2026-01-01T00:00:00Z', + } + + vi.mocked(apiClient.get).mockResolvedValue(apiResponse) + + // Call fetchProfile three times concurrently + const results = await Promise.all([ + store.fetchProfile(), + store.fetchProfile(), + store.fetchProfile(), + ]) + + // All should succeed + expect(results).toEqual([true, true, true]) + // API should have been called three times + expect(apiClient.get).toHaveBeenCalledTimes(3) + // Store should have the profile + expect(store.profile).not.toBeNull() + }) + + it('handles profile with null avatar_url', async () => { + /** + * Test that null avatar_url is handled correctly. + * + * Users without avatars should have null avatar_url, + * not undefined or empty string. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + const apiResponse = { + id: 'user-123', + display_name: 'Test', + avatar_url: null, + has_starter_deck: false, + created_at: '2026-01-01T00:00:00Z', + } + + vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse) + + await store.fetchProfile() + + expect(store.avatarUrl).toBeNull() + }) + + it('handles empty display name in API response', async () => { + /** + * Test that empty display name falls back to "Unknown". + * + * If API returns empty display name (shouldn't happen but + * defensive), the computed property should provide fallback. + */ + const store = useUserStore() + const authStore = useAuthStore() + + authStore.setTokens({ + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + }) + + const apiResponse = { + id: 'user-123', + display_name: '', + avatar_url: null, + has_starter_deck: false, + created_at: '2026-01-01T00:00:00Z', + } + + vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse) + + await store.fetchProfile() + + // Store has empty string, computed should still use it + expect(store.profile?.displayName).toBe('') + // But if profile was null, computed would return "Unknown" + store.profile = null + expect(store.displayName).toBe('Unknown') + }) + }) })