This commit is contained in:
444
app/composables/__tests__/useAuth.test.ts
Normal file
444
app/composables/__tests__/useAuth.test.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
|
||||
import type { AuthProviderInfo, AuthModel, RecordModel } from 'pocketbase';
|
||||
|
||||
/**
|
||||
* Comprehensive tests for useAuth composable
|
||||
* Based on specs from private/specs.md section 3.1 (Authentication API)
|
||||
*
|
||||
* These tests verify actual behavior, not just API existence.
|
||||
*/
|
||||
|
||||
// Mock PocketBase
|
||||
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', () => {
|
||||
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('Composable Export', () => {
|
||||
it('should be exported as a function', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
expect(useAuth).toBeDefined();
|
||||
expect(typeof useAuth).toBe('function');
|
||||
});
|
||||
|
||||
it('should return an object with auth methods and state', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
expect(auth).toBeDefined();
|
||||
expect(typeof auth).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should initialize with user as null', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
expect(auth.user.value).toBeNull();
|
||||
});
|
||||
|
||||
it('should initialize with isAuthenticated as false', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with loading as false', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
expect(auth.loading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with error as null', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
expect(auth.error.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initAuth', () => {
|
||||
it('should sync user from authStore', async () => {
|
||||
const mockUser: AuthModel = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.record = mockUser;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.user.value).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('should register onChange listener', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
expect(mockAuthStore.onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update user when authStore changes', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
// Get the onChange callback
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
|
||||
// Simulate auth change
|
||||
const newUser = {
|
||||
id: 'newUser456',
|
||||
email: 'new@example.com',
|
||||
};
|
||||
|
||||
onChangeCallback('token123', newUser);
|
||||
|
||||
expect(auth.user.value).toEqual(newUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
const mockProviders: AuthProviderInfo[] = [
|
||||
{
|
||||
name: 'google',
|
||||
displayName: 'Google',
|
||||
state: 'state123',
|
||||
codeVerifier: 'verifier',
|
||||
codeChallenge: 'challenge',
|
||||
codeChallengeMethod: 'S256',
|
||||
authURL: 'https://google.com/oauth',
|
||||
},
|
||||
{
|
||||
name: 'microsoft',
|
||||
displayName: 'Microsoft',
|
||||
state: 'state456',
|
||||
codeVerifier: 'verifier2',
|
||||
codeChallenge: 'challenge2',
|
||||
codeChallengeMethod: 'S256',
|
||||
authURL: 'https://microsoft.com/oauth',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockListAuthMethods.mockResolvedValue({
|
||||
oauth2: {
|
||||
enabled: true,
|
||||
providers: mockProviders,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set loading to true when login starts', async () => {
|
||||
mockAuthWithOAuth2.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
const loginPromise = auth.login('google');
|
||||
|
||||
expect(auth.loading.value).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
await Promise.race([loginPromise, new Promise((resolve) => setTimeout(resolve, 10))]);
|
||||
});
|
||||
|
||||
it('should clear previous errors when starting new login', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
// Set an error first
|
||||
auth.error.value = new Error('Previous error');
|
||||
|
||||
mockAuthWithOAuth2.mockResolvedValue({});
|
||||
|
||||
await auth.login('google');
|
||||
|
||||
expect(auth.error.value).toBeNull();
|
||||
});
|
||||
|
||||
it('should call authWithOAuth2 with correct provider', async () => {
|
||||
mockAuthWithOAuth2.mockResolvedValue({});
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('google');
|
||||
|
||||
expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: 'google' });
|
||||
});
|
||||
|
||||
it('should set loading to false after login completes', async () => {
|
||||
mockAuthWithOAuth2.mockResolvedValue({});
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('google');
|
||||
|
||||
expect(auth.loading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error if provider is not configured', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('github'); // Not in mockProviders
|
||||
|
||||
expect(auth.error.value).toBeDefined();
|
||||
expect(auth.error.value?.message).toContain('github');
|
||||
expect(auth.error.value?.message).toContain('not configured');
|
||||
});
|
||||
|
||||
it('should handle OAuth errors gracefully', async () => {
|
||||
const mockError = new Error('OAuth failed');
|
||||
mockAuthWithOAuth2.mockRejectedValue(mockError);
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('google');
|
||||
|
||||
expect(auth.error.value).toEqual(mockError);
|
||||
expect(auth.loading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should support multiple OAuth providers', async () => {
|
||||
mockAuthWithOAuth2.mockResolvedValue({});
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('google');
|
||||
expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: 'google' });
|
||||
|
||||
await auth.login('microsoft');
|
||||
expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: 'microsoft' });
|
||||
});
|
||||
|
||||
it('should return empty array when OAuth is disabled', async () => {
|
||||
mockListAuthMethods.mockResolvedValue({
|
||||
oauth2: {
|
||||
enabled: false,
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('google');
|
||||
|
||||
expect(auth.error.value).toBeDefined();
|
||||
expect(auth.error.value?.message).toContain('not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOAuthCallback', () => {
|
||||
it('should sync user from authStore', async () => {
|
||||
const mockUser: AuthModel = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.record = mockUser;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.handleOAuthCallback();
|
||||
|
||||
expect(auth.user.value).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('should redirect to dashboard when authenticated', async () => {
|
||||
mockAuthStore.record = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as RecordModel;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.handleOAuthCallback();
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/dashboard');
|
||||
});
|
||||
|
||||
it('should redirect to home when not authenticated', async () => {
|
||||
mockAuthStore.record = null;
|
||||
mockAuthStore.isValid = false;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.handleOAuthCallback();
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAuth', () => {
|
||||
it('should call authRefresh on users collection', async () => {
|
||||
mockAuthRefresh.mockResolvedValue({
|
||||
token: 'newToken',
|
||||
record: { id: 'user123', email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.refreshAuth();
|
||||
|
||||
expect(mockCollection).toHaveBeenCalledWith('users');
|
||||
expect(mockAuthRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the refresh result', async () => {
|
||||
const mockResult = {
|
||||
token: 'newToken',
|
||||
record: { id: 'user123', email: 'test@example.com' },
|
||||
};
|
||||
|
||||
mockAuthRefresh.mockResolvedValue(mockResult);
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
const result = await auth.refreshAuth();
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should clear authStore', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
auth.logout();
|
||||
|
||||
expect(mockAuthStore.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear authStore even when user is authenticated', async () => {
|
||||
mockAuthStore.record = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as RecordModel;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
auth.logout();
|
||||
|
||||
expect(mockAuthStore.clear).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticated computed', () => {
|
||||
it('should be false when authStore is invalid', async () => {
|
||||
mockAuthStore.isValid = false;
|
||||
mockAuthStore.record = { id: 'user123', email: 'test@example.com' } as unknown as RecordModel;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should be false when user is null', async () => {
|
||||
mockAuthStore.isValid = true;
|
||||
mockAuthStore.record = null;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when authStore is valid and user exists', async () => {
|
||||
mockAuthStore.isValid = true;
|
||||
mockAuthStore.record = { id: 'user123', email: 'test@example.com' } as unknown as RecordModel;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exposed API', () => {
|
||||
it('should expose all required methods and properties', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
// State
|
||||
expect(auth.user).toBeDefined();
|
||||
expect(auth.isAuthenticated).toBeDefined();
|
||||
expect(auth.loading).toBeDefined();
|
||||
expect(auth.error).toBeDefined();
|
||||
|
||||
// Methods
|
||||
expect(typeof auth.initAuth).toBe('function');
|
||||
expect(typeof auth.login).toBe('function');
|
||||
expect(typeof auth.logout).toBe('function');
|
||||
expect(typeof auth.handleOAuthCallback).toBe('function');
|
||||
expect(typeof auth.refreshAuth).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user