datajunction-ui
Version:
DataJunction UI
643 lines (515 loc) • 18.3 kB
JSX
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { SettingsPage } from '../index';
import DJClientContext from '../../../providers/djclient';
import { UserProvider } from '../../../providers/UserProvider';
describe('SettingsPage', () => {
const mockDjClient = {
whoami: vi.fn(),
getNotificationPreferences: vi.fn(),
getNodesByNames: vi.fn(),
listServiceAccounts: vi.fn(),
subscribeToNotifications: vi.fn(),
unsubscribeFromNotifications: vi.fn(),
createServiceAccount: vi.fn(),
deleteServiceAccount: vi.fn(),
};
const renderWithContext = () => {
return render(
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<UserProvider>
<SettingsPage />
</UserProvider>
</DJClientContext.Provider>,
);
};
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementations
mockDjClient.whoami.mockResolvedValue({
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
});
mockDjClient.getNotificationPreferences.mockResolvedValue([]);
mockDjClient.getNodesByNames.mockResolvedValue([]);
mockDjClient.listServiceAccounts.mockResolvedValue([]);
});
it('shows loading state initially', () => {
// Make whoami hang
mockDjClient.whoami.mockImplementation(() => new Promise(() => {}));
renderWithContext();
// Should show loading icon (or some loading indicator)
expect(document.querySelector('.settings-page')).toBeInTheDocument();
});
it('renders all sections after loading', async () => {
renderWithContext();
await waitFor(() => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
expect(screen.getByText('Profile')).toBeInTheDocument();
expect(screen.getByText('Notification Subscriptions')).toBeInTheDocument();
expect(screen.getByText('Service Accounts')).toBeInTheDocument();
});
it('fetches and displays user profile', async () => {
mockDjClient.whoami.mockResolvedValue({
username: 'alice',
email: 'alice@example.com',
name: 'Alice Smith',
});
renderWithContext();
await waitFor(() => {
expect(screen.getByText('alice')).toBeInTheDocument();
});
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
expect(screen.getByText('AS')).toBeInTheDocument(); // initials
});
it('fetches and displays subscriptions', async () => {
mockDjClient.getNotificationPreferences.mockResolvedValue([
{
entity_name: 'default.my_metric',
entity_type: 'node',
activity_types: ['update'],
},
]);
mockDjClient.getNodesByNames.mockResolvedValue([
{
name: 'default.my_metric',
type: 'METRIC',
current: {
displayName: 'My Metric',
status: 'VALID',
mode: 'PUBLISHED',
},
},
]);
renderWithContext();
await waitFor(() => {
expect(screen.getByText('default.my_metric')).toBeInTheDocument();
});
});
it('fetches and displays service accounts', async () => {
mockDjClient.listServiceAccounts.mockResolvedValue([
{
id: 1,
name: 'my-pipeline',
client_id: 'abc-123',
created_at: '2024-12-01T00:00:00Z',
},
]);
renderWithContext();
await waitFor(() => {
expect(screen.getByText('my-pipeline')).toBeInTheDocument();
});
expect(screen.getByText('abc-123')).toBeInTheDocument();
});
it('handles service accounts API error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation();
mockDjClient.listServiceAccounts.mockRejectedValue(
new Error('Not available'),
);
renderWithContext();
await waitFor(() => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
// Page should still render without service accounts
expect(screen.getByText(/No service accounts yet/i)).toBeInTheDocument();
consoleSpy.mockRestore();
});
it('handles fetch error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation();
mockDjClient.whoami.mockRejectedValue(new Error('Network error'));
renderWithContext();
await waitFor(() => {
// Page should render even after error
expect(document.querySelector('.settings-page')).toBeInTheDocument();
});
consoleSpy.mockRestore();
});
it('enriches subscriptions with node info from GraphQL', async () => {
mockDjClient.getNotificationPreferences.mockResolvedValue([
{
entity_name: 'default.orders',
entity_type: 'node',
activity_types: ['update'],
},
]);
mockDjClient.getNodesByNames.mockResolvedValue([
{
name: 'default.orders',
type: 'SOURCE',
current: {
displayName: 'Orders Table',
status: 'VALID',
},
},
]);
renderWithContext();
await waitFor(() => {
expect(screen.getByText('SOURCE')).toBeInTheDocument();
});
});
it('handles subscription update via edit mode and checkbox toggle', async () => {
mockDjClient.getNotificationPreferences.mockResolvedValue([
{
entity_name: 'default.my_metric',
entity_type: 'node',
activity_types: ['update'],
alert_types: ['web'],
},
]);
mockDjClient.getNodesByNames.mockResolvedValue([
{
name: 'default.my_metric',
type: 'METRIC',
current: {
displayName: 'My Metric',
status: 'VALID',
mode: 'PUBLISHED',
},
},
]);
mockDjClient.subscribeToNotifications.mockResolvedValue({
status: 200,
json: { message: 'Subscribed' },
});
renderWithContext();
await waitFor(() => {
expect(screen.getByText('default.my_metric')).toBeInTheDocument();
});
// First click Edit button to enter edit mode and show checkboxes
const editBtn = screen.getByTitle('Edit subscription');
fireEvent.click(editBtn);
// Wait for checkboxes to appear
await waitFor(() => {
expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
});
// Add another activity type (e.g., 'create')
const createCheckbox = screen.getByLabelText('Create');
fireEvent.click(createCheckbox);
// Save changes
const saveBtn = screen.getByText('Save');
fireEvent.click(saveBtn);
await waitFor(() => {
expect(mockDjClient.subscribeToNotifications).toHaveBeenCalledWith({
entity_type: 'node',
entity_name: 'default.my_metric',
activity_types: expect.any(Array),
alert_types: ['web'],
});
});
});
it('updates local subscription state after subscription update', async () => {
mockDjClient.getNotificationPreferences.mockResolvedValue([
{
entity_name: 'default.my_metric',
entity_type: 'node',
activity_types: ['update', 'status_change'],
alert_types: ['web'],
},
]);
mockDjClient.getNodesByNames.mockResolvedValue([
{
name: 'default.my_metric',
type: 'METRIC',
current: {
displayName: 'My Metric',
status: 'VALID',
mode: 'PUBLISHED',
},
},
]);
mockDjClient.subscribeToNotifications.mockResolvedValue({
status: 200,
json: { message: 'Updated' },
});
renderWithContext();
await waitFor(() => {
expect(screen.getByText('default.my_metric')).toBeInTheDocument();
});
// First click the Edit button to enter edit mode
const editBtn = screen.getByTitle('Edit subscription');
fireEvent.click(editBtn);
// Now checkboxes should be visible
await waitFor(() => {
expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
});
// Toggle a checkbox and save
const checkboxes = screen.getAllByRole('checkbox');
fireEvent.click(checkboxes[0]);
// Click Save button
const saveBtn = screen.getByText('Save');
fireEvent.click(saveBtn);
await waitFor(() => {
expect(mockDjClient.subscribeToNotifications).toHaveBeenCalled();
});
});
it('handles subscription unsubscribe and removes from list', async () => {
// Mock window.confirm to return true
const originalConfirm = window.confirm;
window.confirm = vi.fn().mockReturnValue(true);
mockDjClient.getNotificationPreferences.mockResolvedValue([
{
entity_name: 'default.my_metric',
entity_type: 'node',
activity_types: ['update'],
alert_types: ['web'],
},
{
entity_name: 'default.another_metric',
entity_type: 'node',
activity_types: ['status_change'],
alert_types: ['web'],
},
]);
mockDjClient.getNodesByNames.mockResolvedValue([
{
name: 'default.my_metric',
type: 'METRIC',
current: {
displayName: 'My Metric',
status: 'VALID',
mode: 'PUBLISHED',
},
},
{
name: 'default.another_metric',
type: 'METRIC',
current: {
displayName: 'Another Metric',
status: 'VALID',
mode: 'PUBLISHED',
},
},
]);
mockDjClient.unsubscribeFromNotifications.mockResolvedValue({
status: 200,
json: { message: 'Unsubscribed' },
});
renderWithContext();
await waitFor(() => {
expect(screen.getByText('default.my_metric')).toBeInTheDocument();
expect(screen.getByText('default.another_metric')).toBeInTheDocument();
});
// Find unsubscribe buttons (there are multiple, one per subscription)
const unsubscribeBtns = screen.getAllByTitle('Unsubscribe');
fireEvent.click(unsubscribeBtns[0]);
await waitFor(() => {
expect(window.confirm).toHaveBeenCalled();
expect(mockDjClient.unsubscribeFromNotifications).toHaveBeenCalled();
});
// Restore original confirm
window.confirm = originalConfirm;
});
it('opens create service account modal', async () => {
renderWithContext();
await waitFor(() => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
// Find and click create button
const createBtn = screen.getByRole('button', { name: /create/i });
fireEvent.click(createBtn);
// Modal should be visible
await waitFor(() => {
expect(screen.getByText('Create Service Account')).toBeInTheDocument();
});
});
it('creates service account and adds to list when successful', async () => {
const newAccount = {
id: 99,
name: 'new-account',
client_id: 'new-client-id-123',
client_secret: 'secret-xyz',
created_at: '2025-01-11T00:00:00Z',
};
mockDjClient.listServiceAccounts.mockResolvedValue([]);
mockDjClient.createServiceAccount.mockResolvedValue(newAccount);
renderWithContext();
await waitFor(() => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
// Open create modal
const createBtn = screen.getByRole('button', { name: /create/i });
fireEvent.click(createBtn);
await waitFor(() => {
expect(screen.getByText('Create Service Account')).toBeInTheDocument();
});
// Fill in the name input using ID
const nameInput = document.getElementById('service-account-name');
fireEvent.change(nameInput, { target: { value: 'new-account' } });
// Submit the form by clicking the submit button in the modal
const submitBtns = screen.getAllByRole('button', { name: /create/i });
// The second Create button is the submit button in the modal
fireEvent.click(submitBtns[submitBtns.length - 1]);
await waitFor(() => {
expect(mockDjClient.createServiceAccount).toHaveBeenCalledWith(
'new-account',
);
});
});
it('does not add service account to list if creation returns no client_id', async () => {
mockDjClient.listServiceAccounts.mockResolvedValue([]);
mockDjClient.createServiceAccount.mockResolvedValue({
error: 'Name already exists',
});
renderWithContext();
await waitFor(() => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
// The service accounts section should still show empty state
expect(screen.getByText(/No service accounts yet/i)).toBeInTheDocument();
});
it('handles service account deletion by clicking delete button with confirmation', async () => {
// Mock window.confirm to return true
const originalConfirm = window.confirm;
window.confirm = vi.fn().mockReturnValue(true);
mockDjClient.listServiceAccounts.mockResolvedValue([
{
id: 1,
name: 'my-pipeline',
client_id: 'abc-123',
created_at: '2024-12-01T00:00:00Z',
},
{
id: 2,
name: 'other-pipeline',
client_id: 'def-456',
created_at: '2024-12-02T00:00:00Z',
},
]);
mockDjClient.deleteServiceAccount.mockResolvedValue({
message: 'Deleted',
});
renderWithContext();
await waitFor(() => {
expect(screen.getByText('my-pipeline')).toBeInTheDocument();
expect(screen.getByText('other-pipeline')).toBeInTheDocument();
});
// Find delete button by title attribute (exact match) - first one for my-pipeline
const deleteBtn = screen.getAllByTitle('Delete service account')[0];
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(window.confirm).toHaveBeenCalled();
expect(mockDjClient.deleteServiceAccount).toHaveBeenCalledWith('abc-123');
});
// Restore original confirm
window.confirm = originalConfirm;
});
it('removes service account from list after deletion', async () => {
// Mock window.confirm to return true
const originalConfirm = window.confirm;
window.confirm = vi.fn().mockReturnValue(true);
mockDjClient.listServiceAccounts.mockResolvedValue([
{
id: 1,
name: 'my-pipeline',
client_id: 'abc-123',
created_at: '2024-12-01T00:00:00Z',
},
]);
mockDjClient.deleteServiceAccount.mockResolvedValue({
message: 'Deleted',
});
renderWithContext();
await waitFor(() => {
expect(screen.getByText('my-pipeline')).toBeInTheDocument();
});
// Find and click the delete button
const deleteBtn = screen.getByTitle('Delete service account');
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(mockDjClient.deleteServiceAccount).toHaveBeenCalledWith('abc-123');
});
// After deletion, the account should be removed
await waitFor(() => {
expect(screen.queryByText('my-pipeline')).not.toBeInTheDocument();
});
// Restore original confirm
window.confirm = originalConfirm;
});
it('does not delete service account when confirmation is cancelled', async () => {
// Mock window.confirm to return false
const originalConfirm = window.confirm;
window.confirm = vi.fn().mockReturnValue(false);
mockDjClient.listServiceAccounts.mockResolvedValue([
{
id: 1,
name: 'my-pipeline',
client_id: 'abc-123',
created_at: '2024-12-01T00:00:00Z',
},
]);
renderWithContext();
await waitFor(() => {
expect(screen.getByText('my-pipeline')).toBeInTheDocument();
});
// Find and click the delete button
const deleteBtn = screen.getByTitle('Delete service account');
fireEvent.click(deleteBtn);
// Should show confirmation
expect(window.confirm).toHaveBeenCalled();
// deleteServiceAccount should NOT be called since user cancelled
expect(mockDjClient.deleteServiceAccount).not.toHaveBeenCalled();
// Account should still be in the list
expect(screen.getByText('my-pipeline')).toBeInTheDocument();
// Restore original confirm
window.confirm = originalConfirm;
});
it('handles non-node subscription types gracefully', async () => {
mockDjClient.getNotificationPreferences.mockResolvedValue([
{
entity_name: 'namespace.test',
entity_type: 'namespace',
activity_types: ['create'],
alert_types: ['web'],
},
]);
renderWithContext();
await waitFor(() => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
// Namespace subscription should still appear
await waitFor(() => {
expect(screen.getByText('namespace.test')).toBeInTheDocument();
});
});
it('skips fetching if userLoading', async () => {
// When user is still loading, the component waits
mockDjClient.whoami.mockImplementation(() => new Promise(() => {}));
renderWithContext();
// getNotificationPreferences should not be called while user is loading
expect(mockDjClient.getNotificationPreferences).not.toHaveBeenCalled();
});
it('handles notification preferences fetch error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation();
mockDjClient.getNotificationPreferences.mockRejectedValue(
new Error('Failed to fetch preferences'),
);
renderWithContext();
await waitFor(() => {
// Page should still render after error
expect(screen.getByText('Settings')).toBeInTheDocument();
});
// Error should be logged
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('handles null notification preferences response', async () => {
mockDjClient.getNotificationPreferences.mockResolvedValue(null);
renderWithContext();
await waitFor(() => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
// Should render subscriptions section with empty list
expect(screen.getByText(/not watching any nodes yet/i)).toBeInTheDocument();
});
it('handles null service accounts response', async () => {
mockDjClient.listServiceAccounts.mockResolvedValue(null);
renderWithContext();
await waitFor(() => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
// Should render service accounts section with empty list
expect(screen.getByText(/No service accounts yet/i)).toBeInTheDocument();
});
});