Features: - Add useAuth composable with OAuth flow and token management - Add useStarter composable with API integration and dev mock fallback - Implement app auth initialization blocking navigation until ready - Complete StarterSelectionPage with 5 themed deck options Bug fixes: - Fix CORS by adding localhost:3001 to allowed origins - Fix OAuth URL to include redirect_uri parameter - Fix emoji rendering in nav components (use actual chars, not escapes) - Fix requireStarter guard timing by allowing navigation from /starter - Fix starter "already selected" detection for 400 status code Documentation: - Update dev-server skill to use `docker compose` (newer CLI syntax) - Update .env.example with port 3001 in CORS comment Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
956 lines
28 KiB
TypeScript
956 lines
28 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
|
import { setActivePinia, createPinia } from 'pinia'
|
|
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
// Create mock router instance
|
|
const mockRouter = {
|
|
push: vi.fn(),
|
|
currentRoute: { value: { name: 'AuthCallback', path: '/auth/callback' } },
|
|
}
|
|
|
|
// Mock vue-router (hoisted)
|
|
vi.mock('vue-router', () => ({
|
|
useRouter: () => mockRouter,
|
|
useRoute: () => ({ query: {} }),
|
|
}))
|
|
|
|
// Mock the API client
|
|
vi.mock('@/api/client', () => ({
|
|
apiClient: {
|
|
get: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
// Mock the config
|
|
vi.mock('@/config', () => ({
|
|
config: {
|
|
apiBaseUrl: 'http://localhost:8000',
|
|
wsUrl: 'http://localhost:8000',
|
|
oauthRedirectUri: 'http://localhost:5173/auth/callback',
|
|
isDev: true,
|
|
isProd: false,
|
|
},
|
|
}))
|
|
|
|
import { apiClient } from '@/api/client'
|
|
import { useAuth } from './useAuth'
|
|
|
|
describe('useAuth', () => {
|
|
let mockLocation: { hash: string; pathname: string; search: string; href: string }
|
|
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia())
|
|
|
|
// Reset mock router
|
|
mockRouter.push.mockReset()
|
|
|
|
// Mock window.location with a property descriptor that allows href assignment
|
|
mockLocation = {
|
|
hash: '',
|
|
pathname: '/auth/callback',
|
|
search: '',
|
|
href: 'http://localhost:5173/auth/callback',
|
|
}
|
|
|
|
// Delete and redefine to avoid conflicts
|
|
delete (window as unknown as Record<string, unknown>).location
|
|
Object.defineProperty(window, 'location', {
|
|
value: mockLocation,
|
|
writable: true,
|
|
configurable: true,
|
|
})
|
|
|
|
// Mock history.replaceState
|
|
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {})
|
|
|
|
// Mock fetch for logout calls
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({}),
|
|
})
|
|
|
|
// Reset mocks
|
|
vi.mocked(apiClient.get).mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('initial state', () => {
|
|
it('starts with unauthenticated state', () => {
|
|
/**
|
|
* Test that useAuth starts in an unauthenticated state.
|
|
*
|
|
* Before OAuth flow or initialization completes, the composable
|
|
* should report that the user is not authenticated.
|
|
*/
|
|
const { isAuthenticated, isInitialized, user, error } = useAuth()
|
|
|
|
expect(isAuthenticated.value).toBe(false)
|
|
expect(isInitialized.value).toBe(false)
|
|
expect(user.value).toBeNull()
|
|
expect(error.value).toBeNull()
|
|
})
|
|
|
|
it('starts with isLoading as false', () => {
|
|
/**
|
|
* Test initial loading state.
|
|
*
|
|
* The composable should not be in a loading state until
|
|
* an async operation is initiated.
|
|
*/
|
|
const { isLoading } = useAuth()
|
|
|
|
expect(isLoading.value).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('initiateOAuth', () => {
|
|
it('redirects to Google OAuth URL with redirect_uri', () => {
|
|
/**
|
|
* Test Google OAuth initiation.
|
|
*
|
|
* When initiating Google OAuth, the browser should be redirected
|
|
* to the backend's Google OAuth endpoint with a redirect_uri param,
|
|
* which will then redirect to Google's consent screen.
|
|
*/
|
|
const { initiateOAuth } = useAuth()
|
|
|
|
initiateOAuth('google')
|
|
|
|
expect(mockLocation.href).toContain('/api/auth/google')
|
|
expect(mockLocation.href).toContain('redirect_uri=')
|
|
})
|
|
|
|
it('redirects to Discord OAuth URL with redirect_uri', () => {
|
|
/**
|
|
* Test Discord OAuth initiation.
|
|
*
|
|
* When initiating Discord OAuth, the browser should be redirected
|
|
* to the backend's Discord OAuth endpoint with a redirect_uri param,
|
|
* which will then redirect to Discord's consent screen.
|
|
*/
|
|
const { initiateOAuth } = useAuth()
|
|
|
|
initiateOAuth('discord')
|
|
|
|
expect(mockLocation.href).toContain('/api/auth/discord')
|
|
expect(mockLocation.href).toContain('redirect_uri=')
|
|
})
|
|
|
|
it('clears any existing error', () => {
|
|
/**
|
|
* Test error clearing on OAuth initiation.
|
|
*
|
|
* Starting a new OAuth flow should clear any previous errors
|
|
* so users don't see stale error messages.
|
|
*/
|
|
const auth = useAuth()
|
|
|
|
auth.initiateOAuth('google')
|
|
|
|
expect(auth.error.value).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('handleCallback', () => {
|
|
it('successfully extracts tokens from URL hash', async () => {
|
|
/**
|
|
* Test token extraction from OAuth callback.
|
|
*
|
|
* The OAuth provider redirects back with tokens in the URL fragment.
|
|
* handleCallback must parse these tokens and store them correctly.
|
|
*/
|
|
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
|
|
|
vi.mocked(apiClient.get).mockResolvedValue({
|
|
id: 'user-1',
|
|
display_name: 'Test User',
|
|
avatar_url: null,
|
|
has_starter_deck: true,
|
|
})
|
|
|
|
const { handleCallback } = useAuth()
|
|
const result = await handleCallback()
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(result.user?.id).toBe('user-1')
|
|
expect(result.user?.displayName).toBe('Test User')
|
|
})
|
|
|
|
it('stores tokens in auth store', async () => {
|
|
/**
|
|
* Test that tokens are persisted in the auth store.
|
|
*
|
|
* After extracting tokens, they must be stored in the auth store
|
|
* so they can be used for subsequent API requests.
|
|
*/
|
|
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
|
|
|
vi.mocked(apiClient.get).mockResolvedValue({
|
|
id: 'user-1',
|
|
display_name: 'Test User',
|
|
avatar_url: null,
|
|
has_starter_deck: true,
|
|
})
|
|
|
|
const { handleCallback } = useAuth()
|
|
await handleCallback()
|
|
|
|
const authStore = useAuthStore()
|
|
expect(authStore.accessToken).toBe('abc123')
|
|
expect(authStore.refreshToken).toBe('xyz789')
|
|
expect(authStore.expiresAt).toBeGreaterThan(Date.now())
|
|
})
|
|
|
|
it('fetches user profile after storing tokens', async () => {
|
|
/**
|
|
* Test profile fetch after token storage.
|
|
*
|
|
* Once tokens are stored, we need to fetch the user's profile
|
|
* to know their display name, avatar, and starter deck status.
|
|
*/
|
|
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
|
|
|
vi.mocked(apiClient.get).mockResolvedValue({
|
|
id: 'user-1',
|
|
display_name: 'Test User',
|
|
avatar_url: 'https://example.com/avatar.png',
|
|
has_starter_deck: true,
|
|
})
|
|
|
|
const { handleCallback } = useAuth()
|
|
await handleCallback()
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
|
|
|
|
const authStore = useAuthStore()
|
|
expect(authStore.user).toEqual({
|
|
id: 'user-1',
|
|
displayName: 'Test User',
|
|
avatarUrl: 'https://example.com/avatar.png',
|
|
hasStarterDeck: true,
|
|
})
|
|
})
|
|
|
|
it('returns needsStarter=true for users without starter deck', async () => {
|
|
/**
|
|
* Test starter deck status in callback result.
|
|
*
|
|
* The callback result should indicate if the user needs to select
|
|
* a starter deck, allowing the caller to redirect appropriately.
|
|
*/
|
|
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
|
|
|
vi.mocked(apiClient.get).mockResolvedValue({
|
|
id: 'user-1',
|
|
display_name: 'New User',
|
|
avatar_url: null,
|
|
has_starter_deck: false,
|
|
})
|
|
|
|
const { handleCallback } = useAuth()
|
|
const result = await handleCallback()
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(result.needsStarter).toBe(true)
|
|
})
|
|
|
|
it('returns needsStarter=false for users with starter deck', async () => {
|
|
/**
|
|
* Test starter deck status for existing users.
|
|
*
|
|
* Users who already have a starter deck should have needsStarter=false,
|
|
* allowing them to proceed directly to the dashboard.
|
|
*/
|
|
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
|
|
|
vi.mocked(apiClient.get).mockResolvedValue({
|
|
id: 'user-1',
|
|
display_name: 'Existing User',
|
|
avatar_url: null,
|
|
has_starter_deck: true,
|
|
})
|
|
|
|
const { handleCallback } = useAuth()
|
|
const result = await handleCallback()
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(result.needsStarter).toBe(false)
|
|
})
|
|
|
|
it('returns error for missing tokens in hash', async () => {
|
|
/**
|
|
* Test error handling for malformed OAuth response.
|
|
*
|
|
* If the URL fragment is missing required tokens, handleCallback
|
|
* should return an error result, not throw an exception.
|
|
*/
|
|
mockLocation.hash = '#access_token=abc123' // Missing refresh_token and expires_in
|
|
|
|
const { handleCallback } = useAuth()
|
|
const result = await handleCallback()
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toContain('Missing tokens')
|
|
})
|
|
|
|
it('returns error for empty hash', async () => {
|
|
/**
|
|
* Test error handling for empty hash fragment.
|
|
*
|
|
* If the OAuth provider redirects without any tokens,
|
|
* handleCallback should return an appropriate error.
|
|
*/
|
|
mockLocation.hash = ''
|
|
|
|
const { handleCallback } = useAuth()
|
|
const result = await handleCallback()
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBeDefined()
|
|
})
|
|
|
|
it('returns error from OAuth provider query params', async () => {
|
|
/**
|
|
* Test error forwarding from OAuth provider.
|
|
*
|
|
* When OAuth fails, the backend redirects with error info
|
|
* in query params. handleCallback should extract and return this.
|
|
*/
|
|
mockLocation.hash = ''
|
|
mockLocation.search = '?error=access_denied&message=User%20cancelled'
|
|
|
|
const { handleCallback } = useAuth()
|
|
const result = await handleCallback()
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('User cancelled')
|
|
})
|
|
|
|
it('uses default error message when message param is missing', async () => {
|
|
/**
|
|
* Test fallback error message.
|
|
*
|
|
* If the backend only provides an error code without a message,
|
|
* we should use a sensible default.
|
|
*/
|
|
mockLocation.hash = ''
|
|
mockLocation.search = '?error=unknown_error'
|
|
|
|
const { handleCallback } = useAuth()
|
|
const result = await handleCallback()
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Authentication failed. Please try again.')
|
|
})
|
|
|
|
it('returns error when profile fetch fails', async () => {
|
|
/**
|
|
* Test error handling for profile fetch failures.
|
|
*
|
|
* If we successfully get tokens but fail to fetch the profile,
|
|
* handleCallback should return an error result.
|
|
*/
|
|
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
|
|
|
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'))
|
|
|
|
const { handleCallback } = useAuth()
|
|
const result = await handleCallback()
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Network error')
|
|
})
|
|
|
|
it('clears tokens from URL after parsing', async () => {
|
|
/**
|
|
* Test security cleanup of URL.
|
|
*
|
|
* Tokens in the URL should be cleared after parsing to prevent
|
|
* them from appearing in browser history or being logged.
|
|
*/
|
|
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
|
|
|
vi.mocked(apiClient.get).mockResolvedValue({
|
|
id: 'user-1',
|
|
display_name: 'Test User',
|
|
avatar_url: null,
|
|
has_starter_deck: true,
|
|
})
|
|
|
|
const { handleCallback } = useAuth()
|
|
await handleCallback()
|
|
|
|
expect(window.history.replaceState).toHaveBeenCalledWith(
|
|
null,
|
|
'',
|
|
'/auth/callback'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('logout', () => {
|
|
it('calls auth store logout with server revocation', async () => {
|
|
/**
|
|
* Test logout with server-side token revocation.
|
|
*
|
|
* When logging out, we should revoke the refresh token on the
|
|
* server to prevent it from being used if somehow compromised.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
|
|
const logoutSpy = vi.spyOn(authStore, 'logout')
|
|
|
|
const { logout } = useAuth()
|
|
await logout()
|
|
|
|
expect(logoutSpy).toHaveBeenCalledWith(true)
|
|
})
|
|
|
|
it('clears auth state after logout', async () => {
|
|
/**
|
|
* Test state clearing after logout.
|
|
*
|
|
* All authentication state should be cleared so the user
|
|
* is properly logged out and cannot access protected resources.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
authStore.setUser({
|
|
id: 'user-1',
|
|
displayName: 'Test User',
|
|
avatarUrl: null,
|
|
hasStarterDeck: true,
|
|
})
|
|
|
|
const { logout, isAuthenticated, user } = useAuth()
|
|
await logout(false) // Don't redirect in test
|
|
|
|
expect(isAuthenticated.value).toBe(false)
|
|
expect(user.value).toBeNull()
|
|
})
|
|
|
|
it('redirects to login by default', async () => {
|
|
/**
|
|
* Test default redirect behavior after logout.
|
|
*
|
|
* After logging out, users should be redirected to the login page
|
|
* by default so they can log in again if desired.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
|
|
const { logout } = useAuth()
|
|
await logout()
|
|
|
|
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
|
|
})
|
|
|
|
it('skips redirect when redirectToLogin is false', async () => {
|
|
/**
|
|
* Test optional redirect suppression.
|
|
*
|
|
* Sometimes we want to log out without redirecting (e.g., when
|
|
* the user is already on the login page or when handling errors).
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
|
|
const { logout } = useAuth()
|
|
await logout(false)
|
|
|
|
expect(mockRouter.push).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('logoutAll', () => {
|
|
it('calls logout-all endpoint', async () => {
|
|
/**
|
|
* Test all-device logout API call.
|
|
*
|
|
* logoutAll should call the special endpoint that revokes
|
|
* all refresh tokens for the user, logging them out everywhere.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
|
|
const { logoutAll } = useAuth()
|
|
await logoutAll(false)
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
'http://localhost:8000/api/auth/logout-all',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'Authorization': 'Bearer test-token',
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('clears local state even if server call fails', async () => {
|
|
/**
|
|
* Test graceful handling of server errors.
|
|
*
|
|
* Even if the server call to revoke all tokens fails, we should
|
|
* still clear local state so the user is logged out locally.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
|
|
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
|
|
|
const { logoutAll, isAuthenticated } = useAuth()
|
|
await logoutAll(false)
|
|
|
|
expect(isAuthenticated.value).toBe(false)
|
|
})
|
|
|
|
it('redirects to login by default', async () => {
|
|
/**
|
|
* Test default redirect after all-device logout.
|
|
*
|
|
* After logging out from all devices, users should be
|
|
* redirected to the login page.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
|
|
const { logoutAll } = useAuth()
|
|
await logoutAll()
|
|
|
|
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
|
|
})
|
|
})
|
|
|
|
describe('initialize', () => {
|
|
it('sets isInitialized to true after completion', async () => {
|
|
/**
|
|
* Test initialization completion flag.
|
|
*
|
|
* After initialize() completes, isInitialized should be true
|
|
* so navigation guards know it's safe to check auth state.
|
|
*/
|
|
const { initialize, isInitialized } = useAuth()
|
|
|
|
expect(isInitialized.value).toBe(false)
|
|
|
|
await initialize()
|
|
|
|
expect(isInitialized.value).toBe(true)
|
|
})
|
|
|
|
it('returns true when authenticated with valid tokens', async () => {
|
|
/**
|
|
* Test initialization with existing valid session.
|
|
*
|
|
* If tokens are already stored and valid, initialization should
|
|
* return true and keep the user logged in.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
authStore.setUser({
|
|
id: 'user-1',
|
|
displayName: 'Test User',
|
|
avatarUrl: null,
|
|
hasStarterDeck: true,
|
|
})
|
|
|
|
const { initialize } = useAuth()
|
|
const result = await initialize()
|
|
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
it('returns false when not authenticated', async () => {
|
|
/**
|
|
* Test initialization without existing session.
|
|
*
|
|
* If no tokens are stored, initialization should return false
|
|
* indicating the user needs to log in.
|
|
*/
|
|
const { initialize } = useAuth()
|
|
const result = await initialize()
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('fetches profile if tokens exist but user is null', async () => {
|
|
/**
|
|
* Test profile fetch during initialization.
|
|
*
|
|
* If we have tokens persisted but no user data (e.g., after
|
|
* page reload), we should fetch the profile during init.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
// Note: user is NOT set
|
|
|
|
vi.mocked(apiClient.get).mockResolvedValue({
|
|
id: 'user-1',
|
|
display_name: 'Test User',
|
|
avatar_url: null,
|
|
has_starter_deck: true,
|
|
})
|
|
|
|
const { initialize } = useAuth()
|
|
await initialize()
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
|
|
expect(authStore.user).toEqual({
|
|
id: 'user-1',
|
|
displayName: 'Test User',
|
|
avatarUrl: null,
|
|
hasStarterDeck: true,
|
|
})
|
|
})
|
|
|
|
it('logs out if profile fetch fails during initialization', async () => {
|
|
/**
|
|
* Test handling of invalid tokens during initialization.
|
|
*
|
|
* If tokens exist but profile fetch fails (invalid tokens, etc.),
|
|
* we should clear the invalid tokens and return false.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
|
|
vi.mocked(apiClient.get).mockRejectedValue(new Error('Unauthorized'))
|
|
|
|
const { initialize, isAuthenticated } = useAuth()
|
|
const result = await initialize()
|
|
|
|
expect(result).toBe(false)
|
|
expect(isAuthenticated.value).toBe(false)
|
|
})
|
|
|
|
it('only runs once (returns cached result on second call)', async () => {
|
|
/**
|
|
* Test initialization idempotency.
|
|
*
|
|
* Multiple calls to initialize() should only run the
|
|
* initialization logic once. Subsequent calls should
|
|
* return the same result immediately.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
authStore.setUser({
|
|
id: 'user-1',
|
|
displayName: 'Test User',
|
|
avatarUrl: null,
|
|
hasStarterDeck: true,
|
|
})
|
|
|
|
const { initialize, isInitialized } = useAuth()
|
|
|
|
await initialize()
|
|
expect(isInitialized.value).toBe(true)
|
|
|
|
// Call again - should return immediately
|
|
const result = await initialize()
|
|
expect(result).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('fetchProfile', () => {
|
|
it('fetches and updates user profile', async () => {
|
|
/**
|
|
* Test manual profile refresh.
|
|
*
|
|
* Components may need to refresh the user profile (e.g., after
|
|
* updating display name). fetchProfile should update the store.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
|
|
vi.mocked(apiClient.get).mockResolvedValue({
|
|
id: 'user-1',
|
|
display_name: 'Updated Name',
|
|
avatar_url: 'https://example.com/new-avatar.png',
|
|
has_starter_deck: true,
|
|
})
|
|
|
|
const { fetchProfile } = useAuth()
|
|
const result = await fetchProfile()
|
|
|
|
expect(result.displayName).toBe('Updated Name')
|
|
expect(authStore.user?.displayName).toBe('Updated Name')
|
|
})
|
|
|
|
it('throws error when not authenticated', async () => {
|
|
/**
|
|
* Test unauthenticated profile fetch rejection.
|
|
*
|
|
* fetchProfile should throw an error if called when not
|
|
* authenticated, rather than making a doomed API call.
|
|
*/
|
|
const { fetchProfile } = useAuth()
|
|
|
|
await expect(fetchProfile()).rejects.toThrow('Not authenticated')
|
|
})
|
|
})
|
|
|
|
describe('clearError', () => {
|
|
it('clears the error state', async () => {
|
|
/**
|
|
* Test error clearing.
|
|
*
|
|
* After displaying an error, components may want to clear it
|
|
* (e.g., when user dismisses the error or tries again).
|
|
*/
|
|
// First cause an error
|
|
mockLocation.hash = ''
|
|
|
|
const { handleCallback, clearError, error } = useAuth()
|
|
await handleCallback()
|
|
|
|
// Error should be set
|
|
expect(error.value).toBeDefined()
|
|
|
|
// Clear it
|
|
clearError()
|
|
|
|
expect(error.value).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('loading states', () => {
|
|
it('isLoading is true during handleCallback', async () => {
|
|
/**
|
|
* Test loading state during callback processing.
|
|
*
|
|
* Components should show loading indicators while the callback
|
|
* is being processed (token storage, profile fetch).
|
|
*/
|
|
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
|
|
|
// Create a deferred promise to control when the API responds
|
|
let resolveApi: (value: unknown) => void
|
|
vi.mocked(apiClient.get).mockImplementation(
|
|
() => new Promise((resolve) => { resolveApi = resolve })
|
|
)
|
|
|
|
const { handleCallback, isLoading } = useAuth()
|
|
|
|
// Start callback (don't await)
|
|
const promise = handleCallback()
|
|
|
|
// Should be loading
|
|
expect(isLoading.value).toBe(true)
|
|
|
|
// Resolve the API call
|
|
resolveApi!({
|
|
id: 'user-1',
|
|
display_name: 'Test User',
|
|
avatar_url: null,
|
|
has_starter_deck: true,
|
|
})
|
|
|
|
await promise
|
|
|
|
// Should no longer be loading
|
|
expect(isLoading.value).toBe(false)
|
|
})
|
|
|
|
it('isLoading is true during logout', async () => {
|
|
/**
|
|
* Test loading state during logout.
|
|
*
|
|
* Components should disable logout buttons and show feedback
|
|
* while the logout operation is in progress.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
|
|
// Create a deferred promise
|
|
let resolveFetch: () => void
|
|
global.fetch = vi.fn().mockImplementation(
|
|
() => new Promise((resolve) => {
|
|
resolveFetch = () => resolve({ ok: true })
|
|
})
|
|
)
|
|
|
|
const { logout, isLoading } = useAuth()
|
|
|
|
// Start logout (don't await)
|
|
const promise = logout(false)
|
|
|
|
// Should be loading
|
|
expect(isLoading.value).toBe(true)
|
|
|
|
// Resolve the fetch
|
|
resolveFetch!()
|
|
|
|
await promise
|
|
|
|
// Should no longer be loading
|
|
expect(isLoading.value).toBe(false)
|
|
})
|
|
|
|
it('isLoading is true during initialize', async () => {
|
|
/**
|
|
* Test loading state during initialization.
|
|
*
|
|
* The app should show a loading screen while auth state
|
|
* is being initialized on startup.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
// Note: NOT setting user, so init will need to fetch profile
|
|
|
|
// Create a deferred promise that we control
|
|
let resolveApi: (value: unknown) => void = () => {}
|
|
const apiPromise = new Promise((resolve) => {
|
|
resolveApi = resolve
|
|
})
|
|
vi.mocked(apiClient.get).mockReturnValue(apiPromise as Promise<unknown>)
|
|
|
|
const { initialize, isLoading } = useAuth()
|
|
|
|
// Start init (don't await)
|
|
const promise = initialize()
|
|
|
|
// Wait a tick for async operations to start
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
// Should be loading (needs to fetch profile)
|
|
expect(isLoading.value).toBe(true)
|
|
|
|
// Resolve the API call
|
|
resolveApi({
|
|
id: 'user-1',
|
|
display_name: 'Test User',
|
|
avatar_url: null,
|
|
has_starter_deck: true,
|
|
})
|
|
|
|
await promise
|
|
|
|
// Should no longer be loading
|
|
expect(isLoading.value).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('computed properties', () => {
|
|
it('isAuthenticated reflects auth store state', () => {
|
|
/**
|
|
* Test isAuthenticated reactivity.
|
|
*
|
|
* The isAuthenticated computed should automatically update
|
|
* when the auth store's authentication state changes.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
const { isAuthenticated } = useAuth()
|
|
|
|
expect(isAuthenticated.value).toBe(false)
|
|
|
|
authStore.setTokens({
|
|
accessToken: 'test-token',
|
|
refreshToken: 'test-refresh',
|
|
expiresAt: Date.now() + 3600000,
|
|
})
|
|
|
|
expect(isAuthenticated.value).toBe(true)
|
|
})
|
|
|
|
it('user reflects auth store state', () => {
|
|
/**
|
|
* Test user computed reactivity.
|
|
*
|
|
* The user computed should automatically update when the
|
|
* auth store's user data changes.
|
|
*/
|
|
const authStore = useAuthStore()
|
|
const { user } = useAuth()
|
|
|
|
expect(user.value).toBeNull()
|
|
|
|
authStore.setUser({
|
|
id: 'user-1',
|
|
displayName: 'Test User',
|
|
avatarUrl: null,
|
|
hasStarterDeck: true,
|
|
})
|
|
|
|
expect(user.value?.displayName).toBe('Test User')
|
|
})
|
|
|
|
it('error combines local and store errors', () => {
|
|
/**
|
|
* Test error state composition.
|
|
*
|
|
* The error computed should show local errors (from composable
|
|
* operations) or store errors (from token refresh, etc.).
|
|
*/
|
|
const authStore = useAuthStore()
|
|
const { error } = useAuth()
|
|
|
|
expect(error.value).toBeNull()
|
|
|
|
// Store error should be reflected
|
|
authStore.error = 'Store error'
|
|
expect(error.value).toBe('Store error')
|
|
})
|
|
})
|
|
})
|