Add user store edge case tests - TEST-016 complete (20 tests)

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%)
This commit is contained in:
Cal Corum 2026-02-02 15:53:29 -06:00
parent d03dc1ddd2
commit 8aab41485d

View File

@ -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')
})
})
})