test: add OAuth2 authentication test files (TDD RED phase)
This commit is contained in:
463
app/composables/__tests__/useAuth.cross-tab.test.ts
Normal file
463
app/composables/__tests__/useAuth.cross-tab.test.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
|
||||
import type { AuthModel } from 'pocketbase';
|
||||
|
||||
/**
|
||||
* Integration tests for cross-tab synchronization
|
||||
* Based on US5 (Cross-Tab Synchronization) from specs/001-oauth2-authentication/spec.md
|
||||
*
|
||||
* These tests verify that auth state changes in one tab synchronize to other tabs
|
||||
* via Pocketbase authStore.onChange() callback mechanism.
|
||||
*
|
||||
* Acceptance Criteria (US5):
|
||||
* - Login in tab A should update tab B within 2 seconds
|
||||
* - Logout in tab A should update tab B within 2 seconds
|
||||
* - Auth state should sync across browser windows, not just tabs
|
||||
*/
|
||||
|
||||
// Mock PocketBase authStore with onChange support
|
||||
const mockAuthStore = {
|
||||
isValid: false,
|
||||
record: null as AuthModel | 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;
|
||||
});
|
||||
|
||||
describe('useAuth - Cross-Tab Synchronization', () => {
|
||||
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,
|
||||
});
|
||||
|
||||
// Clear module cache to get fresh imports
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('Login Synchronization', () => {
|
||||
it('should update user.value when authStore onChange fires with new user', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
// Initialize auth (sets up onChange listener)
|
||||
await auth.initAuth();
|
||||
|
||||
// Verify initial state
|
||||
expect(auth.user.value).toBeNull();
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
|
||||
// Get the onChange callback that was registered
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
expect(onChangeCallback).toBeDefined();
|
||||
|
||||
// Simulate login event in another tab (authStore onChange fires)
|
||||
const newUser: AuthModel = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
verified: true,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.isValid = true;
|
||||
mockAuthStore.record = newUser;
|
||||
onChangeCallback('token123', newUser);
|
||||
|
||||
// Verify user state updated
|
||||
expect(auth.user.value).toEqual(newUser);
|
||||
});
|
||||
|
||||
it('should update isAuthenticated when authStore onChange fires with valid user', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
// Initialize auth
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
|
||||
// Get the onChange callback
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
|
||||
// Simulate login in another tab
|
||||
const newUser: AuthModel = {
|
||||
id: 'user456',
|
||||
email: 'another@example.com',
|
||||
name: 'Another User',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.isValid = true;
|
||||
mockAuthStore.record = newUser;
|
||||
onChangeCallback('token456', newUser);
|
||||
|
||||
// Verify authenticated state
|
||||
expect(auth.isAuthenticated.value).toBe(true);
|
||||
expect(auth.user.value?.email).toBe('another@example.com');
|
||||
});
|
||||
|
||||
it('should handle rapid login events from multiple tabs', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
|
||||
// Simulate rapid login events (edge case: multiple tabs logging in)
|
||||
const user1: AuthModel = {
|
||||
id: 'user1',
|
||||
email: 'user1@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
const user2: AuthModel = {
|
||||
id: 'user2',
|
||||
email: 'user2@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
// First login event
|
||||
mockAuthStore.record = user1;
|
||||
onChangeCallback('token1', user1);
|
||||
expect(auth.user.value?.id).toBe('user1');
|
||||
|
||||
// Second login event (should overwrite)
|
||||
mockAuthStore.record = user2;
|
||||
onChangeCallback('token2', user2);
|
||||
expect(auth.user.value?.id).toBe('user2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logout Synchronization', () => {
|
||||
it('should clear user.value when authStore onChange fires with null', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
// Start with authenticated user
|
||||
const initialUser: AuthModel = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.record = initialUser;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.user.value).toEqual(initialUser);
|
||||
expect(auth.isAuthenticated.value).toBe(true);
|
||||
|
||||
// Get the onChange callback
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
|
||||
// Simulate logout event in another tab (authStore onChange fires with null)
|
||||
mockAuthStore.isValid = false;
|
||||
mockAuthStore.record = null;
|
||||
onChangeCallback('', null);
|
||||
|
||||
// Verify user state cleared
|
||||
expect(auth.user.value).toBeNull();
|
||||
});
|
||||
|
||||
it('should update isAuthenticated to false when authStore onChange fires with null', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
// Start authenticated
|
||||
mockAuthStore.record = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as AuthModel;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(true);
|
||||
|
||||
// Get the onChange callback
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
|
||||
// Simulate logout in another tab
|
||||
mockAuthStore.isValid = false;
|
||||
mockAuthStore.record = null;
|
||||
onChangeCallback('', null);
|
||||
|
||||
// Verify not authenticated
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle logout after login in same session', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
|
||||
// Simulate login
|
||||
const user: AuthModel = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.isValid = true;
|
||||
mockAuthStore.record = user;
|
||||
onChangeCallback('token123', user);
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(true);
|
||||
|
||||
// Simulate logout
|
||||
mockAuthStore.isValid = false;
|
||||
mockAuthStore.record = null;
|
||||
onChangeCallback('', null);
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
expect(auth.user.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChange Listener Registration', () => {
|
||||
it('should register onChange listener exactly once during initAuth', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
expect(mockAuthStore.onChange).not.toHaveBeenCalled();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
expect(mockAuthStore.onChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuthStore.onChange).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
it('should not register duplicate onChange listeners on multiple initAuth calls', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
await auth.initAuth();
|
||||
await auth.initAuth();
|
||||
|
||||
// onChange should be called once per initAuth call
|
||||
// (This is acceptable behavior - Pocketbase handles duplicates)
|
||||
expect(mockAuthStore.onChange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should register onChange callback with correct signature', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
|
||||
// Callback should accept token (string) and model (AuthModel | null)
|
||||
expect(typeof onChangeCallback).toBe('function');
|
||||
expect(onChangeCallback.length).toBe(2); // (token, model) => void
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-Tab Edge Cases', () => {
|
||||
it('should handle authStore onChange with undefined model', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
|
||||
// Simulate edge case: authStore fires onChange with undefined
|
||||
mockAuthStore.isValid = false;
|
||||
mockAuthStore.record = null;
|
||||
onChangeCallback('', undefined);
|
||||
|
||||
// Should handle gracefully (treat as logout)
|
||||
expect(auth.user.value).toBeNull();
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle authStore onChange during active login flow', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
// Start login flow (simulates user clicking login in this tab)
|
||||
mockListAuthMethods.mockResolvedValue({
|
||||
oauth2: {
|
||||
enabled: true,
|
||||
providers: [
|
||||
{
|
||||
name: 'google',
|
||||
displayName: 'Google',
|
||||
state: 'state123',
|
||||
codeVerifier: 'verifier',
|
||||
codeChallenge: 'challenge',
|
||||
codeChallengeMethod: 'S256',
|
||||
authURL: 'https://google.com/oauth',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const loginUser: AuthModel = {
|
||||
id: 'loginUser',
|
||||
email: 'login@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthWithOAuth2.mockResolvedValue({
|
||||
record: loginUser,
|
||||
});
|
||||
|
||||
// Start login
|
||||
const loginPromise = auth.login('google');
|
||||
|
||||
// While login is pending, simulate onChange from another tab
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
const crossTabUser: AuthModel = {
|
||||
id: 'crossTabUser',
|
||||
email: 'crosstab@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.isValid = true;
|
||||
mockAuthStore.record = crossTabUser;
|
||||
onChangeCallback('token999', crossTabUser);
|
||||
|
||||
// User should be updated from cross-tab event
|
||||
expect(auth.user.value?.id).toBe('crossTabUser');
|
||||
|
||||
// Wait for login to complete
|
||||
await loginPromise;
|
||||
|
||||
// After login completes, user should be from login flow
|
||||
expect(auth.user.value?.id).toBe('loginUser');
|
||||
});
|
||||
|
||||
it('should synchronize user profile updates from another tab', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
// Start with user
|
||||
const initialUser: AuthModel = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Old Name',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.record = initialUser;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.user.value?.name).toBe('Old Name');
|
||||
|
||||
// Simulate user profile update in another tab
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
const updatedUser: AuthModel = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Updated Name',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.record = updatedUser;
|
||||
onChangeCallback('token123', updatedUser);
|
||||
|
||||
// Verify profile update synced
|
||||
expect(auth.user.value?.name).toBe('Updated Name');
|
||||
expect(auth.user.value?.id).toBe('user123'); // Same user
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Restoration and Sync Integration', () => {
|
||||
it('should restore user from authStore and setup onChange listener', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
const existingUser: AuthModel = {
|
||||
id: 'existing123',
|
||||
email: 'existing@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.record = existingUser;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
// initAuth should both restore and setup listener
|
||||
await auth.initAuth();
|
||||
|
||||
// Verify restoration
|
||||
expect(auth.user.value).toEqual(existingUser);
|
||||
|
||||
// Verify listener setup
|
||||
expect(mockAuthStore.onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify listener works
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
const newUser: AuthModel = {
|
||||
id: 'new456',
|
||||
email: 'new@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.record = newUser;
|
||||
onChangeCallback('token456', newUser);
|
||||
|
||||
expect(auth.user.value?.id).toBe('new456');
|
||||
});
|
||||
|
||||
it('should handle case where authStore is empty on init but login happens in another tab', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
// Start with empty authStore (user logged out or first load)
|
||||
mockAuthStore.record = null;
|
||||
mockAuthStore.isValid = false;
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
|
||||
// Simulate login in another tab
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
const user: AuthModel = {
|
||||
id: 'user789',
|
||||
email: 'user789@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.isValid = true;
|
||||
mockAuthStore.record = user;
|
||||
onChangeCallback('token789', user);
|
||||
|
||||
// Should sync login from other tab
|
||||
expect(auth.isAuthenticated.value).toBe(true);
|
||||
expect(auth.user.value?.id).toBe('user789');
|
||||
});
|
||||
});
|
||||
});
|
||||
518
app/composables/__tests__/useAuth.error-handling.test.ts
Normal file
518
app/composables/__tests__/useAuth.error-handling.test.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user