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