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:
poduck
2025-12-08 02:36:46 -05:00
parent c220612214
commit 8dc2248f1f
145 changed files with 77947 additions and 1048 deletions

View 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);
});
});
});