519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|