feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
549
frontend/src/hooks/__tests__/useOAuth.test.ts
Normal file
549
frontend/src/hooks/__tests__/useOAuth.test.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../api/oauth', () => ({
|
||||
getOAuthProviders: vi.fn(),
|
||||
getOAuthConnections: vi.fn(),
|
||||
initiateOAuth: vi.fn(),
|
||||
handleOAuthCallback: vi.fn(),
|
||||
disconnectOAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/cookies', () => ({
|
||||
setCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
useOAuthProviders,
|
||||
useOAuthConnections,
|
||||
useInitiateOAuth,
|
||||
useOAuthCallback,
|
||||
useDisconnectOAuth,
|
||||
} from '../useOAuth';
|
||||
import * as oauthApi from '../../api/oauth';
|
||||
import * as cookies from '../../utils/cookies';
|
||||
|
||||
// Create a wrapper with QueryClientProvider
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
children
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useOAuth hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useOAuthProviders', () => {
|
||||
it('fetches OAuth providers successfully', async () => {
|
||||
const mockProviders: oauthApi.OAuthProvider[] = [
|
||||
{
|
||||
name: 'google',
|
||||
display_name: 'Google',
|
||||
icon: 'https://example.com/google.png',
|
||||
},
|
||||
{
|
||||
name: 'microsoft',
|
||||
display_name: 'Microsoft',
|
||||
icon: 'https://example.com/microsoft.png',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(oauthApi.getOAuthProviders).mockResolvedValue(mockProviders);
|
||||
|
||||
const { result } = renderHook(() => useOAuthProviders(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockProviders);
|
||||
expect(oauthApi.getOAuthProviders).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles errors when fetching providers fails', async () => {
|
||||
const mockError = new Error('Failed to fetch providers');
|
||||
vi.mocked(oauthApi.getOAuthProviders).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useOAuthProviders(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isError).toBe(true);
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
});
|
||||
|
||||
it('uses correct query configuration', () => {
|
||||
vi.mocked(oauthApi.getOAuthProviders).mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useOAuthProviders(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// The hook should be configured with staleTime and refetchOnWindowFocus
|
||||
// We can verify this by checking that the hook doesn't refetch immediately
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useOAuthConnections', () => {
|
||||
it('fetches OAuth connections successfully', async () => {
|
||||
const mockConnections: oauthApi.OAuthConnection[] = [
|
||||
{
|
||||
id: '1',
|
||||
provider: 'google',
|
||||
provider_user_id: 'user123',
|
||||
email: 'test@example.com',
|
||||
connected_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
provider: 'microsoft',
|
||||
provider_user_id: 'user456',
|
||||
email: 'test@microsoft.com',
|
||||
connected_at: '2025-01-02T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(oauthApi.getOAuthConnections).mockResolvedValue(mockConnections);
|
||||
|
||||
const { result } = renderHook(() => useOAuthConnections(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockConnections);
|
||||
expect(oauthApi.getOAuthConnections).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles errors when fetching connections fails', async () => {
|
||||
const mockError = new Error('Failed to fetch connections');
|
||||
vi.mocked(oauthApi.getOAuthConnections).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useOAuthConnections(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isError).toBe(true);
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
});
|
||||
|
||||
it('returns empty array when no connections exist', async () => {
|
||||
vi.mocked(oauthApi.getOAuthConnections).mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useOAuthConnections(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useInitiateOAuth', () => {
|
||||
it('initiates OAuth flow and redirects to authorization URL', async () => {
|
||||
const mockAuthUrl = 'https://accounts.google.com/oauth/authorize?client_id=123';
|
||||
vi.mocked(oauthApi.initiateOAuth).mockResolvedValue({
|
||||
authorization_url: mockAuthUrl,
|
||||
});
|
||||
|
||||
// Mock window.location
|
||||
const originalLocation = window.location;
|
||||
delete (window as any).location;
|
||||
window.location = { ...originalLocation, href: '' } as Location;
|
||||
|
||||
const { result } = renderHook(() => useInitiateOAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('google');
|
||||
});
|
||||
|
||||
expect(oauthApi.initiateOAuth).toHaveBeenCalledWith('google');
|
||||
expect(window.location.href).toBe(mockAuthUrl);
|
||||
|
||||
// Restore window.location
|
||||
window.location = originalLocation;
|
||||
});
|
||||
|
||||
it('handles errors when initiating OAuth fails', async () => {
|
||||
const mockError = new Error('Failed to initiate OAuth');
|
||||
vi.mocked(oauthApi.initiateOAuth).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useInitiateOAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let caughtError;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync('google');
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(caughtError).toEqual(mockError);
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
});
|
||||
|
||||
it('supports multiple OAuth providers', async () => {
|
||||
const providers = ['google', 'microsoft', 'github'];
|
||||
|
||||
for (const provider of providers) {
|
||||
vi.mocked(oauthApi.initiateOAuth).mockResolvedValue({
|
||||
authorization_url: `https://${provider}.com/oauth/authorize`,
|
||||
});
|
||||
|
||||
const originalLocation = window.location;
|
||||
delete (window as any).location;
|
||||
window.location = { ...originalLocation, href: '' } as Location;
|
||||
|
||||
const { result } = renderHook(() => useInitiateOAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(provider);
|
||||
});
|
||||
|
||||
expect(oauthApi.initiateOAuth).toHaveBeenCalledWith(provider);
|
||||
expect(window.location.href).toBe(`https://${provider}.com/oauth/authorize`);
|
||||
|
||||
window.location = originalLocation;
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('useOAuthCallback', () => {
|
||||
it('handles OAuth callback and stores tokens in cookies', async () => {
|
||||
const mockResponse: oauthApi.OAuthTokenResponse = {
|
||||
access: 'access-token-123',
|
||||
refresh: 'refresh-token-456',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'owner',
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(oauthApi.handleOAuthCallback).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useOAuthCallback(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
provider: 'google',
|
||||
code: 'auth-code-123',
|
||||
state: 'state-456',
|
||||
});
|
||||
});
|
||||
|
||||
expect(oauthApi.handleOAuthCallback).toHaveBeenCalledWith(
|
||||
'google',
|
||||
'auth-code-123',
|
||||
'state-456'
|
||||
);
|
||||
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'access-token-123', 7);
|
||||
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'refresh-token-456', 7);
|
||||
});
|
||||
|
||||
it('sets user in cache after successful callback', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'owner',
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
};
|
||||
|
||||
const mockResponse: oauthApi.OAuthTokenResponse = {
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: mockUser,
|
||||
};
|
||||
|
||||
vi.mocked(oauthApi.handleOAuthCallback).mockResolvedValue(mockResponse);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
const { result } = renderHook(() => useOAuthCallback(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
provider: 'google',
|
||||
code: 'code',
|
||||
state: 'state',
|
||||
});
|
||||
});
|
||||
|
||||
// Verify user was set in cache
|
||||
const cachedUser = queryClient.getQueryData(['currentUser']);
|
||||
expect(cachedUser).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('invalidates OAuth connections after successful callback', async () => {
|
||||
const mockResponse: oauthApi.OAuthTokenResponse = {
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'owner',
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(oauthApi.handleOAuthCallback).mockResolvedValue(mockResponse);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Set initial connections data
|
||||
queryClient.setQueryData(['oauthConnections'], []);
|
||||
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
const { result } = renderHook(() => useOAuthCallback(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
provider: 'google',
|
||||
code: 'code',
|
||||
state: 'state',
|
||||
});
|
||||
});
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['oauthConnections'] });
|
||||
});
|
||||
|
||||
it('handles errors during OAuth callback', async () => {
|
||||
const mockError = new Error('Invalid authorization code');
|
||||
vi.mocked(oauthApi.handleOAuthCallback).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useOAuthCallback(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let caughtError;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
provider: 'google',
|
||||
code: 'invalid-code',
|
||||
state: 'state',
|
||||
});
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(caughtError).toEqual(mockError);
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
expect(cookies.setCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles callback with optional user fields', async () => {
|
||||
const mockResponse: oauthApi.OAuthTokenResponse = {
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'owner',
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
is_staff: true,
|
||||
is_superuser: false,
|
||||
business: 123,
|
||||
business_name: 'Test Business',
|
||||
business_subdomain: 'testbiz',
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(oauthApi.handleOAuthCallback).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useOAuthCallback(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
provider: 'microsoft',
|
||||
code: 'code',
|
||||
state: 'state',
|
||||
});
|
||||
});
|
||||
|
||||
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'access-token', 7);
|
||||
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'refresh-token', 7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDisconnectOAuth', () => {
|
||||
it('disconnects OAuth provider successfully', async () => {
|
||||
vi.mocked(oauthApi.disconnectOAuth).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useDisconnectOAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('google');
|
||||
});
|
||||
|
||||
// React Query passes mutation context as second parameter
|
||||
expect(oauthApi.disconnectOAuth).toHaveBeenCalledWith('google', expect.any(Object));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('invalidates OAuth connections after disconnect', async () => {
|
||||
vi.mocked(oauthApi.disconnectOAuth).mockResolvedValue(undefined);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
const { result } = renderHook(() => useDisconnectOAuth(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('google');
|
||||
});
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['oauthConnections'] });
|
||||
});
|
||||
|
||||
it('handles errors when disconnect fails', async () => {
|
||||
const mockError = new Error('Failed to disconnect');
|
||||
vi.mocked(oauthApi.disconnectOAuth).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useDisconnectOAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let caughtError;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync('google');
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(caughtError).toEqual(mockError);
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
});
|
||||
|
||||
it('can disconnect multiple providers sequentially', async () => {
|
||||
vi.mocked(oauthApi.disconnectOAuth).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useDisconnectOAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Disconnect first provider
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('google');
|
||||
});
|
||||
|
||||
// React Query passes mutation context as second parameter
|
||||
expect(oauthApi.disconnectOAuth).toHaveBeenNthCalledWith(1, 'google', expect.any(Object));
|
||||
|
||||
// Disconnect second provider
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('microsoft');
|
||||
});
|
||||
|
||||
expect(oauthApi.disconnectOAuth).toHaveBeenNthCalledWith(2, 'microsoft', expect.any(Object));
|
||||
expect(oauthApi.disconnectOAuth).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user