mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-12 22:42:13 +02:00
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:
commit
df42eccb72
6 changed files with 1804 additions and 387 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
427
surfsense_backend/app/tasks/test_connectors_indexing_tasks.py
Normal file
427
surfsense_backend/app/tasks/test_connectors_indexing_tasks.py
Normal 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
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
247
surfsense_web/hooks/useSearchSourceConnectors.test.ts
Normal file
247
surfsense_web/hooks/useSearchSourceConnectors.test.ts
Normal 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' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue