Merge pull request #2 from fblgit/feature/slack-connector-enhancements

I've added comprehensive unit and integration tests for the recently …
This commit is contained in:
FBLGit 2025-05-28 17:51:06 +07:00 committed by GitHub
commit df42eccb72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1804 additions and 387 deletions

View file

@ -1,420 +1,338 @@
import unittest import unittest
import time # Imported to be available for patching target module from unittest.mock import patch, MagicMock, call
from unittest.mock import patch, Mock, call from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError from slack_sdk.errors import SlackApiError
from datetime import datetime, timezone, timedelta
# Since test_slack_history.py is in the same directory as slack_history.py # Assuming SlackHistory is in app.connectors.slack_history
from .slack_history import SlackHistory # Adjust the import path if your structure is different.
# For example, if 'app' is the root of your source:
from app.connectors.slack_history import SlackHistory
class TestSlackHistoryGetAllChannels(unittest.TestCase): class TestSlackHistory(unittest.TestCase):
@patch('surfsense_backend.app.connectors.slack_history.logger') def setUp(self):
@patch('surfsense_backend.app.connectors.slack_history.time.sleep') self.dummy_token = "xoxb-test-token"
@patch('slack_sdk.WebClient') # We patch WebClient in each test that needs it, so SlackHistory can be instantiated
def test_get_all_channels_pagination_with_delay(self, MockWebClient, mock_sleep, mock_logger): # without making actual API calls during setup.
mock_client_instance = MockWebClient.return_value self.history = SlackHistory(token=self.dummy_token)
@patch('app.connectors.slack_history.WebClient') # Patch where WebClient is looked up
def test_init_and_set_token(self, MockWebClient):
# Test __init__
mock_client_instance = MockWebClient.return_value # Get the instance returned by MockWebClient()
# Mock API responses now include is_private and is_member history_init = SlackHistory(token="test_init_token")
page1_response = { MockWebClient.assert_called_once_with(token="test_init_token")
self.assertEqual(history_init.client, mock_client_instance)
# Reset mock for the next part of the test
MockWebClient.reset_mock()
# Test set_token
history_set_token = SlackHistory() # Initialize without token first
self.assertIsNone(history_set_token.client)
history_set_token.set_token("test_set_token_token")
MockWebClient.assert_called_once_with(token="test_set_token_token")
self.assertEqual(history_set_token.client, mock_client_instance)
@patch.object(WebClient, 'conversations_list')
def test_get_all_channels_success_and_pagination(self, mock_conversations_list):
# Simulate WebClient being already initialized in self.history
self.history.client = mock_conversations_list.MagicMock() # Replace client with a mock that has conversations_list
mock_response_page1 = MagicMock()
mock_response_page1.data = {
"channels": [ "channels": [
{"name": "general", "id": "C1", "is_private": False, "is_member": True}, {"id": "C1", "name": "general", "is_member": True, "is_private": False},
{"name": "dev", "id": "C0", "is_private": False, "is_member": True} {"id": "C2", "name": "random", "is_member": False, "is_private": False}, # is_member False
], ],
"response_metadata": {"next_cursor": "cursor123"} "response_metadata": {"next_cursor": "cursor_page2"}
} }
page2_response = { mock_response_page2 = MagicMock()
"channels": [{"name": "random", "id": "C2", "is_private": True, "is_member": True}], mock_response_page2.data = {
"channels": [
{"id": "C3", "name": "private-project", "is_member": True, "is_private": True},
],
"response_metadata": {"next_cursor": ""} "response_metadata": {"next_cursor": ""}
} }
self.history.client.conversations_list.side_effect = [mock_response_page1, mock_response_page2]
channels = self.history.get_all_channels(include_private=True)
mock_client_instance.conversations_list.side_effect = [ self.assertEqual(len(channels), 3)
page1_response, self.assertEqual(channels[0]['id'], "C1")
page2_response self.assertTrue(channels[0]['is_member']) # Verify is_member preserved
] self.assertEqual(channels[1]['id'], "C2")
self.assertFalse(channels[1]['is_member']) # Verify is_member preserved
slack_history = SlackHistory(token="fake_token") self.assertEqual(channels[2]['id'], "C3")
channels_list = slack_history.get_all_channels(include_private=True) self.assertTrue(channels[2]['is_private'])
expected_channels_list = [
{"id": "C1", "name": "general", "is_private": False, "is_member": True},
{"id": "C0", "name": "dev", "is_private": False, "is_member": True},
{"id": "C2", "name": "random", "is_private": True, "is_member": True}
]
self.assertEqual(len(channels_list), 3)
self.assertListEqual(channels_list, expected_channels_list) # Assert list equality
expected_calls = [ expected_calls = [
call(types="public_channel,private_channel", cursor=None, limit=1000), call(limit=200, cursor=None, types="public_channel,private_channel"),
call(types="public_channel,private_channel", cursor="cursor123", limit=1000) call(limit=200, cursor="cursor_page2", types="public_channel,private_channel")
] ]
mock_client_instance.conversations_list.assert_has_calls(expected_calls) self.history.client.conversations_list.assert_has_calls(expected_calls)
self.assertEqual(mock_client_instance.conversations_list.call_count, 2)
mock_sleep.assert_called_once_with(3)
mock_logger.info.assert_called_once_with("Paginating for channels, waiting 3 seconds before next call. Cursor: cursor123")
@patch('surfsense_backend.app.connectors.slack_history.logger') @patch.object(WebClient, 'conversations_list')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep') def test_get_all_channels_public_only(self, mock_conversations_list):
@patch('slack_sdk.WebClient') self.history.client = mock_conversations_list.MagicMock()
def test_get_all_channels_rate_limit_with_retry_after(self, MockWebClient, mock_sleep, mock_logger): mock_response = MagicMock()
mock_client_instance = MockWebClient.return_value mock_response.data = {
"channels": [{"id": "C1", "name": "general", "is_member": True, "is_private": False}],
mock_error_response = Mock()
mock_error_response.status_code = 429
mock_error_response.headers = {'Retry-After': '5'}
successful_response = {
"channels": [{"name": "general", "id": "C1", "is_private": False, "is_member": True}],
"response_metadata": {"next_cursor": ""} "response_metadata": {"next_cursor": ""}
} }
self.history.client.conversations_list.return_value = mock_response
mock_client_instance.conversations_list.side_effect = [
SlackApiError(message="ratelimited", response=mock_error_response),
successful_response
]
slack_history = SlackHistory(token="fake_token")
channels_list = slack_history.get_all_channels(include_private=True)
expected_channels_list = [{"id": "C1", "name": "general", "is_private": False, "is_member": True}]
self.assertEqual(len(channels_list), 1)
self.assertListEqual(channels_list, expected_channels_list)
mock_sleep.assert_called_once_with(5)
mock_logger.warning.assert_called_once_with("Slack API rate limit hit while fetching channels. Waiting for 5 seconds. Cursor: None")
expected_calls = [
call(types="public_channel,private_channel", cursor=None, limit=1000),
call(types="public_channel,private_channel", cursor=None, limit=1000)
]
mock_client_instance.conversations_list.assert_has_calls(expected_calls)
self.assertEqual(mock_client_instance.conversations_list.call_count, 2)
@patch('surfsense_backend.app.connectors.slack_history.logger') self.history.get_all_channels(include_private=False)
@patch('surfsense_backend.app.connectors.slack_history.time.sleep') self.history.client.conversations_list.assert_called_once_with(limit=200, cursor=None, types="public_channel")
@patch('slack_sdk.WebClient')
def test_get_all_channels_rate_limit_no_retry_after_valid_header(self, MockWebClient, mock_sleep, mock_logger): @patch.object(WebClient, 'conversations_list')
mock_client_instance = MockWebClient.return_value def test_get_all_channels_empty(self, mock_conversations_list):
self.history.client = mock_conversations_list.MagicMock()
mock_response = MagicMock()
mock_response.data = {"channels": [], "response_metadata": {"next_cursor": ""}}
self.history.client.conversations_list.return_value = mock_response
channels = self.history.get_all_channels()
self.assertEqual(len(channels), 0)
@patch.object(WebClient, 'conversations_list')
def test_get_all_channels_api_error(self, mock_conversations_list):
self.history.client = mock_conversations_list.MagicMock()
# Simulate a SlackApiError
self.history.client.conversations_list.side_effect = SlackApiError("API Error", MagicMock(data={"error": "test_error"}))
mock_error_response = Mock() with self.assertRaises(SlackApiError):
mock_error_response.status_code = 429 self.history.get_all_channels()
mock_error_response.headers = {'Retry-After': 'invalid_value'}
@patch.object(WebClient, 'conversations_history')
successful_response = { def test_get_conversation_history_success_and_pagination(self, mock_conversations_history):
"channels": [{"name": "general", "id": "C1", "is_private": False, "is_member": True}], self.history.client = mock_conversations_history.MagicMock()
"response_metadata": {"next_cursor": ""} mock_response_page1 = MagicMock()
mock_response_page1.data = {
"messages": [{"ts": "123.001", "text": "Hello"}, {"ts": "123.002", "text": "World"}],
"has_more": True,
"response_metadata": {"next_cursor": "cursor_history2"}
} }
mock_response_page2 = MagicMock()
mock_client_instance.conversations_list.side_effect = [ mock_response_page2.data = {
SlackApiError(message="ratelimited", response=mock_error_response), "messages": [{"ts": "123.003", "text": "Again"}],
successful_response
]
slack_history = SlackHistory(token="fake_token")
channels_list = slack_history.get_all_channels(include_private=True)
expected_channels_list = [{"id": "C1", "name": "general", "is_private": False, "is_member": True}]
self.assertListEqual(channels_list, expected_channels_list)
mock_sleep.assert_called_once_with(60) # Default fallback
mock_logger.warning.assert_called_once_with("Slack API rate limit hit while fetching channels. Waiting for 60 seconds. Cursor: None")
self.assertEqual(mock_client_instance.conversations_list.call_count, 2)
@patch('surfsense_backend.app.connectors.slack_history.logger')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep')
@patch('slack_sdk.WebClient')
def test_get_all_channels_rate_limit_no_retry_after_header(self, MockWebClient, mock_sleep, mock_logger):
mock_client_instance = MockWebClient.return_value
mock_error_response = Mock()
mock_error_response.status_code = 429
mock_error_response.headers = {}
successful_response = {
"channels": [{"name": "general", "id": "C1", "is_private": False, "is_member": True}],
"response_metadata": {"next_cursor": ""}
}
mock_client_instance.conversations_list.side_effect = [
SlackApiError(message="ratelimited", response=mock_error_response),
successful_response
]
slack_history = SlackHistory(token="fake_token")
channels_list = slack_history.get_all_channels(include_private=True)
expected_channels_list = [{"id": "C1", "name": "general", "is_private": False, "is_member": True}]
self.assertListEqual(channels_list, expected_channels_list)
mock_sleep.assert_called_once_with(60) # Default fallback
mock_logger.warning.assert_called_once_with("Slack API rate limit hit while fetching channels. Waiting for 60 seconds. Cursor: None")
self.assertEqual(mock_client_instance.conversations_list.call_count, 2)
@patch('surfsense_backend.app.connectors.slack_history.logger')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep')
@patch('slack_sdk.WebClient')
def test_get_all_channels_other_slack_api_error(self, MockWebClient, mock_sleep, mock_logger):
mock_client_instance = MockWebClient.return_value
mock_error_response = Mock()
mock_error_response.status_code = 500
mock_error_response.headers = {}
mock_error_response.data = {"ok": False, "error": "internal_error"}
original_error = SlackApiError(message="server error", response=mock_error_response)
mock_client_instance.conversations_list.side_effect = original_error
slack_history = SlackHistory(token="fake_token")
with self.assertRaises(SlackApiError) as context:
slack_history.get_all_channels(include_private=True)
self.assertEqual(context.exception.response.status_code, 500)
self.assertIn("server error", str(context.exception))
mock_sleep.assert_not_called()
mock_logger.warning.assert_not_called() # Ensure no rate limit log
mock_client_instance.conversations_list.assert_called_once_with(
types="public_channel,private_channel", cursor=None, limit=1000
)
@patch('surfsense_backend.app.connectors.slack_history.logger')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep')
@patch('slack_sdk.WebClient')
def test_get_all_channels_handles_missing_name_id_gracefully(self, MockWebClient, mock_sleep, mock_logger):
mock_client_instance = MockWebClient.return_value
response_with_malformed_data = {
"channels": [
{"id": "C1_missing_name", "is_private": False, "is_member": True},
{"name": "channel_missing_id", "is_private": False, "is_member": True},
{"name": "general", "id": "C2_valid", "is_private": False, "is_member": True}
],
"response_metadata": {"next_cursor": ""}
}
mock_client_instance.conversations_list.return_value = response_with_malformed_data
slack_history = SlackHistory(token="fake_token")
channels_list = slack_history.get_all_channels(include_private=True)
expected_channels_list = [
{"id": "C2_valid", "name": "general", "is_private": False, "is_member": True}
]
self.assertEqual(len(channels_list), 1)
self.assertListEqual(channels_list, expected_channels_list)
self.assertEqual(mock_logger.warning.call_count, 2)
mock_logger.warning.assert_any_call("Channel found with missing name or id. Data: {'id': 'C1_missing_name', 'is_private': False, 'is_member': True}")
mock_logger.warning.assert_any_call("Channel found with missing name or id. Data: {'name': 'channel_missing_id', 'is_private': False, 'is_member': True}")
mock_sleep.assert_not_called()
mock_client_instance.conversations_list.assert_called_once_with(
types="public_channel,private_channel", cursor=None, limit=1000
)
if __name__ == '__main__':
unittest.main()
class TestSlackHistoryGetConversationHistory(unittest.TestCase):
@patch('surfsense_backend.app.connectors.slack_history.logger')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep')
@patch('slack_sdk.WebClient')
def test_proactive_delay_single_page(self, MockWebClient, mock_time_sleep, mock_logger):
mock_client_instance = MockWebClient.return_value
mock_client_instance.conversations_history.return_value = {
"messages": [{"text": "msg1"}],
"has_more": False "has_more": False
} }
self.history.client.conversations_history.side_effect = [mock_response_page1, mock_response_page2]
slack_history = SlackHistory(token="fake_token")
slack_history.get_conversation_history(channel_id="C123")
mock_time_sleep.assert_called_once_with(1.2) # Proactive delay
@patch('surfsense_backend.app.connectors.slack_history.logger') messages = self.history.get_conversation_history("C123", limit=3) # Request total 3 messages
@patch('surfsense_backend.app.connectors.slack_history.time.sleep') self.assertEqual(len(messages), 3)
@patch('slack_sdk.WebClient') self.assertEqual(messages[0]['text'], "Hello")
def test_proactive_delay_multiple_pages(self, MockWebClient, mock_time_sleep, mock_logger): self.assertEqual(messages[2]['text'], "Again")
mock_client_instance = MockWebClient.return_value
mock_client_instance.conversations_history.side_effect = [ # The internal page_limit in get_conversation_history is 100 (can be smaller than requested limit)
{ expected_calls = [
"messages": [{"text": "msg1"}], call(channel="C123", limit=3, oldest=None, latest=None, cursor=None),
"has_more": True, call(channel="C123", limit=1, oldest=None, latest=None, cursor="cursor_history2")
"response_metadata": {"next_cursor": "cursor1"}
},
{
"messages": [{"text": "msg2"}],
"has_more": False
}
] ]
self.history.client.conversations_history.assert_has_calls(expected_calls)
slack_history = SlackHistory(token="fake_token")
slack_history.get_conversation_history(channel_id="C123")
# Expected calls: 1.2 (page1), 1.2 (page2)
self.assertEqual(mock_time_sleep.call_count, 2)
mock_time_sleep.assert_has_calls([call(1.2), call(1.2)])
@patch('surfsense_backend.app.connectors.slack_history.logger')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep')
@patch('slack_sdk.WebClient')
def test_retry_after_logic(self, MockWebClient, mock_time_sleep, mock_logger):
mock_client_instance = MockWebClient.return_value
mock_error_response = Mock()
mock_error_response.status_code = 429
mock_error_response.headers = {'Retry-After': '5'}
mock_client_instance.conversations_history.side_effect = [
SlackApiError(message="ratelimited", response=mock_error_response),
{"messages": [{"text": "msg1"}], "has_more": False}
]
slack_history = SlackHistory(token="fake_token")
messages = slack_history.get_conversation_history(channel_id="C123")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["text"], "msg1")
# Expected sleep calls: 1.2 (proactive for 1st attempt), 5 (rate limit), 1.2 (proactive for 2nd attempt)
mock_time_sleep.assert_has_calls([call(1.2), call(5), call(1.2)], any_order=False)
mock_logger.warning.assert_called_once() # Check that a warning was logged for rate limiting
@patch('surfsense_backend.app.connectors.slack_history.logger') @patch.object(WebClient, 'conversations_history')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep') def test_get_conversation_history_with_timestamps(self, mock_conversations_history):
@patch('slack_sdk.WebClient') self.history.client = mock_conversations_history.MagicMock()
def test_not_in_channel_error(self, MockWebClient, mock_time_sleep, mock_logger): mock_response = MagicMock()
mock_client_instance = MockWebClient.return_value mock_response.data = {"messages": [{"ts": "1609500000.000", "text":"msg1"}], "has_more": False}
self.history.client.conversations_history.return_value = mock_response
mock_error_response = Mock()
mock_error_response.status_code = 403 # Typical for not_in_channel, but data matters more oldest_ts_str = str(int(datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc).timestamp()))
mock_error_response.data = {'ok': False, 'error': 'not_in_channel'} latest_ts_str = str(int(datetime(2021, 1, 2, 0, 0, 0, tzinfo=timezone.utc).timestamp()))
# This error is now raised by the inner try-except, then caught by the outer one
mock_client_instance.conversations_history.side_effect = SlackApiError( self.history.get_conversation_history("C123", oldest=oldest_ts_str, latest=latest_ts_str, limit=50)
message="not_in_channel error", self.history.client.conversations_history.assert_called_once_with(
response=mock_error_response channel="C123", limit=50, oldest=oldest_ts_str, latest=latest_ts_str, cursor=None
) )
slack_history = SlackHistory(token="fake_token")
messages = slack_history.get_conversation_history(channel_id="C123")
self.assertEqual(messages, [])
mock_logger.warning.assert_called_with(
"Bot is not in channel 'C123'. Cannot fetch history. Please add the bot to this channel."
)
mock_time_sleep.assert_called_once_with(1.2) # Proactive delay before the API call
@patch('surfsense_backend.app.connectors.slack_history.logger') @patch('logging.warning') # Patch logging where it's used (directly in slack_history module)
@patch('surfsense_backend.app.connectors.slack_history.time.sleep') @patch.object(WebClient, 'conversations_history')
@patch('slack_sdk.WebClient') def test_get_conversation_history_not_in_channel(self, mock_conversations_history, mock_logging_warning):
def test_other_slack_api_error_propagates(self, MockWebClient, mock_time_sleep, mock_logger): self.history.client = mock_conversations_history.MagicMock()
mock_client_instance = MockWebClient.return_value error_response = MagicMock()
error_response.data = {"ok": False, "error": "not_in_channel"}
mock_error_response = Mock() self.history.client.conversations_history.side_effect = SlackApiError("not_in_channel error", error_response)
mock_error_response.status_code = 500
mock_error_response.data = {'ok': False, 'error': 'internal_error'}
original_error = SlackApiError(message="server error", response=mock_error_response)
mock_client_instance.conversations_history.side_effect = original_error messages = self.history.get_conversation_history("C123")
self.assertEqual(len(messages), 0) # Should return empty list
slack_history = SlackHistory(token="fake_token") mock_logging_warning.assert_called_once()
self.assertIn("Bot is not in channel C123 or history is private.", mock_logging_warning.call_args[0][0])
with self.assertRaises(SlackApiError) as context:
slack_history.get_conversation_history(channel_id="C123")
self.assertIn("Error retrieving history for channel C123", str(context.exception))
self.assertIs(context.exception.response, mock_error_response)
mock_time_sleep.assert_called_once_with(1.2) # Proactive delay
@patch('surfsense_backend.app.connectors.slack_history.logger') @patch.object(WebClient, 'conversations_history')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep') def test_get_conversation_history_other_api_error(self, mock_conversations_history):
@patch('slack_sdk.WebClient') self.history.client = mock_conversations_history.MagicMock()
def test_general_exception_propagates(self, MockWebClient, mock_time_sleep, mock_logger): error_response = MagicMock()
mock_client_instance = MockWebClient.return_value error_response.data = {"ok": False, "error": "some_other_error"}
original_error = Exception("Something broke") self.history.client.conversations_history.side_effect = SlackApiError("Some other error", error_response)
mock_client_instance.conversations_history.side_effect = original_error
slack_history = SlackHistory(token="fake_token")
with self.assertRaises(Exception) as context: # Check for generic Exception
slack_history.get_conversation_history(channel_id="C123")
self.assertIs(context.exception, original_error) # Should re-raise the original error
mock_logger.error.assert_called_once_with("Unexpected error in get_conversation_history for channel C123: Something broke")
mock_time_sleep.assert_called_once_with(1.2) # Proactive delay
class TestSlackHistoryGetUserInfo(unittest.TestCase): with self.assertRaises(SlackApiError):
self.history.get_conversation_history("C123")
@patch('surfsense_backend.app.connectors.slack_history.logger')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep')
@patch('slack_sdk.WebClient')
def test_retry_after_logic(self, MockWebClient, mock_time_sleep, mock_logger):
mock_client_instance = MockWebClient.return_value
mock_error_response = Mock()
mock_error_response.status_code = 429
mock_error_response.headers = {'Retry-After': '3'} # Using 3 seconds for test
successful_user_data = {"id": "U123", "name": "testuser"}
mock_client_instance.users_info.side_effect = [
SlackApiError(message="ratelimited_userinfo", response=mock_error_response),
{"user": successful_user_data}
]
slack_history = SlackHistory(token="fake_token")
user_info = slack_history.get_user_info(user_id="U123")
self.assertEqual(user_info, successful_user_data)
# Assert that time.sleep was called for the rate limit
mock_time_sleep.assert_called_once_with(3)
mock_logger.warning.assert_called_once_with(
"Rate limited by Slack on users.info for user U123. Retrying after 3 seconds."
)
# Assert users_info was called twice (original + retry)
self.assertEqual(mock_client_instance.users_info.call_count, 2)
mock_client_instance.users_info.assert_has_calls([call(user="U123"), call(user="U123")])
@patch('surfsense_backend.app.connectors.slack_history.logger')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep') # time.sleep might be called by other logic, but not expected here
@patch('slack_sdk.WebClient')
def test_other_slack_api_error_propagates(self, MockWebClient, mock_time_sleep, mock_logger):
mock_client_instance = MockWebClient.return_value
mock_error_response = Mock()
mock_error_response.status_code = 500 # Some other error
mock_error_response.data = {'ok': False, 'error': 'internal_server_error'}
original_error = SlackApiError(message="internal server error", response=mock_error_response)
mock_client_instance.users_info.side_effect = original_error
slack_history = SlackHistory(token="fake_token")
with self.assertRaises(SlackApiError) as context:
slack_history.get_user_info(user_id="U123")
# Check that the raised error is the one we expect
self.assertIn("Error retrieving user info for U123", str(context.exception))
self.assertIs(context.exception.response, mock_error_response)
mock_time_sleep.assert_not_called() # No rate limit sleep
@patch('surfsense_backend.app.connectors.slack_history.logger')
@patch('surfsense_backend.app.connectors.slack_history.time.sleep')
@patch('slack_sdk.WebClient')
def test_general_exception_propagates(self, MockWebClient, mock_time_sleep, mock_logger):
mock_client_instance = MockWebClient.return_value
original_error = Exception("A very generic problem")
mock_client_instance.users_info.side_effect = original_error
slack_history = SlackHistory(token="fake_token")
with self.assertRaises(Exception) as context:
slack_history.get_user_info(user_id="U123")
self.assertIs(context.exception, original_error) # Check it's the exact same exception @patch.object(WebClient, 'conversations_history')
mock_logger.error.assert_called_once_with( def test_get_conversation_history_empty_messages_from_api(self, mock_conversations_history):
"Unexpected error in get_user_info for user U123: A very generic problem" self.history.client = mock_conversations_history.MagicMock()
mock_response = MagicMock()
mock_response.data = {"messages": [], "has_more": False}
self.history.client.conversations_history.return_value = mock_response
messages = self.history.get_conversation_history("C123")
self.assertEqual(len(messages), 0)
def test_convert_date_to_timestamp_static(self):
# Valid date
ts = SlackHistory.convert_date_to_timestamp("2023-01-15")
expected_dt = datetime(2023, 1, 15, 0, 0, 0, tzinfo=timezone.utc)
self.assertEqual(ts, str(int(expected_dt.timestamp())))
# Valid date with is_latest = True
ts_latest = SlackHistory.convert_date_to_timestamp("2023-01-15", is_latest=True)
# Should be timestamp for 2023-01-16 00:00:00 UTC to include the whole day of 2023-01-15
expected_dt_latest_inclusive = datetime(2023, 1, 15, 0, 0, 0, tzinfo=timezone.utc) + timedelta(days=1)
self.assertEqual(ts_latest, str(int(expected_dt_latest_inclusive.timestamp())))
# Invalid date format - Expect ValueError as per typical date parsing behavior
with self.assertRaises(ValueError):
SlackHistory.convert_date_to_timestamp("15-01-2023")
# Invalid date value
with self.assertRaises(ValueError):
SlackHistory.convert_date_to_timestamp("2023-13-01") # Month 13
@patch.object(SlackHistory, 'get_conversation_history')
def test_get_history_by_date_range_success(self, mock_get_conv_history):
# Mock convert_date_to_timestamp if it's complex, or test its output directly.
# Here, we assume convert_date_to_timestamp works as tested above.
mock_get_conv_history.return_value = [{"text": "message from range"}]
# Expected timestamps based on convert_date_to_timestamp logic
start_date_str = "2023-01-10"
end_date_str = "2023-01-12"
expected_oldest_ts = str(int(datetime(2023, 1, 10, 0, 0, 0, tzinfo=timezone.utc).timestamp()))
# For latest, it should be the start of the next day to include the entire end_date_str
expected_latest_ts = str(int((datetime(2023, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + timedelta(days=1)).timestamp()))
messages, error = self.history.get_history_by_date_range("C123", start_date_str, end_date_str)
self.assertIsNone(error)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]['text'], "message from range")
mock_get_conv_history.assert_called_once_with(
channel_id="C123",
oldest=expected_oldest_ts,
latest=expected_latest_ts
) )
mock_time_sleep.assert_not_called() # No rate limit sleep
def test_get_history_by_date_range_invalid_dates(self):
# Test with invalid start_date format
result, error = self.history.get_history_by_date_range("C123", "invalid-start-date", "2023-01-12")
self.assertIsNone(result)
self.assertIn("Invalid start_date format: invalid-start-date. Use YYYY-MM-DD.", error)
# Test with invalid end_date format
result, error = self.history.get_history_by_date_range("C123", "2023-01-10", "invalid-end-date")
self.assertIsNone(result)
self.assertIn("Invalid end_date format: invalid-end-date. Use YYYY-MM-DD.", error)
@patch.object(WebClient, 'users_info')
def test_get_user_info_success(self, mock_users_info):
self.history.client = mock_users_info.MagicMock()
mock_response = MagicMock()
mock_response.data = {"user": {"id": "U123", "name": "testuser", "real_name": "Test User"}}
self.history.client.users_info.return_value = mock_response
user_info = self.history.get_user_info("U123")
self.assertIsNotNone(user_info)
self.assertEqual(user_info['name'], "testuser")
self.history.client.users_info.assert_called_once_with(user="U123")
@patch.object(WebClient, 'users_info')
def test_get_user_info_api_error(self, mock_users_info):
self.history.client = mock_users_info.MagicMock()
self.history.client.users_info.side_effect = SlackApiError("API Error", MagicMock(data={"error": "user_not_found"}))
user_info = self.history.get_user_info("U123")
self.assertIsNone(user_info)
def test_format_message_basic(self):
raw_msg = {"type": "message", "user": "U123", "text": "Hello world", "ts": "1609459200.000100"} # 2021-01-01 00:00:00 UTC
formatted = self.history.format_message(raw_msg)
self.assertEqual(formatted['user_id'], "U123")
self.assertEqual(formatted['text'], "Hello world")
self.assertEqual(formatted['datetime'], "2021-01-01 00:00:00 UTC")
self.assertNotIn('user_name', formatted) # Not requested
@patch.object(SlackHistory, 'get_user_info')
def test_format_message_with_user_info_and_real_name(self, mock_get_user_info):
mock_get_user_info.return_value = {"real_name": "Test User Real", "name": "testusername"}
raw_msg = {"type": "message", "user": "U123", "text": "Hello with user", "ts": "1609459200.000200"}
formatted = self.history.format_message(raw_msg, include_user_info=True)
mock_get_user_info.assert_called_once_with("U123")
self.assertEqual(formatted['user_id'], "U123")
self.assertEqual(formatted['user_name'], "Test User Real") # Prefers real_name
self.assertEqual(formatted['text'], "Hello with user")
@patch.object(SlackHistory, 'get_user_info')
def test_format_message_with_user_info_name_fallback(self, mock_get_user_info):
mock_get_user_info.return_value = {"name": "testusername_only"} # No real_name
raw_msg = {"type": "message", "user": "U124", "text": "Name fallback", "ts": "1609459200.000300"}
formatted = self.history.format_message(raw_msg, include_user_info=True)
self.assertEqual(formatted['user_name'], "testusername_only")
@patch.object(SlackHistory, 'get_user_info')
def test_format_message_with_user_info_not_found(self, mock_get_user_info):
mock_get_user_info.return_value = None # User info not found
raw_msg = {"type": "message", "user": "U125", "text": "User not found", "ts": "1609459200.000350"}
formatted = self.history.format_message(raw_msg, include_user_info=True)
self.assertEqual(formatted['user_name'], "Unknown User")
def test_format_message_with_files(self):
raw_msg = {
"type": "message", "user": "U123", "text": "Check attachments", "ts": "1609459200.000400",
"files": [
{"name": "file1.txt", "url_private_download": "http://example.com/file1.txt"},
{"name": "image.png", "url_private_download": "http://example.com/image.png", "permalink": "http://slack.com/image.png"}
]
}
formatted = self.history.format_message(raw_msg)
expected_text = (
"Check attachments\n\n"
"Attachments:\n"
"- file1.txt (http://example.com/file1.txt)\n"
"- image.png (http://slack.com/image.png)" # Prefers permalink
)
self.assertEqual(formatted['text'], expected_text)
def test_format_message_with_thread_ts_and_replies(self):
raw_msg = {
"type": "message", "user": "U123", "text": "In a thread", "ts": "1609459200.000500",
"thread_ts": "1609459100.000100",
"reply_count": 2
}
formatted = self.history.format_message(raw_msg)
self.assertEqual(formatted['text'], "In a thread (in reply to thread 1609459100.000100, 2 replies)")
def test_format_message_subtype_handling(self):
raw_msg = {"type": "message", "subtype": "channel_join", "user": "U123", "text": "U123 has joined the channel", "ts": "1609459200.000600"}
formatted = self.history.format_message(raw_msg)
self.assertIn("[channel_join message]", formatted['text']) # Check if subtype is mentioned
if __name__ == '__main__':
unittest.main(argv=['first-arg-is-ignored'], exit=False)

View file

@ -0,0 +1,297 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock, AsyncMock
from fastapi import BackgroundTasks, HTTPException
from app.main import app # Assuming your FastAPI app instance is here
from app.db import User, SearchSourceConnector, SearchSourceConnectorType, SearchSpace
from app.schemas.search_source_connector import SearchSourceConnectorRead # For response validation if needed
from app.connectors.slack_history import SlackHistory # To mock its methods
from slack_sdk.errors import SlackApiError
# --- Fixtures ---
@pytest.fixture
def mock_user():
user = User(id="test_user_1", email="test@example.com", hashed_password="hashedpassword", is_active=True, is_superuser=False)
return user
@pytest.fixture
def mock_db_session():
session = AsyncMock()
session.execute = AsyncMock()
session.get = AsyncMock() # For individual record fetching by ID
session.commit = AsyncMock()
session.rollback = AsyncMock()
session.refresh = AsyncMock()
return session
@pytest.fixture
def mock_background_tasks():
tasks = MagicMock(spec=BackgroundTasks)
tasks.add_task = MagicMock()
return tasks
@pytest.fixture
def test_client(mock_user, mock_db_session, mock_background_tasks):
# Override dependencies
app.dependency_overrides[current_active_user_dep] = lambda: mock_user
app.dependency_overrides[get_async_session_dep] = lambda: mock_db_session
# To inject BackgroundTasks, it's usually passed directly to the route function.
# So, we'll mock it where it's used, or ensure it's injectable.
# For testing, the TestClient can handle BackgroundTasks if routes are defined correctly.
# If BackgroundTasks is a dependency, it can be overridden too.
# For this test, we'll often patch where BackgroundTasks.add_task is called.
return TestClient(app)
# --- Helper to get current_active_user dependency ---
# This is needed because current_active_user is likely defined in app.users
# and we need to provide the correct path for overriding.
# Assuming it's from app.users:
from app.users import current_active_user as current_active_user_dep
from app.db import get_async_session as get_async_session_dep
# --- Base Slack Connector Data ---
@pytest.fixture
def base_slack_connector_db():
return SearchSourceConnector(
id=1,
user_id="test_user_1",
name="Test Slack",
connector_type=SearchSourceConnectorType.SLACK_CONNECTOR,
is_indexable=True,
config={"SLACK_BOT_TOKEN": "xoxb-valid-token"},
last_indexed_at=None
)
@pytest.fixture
def mock_search_space_db():
return SearchSpace(id=1, name="Test Space", user_id="test_user_1")
# --- Test Classes ---
class TestDiscoverSlackChannels:
@patch('app.routes.search_source_connectors_routes.SlackHistory')
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_discover_channels_success(self, mock_check_ownership, MockSlackHistory, test_client, mock_db_session, base_slack_connector_db):
mock_check_ownership.return_value = base_slack_connector_db
mock_db_session.get.return_value = base_slack_connector_db # For direct session.get calls
mock_slack_instance = MockSlackHistory.return_value
mock_slack_instance.get_all_channels.return_value = [
{"id": "C1", "name": "General", "is_private": False, "is_member": True},
{"id": "C2", "name": "Private", "is_private": True, "is_member": True},
{"id": "C3", "name": "Public-NotMember", "is_private": False, "is_member": False},
]
response = test_client.get("/api/v1/slack/1/discover-channels")
assert response.status_code == 200
data = response.json()
assert len(data["channels"]) == 2 # C3 should be filtered out
assert data["channels"][0]["id"] == "C1"
assert data["channels"][1]["id"] == "C2"
MockSlackHistory.assert_called_once_with(token="xoxb-valid-token")
mock_slack_instance.get_all_channels.assert_called_once_with(include_private=True)
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_discover_channels_connector_not_found(self, mock_check_ownership, test_client):
mock_check_ownership.side_effect = HTTPException(status_code=404, detail="Connector not found")
# Or, if session.get is used directly before check_ownership in the route:
# mock_db_session.get.return_value = None
# For this test, assuming check_ownership is the primary guard or session.get is part of it.
response = test_client.get("/api/v1/slack/999/discover-channels")
assert response.status_code == 404
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_discover_channels_not_slack_connector(self, mock_check_ownership, test_client, base_slack_connector_db):
base_slack_connector_db.connector_type = SearchSourceConnectorType.NOTION_CONNECTOR
mock_check_ownership.return_value = base_slack_connector_db
# mock_db_session.get.return_value = base_slack_connector_db
response = test_client.get("/api/v1/slack/1/discover-channels")
assert response.status_code == 400
assert "Connector is not a Slack connector" in response.json()["detail"]
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_discover_channels_token_not_configured(self, mock_check_ownership, test_client, base_slack_connector_db):
base_slack_connector_db.config = {} # No token
mock_check_ownership.return_value = base_slack_connector_db
# mock_db_session.get.return_value = base_slack_connector_db
response = test_client.get("/api/v1/slack/1/discover-channels")
assert response.status_code == 400
assert "Slack token not configured" in response.json()["detail"]
@patch('app.routes.search_source_connectors_routes.SlackHistory')
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_discover_channels_slack_api_error(self, mock_check_ownership, MockSlackHistory, test_client, base_slack_connector_db):
mock_check_ownership.return_value = base_slack_connector_db
# mock_db_session.get.return_value = base_slack_connector_db
mock_slack_instance = MockSlackHistory.return_value
# Simulate SlackApiError with a mock response object that has a 'data' attribute
mock_error_response = MagicMock()
mock_error_response.data = {"error": "test_slack_api_error"}
mock_slack_instance.get_all_channels.side_effect = SlackApiError("Slack API failed", mock_error_response)
response = test_client.get("/api/v1/slack/1/discover-channels")
assert response.status_code == 500 # Based on current route error handling
assert "Slack API error: test_slack_api_error" in response.json()["detail"]
@patch('app.routes.search_source_connectors_routes.SlackHistory')
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_discover_channels_value_error(self, mock_check_ownership, MockSlackHistory, test_client, base_slack_connector_db):
mock_check_ownership.return_value = base_slack_connector_db
# mock_db_session.get.return_value = base_slack_connector_db
MockSlackHistory.side_effect = ValueError("Invalid token format") # Error on SlackHistory init
response = test_client.get("/api/v1/slack/1/discover-channels")
assert response.status_code == 400
assert "Invalid token format" in response.json()["detail"]
class TestReindexSlackChannels:
@patch('app.routes.search_source_connectors_routes.run_slack_indexing_with_new_session', new_callable=AsyncMock)
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_reindex_channels_success_defaults(self, mock_check_ownership, mock_run_indexing, test_client, mock_db_session, base_slack_connector_db, mock_search_space_db, mock_background_tasks):
mock_check_ownership.return_value = base_slack_connector_db
mock_db_session.get.return_value = base_slack_connector_db
# Mock the search space query within the endpoint
mock_db_session.execute.return_value.scalars.return_value.first.return_value = mock_search_space_db
# Override BackgroundTasks for this specific test or ensure it's injected globally
with patch('fastapi.BackgroundTasks', return_value=mock_background_tasks) as MockedBGTasks:
# Re-apply dependency override if TestClient re-initializes app without it for BG tasks
app.dependency_overrides[BackgroundTasks] = lambda: mock_background_tasks
response = test_client.post("/api/v1/slack/1/reindex-channels", json={"channel_ids": ["C1", "C2"]})
assert response.status_code == 202
assert response.json()["message"] == "Re-indexing task for specific channels has been scheduled."
mock_background_tasks.add_task.assert_called_once_with(
mock_run_indexing, # Direct reference to the mocked task runner
connector_id=1,
search_space_id=mock_search_space_db.id,
target_channel_ids=["C1", "C2"],
force_reindex_all_messages=False, # Default
reindex_start_date_str=None, # Default
reindex_latest_date_str=None # Default
)
# Clean up dependency override if it was local to this test
if BackgroundTasks in app.dependency_overrides:
del app.dependency_overrides[BackgroundTasks]
@patch('app.routes.search_source_connectors_routes.run_slack_indexing_with_new_session', new_callable=AsyncMock)
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_reindex_channels_success_with_optional_params(self, mock_check_ownership, mock_run_indexing, test_client, mock_db_session, base_slack_connector_db, mock_search_space_db, mock_background_tasks):
mock_check_ownership.return_value = base_slack_connector_db
mock_db_session.get.return_value = base_slack_connector_db
mock_db_session.execute.return_value.scalars.return_value.first.return_value = mock_search_space_db
with patch('fastapi.BackgroundTasks', return_value=mock_background_tasks):
app.dependency_overrides[BackgroundTasks] = lambda: mock_background_tasks
payload = {
"channel_ids": ["C1"],
"force_reindex_all_messages": True,
"reindex_start_date": "2023-01-01",
"reindex_latest_date": "2023-01-31"
}
response = test_client.post("/api/v1/slack/1/reindex-channels", json=payload)
assert response.status_code == 202
mock_background_tasks.add_task.assert_called_once_with(
mock_run_indexing,
connector_id=1,
search_space_id=mock_search_space_db.id,
target_channel_ids=["C1"],
force_reindex_all_messages=True,
reindex_start_date_str="2023-01-01",
reindex_latest_date_str="2023-01-31"
)
if BackgroundTasks in app.dependency_overrides:
del app.dependency_overrides[BackgroundTasks]
# Error cases for reindex-channels (similar structure to discover-channels errors)
# Connector not found, not Slack, token missing, no channel_ids
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_reindex_channels_no_channel_ids(self, mock_check_ownership, test_client, mock_db_session, base_slack_connector_db):
mock_check_ownership.return_value = base_slack_connector_db
mock_db_session.get.return_value = base_slack_connector_db
response = test_client.post("/api/v1/slack/1/reindex-channels", json={"channel_ids": []}) # Empty list
assert response.status_code == 400 # Or 422 depending on Pydantic validation
assert "No channel_ids provided" in response.json()["detail"]
class TestIndexConnectorContentSlack:
@patch('app.routes.search_source_connectors_routes.run_slack_indexing_with_new_session', new_callable=AsyncMock)
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_index_slack_connector_default(self, mock_check_ownership, mock_run_indexing, test_client, mock_db_session, base_slack_connector_db, mock_search_space_db, mock_background_tasks):
# Mock check_ownership for connector and search_space
mock_check_ownership.side_effect = [base_slack_connector_db, mock_search_space_db]
with patch('fastapi.BackgroundTasks', return_value=mock_background_tasks):
app.dependency_overrides[BackgroundTasks] = lambda: mock_background_tasks
response = test_client.post("/api/v1/search-source-connectors/1/index?search_space_id=1")
assert response.status_code == 200 # The route itself returns 200, task is background
assert "Slack indexing started" in response.json()["message"]
mock_background_tasks.add_task.assert_called_once_with(
mock_run_indexing,
1, # connector_id
1, # search_space_id
target_channel_ids=None, # From default in run_slack_indexing_with_new_session
force_reindex_all_messages=False, # Default for this endpoint
reindex_start_date_str=None,
reindex_latest_date_str=None
)
if BackgroundTasks in app.dependency_overrides:
del app.dependency_overrides[BackgroundTasks]
@patch('app.routes.search_source_connectors_routes.run_slack_indexing_with_new_session', new_callable=AsyncMock)
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_index_slack_connector_force_full_reindex(self, mock_check_ownership, mock_run_indexing, test_client, mock_db_session, base_slack_connector_db, mock_search_space_db, mock_background_tasks):
mock_check_ownership.side_effect = [base_slack_connector_db, mock_search_space_db]
with patch('fastapi.BackgroundTasks', return_value=mock_background_tasks):
app.dependency_overrides[BackgroundTasks] = lambda: mock_background_tasks
response = test_client.post("/api/v1/search-source-connectors/1/index?search_space_id=1&force_full_reindex=true")
assert response.status_code == 200
assert "Full re-index initiated" in response.json()["message"]
mock_background_tasks.add_task.assert_called_once_with(
mock_run_indexing,
1,
1,
target_channel_ids=None,
force_reindex_all_messages=True, # Key check for this test
reindex_start_date_str=None,
reindex_latest_date_str=None
)
if BackgroundTasks in app.dependency_overrides:
del app.dependency_overrides[BackgroundTasks]
@patch('app.routes.search_source_connectors_routes.check_ownership', new_callable=AsyncMock)
async def test_index_connector_not_supported_type(self, mock_check_ownership, test_client, base_slack_connector_db, mock_search_space_db):
base_slack_connector_db.connector_type = "UNSUPPORTED_TYPE" # Simulate an unsupported type
mock_check_ownership.side_effect = [base_slack_connector_db, mock_search_space_db]
response = test_client.post("/api/v1/search-source-connectors/1/index?search_space_id=1")
assert response.status_code == 400
assert "Indexing not supported for connector type: UNSUPPORTED_TYPE" in response.json()["detail"]
# It's important to clear dependency_overrides after tests if they are not scoped to TestClient instance
# However, pytest fixtures usually handle setup/teardown per test.
# If overrides are global to 'app', they might need explicit cleanup in a teardown fixture if not using TestClient's context management fully.
# For TestClient(app), it usually handles this.

View file

@ -0,0 +1,427 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch, call
from datetime import datetime, timedelta, timezone
from sqlalchemy.exc import SQLAlchemyError
from app.tasks.connectors_indexing_tasks import index_slack_messages
from app.db import SearchSourceConnector, SearchSourceConnectorType, Document, DocumentType, Chunk
from app.schemas.search_source_connector import SearchSourceConnectorCreate # For creating test connector data
from app.connectors.slack_history import SlackHistory # To mock its methods
# Mock global config object if it's accessed directly
# If it's passed around, then mock where it's used/passed.
# For this example, assuming app.config.config is used.
@pytest.fixture(autouse=True)
def mock_app_config():
mock_llm = MagicMock()
mock_llm.ainvoke = AsyncMock(return_value=MagicMock(content="Test Summary"))
mock_embedding_model = MagicMock()
mock_embedding_model.embed = MagicMock(return_value=[0.1, 0.2, 0.3])
mock_chunker = MagicMock()
# Simulate chunker returning list of objects with a 'text' attribute
mock_chunker.chunk = MagicMock(return_value=[MagicMock(text="chunk1"), MagicMock(text="chunk2")])
with patch('app.config.config.long_context_llm_instance', mock_llm), \
patch('app.config.config.embedding_model_instance', mock_embedding_model), \
patch('app.config.config.chunker_instance', mock_chunker), \
patch('app.config.config.SUMMARY_PROMPT_TEMPLATE', MagicMock()): # Assuming this is a simple object
yield
@pytest.fixture
def mock_session():
session = AsyncMock()
session.execute = AsyncMock()
session.scalars = MagicMock(return_value=MagicMock(first=MagicMock(return_value=None), all=MagicMock(return_value=[])))
session.commit = AsyncMock()
session.rollback = AsyncMock()
session.add = MagicMock()
session.refresh = AsyncMock()
return session
@pytest.fixture
def base_slack_connector():
return SearchSourceConnector(
id=1,
user_id="test_user_1",
search_space_id=1,
name="Test Slack Connector",
connector_type=SearchSourceConnectorType.SLACK_CONNECTOR,
is_indexable=True,
config={
"SLACK_BOT_TOKEN": "xoxb-dummy-token",
"slack_initial_indexing_days": 30,
"slack_initial_max_messages_per_channel": 100,
"slack_max_messages_per_channel_periodic": 50,
"slack_membership_filter_type": "all_member_channels",
"slack_selected_channel_ids": [],
"slack_periodic_indexing_enabled": True, # New field from previous task
"slack_periodic_indexing_frequency": "daily" # New field
},
last_indexed_at=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
@pytest.fixture
def mock_slack_history_instance():
instance = AsyncMock(spec=SlackHistory)
instance.get_all_channels = AsyncMock(return_value=[])
instance.get_conversation_history = AsyncMock(return_value=[])
# format_message should return a dict, not an AsyncMock
instance.format_message = MagicMock(return_value={
"user_id": "U123", "text": "Formatted message", "datetime": "2023-01-01 10:00:00 UTC"
})
return instance
@pytest.mark.asyncio
async def test_connector_not_found(mock_session):
mock_session.execute.return_value.scalars.return_value.first.return_value = None
count, error = await index_slack_messages(mock_session, 999, 1)
assert count == 0
assert "Connector with ID 999 not found" in error
@pytest.mark.asyncio
async def test_connector_not_slack_type(mock_session, base_slack_connector):
base_slack_connector.connector_type = SearchSourceConnectorType.NOTION_CONNECTOR
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
count, error = await index_slack_messages(mock_session, 1, 1)
assert count == 0
assert "is not a Slack connector" in error
@pytest.mark.asyncio
async def test_slack_token_missing(mock_session, base_slack_connector):
base_slack_connector.config = {} # No token
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
count, error = await index_slack_messages(mock_session, 1, 1)
assert count == 0
assert "Slack token not found" in error
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_no_channels_returned_from_api(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
mock_slack_history_instance.get_all_channels = AsyncMock(return_value=[])
MockSlackHistory.return_value = mock_slack_history_instance
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
count, error = await index_slack_messages(mock_session, 1, 1)
assert count == 0
assert error == "No channels to index after filtering." # This is the specific message when no channels are left
# Verify last_indexed_at is updated because the task "ran" successfully even if no channels
assert base_slack_connector.last_indexed_at is not None
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_all_member_channels_filter(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
mock_slack_history_instance.get_all_channels.return_value = [
{"id": "C1", "name": "Channel1", "is_member": True, "is_private": False},
{"id": "C2", "name": "Channel2", "is_member": True, "is_private": False}, # Bot is member
]
mock_slack_history_instance.get_conversation_history.return_value = [
{"ts": "1", "user": "U1", "text": "msg1"},
]
MockSlackHistory.return_value = mock_slack_history_instance
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
base_slack_connector.config["slack_membership_filter_type"] = "all_member_channels"
count, _ = await index_slack_messages(mock_session, 1, 1)
assert count == 2 # Two documents created, one for each channel
assert mock_slack_history_instance.get_conversation_history.call_count == 2
mock_slack_history_instance.get_conversation_history.assert_any_call(channel_id="C1", limit=100, oldest=mock_slack_history_instance.convert_date_to_timestamp.return_value, latest=mock_slack_history_instance.convert_date_to_timestamp.return_value)
mock_slack_history_instance.get_conversation_history.assert_any_call(channel_id="C2", limit=100, oldest=mock_slack_history_instance.convert_date_to_timestamp.return_value, latest=mock_slack_history_instance.convert_date_to_timestamp.return_value)
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_selected_member_channels_filter(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
mock_slack_history_instance.get_all_channels.return_value = [
{"id": "C1", "name": "Channel1", "is_member": True, "is_private": False},
{"id": "C2", "name": "Channel2", "is_member": True, "is_private": False},
{"id": "C3", "name": "Channel3", "is_member": True, "is_private": False},
]
mock_slack_history_instance.get_conversation_history.return_value = [{"ts": "1", "user": "U1", "text": "msg1"}]
MockSlackHistory.return_value = mock_slack_history_instance
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
base_slack_connector.config["slack_membership_filter_type"] = "selected_member_channels"
base_slack_connector.config["slack_selected_channel_ids"] = ["C1", "C3"]
count, _ = await index_slack_messages(mock_session, 1, 1)
assert count == 2
assert mock_slack_history_instance.get_conversation_history.call_count == 2
mock_slack_history_instance.get_conversation_history.assert_any_call(channel_id="C1", limit=100, oldest=mock_slack_history_instance.convert_date_to_timestamp.return_value, latest=mock_slack_history_instance.convert_date_to_timestamp.return_value)
mock_slack_history_instance.get_conversation_history.assert_any_call(channel_id="C3", limit=100, oldest=mock_slack_history_instance.convert_date_to_timestamp.return_value, latest=mock_slack_history_instance.convert_date_to_timestamp.return_value)
# C2 should not have been called
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_target_channel_ids_interaction(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
mock_slack_history_instance.get_all_channels.return_value = [
{"id": "C1", "name": "Channel1", "is_member": True, "is_private": False},
{"id": "C2", "name": "Channel2", "is_member": True, "is_private": False}, # Configured but not targeted
{"id": "C3", "name": "Channel3", "is_member": True, "is_private": False}, # Targeted
]
mock_slack_history_instance.get_conversation_history.return_value = [{"ts": "1", "user": "U1", "text": "msg1"}]
MockSlackHistory.return_value = mock_slack_history_instance
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
base_slack_connector.config["slack_membership_filter_type"] = "all_member_channels" # All are initially valid
count, _ = await index_slack_messages(mock_session, 1, 1, target_channel_ids=["C1", "C3"])
assert count == 2 # Only C1 and C3
mock_slack_history_instance.get_conversation_history.assert_any_call(channel_id="C1", limit=base_slack_connector.config["slack_max_messages_per_channel_periodic"], oldest=mock_slack_history_instance.convert_date_to_timestamp.return_value, latest=mock_slack_history_instance.convert_date_to_timestamp.return_value)
mock_slack_history_instance.get_conversation_history.assert_any_call(channel_id="C3", limit=base_slack_connector.config["slack_max_messages_per_channel_periodic"], oldest=mock_slack_history_instance.convert_date_to_timestamp.return_value, latest=mock_slack_history_instance.convert_date_to_timestamp.return_value)
# Ensure C2 was not called for history
calls = mock_slack_history_instance.get_conversation_history.call_args_list
for c in calls:
assert c.kwargs['channel_id'] != "C2"
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_initial_indexing_days_logic(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
base_slack_connector.last_indexed_at = None # Ensure initial run
base_slack_connector.config["slack_initial_indexing_days"] = 7
# Mock convert_date_to_timestamp to check its input
mock_converter = MagicMock()
# Make it return a fixed value so we can check the call to get_conversation_history
fixed_ts = str(int((datetime.now(timezone.utc) - timedelta(days=7)).timestamp()))
mock_converter.return_value = fixed_ts
# Patch the static method on the class, not the instance
with patch.object(SlackHistory, 'convert_date_to_timestamp', mock_converter):
mock_slack_history_instance.get_all_channels.return_value = [{"id": "C1", "name": "Chan1", "is_member": True}]
mock_slack_history_instance.get_conversation_history.return_value = [{"ts": "1"}]
MockSlackHistory.return_value = mock_slack_history_instance
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
await index_slack_messages(mock_session, 1, 1)
# Check if convert_date_to_timestamp was called correctly for the start date
# It's called internally for both oldest and latest. We are interested in the 'oldest' logic.
# The actual call to SlackHistory.convert_date_to_timestamp for 'oldest' happens inside index_slack_messages.
# We can assert that get_conversation_history was called with the 'fixed_ts'
latest_ts_for_call = str(int((datetime.now(timezone.utc) + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).timestamp()))
mock_slack_history_instance.get_conversation_history.assert_called_once_with(
channel_id="C1",
limit=base_slack_connector.config["slack_initial_max_messages_per_channel"],
oldest=fixed_ts, # This is the key check
latest=latest_ts_for_call
)
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_initial_indexing_all_time(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
base_slack_connector.last_indexed_at = None
base_slack_connector.config["slack_initial_indexing_days"] = -1
mock_slack_history_instance.get_all_channels.return_value = [{"id": "C1", "name": "Chan1", "is_member": True}]
mock_slack_history_instance.get_conversation_history.return_value = [{"ts": "1"}]
MockSlackHistory.return_value = mock_slack_history_instance
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
await index_slack_messages(mock_session, 1, 1)
latest_ts_for_call = str(int((datetime.now(timezone.utc) + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).timestamp()))
mock_slack_history_instance.get_conversation_history.assert_called_once_with(
channel_id="C1",
limit=base_slack_connector.config["slack_initial_max_messages_per_channel"],
oldest="0", # All time
latest=latest_ts_for_call
)
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_periodic_indexing_logic(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
last_indexed_time = datetime.now(timezone.utc) - timedelta(days=5)
base_slack_connector.last_indexed_at = last_indexed_time
mock_slack_history_instance.get_all_channels.return_value = [{"id": "C1", "name": "Chan1", "is_member": True}]
mock_slack_history_instance.get_conversation_history.return_value = [{"ts": "1"}]
MockSlackHistory.return_value = mock_slack_history_instance
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
await index_slack_messages(mock_session, 1, 1)
expected_oldest_ts = str(int(last_indexed_time.timestamp()))
latest_ts_for_call = str(int((datetime.now(timezone.utc) + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).timestamp()))
mock_slack_history_instance.get_conversation_history.assert_called_once_with(
channel_id="C1",
limit=base_slack_connector.config["slack_max_messages_per_channel_periodic"],
oldest=expected_oldest_ts,
latest=latest_ts_for_call
)
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_reindex_force_all_with_dates(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
mock_slack_history_instance.get_all_channels.return_value = [{"id": "C1", "name": "Chan1", "is_member": True}]
mock_slack_history_instance.get_conversation_history.return_value = [{"ts": "1"}]
MockSlackHistory.return_value = mock_slack_history_instance
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
start_date_str = "2023-03-01"
latest_date_str = "2023-03-10"
expected_oldest = SlackHistory.convert_date_to_timestamp(start_date_str)
expected_latest = SlackHistory.convert_date_to_timestamp(latest_date_str, is_latest=True)
await index_slack_messages(mock_session, 1, 1,
target_channel_ids=["C1"],
force_reindex_all_messages=True,
reindex_start_date_str=start_date_str,
reindex_latest_date_str=latest_date_str)
mock_slack_history_instance.get_conversation_history.assert_called_once_with(
channel_id="C1",
limit=base_slack_connector.config["slack_initial_max_messages_per_channel"], # Uses initial limit
oldest=expected_oldest,
latest=expected_latest
)
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_document_creation(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
mock_slack_history_instance.get_all_channels.return_value = [{"id": "C1", "name": "TestChannel", "is_member": True}]
mock_slack_history_instance.get_conversation_history.return_value = [
{"ts": "1", "user": "U1", "text": "Hello"},
{"ts": "2", "user": "U2", "text": "World", "subtype": "bot_message"}, # Should be skipped
]
# Configure format_message to return what's needed by the task
mock_slack_history_instance.format_message.side_effect = lambda msg, include_user_info=False: {
"user_id": msg["user"], "text": msg["text"], "datetime": "time", "subtype": msg.get("subtype")
}
MockSlackHistory.return_value = mock_slack_history_instance
# Simulate no existing document for this channel
mock_session.execute.return_value.scalars.return_value.all.return_value = []
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector # For connector fetch
count, _ = await index_slack_messages(mock_session, 1, 1)
assert count == 1
# Check Document creation (session.add was called with a Document instance)
added_object = mock_session.add.call_args[0][0]
assert isinstance(added_object, Document)
assert added_object.title == "Slack - TestChannel"
assert added_object.document_type == DocumentType.SLACK_CONNECTOR
assert added_object.document_metadata["channel_id"] == "C1"
assert added_object.document_metadata["message_count"] == 1 # Only one valid message
assert added_object.content == "Test Summary" # From mock_app_config
assert len(added_object.chunks) == 2 # From mock_app_config chunker
# Check that bot message was skipped via format_message calls
format_calls = mock_slack_history_instance.format_message.call_args_list
assert len(format_calls) == 1 # Only called for the non-bot message
assert format_calls[0].args[0]['text'] == "Hello"
assert mock_session.commit.call_count == 1
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_document_update(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
mock_slack_history_instance.get_all_channels.return_value = [{"id": "C1", "name": "TestChannel", "is_member": True}]
mock_slack_history_instance.get_conversation_history.return_value = [{"ts": "1", "user": "U1", "text": "Updated Content"}]
mock_slack_history_instance.format_message.return_value = {"user_id": "U1", "text": "Updated Content", "datetime":"time"}
MockSlackHistory.return_value = mock_slack_history_instance
existing_doc = Document(id=101, search_space_id=1, title="Old Title", document_type=DocumentType.SLACK_CONNECTOR, document_metadata={"channel_id": "C1"})
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
# Simulate existing document for this channel_id
mock_session.execute.return_value.scalars.return_value.all.return_value = [existing_doc]
count, _ = await index_slack_messages(mock_session, 1, 1)
assert count == 1 # 0 new, 1 updated
assert existing_doc.content == "Test Summary" # Updated content from summary
assert existing_doc.document_metadata["message_count"] == 1
# Check that session.delete was called for old chunks (implicitly via relationship or explicit call)
# This depends on ORM setup or if task explicitly deletes.
# For this test, we assume the task handles deleting old chunks if needed.
# The provided task code does execute a delete statement for chunks.
delete_chunk_call = None
for call_item in mock_session.execute.call_args_list:
if "DELETE" in str(call_item.args[0]): # Check if a DELETE statement was executed
delete_chunk_call = call_item
break
assert delete_chunk_call is not None
# Verify new chunks are added
num_new_chunks_added = 0
for call_item in mock_session.add.call_args_list:
if isinstance(call_item.args[0], Chunk):
num_new_chunks_added +=1
assert call_item.args[0].document_id == existing_doc.id # Associated with existing doc
assert num_new_chunks_added == 2 # From mock_app_config chunker
assert mock_session.commit.call_count == 1
@pytest.mark.asyncio
async def test_last_indexed_at_update_logic(mock_session, base_slack_connector):
# Scenario 1: update_last_indexed = True, documents processed
mock_slack_history_instance_s1 = MagicMock(spec=SlackHistory)
mock_slack_history_instance_s1.get_all_channels = AsyncMock(return_value=[{"id": "C1", "name": "Chan1", "is_member": True}])
mock_slack_history_instance_s1.get_conversation_history = AsyncMock(return_value=[{"ts": "1", "user": "U1", "text": "msg"}])
mock_slack_history_instance_s1.format_message = MagicMock(return_value={"user_id":"U1", "text":"msg", "datetime":"t"})
with patch('app.tasks.connectors_indexing_tasks.SlackHistory', return_value=mock_slack_history_instance_s1):
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
base_slack_connector.last_indexed_at = None # Reset for this test part
await index_slack_messages(mock_session, 1, 1, update_last_indexed=True)
assert base_slack_connector.last_indexed_at is not None
original_last_indexed_at = base_slack_connector.last_indexed_at
# Scenario 2: update_last_indexed = False
mock_slack_history_instance_s2 = MagicMock(spec=SlackHistory)
mock_slack_history_instance_s2.get_all_channels = AsyncMock(return_value=[{"id": "C1", "name": "Chan1", "is_member": True}])
mock_slack_history_instance_s2.get_conversation_history = AsyncMock(return_value=[{"ts": "1", "user": "U1", "text": "msg"}])
mock_slack_history_instance_s2.format_message = MagicMock(return_value={"user_id":"U1", "text":"msg", "datetime":"t"})
with patch('app.tasks.connectors_indexing_tasks.SlackHistory', return_value=mock_slack_history_instance_s2):
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
# last_indexed_at should remain as it was from scenario 1
await index_slack_messages(mock_session, 1, 1, update_last_indexed=False)
assert base_slack_connector.last_indexed_at == original_last_indexed_at
@pytest.mark.asyncio
@patch('app.tasks.connectors_indexing_tasks.SlackHistory')
async def test_error_get_all_channels_exception(MockSlackHistory, mock_session, base_slack_connector, mock_slack_history_instance):
mock_slack_history_instance.get_all_channels.side_effect = Exception("Failed to get channels")
MockSlackHistory.return_value = mock_slack_history_instance
mock_session.execute.return_value.scalars.return_value.first.return_value = base_slack_connector
count, error = await index_slack_messages(mock_session, 1, 1)
assert count == 0
assert "Failed to get Slack channels: Failed to get channels" in error
assert mock_session.rollback.call_count == 1 # Should rollback on general exception
@pytest.mark.asyncio
async def test_db_error_on_commit(mock_session, base_slack_connector):
# This test needs to allow the initial connector fetch to succeed, then fail on commit
mock_connector_fetch_result = MagicMock()
mock_connector_fetch_result.scalars.return_value.first.return_value = base_slack_connector
mock_docs_fetch_result = MagicMock()
mock_docs_fetch_result.scalars.return_value.all.return_value = [] # No existing docs
# Configure side_effect for session.execute
mock_session.execute.side_effect = [
mock_connector_fetch_result, # First call for getting connector
mock_docs_fetch_result # Second call for getting existing_docs
]
mock_session.commit.side_effect = SQLAlchemyError("DB Commit Failed")
mock_slack_history_instance_db_error = MagicMock(spec=SlackHistory)
mock_slack_history_instance_db_error.get_all_channels = AsyncMock(return_value=[{"id": "C1", "name": "Chan1", "is_member": True}])
mock_slack_history_instance_db_error.get_conversation_history = AsyncMock(return_value=[{"ts": "1", "user":"U1", "text":"msg"}])
mock_slack_history_instance_db_error.format_message = MagicMock(return_value={"user_id":"U1", "text":"msg", "datetime":"t"})
with patch('app.tasks.connectors_indexing_tasks.SlackHistory', return_value=mock_slack_history_instance_db_error):
count, error = await index_slack_messages(mock_session, 1, 1)
assert count == 0
assert "Database error: DB Commit Failed" in error
assert mock_session.rollback.call_count == 1 # Ensure rollback on SQLAlchemyError

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' }),
})
);
});
});
});