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:
parent
d03dc1ddd2
commit
8aab41485d
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user