import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mockNuxtImport } from '@nuxt/test-utils/runtime'; import type { AuthProviderInfo } from 'pocketbase'; /** * Enhanced Error Handling Tests for useAuth composable * Based on User Story 7 (US7) - OAuth Provider Failure Handling * * These tests verify that the login() method provides user-friendly error messages * for different OAuth failure scenarios and logs errors to the console for debugging. * * Test Strategy (TDD RED Phase): * - Test unconfigured provider → "This login provider is not available. Contact admin." * - Test denied authorization → "Login was cancelled. Please try again." * - Test network error → "Connection failed. Check your internet and try again." * - Test generic error → "Login failed. Please try again later." * - Test console.error called for all error scenarios * * Expected Behavior (from plan.md): * The login() catch block should: * 1. Log the error to console with [useAuth] prefix * 2. Check error message for specific patterns * 3. Set user-friendly error message based on pattern match * 4. Provide fallback message for unknown errors */ // Mock PocketBase const mockAuthStore = { isValid: false, record: null, clear: vi.fn(), onChange: vi.fn(), }; const mockCollection = vi.fn(); const mockAuthWithOAuth2 = vi.fn(); const mockListAuthMethods = vi.fn(); const mockAuthRefresh = vi.fn(); vi.mock('../usePocketbase', () => ({ usePocketbase: () => ({ authStore: mockAuthStore, collection: mockCollection, }), })); // Mock router using Nuxt's test utils const mockRouterPush = vi.fn(); const mockRouter = { push: mockRouterPush, }; mockNuxtImport('useRouter', () => { return () => mockRouter; }); // Spy on console.error const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); describe('useAuth - Enhanced Error Handling', () => { const mockProviders: AuthProviderInfo[] = [ { name: 'google', displayName: 'Google', state: 'state123', codeVerifier: 'verifier', codeChallenge: 'challenge', codeChallengeMethod: 'S256', authURL: 'https://google.com/oauth', }, ]; beforeEach(async () => { // Reset all mocks vi.clearAllMocks(); mockAuthStore.isValid = false; mockAuthStore.record = null; // Setup default mock implementations mockCollection.mockReturnValue({ authWithOAuth2: mockAuthWithOAuth2, listAuthMethods: mockListAuthMethods, authRefresh: mockAuthRefresh, }); mockListAuthMethods.mockResolvedValue({ oauth2: { enabled: true, providers: mockProviders, }, }); // Clear module cache to get fresh imports vi.resetModules(); }); describe('Unconfigured Provider Error', () => { it('should show user-friendly message when provider is not configured', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Simulate error from Pocketbase SDK when provider is not configured const pbError = new Error('OAuth2 provider "github" is not configured'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value).toBeDefined(); expect(auth.error.value?.message).toBe( 'This login provider is not available. Contact admin.' ); }); it('should log original error to console when provider is not configured', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('OAuth2 provider "github" is not configured'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(consoleErrorSpy).toHaveBeenCalledWith( '[useAuth] Login failed:', pbError ); }); it('should detect "not configured" in error message (case insensitive)', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('Provider NOT CONFIGURED on server'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value?.message).toBe( 'This login provider is not available. Contact admin.' ); }); }); describe('Denied Authorization Error', () => { it('should show user-friendly message when user denies authorization', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Simulate error when user clicks "Deny" on OAuth consent screen const pbError = new Error('OAuth2 authorization was denied by the user'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value).toBeDefined(); expect(auth.error.value?.message).toBe( 'Login was cancelled. Please try again.' ); }); it('should show user-friendly message when user cancels authorization', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Simulate error when user clicks "Cancel" on OAuth consent screen const pbError = new Error('User cancelled the OAuth flow'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value).toBeDefined(); expect(auth.error.value?.message).toBe( 'Login was cancelled. Please try again.' ); }); it('should log original error to console when authorization is denied', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('OAuth2 authorization was denied by the user'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(consoleErrorSpy).toHaveBeenCalledWith( '[useAuth] Login failed:', pbError ); }); it('should detect "denied" in error message (case insensitive)', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('Access DENIED by user'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value?.message).toBe( 'Login was cancelled. Please try again.' ); }); it('should detect "cancel" in error message (case insensitive)', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('User CANCELLED the request'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value?.message).toBe( 'Login was cancelled. Please try again.' ); }); }); describe('Network Error', () => { it('should show user-friendly message for network failures', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Simulate network error const pbError = new Error('Network request failed'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value).toBeDefined(); expect(auth.error.value?.message).toBe( 'Connection failed. Check your internet and try again.' ); }); it('should show user-friendly message for fetch failures', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Simulate fetch error const pbError = new Error('Failed to fetch OAuth2 token'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value).toBeDefined(); expect(auth.error.value?.message).toBe( 'Connection failed. Check your internet and try again.' ); }); it('should log original error to console for network failures', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('Network request failed'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(consoleErrorSpy).toHaveBeenCalledWith( '[useAuth] Login failed:', pbError ); }); it('should detect "network" in error message (case insensitive)', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('NETWORK connection lost'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value?.message).toBe( 'Connection failed. Check your internet and try again.' ); }); it('should detect "fetch" in error message (case insensitive)', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('FETCH operation timed out'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value?.message).toBe( 'Connection failed. Check your internet and try again.' ); }); }); describe('Generic Error Fallback', () => { it('should show fallback message for unknown error types', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Simulate unexpected error const pbError = new Error('Something went wrong with the database'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value).toBeDefined(); expect(auth.error.value?.message).toBe( 'Login failed. Please try again later.' ); }); it('should show fallback message for empty error message', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Simulate error with empty message const pbError = new Error(''); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value).toBeDefined(); expect(auth.error.value?.message).toBe( 'Login failed. Please try again later.' ); }); it('should log original error to console for unknown errors', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('Unexpected server error'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(consoleErrorSpy).toHaveBeenCalledWith( '[useAuth] Login failed:', pbError ); }); it('should handle non-Error objects gracefully', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Simulate error thrown as plain object (edge case) const pbError = { message: 'Strange error format' }; mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value).toBeDefined(); // Should still get fallback message expect(auth.error.value?.message).toBe( 'Login failed. Please try again later.' ); }); }); describe('Console Logging', () => { it('should always log errors to console with [useAuth] prefix', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const testCases = [ new Error('not configured error'), new Error('denied error'), new Error('network error'), new Error('generic error'), ]; for (const pbError of testCases) { consoleErrorSpy.mockClear(); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(consoleErrorSpy).toHaveBeenCalledWith( '[useAuth] Login failed:', pbError ); } }); it('should log errors before setting user-friendly error message', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('Test error'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); // Verify console.error was called expect(consoleErrorSpy).toHaveBeenCalledWith( '[useAuth] Login failed:', pbError ); // Verify user-friendly message is set after logging expect(auth.error.value).toBeDefined(); }); }); describe('Error Message Priority', () => { it('should prioritize "not configured" over other patterns', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Error message contains both "not configured" and "denied" const pbError = new Error('Provider not configured, access denied'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); // Should match "not configured" first expect(auth.error.value?.message).toBe( 'This login provider is not available. Contact admin.' ); }); it('should prioritize "denied" over "network"', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Error message contains both "denied" and "network" const pbError = new Error('Access denied due to network policy'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); // Should match "denied" before "network" expect(auth.error.value?.message).toBe( 'Login was cancelled. Please try again.' ); }); it('should prioritize "network" over generic fallback', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Error message contains "network" but nothing else const pbError = new Error('Network timeout occurred'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); // Should match "network" before falling back to generic expect(auth.error.value?.message).toBe( 'Connection failed. Check your internet and try again.' ); }); }); describe('Error State Management', () => { it('should set loading to false after error', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const pbError = new Error('Test error'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.loading.value).toBe(false); }); it('should preserve error state across multiple failed login attempts', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // First failed attempt const error1 = new Error('Network error'); mockAuthWithOAuth2.mockRejectedValue(error1); await auth.login('google'); const firstErrorMessage = auth.error.value?.message; expect(firstErrorMessage).toBe( 'Connection failed. Check your internet and try again.' ); // Second failed attempt with different error const error2 = new Error('Provider not configured'); mockAuthWithOAuth2.mockRejectedValue(error2); await auth.login('google'); // Error should be updated to new error expect(auth.error.value?.message).toBe( 'This login provider is not available. Contact admin.' ); expect(auth.error.value?.message).not.toBe(firstErrorMessage); }); it('should clear error on successful login after previous error', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Failed attempt const pbError = new Error('Network error'); mockAuthWithOAuth2.mockRejectedValue(pbError); await auth.login('google'); expect(auth.error.value).toBeDefined(); // Successful attempt mockAuthWithOAuth2.mockResolvedValue({ record: { id: 'user123', email: 'test@example.com' }, }); await auth.login('google'); expect(auth.error.value).toBeNull(); }); }); });