Add unit tests for settings components and session hooks

- Implement comprehensive tests for `ProfileSettingsForm`, `PasswordChangeForm`, and `SessionCard` components to validate rendering, interactions, and state handling.
- Add tests for session management hooks (`useSession`, `useRevokeSession`, and `useRevokeAllOtherSessions`) to verify logic and API integration.
- Ensure coverage of edge cases, error handling, and success callbacks across all new tests.
This commit is contained in:
2025-11-03 00:12:59 +01:00
parent 54a14047be
commit 388ca08724
8 changed files with 1472 additions and 5 deletions

View File

@@ -0,0 +1,251 @@
/**
* Tests for PasswordChangeForm Component
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PasswordChangeForm } from '@/components/settings/PasswordChangeForm';
import * as useAuthModule from '@/lib/api/hooks/useAuth';
import { toast } from 'sonner';
jest.mock('@/lib/api/hooks/useAuth');
jest.mock('sonner', () => ({ toast: { success: jest.fn(), error: jest.fn() } }));
const mockUsePasswordChange = useAuthModule.usePasswordChange as jest.Mock;
const mockToast = toast as jest.Mocked<typeof toast>;
describe('PasswordChangeForm', () => {
let queryClient: QueryClient;
let user: ReturnType<typeof userEvent.setup>;
const mockMutateAsync = jest.fn();
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
user = userEvent.setup();
jest.clearAllMocks();
mockUsePasswordChange.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
isError: false,
isSuccess: false,
error: null,
});
});
const renderWithProvider = (component: React.ReactElement) =>
render(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>);
describe('Rendering', () => {
it('renders all password fields', () => {
renderWithProvider(<PasswordChangeForm />);
expect(screen.getByLabelText(/current password/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^new password/i)).toBeInTheDocument();
expect(screen.getByLabelText(/confirm new password/i)).toBeInTheDocument();
});
it('renders change password button', () => {
renderWithProvider(<PasswordChangeForm />);
expect(screen.getByRole('button', { name: /change password/i })).toBeInTheDocument();
});
it('shows password strength requirements', () => {
renderWithProvider(<PasswordChangeForm />);
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
});
it('uses usePasswordChange hook', () => {
renderWithProvider(<PasswordChangeForm />);
expect(mockUsePasswordChange).toHaveBeenCalled();
});
});
describe('Form State', () => {
it('disables submit when pristine', () => {
renderWithProvider(<PasswordChangeForm />);
expect(screen.getByRole('button', { name: /change password/i })).toBeDisabled();
});
it('disables inputs while submitting', () => {
mockUsePasswordChange.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
isError: false,
isSuccess: false,
error: null,
});
renderWithProvider(<PasswordChangeForm />);
expect(screen.getByLabelText(/current password/i)).toBeDisabled();
expect(screen.getByLabelText(/^new password/i)).toBeDisabled();
expect(screen.getByLabelText(/confirm new password/i)).toBeDisabled();
});
it('shows loading text while submitting', () => {
mockUsePasswordChange.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
isError: false,
isSuccess: false,
error: null,
});
renderWithProvider(<PasswordChangeForm />);
expect(screen.getByText(/changing password/i)).toBeInTheDocument();
});
it('shows cancel button when form is dirty', async () => {
renderWithProvider(<PasswordChangeForm />);
const currentPasswordInput = screen.getByLabelText(/current password/i);
await user.type(currentPasswordInput, 'password');
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
});
});
describe('User Interactions', () => {
it('allows typing in current password field', async () => {
renderWithProvider(<PasswordChangeForm />);
const currentPasswordInput = screen.getByLabelText(/current password/i) as HTMLInputElement;
await user.type(currentPasswordInput, 'OldPassword123!');
expect(currentPasswordInput.value).toBe('OldPassword123!');
});
it('allows typing in new password field', async () => {
renderWithProvider(<PasswordChangeForm />);
const newPasswordInput = screen.getByLabelText(/^new password/i) as HTMLInputElement;
await user.type(newPasswordInput, 'NewPassword123!');
expect(newPasswordInput.value).toBe('NewPassword123!');
});
it('allows typing in confirm password field', async () => {
renderWithProvider(<PasswordChangeForm />);
const confirmPasswordInput = screen.getByLabelText(/confirm new password/i) as HTMLInputElement;
await user.type(confirmPasswordInput, 'NewPassword123!');
expect(confirmPasswordInput.value).toBe('NewPassword123!');
});
it('resets form when cancel button is clicked', async () => {
renderWithProvider(<PasswordChangeForm />);
const currentPasswordInput = screen.getByLabelText(/current password/i) as HTMLInputElement;
await user.type(currentPasswordInput, 'password');
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
await waitFor(() => {
expect(currentPasswordInput.value).toBe('');
});
});
});
describe('Form Submission - Success', () => {
it('calls mutateAsync with correct data on successful submission', async () => {
mockMutateAsync.mockResolvedValueOnce({});
renderWithProvider(<PasswordChangeForm />);
// Simulate the hook callback being triggered (success path)
const hookCallback = mockUsePasswordChange.mock.calls[0][0];
// Trigger the callback as if mutation succeeded
hookCallback('Password changed successfully');
expect(mockToast.success).toHaveBeenCalledWith('Password changed successfully');
});
it('calls onSuccess callback after successful password change', async () => {
const onSuccess = jest.fn();
mockMutateAsync.mockResolvedValueOnce({});
renderWithProvider(<PasswordChangeForm onSuccess={onSuccess} />);
// Simulate successful password change through hook callback
const hookCallback = mockUsePasswordChange.mock.calls[0][0];
hookCallback('Password changed successfully');
expect(onSuccess).toHaveBeenCalled();
});
it('shows success toast with custom message', async () => {
renderWithProvider(<PasswordChangeForm />);
const hookCallback = mockUsePasswordChange.mock.calls[0][0];
hookCallback('Your password has been updated');
expect(mockToast.success).toHaveBeenCalledWith('Your password has been updated');
});
});
describe('Form Validation', () => {
it('validates password match', async () => {
renderWithProvider(<PasswordChangeForm />);
await user.type(screen.getByLabelText(/current password/i), 'OldPass123!');
await user.type(screen.getByLabelText(/^new password/i), 'NewPass123!');
await user.type(screen.getByLabelText(/confirm new password/i), 'DifferentPass123!');
// Try to submit the form
const form = screen.getByRole('button', { name: /change password/i }).closest('form');
if (form) {
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
await waitFor(() => {
expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument();
});
}
});
it('validates password strength requirements', async () => {
renderWithProvider(<PasswordChangeForm />);
await user.type(screen.getByLabelText(/current password/i), 'OldPass123!');
await user.type(screen.getByLabelText(/^new password/i), 'weak');
await user.type(screen.getByLabelText(/confirm new password/i), 'weak');
const form = screen.getByRole('button', { name: /change password/i }).closest('form');
if (form) {
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
await waitFor(() => {
expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument();
});
}
});
it('requires all fields to be filled', async () => {
renderWithProvider(<PasswordChangeForm />);
// Leave fields empty and try to submit
const form = screen.getByRole('button', { name: /change password/i }).closest('form');
if (form) {
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
await waitFor(() => {
expect(screen.getByText(/current password is required/i)).toBeInTheDocument();
});
}
});
});
});

View File

@@ -0,0 +1,243 @@
/**
* Tests for ProfileSettingsForm Component
* Tests profile editing functionality
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ProfileSettingsForm } from '@/components/settings/ProfileSettingsForm';
import * as useAuthModule from '@/lib/api/hooks/useAuth';
import * as useUserModule from '@/lib/api/hooks/useUser';
import { toast } from 'sonner';
// Mock dependencies
jest.mock('@/lib/api/hooks/useAuth');
jest.mock('@/lib/api/hooks/useUser');
jest.mock('sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const mockUseCurrentUser = useAuthModule.useCurrentUser as jest.Mock;
const mockUseUpdateProfile = useUserModule.useUpdateProfile as jest.Mock;
const mockToast = toast as jest.Mocked<typeof toast>;
describe('ProfileSettingsForm', () => {
let queryClient: QueryClient;
let user: ReturnType<typeof userEvent.setup>;
const mockMutateAsync = jest.fn();
const mockUser = {
id: '1',
email: 'test@example.com',
first_name: 'John',
last_name: 'Doe',
is_active: true,
is_superuser: false,
created_at: '2024-01-01T00:00:00Z',
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
user = userEvent.setup();
jest.clearAllMocks();
// Mock useCurrentUser to return user
mockUseCurrentUser.mockReturnValue(mockUser);
// Mock useUpdateProfile
mockUseUpdateProfile.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
isError: false,
isSuccess: false,
error: null,
});
});
const renderWithProvider = (component: React.ReactElement) => {
return render(
<QueryClientProvider client={queryClient}>
{component}
</QueryClientProvider>
);
};
describe('Rendering', () => {
it('renders form with all fields', () => {
renderWithProvider(<ProfileSettingsForm />);
expect(screen.getByText('Profile Information')).toBeInTheDocument();
expect(screen.getByLabelText(/first name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/last name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
});
it('populates form with current user data', async () => {
renderWithProvider(<ProfileSettingsForm />);
await waitFor(() => {
const firstNameInput = screen.getByLabelText(/first name/i) as HTMLInputElement;
const lastNameInput = screen.getByLabelText(/last name/i) as HTMLInputElement;
const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement;
expect(firstNameInput.value).toBe('John');
expect(lastNameInput.value).toBe('Doe');
expect(emailInput.value).toBe('test@example.com');
});
});
it('disables email field', () => {
renderWithProvider(<ProfileSettingsForm />);
const emailInput = screen.getByLabelText(/email/i);
expect(emailInput).toBeDisabled();
});
it('shows email cannot be changed message', () => {
renderWithProvider(<ProfileSettingsForm />);
expect(screen.getByText(/cannot be changed from this form/i)).toBeInTheDocument();
});
it('marks first name as required', () => {
renderWithProvider(<ProfileSettingsForm />);
const firstNameLabel = screen.getByText(/first name/i);
expect(firstNameLabel.parentElement?.textContent).toContain('*');
});
});
describe('User Interactions', () => {
it('allows typing in first name field', async () => {
renderWithProvider(<ProfileSettingsForm />);
await waitFor(() => {
expect((screen.getByLabelText(/first name/i) as HTMLInputElement).value).toBe('John');
});
const firstNameInput = screen.getByLabelText(/first name/i);
await user.clear(firstNameInput);
await user.type(firstNameInput, 'Jane');
expect((firstNameInput as HTMLInputElement).value).toBe('Jane');
});
it('allows typing in last name field', async () => {
renderWithProvider(<ProfileSettingsForm />);
await waitFor(() => {
expect((screen.getByLabelText(/last name/i) as HTMLInputElement).value).toBe('Doe');
});
const lastNameInput = screen.getByLabelText(/last name/i);
await user.clear(lastNameInput);
await user.type(lastNameInput, 'Smith');
expect((lastNameInput as HTMLInputElement).value).toBe('Smith');
});
});
describe('Form Submission', () => {
it('uses useUpdateProfile hook correctly', async () => {
renderWithProvider(<ProfileSettingsForm />);
// Verify the hook is called
expect(mockUseUpdateProfile).toHaveBeenCalled();
// Verify the hook callback would call success handler
const hookCallback = mockUseUpdateProfile.mock.calls[0][0];
expect(typeof hookCallback).toBe('function');
});
it('calls success toast through hook callback', async () => {
renderWithProvider(<ProfileSettingsForm />);
// Get the hook callback
const hookCallback = mockUseUpdateProfile.mock.calls[0][0];
if (hookCallback) {
hookCallback('Profile updated successfully');
expect(mockToast.success).toHaveBeenCalledWith('Profile updated successfully');
}
});
it('calls onSuccess callback when provided', async () => {
const onSuccess = jest.fn();
renderWithProvider(<ProfileSettingsForm onSuccess={onSuccess} />);
// Verify onSuccess is passed to the component
expect(onSuccess).not.toHaveBeenCalled(); // Not called on mount
// The onSuccess would be called through the useUpdateProfile hook callback
const hookCallback = mockUseUpdateProfile.mock.calls[0][0];
if (hookCallback) {
hookCallback('Profile updated successfully');
expect(onSuccess).toHaveBeenCalled();
}
});
});
describe('Validation', () => {
it('marks first name field as required in UI', () => {
renderWithProvider(<ProfileSettingsForm />);
// First name label should have asterisk indicating it's required
const firstNameLabel = screen.getByText(/first name/i);
expect(firstNameLabel.parentElement?.textContent).toContain('*');
});
});
describe('Form State', () => {
it('disables submit button when form is pristine', async () => {
renderWithProvider(<ProfileSettingsForm />);
await waitFor(() => {
const submitButton = screen.getByRole('button', { name: /save changes/i });
expect(submitButton).toBeDisabled();
});
});
it('disables inputs while submitting', async () => {
mockUseUpdateProfile.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
isError: false,
isSuccess: false,
error: null,
});
renderWithProvider(<ProfileSettingsForm />);
await waitFor(() => {
const firstNameInput = screen.getByLabelText(/first name/i);
expect(firstNameInput).toBeDisabled();
});
});
it('shows loading text while submitting', async () => {
mockUseUpdateProfile.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
isError: false,
isSuccess: false,
error: null,
});
renderWithProvider(<ProfileSettingsForm />);
await waitFor(() => {
expect(screen.getByText(/saving/i)).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,144 @@
/**
* Tests for SessionCard Component
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { SessionCard } from '@/components/settings/SessionCard';
import type { Session } from '@/lib/api/hooks/useSession';
describe('SessionCard', () => {
const mockOnRevoke = jest.fn();
const currentSession: Session = {
id: '1',
device_name: 'Chrome on Mac',
ip_address: '192.168.1.1',
location_city: 'San Francisco',
location_country: 'USA',
last_used_at: '2024-01-01T12:00:00Z',
created_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z',
is_current: true,
};
const otherSession: Session = {
...currentSession,
id: '2',
device_name: 'Firefox on Windows',
location_city: 'New York',
is_current: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders session information', () => {
render(<SessionCard session={currentSession} onRevoke={mockOnRevoke} />);
expect(screen.getByText('Chrome on Mac')).toBeInTheDocument();
expect(screen.getByText(/San Francisco.*USA/)).toBeInTheDocument();
expect(screen.getByText('192.168.1.1')).toBeInTheDocument();
});
it('shows current session badge', () => {
render(<SessionCard session={currentSession} onRevoke={mockOnRevoke} />);
expect(screen.getByText('Current Session')).toBeInTheDocument();
});
it('does not show revoke button for current session', () => {
render(<SessionCard session={currentSession} onRevoke={mockOnRevoke} />);
expect(screen.queryByRole('button', { name: /revoke/i })).not.toBeInTheDocument();
});
it('shows revoke button for other sessions', () => {
render(<SessionCard session={otherSession} onRevoke={mockOnRevoke} />);
expect(screen.getByRole('button', { name: /revoke/i })).toBeInTheDocument();
});
it('opens confirmation dialog on revoke click', async () => {
render(<SessionCard session={otherSession} onRevoke={mockOnRevoke} />);
fireEvent.click(screen.getByRole('button', { name: /revoke/i }));
await waitFor(() => {
expect(screen.getByText('Revoke Session?')).toBeInTheDocument();
});
});
it('calls onRevoke when confirmed', async () => {
render(<SessionCard session={otherSession} onRevoke={mockOnRevoke} />);
fireEvent.click(screen.getByRole('button', { name: /revoke/i }));
await waitFor(() => {
expect(screen.getByText('Revoke Session?')).toBeInTheDocument();
});
const confirmButton = screen.getByRole('button', { name: /revoke session/i });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockOnRevoke).toHaveBeenCalledWith('2');
});
});
it('closes dialog on cancel', async () => {
render(<SessionCard session={otherSession} onRevoke={mockOnRevoke} />);
fireEvent.click(screen.getByRole('button', { name: /revoke/i }));
await waitFor(() => {
expect(screen.getByText('Revoke Session?')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(screen.queryByText('Revoke Session?')).not.toBeInTheDocument();
});
expect(mockOnRevoke).not.toHaveBeenCalled();
});
it('handles missing location gracefully', () => {
const sessionWithoutLocation: Session = {
...otherSession,
location_city: null,
location_country: null,
};
render(<SessionCard session={sessionWithoutLocation} onRevoke={mockOnRevoke} />);
expect(screen.queryByText(/San Francisco/)).not.toBeInTheDocument();
// Component hides location when unknown, so it should not be displayed
expect(screen.queryByText(/location/i)).not.toBeInTheDocument();
});
it('handles missing device name gracefully', () => {
const sessionWithoutDevice: Session = {
...otherSession,
device_name: null,
};
render(<SessionCard session={sessionWithoutDevice} onRevoke={mockOnRevoke} />);
expect(screen.getByText('Unknown device')).toBeInTheDocument();
});
it('disables revoke button while revoking', () => {
render(<SessionCard session={otherSession} onRevoke={mockOnRevoke} isRevoking />);
const revokeButton = screen.getByRole('button', { name: /revoke/i });
expect(revokeButton).toBeDisabled();
});
it('highlights current session with border', () => {
const { container } = render(
<SessionCard session={currentSession} onRevoke={mockOnRevoke} />
);
const card = container.querySelector('.border-primary');
expect(card).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,258 @@
/**
* Tests for SessionsManager Component
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SessionsManager } from '@/components/settings/SessionsManager';
import * as useSessionModule from '@/lib/api/hooks/useSession';
jest.mock('@/lib/api/hooks/useSession');
jest.mock('sonner', () => ({ toast: { success: jest.fn(), error: jest.fn() } }));
const mockUseListSessions = useSessionModule.useListSessions as jest.Mock;
const mockUseRevokeSession = useSessionModule.useRevokeSession as jest.Mock;
const mockUseRevokeAllOtherSessions = useSessionModule.useRevokeAllOtherSessions as jest.Mock;
describe('SessionsManager', () => {
let queryClient: QueryClient;
const mockRevokeMutate = jest.fn();
const mockRevokeAllMutate = jest.fn();
const mockSessions = [
{
id: '1',
device_name: 'Chrome on Mac',
ip_address: '192.168.1.1',
location_city: 'San Francisco',
location_country: 'USA',
last_used_at: '2024-01-01T12:00:00Z',
created_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z',
is_current: true,
},
{
id: '2',
device_name: 'Firefox on Windows',
ip_address: '192.168.1.2',
location_city: 'New York',
location_country: 'USA',
last_used_at: '2024-01-01T11:00:00Z',
created_at: '2023-12-31T00:00:00Z',
expires_at: '2024-01-07T00:00:00Z',
is_current: false,
},
];
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
jest.clearAllMocks();
mockUseRevokeSession.mockReturnValue({
mutate: mockRevokeMutate,
isPending: false,
});
mockUseRevokeAllOtherSessions.mockReturnValue({
mutate: mockRevokeAllMutate,
isPending: false,
});
});
const renderWithProvider = (component: React.ReactElement) =>
render(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>);
it('shows loading state', () => {
mockUseListSessions.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
});
renderWithProvider(<SessionsManager />);
expect(screen.getByText(/loading your active sessions/i)).toBeInTheDocument();
});
it('shows error state', () => {
mockUseListSessions.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to load'),
});
renderWithProvider(<SessionsManager />);
expect(screen.getByText(/unable to load your sessions/i)).toBeInTheDocument();
});
it('renders sessions list', () => {
mockUseListSessions.mockReturnValue({
data: mockSessions,
isLoading: false,
error: null,
});
renderWithProvider(<SessionsManager />);
expect(screen.getByText('Chrome on Mac')).toBeInTheDocument();
expect(screen.getByText('Firefox on Windows')).toBeInTheDocument();
});
it('shows "Revoke All Others" button when multiple sessions exist', () => {
mockUseListSessions.mockReturnValue({
data: mockSessions,
isLoading: false,
error: null,
});
renderWithProvider(<SessionsManager />);
expect(screen.getByRole('button', { name: /revoke all others/i })).toBeInTheDocument();
});
it('does not show "Revoke All Others" when only current session', () => {
mockUseListSessions.mockReturnValue({
data: [mockSessions[0]], // Only current session
isLoading: false,
error: null,
});
renderWithProvider(<SessionsManager />);
expect(
screen.queryByRole('button', { name: /revoke all others/i })
).not.toBeInTheDocument();
});
it('opens bulk revoke dialog', async () => {
mockUseListSessions.mockReturnValue({
data: mockSessions,
isLoading: false,
error: null,
});
renderWithProvider(<SessionsManager />);
fireEvent.click(screen.getByRole('button', { name: /revoke all others/i }));
await waitFor(() => {
expect(screen.getByText('Revoke All Other Sessions?')).toBeInTheDocument();
});
});
it('calls bulk revoke when confirmed', async () => {
mockUseListSessions.mockReturnValue({
data: mockSessions,
isLoading: false,
error: null,
});
renderWithProvider(<SessionsManager />);
fireEvent.click(screen.getByRole('button', { name: /revoke all others/i }));
await waitFor(() => {
expect(screen.getByText('Revoke All Other Sessions?')).toBeInTheDocument();
});
// Find the destructive button in the dialog (not the "Cancel" button)
const buttons = screen.getAllByRole('button');
const confirmButton = buttons.find(btn => btn.textContent === 'Revoke All Others');
if (confirmButton) {
fireEvent.click(confirmButton);
}
expect(mockRevokeAllMutate).toHaveBeenCalled();
});
it('calls revoke when individual session revoke clicked', async () => {
mockUseListSessions.mockReturnValue({
data: mockSessions,
isLoading: false,
error: null,
});
renderWithProvider(<SessionsManager />);
// Click the individual "Revoke" button (not "Revoke All Others")
const revokeButtons = screen.getAllByRole('button', { name: /^revoke$/i });
fireEvent.click(revokeButtons[0]);
await waitFor(() => {
expect(screen.getByText('Revoke Session?')).toBeInTheDocument();
});
const confirmButton = screen.getByRole('button', { name: /revoke session/i });
fireEvent.click(confirmButton);
expect(mockRevokeMutate).toHaveBeenCalledWith('2');
});
it('shows empty state when no sessions', () => {
mockUseListSessions.mockReturnValue({
data: [],
isLoading: false,
error: null,
});
renderWithProvider(<SessionsManager />);
expect(screen.getByText('No active sessions to display')).toBeInTheDocument();
});
it('shows info message when only one session', () => {
mockUseListSessions.mockReturnValue({
data: [mockSessions[0]],
isLoading: false,
error: null,
});
renderWithProvider(<SessionsManager />);
expect(
screen.getByText(/you're viewing your only active session/i)
).toBeInTheDocument();
});
it('shows security tip', () => {
mockUseListSessions.mockReturnValue({
data: mockSessions,
isLoading: false,
error: null,
});
renderWithProvider(<SessionsManager />);
expect(screen.getByText(/security tip/i)).toBeInTheDocument();
expect(
screen.getByText(/if you see a session you don't recognize/i)
).toBeInTheDocument();
});
it('closes bulk revoke dialog on cancel', async () => {
mockUseListSessions.mockReturnValue({
data: mockSessions,
isLoading: false,
error: null,
});
renderWithProvider(<SessionsManager />);
fireEvent.click(screen.getByRole('button', { name: /revoke all others/i }));
await waitFor(() => {
expect(screen.getByText('Revoke All Other Sessions?')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(screen.queryByText('Revoke All Other Sessions?')).not.toBeInTheDocument();
});
});
});