I've added comprehensive unit and integration tests for the recently implemented Slack connector features. This includes tests for backend logic (SlackHistory, indexing tasks, API routes) and frontend components and hooks.

Backend Tests (`surfsense_backend`):
- `test_slack_history.py`:
    - Tests for `SlackHistory` class methods including `get_all_channels`,
      `get_conversation_history`, timestamp conversions, and message
      formatting. Covers various API response scenarios, pagination,
      and error handling.
- `test_connectors_indexing_tasks.py`:
    - Unit tests for `index_slack_messages` task.
    - Scenarios include initial indexing, periodic indexing, on-demand
      re-indexing (targeted channels, force re-index, custom dates),
      channel filtering, and error handling.
    - Mocks `SlackHistory` and database interactions.
- `test_search_source_connectors_routes.py`:
    - Tests for `/slack/{connector_id}/discover-channels` endpoint.
    - Tests for `/slack/{connector_id}/reindex-channels` endpoint,
      verifying parameter passing to the background task.
    - Tests for the main `/search-source-connectors/{connector_id}/index`
      endpoint for Slack connectors, ensuring the `force_full_reindex`
      flag is handled.

Frontend Tests (`surfsense_web`):
- `components/editConnector/EditSlackConnectorConfigForm.test.tsx`:
    - Tests rendering of all Slack configuration fields.
    - Verifies `onConfigChange` callback functionality.
    - Tests conditional rendering of periodic indexing settings.
    - Checks disabled state of the form.
- `app/dashboard/[search_space_id]/connectors/[connector_id]/edit/page.test.tsx`:
    - Tests for the "Channel Management" tab specific to Slack connectors.
    - Verifies UI for discovering channels (mocking API calls).
    - Tests selection of channels and updating the connector configuration.
    - Tests UI for triggering on-demand re-indexing with various options.
- `hooks/useSearchSourceConnectors.test.ts`:
    - Unit tests for the `discoverSlackChannels` function, mocking `fetch`.
    - Unit tests for the `reindexSlackChannels` function, mocking `fetch`
      and verifying correct request body construction.
    - Includes testing of the `fetchWithAuth` helper function.
This commit is contained in:
google-labs-jules[bot] 2025-05-28 10:48:28 +00:00
parent d1f11d4fbb
commit 4bf09b8bde
6 changed files with 1804 additions and 387 deletions

View file

@ -0,0 +1,302 @@
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditConnectorPage from './page'; // Adjust path if necessary
import { useConnectorEditPage } from '@/hooks/useConnectorEditPage'; // Mock this hook
import { SearchSourceConnector } from '@/hooks/useSearchSourceConnectors'; // For types
import { toast } from 'sonner'; // Mock toast
// Mock Next.js router and params
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
}),
useParams: () => ({
search_space_id: '1',
connector_id: '1', // Default for most tests, can be overridden in mock setup
}),
}));
// Mock the custom hook
jest.mock('@/hooks/useConnectorEditPage');
// Mock sonner
jest.mock('sonner', () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
},
}));
const mockDefaultForm = {
control: {} as any, // Basic mock for form control
handleSubmit: (fn: any) => (e: any) => { e.preventDefault(); fn(); },
setValue: jest.fn(),
getValues: jest.fn((key) => {
if (key === 'config') return mockUseConnectorEditPageValues.connector?.config || {};
return undefined;
}),
watch: jest.fn((key, defaultValue) => {
if (key === 'config.slack_membership_filter_type') {
return mockUseConnectorEditPageValues.connector?.config?.slack_membership_filter_type || defaultValue;
}
return defaultValue;
}),
formState: { errors: {} },
} as any;
let mockUseConnectorEditPageValues: any;
const setupMockHook = (connectorData: Partial<SearchSourceConnector> | null) => {
const baseConnector: SearchSourceConnector = {
id: 1,
name: 'Test Connector',
connector_type: 'GENERIC',
is_indexable: true,
last_indexed_at: null,
config: {},
user_id: 'user1',
created_at: new Date().toISOString(),
...connectorData,
};
mockUseConnectorEditPageValues = {
connectorsLoading: false,
connector: connectorData ? baseConnector : null,
isSaving: false,
editForm: { ...mockDefaultForm, getValues: jest.fn((key) => { // Ensure getValues is fresh
if (key === 'config') return baseConnector.config || {};
return undefined;
}), watch: jest.fn((key, defaultValue) => {
if (key === 'config.slack_membership_filter_type') {
return baseConnector.config?.slack_membership_filter_type || defaultValue;
}
return defaultValue;
})
},
patForm: { ...mockDefaultForm },
handleSaveChanges: jest.fn(),
// GitHub specific (not primary focus but part of hook)
editMode: false, setEditMode: jest.fn(), originalPat: '', currentSelectedRepos: [],
fetchedRepos: [], setFetchedRepos: jest.fn(), newSelectedRepos: [], setNewSelectedRepos: jest.fn(),
isFetchingRepos: false, handleFetchRepositories: jest.fn(), handleRepoSelectionChange: jest.fn(),
// Placeholder for Slack specific functions - these would be part of useSearchSourceConnectors usually
// and then exposed via useConnectorEditPage or called directly if page uses useSearchSourceConnectors
discoverSlackChannels: jest.fn(),
triggerSlackReindex: jest.fn(),
};
(useConnectorEditPage as jest.Mock).mockReturnValue(mockUseConnectorEditPageValues);
};
describe('EditConnectorPage - Slack Channel Management', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Tab Visibility', () => {
test('shows "Channel Management" tab only for SLACK_CONNECTOR', () => {
setupMockHook({ connector_type: 'SLACK_CONNECTOR', config: { SLACK_BOT_TOKEN: 'token' } });
render(<EditConnectorPage />);
expect(screen.getByText('Configuration')).toBeInTheDocument();
expect(screen.getByText('Channel Management')).toBeInTheDocument();
expect(screen.getByText('Channel Management')).not.toBeDisabled();
});
test('disables "Channel Management" tab for non-Slack connectors', () => {
setupMockHook({ connector_type: 'GITHUB_CONNECTOR' });
render(<EditConnectorPage />);
expect(screen.getByText('Configuration')).toBeInTheDocument();
expect(screen.getByText('Channel Management')).toBeInTheDocument(); // Tab exists but is disabled
expect(screen.getByText('Channel Management')).toHaveAttribute('aria-disabled', 'true');
});
});
describe('Granular Channel Selection UI (SLACK_CONNECTOR)', () => {
const slackConnectorBase = {
connector_type: 'SLACK_CONNECTOR',
config: {
SLACK_BOT_TOKEN: 'valid-token',
slack_membership_filter_type: 'selected_member_channels', // Default to selected for these tests
slack_selected_channel_ids: [],
}
};
test('shows channel selection UI when type is "selected_member_channels"', async () => {
setupMockHook(slackConnectorBase);
render(<EditConnectorPage />);
await userEvent.click(screen.getByText('Channel Management')); // Navigate to tab
expect(screen.getByText('Granular Channel Selection')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Discover & Select Channels/i })).toBeInTheDocument();
});
test('shows "All Channels Mode" message when type is "all_member_channels"', async () => {
setupMockHook({
...slackConnectorBase,
config: { ...slackConnectorBase.config, slack_membership_filter_type: 'all_member_channels' }
});
render(<EditConnectorPage />);
await userEvent.click(screen.getByText('Channel Management'));
expect(screen.getByText('All Channels Mode')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Discover & Select Channels/i })).not.toBeInTheDocument();
});
test('discovering channels populates table and handles loading state', async () => {
setupMockHook(slackConnectorBase);
const mockDiscoveredChannels = [
{ id: 'C1', name: 'General', is_private: false, is_member: true },
{ id: 'C2', name: 'Random', is_private: false, is_member: true },
];
// For this test, we assume handleDiscoverChannels is part of the page component itself, not the hook
// In a real scenario, this might be:
// mockUseConnectorEditPageValues.discoverSlackChannels.mockResolvedValue(mockDiscoveredChannels);
// For now, we'll check the button click and assume the mocked console.log from page implementation
render(<EditConnectorPage />);
await userEvent.click(screen.getByText('Channel Management'));
const discoverButton = screen.getByRole('button', { name: /Discover & Select Channels/i });
// Simulate the page's handleDiscoverChannels
// This is a bit of a workaround as the function is internal to the component
// A better test would mock the API call if discoverSlackChannels was from the hook
// For now, we assert the button is there and would trigger the internal logic
expect(discoverButton).not.toBeDisabled();
await userEvent.click(discoverButton);
// Since handleDiscoverChannels is internal and uses setTimeout for mock, we need to wait
// This relies on the mock implementation within EditConnectorPage.tsx
expect(await screen.findByText('General')).toBeInTheDocument(); // Wait for table to populate
expect(screen.getByText('Random')).toBeInTheDocument();
expect(toast.success).toHaveBeenCalledWith("Discovered 2 channels where bot is a member.");
});
test('selecting channels and clicking "Update Channel Selection" calls setValue', async () => {
setupMockHook({
...slackConnectorBase,
config: { ...slackConnectorBase.config, slack_selected_channel_ids: ['C1'] } // Pre-select one
});
render(<EditConnectorPage />);
await userEvent.click(screen.getByText('Channel Management'));
// First, discover channels to populate the table
const discoverButton = screen.getByRole('button', { name: /Discover & Select Channels/i });
await userEvent.click(discoverButton);
await screen.findByText('General'); // Wait for table
const checkboxGeneral = screen.getByRole('checkbox', { name: /select channel General/i }); // Assuming aria-label for checkbox
const checkboxRandom = screen.getByRole('checkbox', { name: /select channel Random/i });
// Initial state from config
expect(checkboxGeneral).toBeChecked();
expect(checkboxRandom).not.toBeChecked();
// Deselect General, Select Random
await userEvent.click(checkboxGeneral);
await userEvent.click(checkboxRandom);
const updateButton = screen.getByRole('button', { name: /Update Channel Selection in Config/i });
await userEvent.click(updateButton);
expect(mockUseConnectorEditPageValues.editForm.setValue).toHaveBeenCalledWith(
'config',
expect.objectContaining({ slack_selected_channel_ids: ['C2'] }), // C1 deselected, C2 selected
{ shouldValidate: true, shouldDirty: true }
);
expect(toast.success).toHaveBeenCalledWith("Channel selection updated. Save changes to persist.");
});
});
describe('On-Demand Re-indexing UI (SLACK_CONNECTOR)', () => {
const slackConnectorWithChannels = {
connector_type: 'SLACK_CONNECTOR',
config: {
SLACK_BOT_TOKEN: 'valid-token',
slack_membership_filter_type: 'selected_member_channels',
slack_selected_channel_ids: ['C1_CONFIG', 'C2_CONFIG'], // Channels from config
}
};
test('displays channels for re-indexing and triggers reindex call', async () => {
setupMockHook(slackConnectorWithChannels);
// mockUseConnectorEditPageValues.triggerSlackReindex.mockResolvedValue({ message: "Re-index started" });
render(<EditConnectorPage />);
await userEvent.click(screen.getByText('Channel Management'));
// Channels from config should be listed
expect(screen.getByText('Known ID: C1_CONFIG')).toBeInTheDocument();
expect(screen.getByText('Known ID: C2_CONFIG')).toBeInTheDocument();
const reindexCheckboxC1 = screen.getByRole('checkbox', { name: /select channel Known ID: C1_CONFIG/i });
await userEvent.click(reindexCheckboxC1); // Select C1 for re-index
const forceReindexCheckbox = screen.getByLabelText(/Full Re-index/i);
await userEvent.click(forceReindexCheckbox); // Enable force re-index
const startDateInput = screen.getByLabelText(/Re-index Start Date/i);
const latestDateInput = screen.getByLabelText(/Re-index Latest Date/i);
await userEvent.type(startDateInput, '2023-01-01');
await userEvent.type(latestDateInput, '2023-01-31');
const reindexButton = screen.getByRole('button', { name: /Re-index Selected Channels/i });
expect(reindexButton).not.toBeDisabled();
await userEvent.click(reindexButton);
// Verify the internal handleTriggerReindex was called (via console.log or toast in mock)
// This relies on the mocked implementation within the page.
// A direct mock of an API call function from the hook would be better.
expect(toast.info).toHaveBeenCalledWith("Triggering re-indexing for selected channels...");
// We can't directly assert on `hookTriggerSlackReindex` as it's not directly called by the component
// but by the internal `handleTriggerReindex`.
// The console.log in the component's handleTriggerReindex would show:
// Re-indexing payload: { channel_ids: ['C1_CONFIG'], force_reindex_all_messages: true, reindex_start_date: '2023-01-01', reindex_latest_date: '2023-01-31' }
// Wait for mocked async operation in handleTriggerReindex
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Re-indexing task scheduled successfully.");
});
});
test('re-index button is disabled if no channels are selected', async () => {
setupMockHook(slackConnectorWithChannels);
render(<EditConnectorPage />);
await userEvent.click(screen.getByText('Channel Management'));
const reindexButton = screen.getByRole('button', { name: /Re-index Selected Channels/i });
expect(reindexButton).toBeDisabled(); // Initially disabled
const reindexCheckboxC1 = screen.getByRole('checkbox', { name: /select channel Known ID: C1_CONFIG/i });
await userEvent.click(reindexCheckboxC1); // Select one
expect(reindexButton).not.toBeDisabled();
await userEvent.click(reindexCheckboxC1); // Deselect again
expect(reindexButton).toBeDisabled();
});
test('date inputs for re-indexing are shown only when "Full Re-index" is checked', async () => {
setupMockHook(slackConnectorWithChannels);
render(<EditConnectorPage />);
await userEvent.click(screen.getByText('Channel Management'));
expect(screen.queryByLabelText(/Re-index Start Date/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Re-index Latest Date/i)).not.toBeInTheDocument();
const forceReindexCheckbox = screen.getByLabelText(/Full Re-index/i);
await userEvent.click(forceReindexCheckbox);
expect(screen.getByLabelText(/Re-index Start Date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Re-index Latest Date/i)).toBeInTheDocument();
await userEvent.click(forceReindexCheckbox); // Uncheck
expect(screen.queryByLabelText(/Re-index Start Date/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Re-index Latest Date/i)).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,226 @@
import React from 'react';
import { render, screen, fireEvent, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; // For more realistic interactions with Select
import EditSlackConnectorConfigForm from './EditSlackConnectorConfigForm';
import { SearchSourceConnector } from '@/hooks/useSearchSourceConnectors'; // Adjust path as needed
// Mock UI components that are not part of the core logic being tested, if necessary
// For ShadCN components, often direct interaction is fine, but sometimes they need setup.
// For this test, we'll assume direct interaction works.
// jest.mock("@/components/ui/input", () => (props: any) => <input {...props} data-testid={props.id || 'input-mock'} />);
// jest.mock("@/components/ui/checkbox", () => (props: any) => <input type="checkbox" {...props} data-testid={props.id || 'checkbox-mock'} />);
// jest.mock("@/components/ui/select", () => ({
// Select: (props: any) => <div data-testid={props.id || 'select-mock'}>{props.children}</div>,
// SelectTrigger: (props: any) => <button {...props}>{props.children}</button>,
// SelectValue: (props: any) => <div {...props}>{props.placeholder}</div>,
// SelectContent: (props: any) => <div {...props}>{props.children}</div>,
// SelectItem: (props: any) => <option value={props.value} {...props}>{props.children}</option>,
// }));
const initialMockConfig = {
SLACK_BOT_TOKEN: 'xoxb-initial-token',
slack_membership_filter_type: 'all_member_channels',
slack_selected_channel_ids: ['C123', 'C456'],
slack_initial_indexing_days: 30,
slack_initial_max_messages_per_channel: 1000,
slack_periodic_indexing_enabled: false,
slack_periodic_indexing_frequency: 'daily',
slack_max_messages_per_channel_periodic: 100,
};
const mockConnector: SearchSourceConnector = {
id: 1,
name: 'Slack Test Connector',
connector_type: 'SLACK_CONNECTOR',
is_indexable: true,
last_indexed_at: null,
config: { ...initialMockConfig },
user_id: 'user1',
created_at: new Date().toISOString(),
};
describe('EditSlackConnectorConfigForm', () => {
let mockOnConfigChange: jest.Mock;
beforeEach(() => {
mockOnConfigChange = jest.fn();
});
test('renders all form fields correctly with initial values', () => {
render(
<EditSlackConnectorConfigForm
connector={mockConnector}
onConfigChange={mockOnConfigChange}
disabled={false}
/>
);
// Authentication
expect(screen.getByLabelText(/Slack Bot Token/i)).toHaveValue(initialMockConfig.SLACK_BOT_TOKEN);
// Initial Indexing Settings
expect(screen.getByText('Index All Channels Where Bot is Member')).toBeInTheDocument(); // For Select value display
expect(screen.getByText(/Channel selection is managed in the 'Channels' tab/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Initial Indexing Period \(days\)/i)).toHaveValue(initialMockConfig.slack_initial_indexing_days);
expect(screen.getByLabelText(/Max Messages Per Channel \(Initial Sync\)/i)).toHaveValue(initialMockConfig.slack_initial_max_messages_per_channel);
// Periodic Indexing Settings
const periodicCheckbox = screen.getByLabelText(/Enable Periodic Indexing/i) as HTMLInputElement;
expect(periodicCheckbox.checked).toBe(initialMockConfig.slack_periodic_indexing_enabled);
// Periodic fields should initially be hidden if checkbox is false
if (!initialMockConfig.slack_periodic_indexing_enabled) {
expect(screen.queryByLabelText(/Periodic Indexing Frequency/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Max Messages Per Channel \(Periodic Sync\)/i)).not.toBeInTheDocument();
}
});
test('calls onConfigChange with updated config for SLACK_BOT_TOKEN', () => {
render(
<EditSlackConnectorConfigForm
connector={mockConnector}
onConfigChange={mockOnConfigChange}
disabled={false}
/>
);
const tokenInput = screen.getByLabelText(/Slack Bot Token/i);
fireEvent.change(tokenInput, { target: { value: 'new-token' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({ SLACK_BOT_TOKEN: 'new-token' })
);
});
test('calls onConfigChange for slack_initial_indexing_days', () => {
render(<EditSlackConnectorConfigForm connector={mockConnector} onConfigChange={mockOnConfigChange} disabled={false} />);
const input = screen.getByLabelText(/Initial Indexing Period \(days\)/i);
fireEvent.change(input, { target: { value: '10' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(expect.objectContaining({ slack_initial_indexing_days: 10 }));
fireEvent.change(input, { target: { value: '-1' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(expect.objectContaining({ slack_initial_indexing_days: -1 }));
fireEvent.change(input, { target: { value: '' } }); // Empty value
expect(mockOnConfigChange).toHaveBeenCalledWith(expect.objectContaining({ slack_initial_indexing_days: null }));
});
test('calls onConfigChange for slack_initial_max_messages_per_channel', () => {
render(<EditSlackConnectorConfigForm connector={mockConnector} onConfigChange={mockOnConfigChange} disabled={false} />);
const input = screen.getByLabelText(/Max Messages Per Channel \(Initial Sync\)/i);
fireEvent.change(input, { target: { value: '500' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(expect.objectContaining({ slack_initial_max_messages_per_channel: 500 }));
});
test('calls onConfigChange for slack_membership_filter_type', async () => {
render(<EditSlackConnectorConfigForm connector={mockConnector} onConfigChange={mockOnConfigChange} disabled={false} />);
// For ShadCN Select, we need to click the trigger, then the item.
// `userEvent` is better for this, but `fireEvent` can also work.
const selectTrigger = screen.getByRole('combobox', { name: /Channel Indexing Behavior/i });
await userEvent.click(selectTrigger);
// Assuming SelectItem roles are 'option' or similar, and they are now in the document
const option = await screen.findByText('Index Only Selected Channels'); // Wait for option to appear
await userEvent.click(option);
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({ slack_membership_filter_type: 'selected_member_channels' })
);
});
test('shows/hides periodic indexing fields and calls onConfigChange', async () => {
render(<EditSlackConnectorConfigForm connector={mockConnector} onConfigChange={mockOnConfigChange} disabled={false} />);
const periodicCheckbox = screen.getByLabelText(/Enable Periodic Indexing/i);
// Initially periodic fields are not visible (as per initialMockConfig)
expect(screen.queryByLabelText(/Periodic Indexing Frequency/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Max Messages Per Channel \(Periodic Sync\)/i)).not.toBeInTheDocument();
// Check the checkbox
fireEvent.click(periodicCheckbox);
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({ slack_periodic_indexing_enabled: true })
);
// Now the fields should be visible
const frequencySelect = await screen.findByLabelText(/Periodic Indexing Frequency/i); // Wait for it
const maxMessagesInput = screen.getByLabelText(/Max Messages Per Channel \(Periodic Sync\)/i);
expect(frequencySelect).toBeInTheDocument();
expect(maxMessagesInput).toBeInTheDocument();
// Change frequency
const freqSelectTrigger = screen.getByRole('combobox', { name: /Periodic Indexing Frequency/i });
await userEvent.click(freqSelectTrigger);
const weeklyOption = await screen.findByText('Weekly');
await userEvent.click(weeklyOption);
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({ slack_periodic_indexing_frequency: 'weekly', slack_periodic_indexing_enabled: true })
);
// Change max messages periodic
fireEvent.change(maxMessagesInput, { target: { value: '75' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({ slack_max_messages_per_channel_periodic: 75, slack_periodic_indexing_enabled: true })
);
// Uncheck the checkbox
fireEvent.click(periodicCheckbox);
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({ slack_periodic_indexing_enabled: false })
);
// Fields should be hidden again
expect(screen.queryByLabelText(/Periodic Indexing Frequency/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Max Messages Per Channel \(Periodic Sync\)/i)).not.toBeInTheDocument();
});
test('preserves other config values when one changes', () => {
render(<EditSlackConnectorConfigForm connector={mockConnector} onConfigChange={mockOnConfigChange} disabled={false} />);
const tokenInput = screen.getByLabelText(/Slack Bot Token/i);
fireEvent.change(tokenInput, { target: { value: 'new-xoxb-token' } });
expect(mockOnConfigChange).toHaveBeenCalledWith({
...initialMockConfig, // All original values
SLACK_BOT_TOKEN: 'new-xoxb-token', // Only this one changed
});
});
test('disables all form fields when disabled prop is true', () => {
render(<EditSlackConnectorConfigForm connector={mockConnector} onConfigChange={mockOnConfigChange} disabled={true} />);
expect(screen.getByLabelText(/Slack Bot Token/i)).toBeDisabled();
expect(screen.getByRole('combobox', { name: /Channel Indexing Behavior/i })).toBeDisabled();
expect(screen.getByLabelText(/Initial Indexing Period \(days\)/i)).toBeDisabled();
expect(screen.getByLabelText(/Max Messages Per Channel \(Initial Sync\)/i)).toBeDisabled();
expect(screen.getByLabelText(/Enable Periodic Indexing/i)).toBeDisabled();
// If periodic indexing was enabled by default in mock, check those too
const connectorWithPeriodicEnabled = {
...mockConnector,
config: { ...mockConnector.config, slack_periodic_indexing_enabled: true }
};
render(<EditSlackConnectorConfigForm connector={connectorWithPeriodicEnabled} onConfigChange={mockOnConfigChange} disabled={true} />);
expect(screen.getByRole('combobox', { name: /Periodic Indexing Frequency/i })).toBeDisabled();
expect(screen.getByLabelText(/Max Messages Per Channel \(Periodic Sync\)/i)).toBeDisabled();
});
test('handles empty string for number inputs correctly by converting to null', () => {
render(<EditSlackConnectorConfigForm connector={mockConnector} onConfigChange={mockOnConfigChange} disabled={false} />);
const initialDaysInput = screen.getByLabelText(/Initial Indexing Period \(days\)/i);
fireEvent.change(initialDaysInput, { target: { value: '' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(expect.objectContaining({ slack_initial_indexing_days: null }));
const initialMaxMessagesInput = screen.getByLabelText(/Max Messages Per Channel \(Initial Sync\)/i);
fireEvent.change(initialMaxMessagesInput, { target: { value: '' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(expect.objectContaining({ slack_initial_max_messages_per_channel: null }));
// Enable periodic to test that field
const periodicCheckbox = screen.getByLabelText(/Enable Periodic Indexing/i);
fireEvent.click(periodicCheckbox); // Enable
const periodicMaxMessagesInput = screen.getByLabelText(/Max Messages Per Channel \(Periodic Sync\)/i);
fireEvent.change(periodicMaxMessagesInput, { target: { value: '' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(expect.objectContaining({ slack_max_messages_per_channel_periodic: null }));
});
});

View file

@ -0,0 +1,247 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useSearchSourceConnectors, SlackChannelInfo } from './useSearchSourceConnectors'; // Adjust path as needed
// Helper to create a mock response for fetch
const mockFetchResponse = (data: any, ok: boolean = true, status: number = 200) => {
return Promise.resolve({
ok,
status,
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)), // For error messages
} as Response);
};
const mockFetchResponseNoContent = (ok: boolean = true, status: number = 202) => {
return Promise.resolve({
ok,
status,
json: () => Promise.reject(new Error("No JSON content")), // Should not be called for 202/204
text: () => Promise.resolve(""),
} as Response);
};
describe('useSearchSourceConnectors Hook - Slack Operations', () => {
let mockFetch: jest.SpyInstance;
let mockLocalStorageGetItem: jest.SpyInstance;
const mockApiUrl = 'http://localhost:8000/api/v1'; // Example, ensure it matches env
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL = mockApiUrl;
beforeEach(() => {
// Spy on global fetch
mockFetch = jest.spyOn(window, 'fetch');
// Mock localStorage
mockLocalStorageGetItem = jest.spyOn(Storage.prototype, 'getItem');
mockLocalStorageGetItem.mockReturnValue('test-token'); // Default to having a token
});
afterEach(() => {
mockFetch.mockRestore();
mockLocalStorageGetItem.mockRestore();
});
describe('discoverSlackChannels', () => {
test('successful API call returns channels and calls fetch correctly', async () => {
const mockChannelsData: { channels: SlackChannelInfo[] } = {
channels: [
{ id: 'C1', name: 'General', is_private: false, is_member: true },
{ id: 'C2', name: 'Random', is_private: true, is_member: true },
],
};
mockFetch.mockReturnValueOnce(mockFetchResponse(mockChannelsData));
const { result } = renderHook(() => useSearchSourceConnectors());
// Wait for initial connectors fetch to complete if any (though not directly tested here)
await waitFor(() => expect(result.current.isLoading).toBe(false));
let discoveredChannels: SlackChannelInfo[] = [];
await act(async () => {
discoveredChannels = await result.current.discoverSlackChannels(1);
});
expect(discoveredChannels).toEqual(mockChannelsData.channels);
expect(mockFetch).toHaveBeenCalledWith(
`${mockApiUrl}/slack/1/discover-channels`,
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'Authorization': 'Bearer test-token',
'Content-Type': 'application/json',
}),
})
);
});
test('API error throws an error', async () => {
mockFetch.mockReturnValueOnce(mockFetchResponse({ detail: 'API Error' }, false, 500));
const { result } = renderHook(() => useSearchSourceConnectors());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await expect(result.current.discoverSlackChannels(1)).rejects.toThrow(
/API request failed: Internal Server Error - {"detail":"API Error"}/
);
});
test('no token throws an error', async () => {
mockLocalStorageGetItem.mockReturnValueOnce(null); // No token
const { result } = renderHook(() => useSearchSourceConnectors());
// Initial fetch will fail here, which is fine for this test's focus
await waitFor(() => expect(result.current.isLoading).toBe(false));
await expect(result.current.discoverSlackChannels(1)).rejects.toThrow(
'No authentication token found'
);
expect(mockFetch).not.toHaveBeenCalled(); // fetchWithAuth should prevent call
});
});
describe('reindexSlackChannels', () => {
test('successful API call (basic) calls fetch correctly', async () => {
mockFetch.mockReturnValueOnce(mockFetchResponseNoContent(true, 202)); // 202 Accepted
const { result } = renderHook(() => useSearchSourceConnectors());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
await result.current.reindexSlackChannels(1, ['C1', 'C2']);
});
expect(mockFetch).toHaveBeenCalledWith(
`${mockApiUrl}/slack/1/reindex-channels`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer test-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify({
channel_ids: ['C1', 'C2'],
force_reindex_all_messages: undefined, // Explicitly undefined if not passed
reindex_start_date: null, // Defaulted to null if not passed
reindex_latest_date: null, // Defaulted to null if not passed
}),
})
);
});
test('successful API call with all optional parameters', async () => {
mockFetch.mockReturnValueOnce(mockFetchResponseNoContent(true, 202));
const { result } = renderHook(() => useSearchSourceConnectors());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
await result.current.reindexSlackChannels(
1,
['C1'],
true,
'2023-01-01',
'2023-01-31'
);
});
expect(mockFetch).toHaveBeenCalledWith(
`${mockApiUrl}/slack/1/reindex-channels`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
channel_ids: ['C1'],
force_reindex_all_messages: true,
reindex_start_date: '2023-01-01',
reindex_latest_date: '2023-01-31',
}),
})
);
});
test('handles empty date strings by passing null', async () => {
mockFetch.mockReturnValueOnce(mockFetchResponseNoContent(true, 202));
const { result } = renderHook(() => useSearchSourceConnectors());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
await result.current.reindexSlackChannels(
1,
['C1'],
true,
'', // Empty start date
'' // Empty latest date
);
});
expect(mockFetch).toHaveBeenCalledWith(
`${mockApiUrl}/slack/1/reindex-channels`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
channel_ids: ['C1'],
force_reindex_all_messages: true,
reindex_start_date: null, // Should be null
reindex_latest_date: null, // Should be null
}),
})
);
});
test('API error throws an error during reindex', async () => {
mockFetch.mockReturnValueOnce(mockFetchResponse({ detail: 'Reindex Failed' }, false, 400));
const { result } = renderHook(() => useSearchSourceConnectors());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await expect(result.current.reindexSlackChannels(1, ['C1'])).rejects.toThrow(
/API request failed: Bad Request - {"detail":"Reindex Failed"}/
);
});
test('no token throws an error during reindex', async () => {
mockLocalStorageGetItem.mockReturnValueOnce(null); // No token
const { result } = renderHook(() => useSearchSourceConnectors());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await expect(result.current.reindexSlackChannels(1, ['C1'])).rejects.toThrow(
'No authentication token found'
);
expect(mockFetch).not.toHaveBeenCalled();
});
});
// Basic test for fetchWithAuth behavior (implicitly tested by others, but can be explicit)
describe('fetchWithAuth internal behavior', () => {
test('fetchWithAuth throws if no token', async () => {
mockLocalStorageGetItem.mockReturnValueOnce(null);
const { result } = renderHook(() => useSearchSourceConnectors());
// The hook itself might make initial calls, let them pass/fail
await waitFor(() => expect(result.current.isLoading).toBe(false));
// Attempting any operation that uses fetchWithAuth internally should fail early
// For example, discoverSlackChannels
await expect(result.current.discoverSlackChannels(1)).rejects.toThrow('No authentication token found');
expect(mockFetch).not.toHaveBeenCalled();
});
test('fetchWithAuth includes Authorization header', async () => {
mockFetch.mockReturnValueOnce(mockFetchResponse({ channels: [] })); // For discoverSlackChannels
const { result } = renderHook(() => useSearchSourceConnectors());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
await result.current.discoverSlackChannels(1);
});
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
})
);
});
});
});