Add unit tests for OAuthButtons and LinkedAccountsSettings components

- Introduced comprehensive test coverage for `OAuthButtons` and `LinkedAccountsSettings`, including loading states, button behaviors, error handling, and custom class support.
- Implemented `LinkedAccountsPage` tests for rendering and component integration.
- Adjusted E2E coverage exclusions in various components, focusing on UI-heavy and animation-based flows best suited for E2E tests.
- Refined Jest coverage thresholds to align with improved unit test additions.
This commit is contained in:
Felipe Cardoso
2025-11-25 08:52:11 +01:00
parent 13f617828b
commit aeed9dfdbc
15 changed files with 606 additions and 6 deletions

View File

@@ -0,0 +1,41 @@
/**
* Tests for Linked Accounts Settings Page
*/
import { render, screen } from '@testing-library/react';
import LinkedAccountsPage from '@/app/[locale]/(authenticated)/settings/accounts/page';
// Mock next-intl
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const translations: Record<string, string> = {
pageTitle: 'Linked Accounts',
pageSubtitle: 'Manage your linked social accounts for quick sign-in',
};
return translations[key] || key;
},
}));
// Mock the LinkedAccountsSettings component
jest.mock('@/components/settings', () => ({
LinkedAccountsSettings: () => (
<div data-testid="linked-accounts-settings">Mocked LinkedAccountsSettings</div>
),
}));
describe('LinkedAccountsPage', () => {
it('renders page title and subtitle', () => {
render(<LinkedAccountsPage />);
expect(screen.getByText('Linked Accounts')).toBeInTheDocument();
expect(
screen.getByText('Manage your linked social accounts for quick sign-in')
).toBeInTheDocument();
});
it('renders LinkedAccountsSettings component', () => {
render(<LinkedAccountsPage />);
expect(screen.getByTestId('linked-accounts-settings')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,216 @@
/**
* Tests for OAuthButtons Component
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { OAuthButtons } from '@/components/auth/OAuthButtons';
import { useOAuthProviders, useOAuthStart } from '@/lib/api/hooks/useOAuth';
// Mock the OAuth hooks
jest.mock('@/lib/api/hooks/useOAuth', () => ({
useOAuthProviders: jest.fn(),
useOAuthStart: jest.fn(),
}));
// Mock next-intl
jest.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: { provider?: string }) => {
const translations: Record<string, string> = {
loading: 'Loading...',
divider: 'or continue with',
continueWith: `Continue with ${params?.provider || ''}`,
signUpWith: `Sign up with ${params?.provider || ''}`,
};
return translations[key] || key;
},
}));
// Mock config - must be complete to avoid undefined access
jest.mock('@/config/app.config', () => ({
__esModule: true,
default: {
oauth: {
enabled: true,
providers: {
google: { name: 'Google', enabled: true },
github: { name: 'GitHub', enabled: true },
},
callbackPath: '/auth/callback',
},
routes: {
dashboard: '/dashboard',
login: '/login',
profile: '/settings/profile',
},
app: {
url: 'http://localhost:3000',
},
},
}));
describe('OAuthButtons', () => {
const mockProviders = {
enabled: true,
providers: [
{ provider: 'google', name: 'Google' },
{ provider: 'github', name: 'GitHub' },
],
};
const mockOAuthStart = {
mutateAsync: jest.fn(),
isPending: false,
};
beforeEach(() => {
jest.clearAllMocks();
(useOAuthProviders as jest.Mock).mockReturnValue({
data: mockProviders,
isLoading: false,
error: null,
});
(useOAuthStart as jest.Mock).mockReturnValue(mockOAuthStart);
});
it('renders nothing when OAuth is disabled', () => {
(useOAuthProviders as jest.Mock).mockReturnValue({
data: { enabled: false, providers: [] },
isLoading: false,
error: null,
});
const { container } = render(<OAuthButtons mode="login" />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when no providers available', () => {
(useOAuthProviders as jest.Mock).mockReturnValue({
data: { enabled: true, providers: [] },
isLoading: false,
error: null,
});
const { container } = render(<OAuthButtons mode="login" />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when there is an error', () => {
(useOAuthProviders as jest.Mock).mockReturnValue({
data: mockProviders,
isLoading: false,
error: new Error('Failed to fetch'),
});
const { container } = render(<OAuthButtons mode="login" />);
expect(container).toBeEmptyDOMElement();
});
it('shows loading state', () => {
(useOAuthProviders as jest.Mock).mockReturnValue({
data: null,
isLoading: true,
error: null,
});
render(<OAuthButtons mode="login" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeDisabled();
});
it('renders provider buttons in login mode', () => {
render(<OAuthButtons mode="login" />);
expect(screen.getByText('Continue with Google')).toBeInTheDocument();
expect(screen.getByText('Continue with GitHub')).toBeInTheDocument();
});
it('renders provider buttons in register mode', () => {
render(<OAuthButtons mode="register" />);
expect(screen.getByText('Sign up with Google')).toBeInTheDocument();
expect(screen.getByText('Sign up with GitHub')).toBeInTheDocument();
});
it('renders divider when showDivider is true (default)', () => {
render(<OAuthButtons mode="login" />);
expect(screen.getByText('or continue with')).toBeInTheDocument();
});
it('does not render divider when showDivider is false', () => {
render(<OAuthButtons mode="login" showDivider={false} />);
expect(screen.queryByText('or continue with')).not.toBeInTheDocument();
});
it('calls OAuth start when clicking provider button', async () => {
render(<OAuthButtons mode="login" />);
const googleButton = screen.getByText('Continue with Google');
fireEvent.click(googleButton);
await waitFor(() => {
expect(mockOAuthStart.mutateAsync).toHaveBeenCalledWith({
provider: 'google',
mode: 'login',
});
});
});
it('calls onStart callback when OAuth flow starts', async () => {
const onStart = jest.fn();
render(<OAuthButtons mode="login" onStart={onStart} />);
const googleButton = screen.getByText('Continue with Google');
fireEvent.click(googleButton);
await waitFor(() => {
expect(onStart).toHaveBeenCalledWith('google');
});
});
it('calls onError callback when OAuth start fails', async () => {
const error = new Error('OAuth failed');
mockOAuthStart.mutateAsync.mockRejectedValue(error);
const onError = jest.fn();
render(<OAuthButtons mode="login" onError={onError} />);
const googleButton = screen.getByText('Continue with Google');
fireEvent.click(googleButton);
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(error);
});
});
it('disables buttons while OAuth is pending', () => {
(useOAuthStart as jest.Mock).mockReturnValue({
...mockOAuthStart,
isPending: true,
});
render(<OAuthButtons mode="login" />);
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toBeDisabled();
});
});
it('applies custom className', () => {
render(<OAuthButtons mode="login" className="custom-class" />);
const container = document.querySelector('.custom-class');
expect(container).toBeInTheDocument();
});
it('renders provider icons', () => {
render(<OAuthButtons mode="login" />);
// Icons are SVG elements
const svgs = document.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,251 @@
/**
* Tests for LinkedAccountsSettings Component
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { LinkedAccountsSettings } from '@/components/settings/LinkedAccountsSettings';
import {
useOAuthProviders,
useOAuthAccounts,
useOAuthLink,
useOAuthUnlink,
} from '@/lib/api/hooks/useOAuth';
// Mock the OAuth hooks
jest.mock('@/lib/api/hooks/useOAuth', () => ({
useOAuthProviders: jest.fn(),
useOAuthAccounts: jest.fn(),
useOAuthLink: jest.fn(),
useOAuthUnlink: jest.fn(),
}));
// Mock next-intl
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const translations: Record<string, string> = {
title: 'Linked Accounts',
description: 'Manage your linked social accounts',
linked: 'Linked',
link: 'Link',
unlink: 'Unlink',
linkError: 'Failed to link account',
unlinkError: 'Failed to unlink account',
};
return translations[key] || key;
},
}));
// Mock config - must be complete to avoid undefined access
jest.mock('@/config/app.config', () => ({
__esModule: true,
default: {
oauth: {
enabled: true,
providers: {
google: { name: 'Google', enabled: true },
github: { name: 'GitHub', enabled: true },
},
callbackPath: '/auth/callback',
},
routes: {
dashboard: '/dashboard',
login: '/login',
profile: '/settings/profile',
},
app: {
url: 'http://localhost:3000',
},
},
}));
describe('LinkedAccountsSettings', () => {
const mockProviders = {
enabled: true,
providers: [
{ provider: 'google', name: 'Google' },
{ provider: 'github', name: 'GitHub' },
],
};
const mockLinkedAccounts = {
accounts: [{ provider: 'google', provider_email: 'user@gmail.com' }],
};
const mockLinkMutation = {
mutateAsync: jest.fn(),
isPending: false,
};
const mockUnlinkMutation = {
mutateAsync: jest.fn(),
isPending: false,
};
beforeEach(() => {
jest.clearAllMocks();
(useOAuthProviders as jest.Mock).mockReturnValue({
data: mockProviders,
isLoading: false,
});
(useOAuthAccounts as jest.Mock).mockReturnValue({
data: mockLinkedAccounts,
isLoading: false,
});
(useOAuthLink as jest.Mock).mockReturnValue(mockLinkMutation);
(useOAuthUnlink as jest.Mock).mockReturnValue(mockUnlinkMutation);
});
it('renders nothing when OAuth is disabled', () => {
(useOAuthProviders as jest.Mock).mockReturnValue({
data: { enabled: false, providers: [] },
isLoading: false,
});
const { container } = render(<LinkedAccountsSettings />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when no providers available', () => {
(useOAuthProviders as jest.Mock).mockReturnValue({
data: { enabled: true, providers: [] },
isLoading: false,
});
const { container } = render(<LinkedAccountsSettings />);
expect(container).toBeEmptyDOMElement();
});
it('shows loading state', () => {
(useOAuthProviders as jest.Mock).mockReturnValue({
data: null,
isLoading: true,
});
(useOAuthAccounts as jest.Mock).mockReturnValue({
data: null,
isLoading: true,
});
render(<LinkedAccountsSettings />);
// Should show loading indicator (spinner is an SVG with animate-spin)
const loadingElement = document.querySelector('.animate-spin');
expect(loadingElement).toBeInTheDocument();
});
it('renders available providers', () => {
render(<LinkedAccountsSettings />);
expect(screen.getByText('Linked Accounts')).toBeInTheDocument();
expect(screen.getByText('Manage your linked social accounts')).toBeInTheDocument();
expect(screen.getByText('Google')).toBeInTheDocument();
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
it('shows linked badge for linked accounts', () => {
render(<LinkedAccountsSettings />);
// Google is linked
expect(screen.getByText('user@gmail.com')).toBeInTheDocument();
expect(screen.getByText('Linked')).toBeInTheDocument();
});
it('shows Link button for unlinked accounts', () => {
render(<LinkedAccountsSettings />);
// GitHub is not linked, should show Link button
const linkButtons = screen.getAllByRole('button', { name: /link/i });
expect(linkButtons.length).toBeGreaterThan(0);
});
it('shows Unlink button for linked accounts', () => {
render(<LinkedAccountsSettings />);
// Google is linked, should show Unlink button
expect(screen.getByRole('button', { name: /unlink/i })).toBeInTheDocument();
});
it('calls link mutation when clicking Link button', async () => {
render(<LinkedAccountsSettings />);
// Find all buttons - GitHub's Link button should exist (Google shows Unlink)
const buttons = screen.getAllByRole('button');
// Find the button that contains "Link" text (not "Unlink")
const linkButton = buttons.find(
(btn) => btn.textContent?.includes('Link') && !btn.textContent?.includes('Unlink')
);
expect(linkButton).toBeDefined();
if (linkButton) {
fireEvent.click(linkButton);
await waitFor(() => {
expect(mockLinkMutation.mutateAsync).toHaveBeenCalledWith({ provider: 'github' });
});
}
});
it('calls unlink mutation when clicking Unlink button', async () => {
render(<LinkedAccountsSettings />);
const unlinkButton = screen.getByRole('button', { name: /unlink/i });
fireEvent.click(unlinkButton);
await waitFor(() => {
expect(mockUnlinkMutation.mutateAsync).toHaveBeenCalledWith({ provider: 'google' });
});
});
it('shows error when unlink fails', async () => {
mockUnlinkMutation.mutateAsync.mockRejectedValue(new Error('Unlink failed'));
render(<LinkedAccountsSettings />);
const unlinkButton = screen.getByRole('button', { name: /unlink/i });
fireEvent.click(unlinkButton);
await waitFor(() => {
expect(screen.getByText('Unlink failed')).toBeInTheDocument();
});
});
it('disables unlink button while unlink mutation is pending for that provider', () => {
(useOAuthProviders as jest.Mock).mockReturnValue({
data: mockProviders,
isLoading: false,
});
(useOAuthAccounts as jest.Mock).mockReturnValue({
data: mockLinkedAccounts,
isLoading: false,
});
(useOAuthLink as jest.Mock).mockReturnValue(mockLinkMutation);
(useOAuthUnlink as jest.Mock).mockReturnValue({
...mockUnlinkMutation,
isPending: true,
});
render(<LinkedAccountsSettings />);
// Google is linked, unlink button should be disabled when mutation is pending
const unlinkButton = screen.getByRole('button', { name: /unlink/i });
expect(unlinkButton).toBeDisabled();
});
it('applies custom className', () => {
(useOAuthProviders as jest.Mock).mockReturnValue({
data: mockProviders,
isLoading: false,
});
(useOAuthAccounts as jest.Mock).mockReturnValue({
data: mockLinkedAccounts,
isLoading: false,
});
(useOAuthLink as jest.Mock).mockReturnValue(mockLinkMutation);
(useOAuthUnlink as jest.Mock).mockReturnValue(mockUnlinkMutation);
render(<LinkedAccountsSettings className="custom-class" />);
// The Card component should have the custom class
const card = document.querySelector('.custom-class');
expect(card).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,67 @@
/**
* Tests for lib/api/admin.ts
*/
import { getAdminStats } from '@/lib/api/admin';
import { apiClient } from '@/lib/api/client';
// Mock the apiClient
jest.mock('@/lib/api/client', () => ({
apiClient: {
get: jest.fn(),
},
}));
describe('getAdminStats', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('calls apiClient.get with correct parameters', async () => {
const mockResponse = {
user_growth: [],
organization_distribution: [],
registration_activity: [],
user_status: [],
};
(apiClient.get as jest.Mock).mockResolvedValue(mockResponse);
await getAdminStats();
expect(apiClient.get).toHaveBeenCalledWith({
responseType: 'json',
security: [
{
scheme: 'bearer',
type: 'http',
},
],
url: '/api/v1/admin/stats',
});
});
it('uses custom client when provided', async () => {
const customClient = {
get: jest.fn().mockResolvedValue({}),
};
await getAdminStats({ client: customClient as any });
expect(customClient.get).toHaveBeenCalled();
expect(apiClient.get).not.toHaveBeenCalled();
});
it('passes through additional options', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({});
await getAdminStats({ throwOnError: true } as any);
expect(apiClient.get).toHaveBeenCalledWith(
expect.objectContaining({
url: '/api/v1/admin/stats',
throwOnError: true,
})
);
});
});