diff --git a/backend/Makefile b/backend/Makefile index 86872042f7..ed4d5c2893 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -156,6 +156,10 @@ slack-sync-data: @echo "Syncing Slack data" @CMD="python manage.py slack_sync_data" $(MAKE) exec-backend-command +slack-sync-messages: + @echo "Syncing Slack messages" + @CMD="python manage.py slack_sync_messages" $(MAKE) exec-backend-command + sync-data: \ update-data \ enrich-data \ diff --git a/backend/apps/slack/admin.py b/backend/apps/slack/admin.py index 4c37d8024b..89ce76dd4b 100644 --- a/backend/apps/slack/admin.py +++ b/backend/apps/slack/admin.py @@ -5,6 +5,7 @@ from apps.slack.models.conversation import Conversation from apps.slack.models.event import Event from apps.slack.models.member import Member +from apps.slack.models.message import Message from apps.slack.models.workspace import Workspace @@ -127,6 +128,19 @@ def approve_suggested_users(self, request, queryset): approve_suggested_users.short_description = "Approve the suggested user (if only one exists)" +class MessageAdmin(admin.ModelAdmin): + autocomplete_fields = ("author", "conversation", "parent_message") + list_display = ( + "text", + "has_replies", + "author", + ) + search_fields = ( + "slack_message_id", + "text", + ) + + class WorkspaceAdmin(admin.ModelAdmin): search_fields = ( "name", @@ -137,4 +151,5 @@ class WorkspaceAdmin(admin.ModelAdmin): admin.site.register(Conversation, ConversationAdmin) admin.site.register(Event, EventAdmin) admin.site.register(Member, MemberAdmin) +admin.site.register(Message, MessageAdmin) admin.site.register(Workspace, WorkspaceAdmin) diff --git a/backend/apps/slack/management/commands/slack_sync_messages.py b/backend/apps/slack/management/commands/slack_sync_messages.py new file mode 100644 index 0000000000..4d1bf57e94 --- /dev/null +++ b/backend/apps/slack/management/commands/slack_sync_messages.py @@ -0,0 +1,308 @@ +"""A command to populate Slack messages data for all conversations.""" + +import logging +import time + +from django.core.management.base import BaseCommand +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from apps.slack.models import Conversation, Member, Message, Workspace + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Populate messages for all Slack conversations" + + def add_arguments(self, parser): + """Define command line arguments.""" + parser.add_argument( + "--batch-size", + type=int, + default=200, + help="Number of messages to retrieve per request", + ) + parser.add_argument( + "--delay", + type=float, + default=0.5, + help="Delay between API requests in seconds", + ) + parser.add_argument( + "--channel-id", + type=str, + help="Specific channel ID to fetch messages from", + ) + + def handle(self, *args, **options): + batch_size = options["batch_size"] + channel_id = options["channel_id"] + delay = options["delay"] + + workspaces = Workspace.objects.all() + if not workspaces.exists(): + self.stdout.write(self.style.WARNING("No workspaces found in the database")) + return + + for workspace in workspaces: + self.stdout.write(f"\nProcessing workspace: {workspace.name}") + + if not (bot_token := workspace.bot_token): + self.stdout.write(self.style.ERROR(f"No bot token found for {workspace}")) + continue + + client = WebClient(token=bot_token) + + conversations = ( + Conversation.objects.filter(slack_channel_id=channel_id) + if channel_id + else Conversation.objects.filter(workspace=workspace) + ) + + for conversation in conversations: + self._fetch_conversation( + batch_size=batch_size, + client=client, + conversation=conversation, + delay=delay, + include_replies=True, + ) + + self.stdout.write(self.style.SUCCESS("\nFinished processing all workspaces")) + + def _fetch_conversation( + self, + client: WebClient, + conversation: Conversation, + batch_size: int, + delay: float, + *, + include_replies: bool = True, + ): + """Fetch messages for a single conversation from its beginning.""" + self.stdout.write(f"\nProcessing channel: {conversation.name}") + + try: + messages = self._fetch_messages( + client=client, conversation=conversation, batch_size=batch_size, delay=delay + ) + + if include_replies: + for message in messages: + self._fetch_replies( + client=client, + conversation=conversation, + message=message, + delay=delay, + ) + + self.stdout.write( + self.style.SUCCESS(f"Finished processing messages from {conversation.name}") + ) + + except SlackApiError as e: + self.stdout.write( + self.style.ERROR( + f"Failed to fetch messages for {conversation.name}: {e.response['error']}" + ) + ) + + def _fetch_messages( + self, client: WebClient, conversation: Conversation, batch_size: int, delay: float + ) -> list[Message]: + """Fetch all parent messages (non-thread) for a conversation.""" + cursor = None + has_more = True + batch_messages = [] + all_threaded_parents = [] + + latest_message = ( + Message.objects.filter(conversation=conversation).order_by("-created_at").first() + ) + + while has_more: + try: + response = client.conversations_history( + channel=conversation.slack_channel_id, + cursor=cursor, + limit=batch_size, + oldest=latest_message.created_at.timestamp() if latest_message else None, + ) + self._handle_slack_response(response, "conversations_history") + + for message_data in response.get("messages", []): + if message_data.get("thread_ts") and message_data.get( + "ts" + ) != message_data.get("thread_ts"): + continue + + message = self._create_message_from_data( + client=client, + conversation=conversation, + message_data=message_data, + ) + + if message: + batch_messages.append(message) + if message.has_replies: + all_threaded_parents.append(message) + + if batch_messages: + Message.bulk_save(batch_messages) + batch_messages = [] + + cursor = response.get("response_metadata", {}).get("next_cursor") + has_more = bool(cursor) + + if delay and has_more: + time.sleep(delay) + + except SlackApiError as e: + self.stdout.write( + self.style.ERROR(f"Error fetching messages: {e.response['error']}") + ) + break + + return all_threaded_parents + + def _fetch_replies( + self, + client: WebClient, + conversation: Conversation, + message: Message, + delay: float, + ): + """Fetch all thread replies for parent messages.""" + if not message: + return + + replies_to_save = [] + + try: + latest_reply = ( + Message.objects.filter( + conversation=conversation, + parent_message=message, + ) + .order_by("-created_at") + .first() + ) + oldest_ts = latest_reply.created_at.timestamp() if latest_reply else None + + cursor = None + has_more = True + thread_reply_count = 0 + + while has_more: + params = { + "channel": conversation.slack_channel_id, + "ts": message.slack_message_id, + "cursor": cursor, + "limit": 100, + "inclusive": True, + } + if oldest_ts: + params["oldest"] = str(oldest_ts) + + response = client.conversations_replies(**params) + self._handle_slack_response(response, "conversations_replies") + + messages_in_response = response.get("messages", []) + if not messages_in_response: + break + + for reply_data in messages_in_response[1:]: + reply = self._create_message_from_data( + client=client, + message_data=reply_data, + conversation=conversation, + parent_message=message, + ) + if reply: + replies_to_save.append(reply) + thread_reply_count += 1 + + cursor = response.get("response_metadata", {}).get("next_cursor") + has_more = bool(cursor) + + if delay and has_more: + time.sleep(delay) + + except SlackApiError as e: + self.stdout.write( + self.style.ERROR( + f"Failed to fetch thread replies for message {e.response['error']}" + ) + ) + + if replies_to_save: + batch_size = 1000 + for i in range(0, len(replies_to_save), batch_size): + batch = replies_to_save[i : i + batch_size] + Message.bulk_save(batch) + + def _create_message_from_data( + self, + client: WebClient, + message_data: dict, + conversation: Conversation, + *, + parent_message: Message | None = None, + ) -> Message | None: + """Create Message instance using from_slack pattern.""" + if message_data.get("subtype") in {"channel_join", "channel_leave", "bot_message"}: + return None + + if not any( + [ + message_data.get("text"), + message_data.get("attachments"), + message_data.get("files"), + message_data.get("blocks"), + ] + ): + return None + + try: + if not (slack_user_id := (message_data.get("user") or message_data.get("bot_id"))): + return None + + try: + author = Member.objects.get( + slack_user_id=slack_user_id, workspace=conversation.workspace + ) + except Member.DoesNotExist: + try: + user_info = client.users_info(user=slack_user_id) + self._handle_slack_response(user_info, "users_info") + + author = Member.update_data( + user_info["user"], conversation.workspace, save=True + ) + self.stdout.write(self.style.SUCCESS(f"Created new member: {slack_user_id}")) + except SlackApiError as e: + self.stdout.write( + self.style.WARNING( + f"Failed to fetch user data for {slack_user_id}: {e.response['error']}" + ) + ) + return None + + return Message.update_data( + data=message_data, + conversation=conversation, + author=author, + parent_message=parent_message, + save=False, + ) + except Exception: + logger.exception("Error creating message from data") + return None + + def _handle_slack_response(self, response, api_method): + """Handle Slack API response and raise exception if needed.""" + if not response["ok"]: + error_message = f"{api_method} API call failed" + logger.error(error_message) + self.stdout.write(self.style.ERROR(error_message)) diff --git a/backend/apps/slack/migrations/0014_message.py b/backend/apps/slack/migrations/0014_message.py new file mode 100644 index 0000000000..c7d81253a2 --- /dev/null +++ b/backend/apps/slack/migrations/0014_message.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.2 on 2025-06-11 06:24 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0013_alter_conversation_total_members_count_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Message", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ("is_thread_parent", models.BooleanField(default=False, verbose_name="Is Thread")), + ( + "slack_message_id", + models.CharField(max_length=50, verbose_name="Slack Message ID"), + ), + ("text", models.TextField(blank=True, verbose_name="Message Text")), + ( + "created_at", + models.DateTimeField(blank=True, null=True, verbose_name="Created at"), + ), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="slack.member", + ), + ), + ( + "conversation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="slack.conversation", + ), + ), + ( + "parent_message", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="thread_replies", + to="slack.message", + ), + ), + ], + options={ + "verbose_name_plural": "Messages", + "db_table": "slack_messages", + "unique_together": {("conversation", "slack_message_id")}, + }, + ), + ] diff --git a/backend/apps/slack/migrations/0015_remove_message_is_thread_parent_message_has_replies_and_more.py b/backend/apps/slack/migrations/0015_remove_message_is_thread_parent_message_has_replies_and_more.py new file mode 100644 index 0000000000..27058f357b --- /dev/null +++ b/backend/apps/slack/migrations/0015_remove_message_is_thread_parent_message_has_replies_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.2 on 2025-06-11 17:03 + +import datetime + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0014_message"), + ] + + operations = [ + migrations.RemoveField( + model_name="message", + name="is_thread_parent", + ), + migrations.AddField( + model_name="message", + name="has_replies", + field=models.BooleanField(default=False, verbose_name="Has replies"), + ), + migrations.AlterField( + model_name="message", + name="created_at", + field=models.DateTimeField( + default=datetime.datetime(2025, 6, 11, 17, 3, 32, 737168, tzinfo=datetime.UTC), + verbose_name="Created at", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="message", + name="slack_message_id", + field=models.CharField(max_length=50, verbose_name="Slack message ID"), + ), + migrations.AlterField( + model_name="message", + name="text", + field=models.TextField(verbose_name="Text"), + ), + ] diff --git a/backend/apps/slack/models/__init__.py b/backend/apps/slack/models/__init__.py index e3c913a5dd..3bbe0878de 100644 --- a/backend/apps/slack/models/__init__.py +++ b/backend/apps/slack/models/__init__.py @@ -1,4 +1,5 @@ from .conversation import Conversation from .event import Event from .member import Member +from .message import Message from .workspace import Workspace diff --git a/backend/apps/slack/models/conversation.py b/backend/apps/slack/models/conversation.py index 6ced4ce625..3f273adf90 100644 --- a/backend/apps/slack/models/conversation.py +++ b/backend/apps/slack/models/conversation.py @@ -1,4 +1,4 @@ -"""Slack app channel model.""" +"""Slack app conversation model.""" from datetime import UTC, datetime diff --git a/backend/apps/slack/models/message.py b/backend/apps/slack/models/message.py new file mode 100644 index 0000000000..9307355097 --- /dev/null +++ b/backend/apps/slack/models/message.py @@ -0,0 +1,101 @@ +"""Slack app message model.""" + +from datetime import UTC, datetime + +from django.db import models + +from apps.common.models import BulkSaveModel, TimestampedModel +from apps.common.utils import truncate +from apps.slack.models.conversation import Conversation +from apps.slack.models.member import Member + + +class Message(TimestampedModel): + """Slack Message model.""" + + class Meta: + db_table = "slack_messages" + verbose_name_plural = "Messages" + unique_together = ("conversation", "slack_message_id") + + created_at = models.DateTimeField(verbose_name="Created at") + has_replies = models.BooleanField(verbose_name="Has replies", default=False) + slack_message_id = models.CharField(verbose_name="Slack message ID", max_length=50) + text = models.TextField(verbose_name="Text") + + # FKs. + author = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="messages") + conversation = models.ForeignKey( + Conversation, on_delete=models.CASCADE, related_name="messages" + ) + parent_message = models.ForeignKey( + "self", + on_delete=models.CASCADE, + related_name="thread_replies", + null=True, + blank=True, + ) + + def __str__(self): + """Human readable representation.""" + return truncate(self.text, 50) + + def from_slack( + self, + message_data: dict, + conversation: Conversation, + author: Member, + *, + parent_message: "Message | None" = None, + ) -> None: + """Update instance based on Slack message data.""" + self.created_at = datetime.fromtimestamp(float(message_data["ts"]), tz=UTC) + self.has_replies = message_data.get("reply_count", 0) > 0 + self.slack_message_id = message_data.get("ts", "") + self.text = message_data.get("text", "") + + self.author = author + self.conversation = conversation + self.parent_message = parent_message + + @staticmethod + def bulk_save(messages: list["Message"], fields=None) -> None: + """Bulk save messages.""" + BulkSaveModel.bulk_save(Message, messages, fields=fields) + + @staticmethod + def update_data( + data: dict, + conversation: Conversation, + author: Member, + *, + parent_message: "Message | None" = None, + save: bool = True, + ) -> "Message": + """Update message data. + + Args: + data (dict): Data to update the message with. + conversation (Conversation): The conversation the message belongs to. + author (Member): The author of the message. + parent_message (Message | None): The parent message if this is a thread reply. + save (bool): Whether to save the message to the database. + + Returns: + Message: The updated message instance. + + """ + slack_message_id = data["ts"] + try: + message = Message.objects.get( + slack_message_id=slack_message_id, conversation=conversation + ) + except Message.DoesNotExist: + message = Message(slack_message_id=slack_message_id, conversation=conversation) + + message.from_slack(data, conversation, author, parent_message=parent_message) + + if save: + message.save() + + return message diff --git a/backend/tests/slack/commands/management/slack_sync_data_test.py b/backend/tests/slack/commands/management/slack_sync_data_test.py new file mode 100644 index 0000000000..7d78384a5f --- /dev/null +++ b/backend/tests/slack/commands/management/slack_sync_data_test.py @@ -0,0 +1,568 @@ +"""Tests for the slack_sync_data management command.""" + +from io import StringIO +from unittest.mock import MagicMock, Mock, patch + +import pytest +from django.core.management import call_command +from slack_sdk.errors import SlackApiError + +from apps.slack.management.commands.slack_sync_data import Command +from apps.slack.models import Conversation, Member, Workspace + +CONSTANT_2 = 2 +CONSTANT_3 = 3 +TEST_TOKEN = "xoxb-test-token" # noqa: S105 +TEST_TOKEN_1 = "xoxb-token-1" # noqa: S105 +TEST_TOKEN_2 = "xoxb-token-2" # noqa: S105 + + +class TestSlackSyncDataCommand: + """Test cases for the slack_sync_data management command.""" + + @pytest.fixture + def command(self): + """Create a Command instance for testing.""" + return Command() + + @pytest.fixture + def mock_workspace(self): + """Create a mock workspace.""" + workspace = Mock(spec=Workspace) + workspace.__str__ = Mock(return_value="Test Workspace") + workspace.bot_token = TEST_TOKEN + return workspace + + @pytest.fixture + def mock_workspace_no_token(self): + """Create a mock workspace without a bot token.""" + workspace = Mock(spec=Workspace) + workspace.__str__ = Mock(return_value="Workspace No Token") + workspace.bot_token = None + return workspace + + @pytest.fixture + def mock_slack_conversations_response(self): + """Create a mock Slack conversations_list response.""" + return { + "ok": True, + "channels": [ + { + "id": "C12345", + "name": "general", + "created": "1605000000", + "is_private": False, + "is_archived": False, + "is_general": True, + "topic": {"value": "General discussion"}, + "purpose": {"value": "General purpose"}, + "creator": "U12345", + }, + { + "id": "C67890", + "name": "random", + "created": "1605000001", + "is_private": True, + "is_archived": False, + "is_general": False, + "topic": {"value": "Random chat"}, + "purpose": {"value": "Random discussions"}, + "creator": "U67890", + }, + ], + "response_metadata": { + "next_cursor": "next-cursor-123", + }, + } + + @pytest.fixture + def mock_slack_conversations_response_final(self): + """Create a mock Slack conversations_list response with no next cursor.""" + return { + "ok": True, + "channels": [ + { + "id": "C11111", + "name": "dev", + "created": "1605000002", + "is_private": False, + "is_archived": True, + "is_general": False, + "topic": {"value": "Development"}, + "purpose": {"value": "Development discussions"}, + "creator": "U11111", + }, + ], + "response_metadata": {}, + } + + @pytest.fixture + def mock_slack_users_response(self): + """Create a mock Slack users_list response.""" + return { + "ok": True, + "members": [ + { + "id": "U12345", + "name": "john.doe", + "real_name": "John Doe", + "is_bot": False, + "profile": { + "email": "john.doe@example.com", + }, + }, + { + "id": "U67890", + "name": "jane.smith", + "real_name": "Jane Smith", + "is_bot": False, + "profile": { + "email": "jane.smith@example.com", + }, + }, + ], + "response_metadata": { + "next_cursor": "user-cursor-123", + }, + } + + @pytest.fixture + def mock_slack_users_response_final(self): + """Create a mock Slack users_list response with no next cursor.""" + return { + "ok": True, + "members": [ + { + "id": "BOT123", + "name": "test.bot", + "real_name": "Test Bot", + "is_bot": True, + "profile": { + "email": "", + }, + }, + ], + "response_metadata": {}, + } + + def test_handle_no_workspaces(self, command): + """Test handle when no workspaces exist.""" + stdout = StringIO() + with patch.object(Workspace.objects, "all") as mock_all: + mock_all.return_value.exists.return_value = False + command.stdout = stdout + command.handle(batch_size=1000, delay=0.5) + + output = stdout.getvalue() + assert "No workspaces found in the database" in output + + @patch("apps.slack.management.commands.slack_sync_data.WebClient") + @patch("apps.slack.management.commands.slack_sync_data.time.sleep") + def test_handle_successful_sync( + self, + mock_sleep, + mock_web_client, + command, + mock_workspace, + mock_slack_conversations_response, + mock_slack_conversations_response_final, + mock_slack_users_response, + mock_slack_users_response_final, + ): + """Test successful synchronization of conversations and members.""" + mock_workspaces = Mock() + mock_workspaces.exists.return_value = True + mock_workspaces.__iter__ = Mock(return_value=iter([mock_workspace])) + + mock_client = Mock() + mock_web_client.return_value = mock_client + + mock_client.conversations_list.side_effect = [ + mock_slack_conversations_response, + mock_slack_conversations_response_final, + ] + mock_client.users_list.side_effect = [ + mock_slack_users_response, + mock_slack_users_response_final, + ] + + mock_conversation1 = Mock() + mock_conversation2 = Mock() + mock_conversation3 = Mock() + + mock_member1 = Mock() + mock_member2 = Mock() + mock_member3 = Mock() + + stdout = StringIO() + with ( + patch.object(Workspace.objects, "all", return_value=mock_workspaces), + patch.object(Conversation, "update_data") as mock_conv_update, + patch.object(Conversation, "bulk_save") as mock_conv_bulk_save, + patch.object(Member, "update_data") as mock_member_update, + patch.object(Member, "bulk_save") as mock_member_bulk_save, + ): + mock_conv_update.side_effect = [ + mock_conversation1, + mock_conversation2, + mock_conversation3, + ] + mock_member_update.side_effect = [mock_member1, mock_member2, mock_member3] + + command.stdout = stdout + command.handle(batch_size=10, delay=0.1) + + mock_web_client.assert_called_once_with(token=TEST_TOKEN) + + assert mock_client.conversations_list.call_count == CONSTANT_2 + mock_client.conversations_list.assert_any_call( + cursor=None, + exclude_archived=False, + limit=10, + timeout=30, + types="public_channel,private_channel", + ) + mock_client.conversations_list.assert_any_call( + cursor="next-cursor-123", + exclude_archived=False, + limit=10, + timeout=30, + types="public_channel,private_channel", + ) + + assert mock_client.users_list.call_count == CONSTANT_2 + mock_client.users_list.assert_any_call(cursor=None, limit=10, timeout=30) + mock_client.users_list.assert_any_call(cursor="user-cursor-123", limit=10, timeout=30) + + assert mock_conv_update.call_count == CONSTANT_3 + mock_conv_update.assert_any_call( + mock_slack_conversations_response["channels"][0], mock_workspace + ) + mock_conv_update.assert_any_call( + mock_slack_conversations_response["channels"][1], mock_workspace + ) + mock_conv_update.assert_any_call( + mock_slack_conversations_response_final["channels"][0], mock_workspace + ) + + assert mock_member_update.call_count == CONSTANT_3 + mock_member_update.assert_any_call(mock_slack_users_response["members"][0], mock_workspace) + mock_member_update.assert_any_call(mock_slack_users_response["members"][1], mock_workspace) + mock_member_update.assert_any_call( + mock_slack_users_response_final["members"][0], mock_workspace + ) + + mock_conv_bulk_save.assert_called_once_with( + [ + mock_conversation1, + mock_conversation2, + mock_conversation3, + ] + ) + mock_member_bulk_save.assert_called_once_with([mock_member1, mock_member2, mock_member3]) + + mock_sleep.assert_called_with(0.1) + + output = stdout.getvalue() + assert "Processing workspace: Test Workspace" in output + assert "Fetching conversations for Test Workspace" in output + assert "Populated 3 channels" in output + assert "Fetching members for Test Workspace" in output + assert "Populated 3 members" in output + assert "Finished processing all workspaces" in output + + @patch("apps.slack.management.commands.slack_sync_data.WebClient") + def test_handle_workspace_without_bot_token( + self, mock_web_client, command, mock_workspace_no_token + ): + """Test handling workspace without bot token.""" + mock_workspaces = Mock() + mock_workspaces.exists.return_value = True + mock_workspaces.__iter__ = Mock(return_value=iter([mock_workspace_no_token])) + + stdout = StringIO() + with patch.object(Workspace.objects, "all", return_value=mock_workspaces): + command.stdout = stdout + command.handle(batch_size=1000, delay=0.5) + + mock_web_client.assert_not_called() + + output = stdout.getvalue() + assert "Processing workspace: Workspace No Token" in output + assert "No bot token found for Workspace No Token" in output + + @patch("apps.slack.management.commands.slack_sync_data.WebClient") + def test_handle_users_api_error( + self, + mock_web_client, + command, + mock_workspace, + mock_slack_conversations_response_final, + ): + """Test handling Slack API error when fetching users.""" + mock_workspaces = Mock() + mock_workspaces.exists.return_value = True + mock_workspaces.__iter__ = Mock(return_value=iter([mock_workspace])) + + mock_client = Mock() + mock_web_client.return_value = mock_client + + mock_slack_conversations_response_final["response_metadata"] = {} + mock_client.conversations_list.return_value = mock_slack_conversations_response_final + + slack_error = SlackApiError( + message="API Error", + response={"error": "invalid_auth"}, + ) + mock_client.users_list.side_effect = slack_error + + mock_conversation = Mock() + + stdout = StringIO() + with ( + patch.object(Workspace.objects, "all", return_value=mock_workspaces), + patch.object(Conversation, "update_data", return_value=mock_conversation), + patch.object(Conversation, "bulk_save"), + ): + command.stdout = stdout + command.handle(batch_size=1000, delay=0.5) + + output = stdout.getvalue() + assert "Processing workspace: Test Workspace" in output + assert "Populated 1 channels" in output + assert "Failed to fetch members: invalid_auth" in output + + @patch("apps.slack.management.commands.slack_sync_data.WebClient") + def test_handle_no_conversations_or_members(self, mock_web_client, command, mock_workspace): + """Test handling when API returns empty results.""" + mock_workspaces = Mock() + mock_workspaces.exists.return_value = True + mock_workspaces.__iter__ = Mock(return_value=iter([mock_workspace])) + + mock_client = Mock() + mock_web_client.return_value = mock_client + + empty_conversations_response = { + "ok": True, + "channels": [], + "response_metadata": {}, + } + empty_users_response = { + "ok": True, + "members": [], + "response_metadata": {}, + } + + mock_client.conversations_list.return_value = empty_conversations_response + mock_client.users_list.return_value = empty_users_response + + stdout = StringIO() + with ( + patch.object(Workspace.objects, "all", return_value=mock_workspaces), + patch.object(Conversation, "bulk_save") as mock_conv_bulk_save, + patch.object(Member, "bulk_save") as mock_member_bulk_save, + ): + command.stdout = stdout + command.handle(batch_size=1000, delay=0.5) + + mock_conv_bulk_save.assert_not_called() + mock_member_bulk_save.assert_not_called() + + output = stdout.getvalue() + assert "Processing workspace: Test Workspace" in output + + @patch("apps.slack.management.commands.slack_sync_data.WebClient") + def test_handle_update_data_returns_none( + self, + mock_web_client, + command, + mock_workspace, + mock_slack_conversations_response_final, + mock_slack_users_response_final, + ): + """Test handling when update_data returns None.""" + mock_workspaces = Mock() + mock_workspaces.exists.return_value = True + mock_workspaces.__iter__ = Mock(return_value=iter([mock_workspace])) + + mock_client = Mock() + mock_web_client.return_value = mock_client + + mock_slack_conversations_response_final["response_metadata"] = {} + mock_slack_users_response_final["response_metadata"] = {} + + mock_client.conversations_list.return_value = mock_slack_conversations_response_final + mock_client.users_list.return_value = mock_slack_users_response_final + + stdout = StringIO() + with ( + patch.object(Workspace.objects, "all", return_value=mock_workspaces), + patch.object(Conversation, "update_data", return_value=None), + patch.object(Member, "update_data", return_value=None), + patch.object(Conversation, "bulk_save") as mock_conv_bulk_save, + patch.object(Member, "bulk_save") as mock_member_bulk_save, + ): + command.stdout = stdout + command.handle(batch_size=1000, delay=0.5) + + mock_conv_bulk_save.assert_not_called() + mock_member_bulk_save.assert_not_called() + + def test_handle_slack_response_with_invalid_response(self, command): + """Test _handle_slack_response with invalid response.""" + stdout = StringIO() + command.stdout = stdout + + response = {"ok": False} + result = command._handle_slack_response(response, "test_method") + + assert result is None + output = stdout.getvalue() + assert "test_method API call failed" in output + + def test_handle_slack_response_with_valid_response(self, command): + """Test _handle_slack_response with valid response.""" + response = {"ok": True, "data": "test"} + result = command._handle_slack_response(response, "test_method") + + assert result is None + + def test_add_arguments(self, command): + """Test add_arguments method.""" + parser = MagicMock() + command.add_arguments(parser) + + assert parser.add_argument.call_count == CONSTANT_2 + + parser.add_argument.assert_any_call( + "--batch-size", + type=int, + default=1000, + help="Number of conversations to retrieve per request", + ) + + parser.add_argument.assert_any_call( + "--delay", + type=float, + default=0.5, + help="Delay between API requests in seconds", + ) + + @patch("apps.slack.management.commands.slack_sync_data.WebClient") + @patch("apps.slack.management.commands.slack_sync_data.time.sleep") + def test_handle_with_custom_options( + self, + mock_sleep, + mock_web_client, + command, + mock_workspace, + mock_slack_conversations_response_final, + mock_slack_users_response_final, + ): + """Test handle with custom batch_size and delay options.""" + mock_workspaces = Mock() + mock_workspaces.exists.return_value = True + mock_workspaces.__iter__ = Mock(return_value=iter([mock_workspace])) + + mock_client = Mock() + mock_web_client.return_value = mock_client + + mock_slack_conversations_response_final["response_metadata"] = {} + mock_slack_users_response_final["response_metadata"] = {} + + mock_client.conversations_list.return_value = mock_slack_conversations_response_final + mock_client.users_list.return_value = mock_slack_users_response_final + + mock_conversation = Mock() + mock_member = Mock() + + stdout = StringIO() + with ( + patch.object(Workspace.objects, "all", return_value=mock_workspaces), + patch.object(Conversation, "update_data", return_value=mock_conversation), + patch.object(Member, "update_data", return_value=mock_member), + patch.object(Conversation, "bulk_save"), + patch.object(Member, "bulk_save"), + ): + command.stdout = stdout + command.handle(batch_size=500, delay=1.0) + + mock_client.conversations_list.assert_called_once_with( + cursor=None, + exclude_archived=False, + limit=500, + timeout=30, + types="public_channel,private_channel", + ) + mock_client.users_list.assert_called_once_with(cursor=None, limit=500, timeout=30) + + def test_management_command_via_call_command(self): + """Test running the command via Django's call_command.""" + stdout = StringIO() + + with ( + patch.object(Workspace.objects, "all") as mock_all, + patch("builtins.print") as mock_print, + ): + mock_all.return_value.exists.return_value = False + call_command("slack_sync_data", stdout=stdout) + + mock_print.assert_not_called() + + @patch("apps.slack.management.commands.slack_sync_data.WebClient") + def test_handle_multiple_workspaces( + self, + mock_web_client, + command, + mock_slack_conversations_response_final, + mock_slack_users_response_final, + ): + """Test handling multiple workspaces.""" + workspace1 = Mock(spec=Workspace) + workspace1.__str__ = Mock(return_value="Workspace 1") + workspace1.bot_token = TEST_TOKEN_1 + + workspace2 = Mock(spec=Workspace) + workspace2.__str__ = Mock(return_value="Workspace 2") + workspace2.bot_token = TEST_TOKEN_2 + + mock_workspaces = Mock() + mock_workspaces.exists.return_value = True + mock_workspaces.__iter__ = Mock(return_value=iter([workspace1, workspace2])) + + mock_client1 = Mock() + mock_client2 = Mock() + mock_web_client.side_effect = [mock_client1, mock_client2] + + mock_slack_conversations_response_final["response_metadata"] = {} + mock_slack_users_response_final["response_metadata"] = {} + + mock_client1.conversations_list.return_value = mock_slack_conversations_response_final + mock_client1.users_list.return_value = mock_slack_users_response_final + mock_client2.conversations_list.return_value = mock_slack_conversations_response_final + mock_client2.users_list.return_value = mock_slack_users_response_final + + mock_conversation = Mock() + mock_member = Mock() + + stdout = StringIO() + with ( + patch.object(Workspace.objects, "all", return_value=mock_workspaces), + patch.object(Conversation, "update_data", return_value=mock_conversation), + patch.object(Member, "update_data", return_value=mock_member), + patch.object(Conversation, "bulk_save") as mock_conv_bulk_save, + patch.object(Member, "bulk_save") as mock_member_bulk_save, + ): + command.stdout = stdout + command.handle(batch_size=1000, delay=0.5) + + assert mock_web_client.call_count == CONSTANT_2 + mock_web_client.assert_any_call(token=TEST_TOKEN_1) + mock_web_client.assert_any_call(token=TEST_TOKEN_2) + + assert mock_conv_bulk_save.call_count == CONSTANT_2 + assert mock_member_bulk_save.call_count == CONSTANT_2 + + output = stdout.getvalue() + assert "Processing workspace: Workspace 1" in output + assert "Processing workspace: Workspace 2" in output diff --git a/backend/tests/slack/commands/management/slack_sync_messages_test.py b/backend/tests/slack/commands/management/slack_sync_messages_test.py new file mode 100644 index 0000000000..c0511b50ce --- /dev/null +++ b/backend/tests/slack/commands/management/slack_sync_messages_test.py @@ -0,0 +1,437 @@ +"""Tests for the slack_sync_messages management command.""" + +from io import StringIO +from unittest.mock import MagicMock, Mock, patch + +import pytest +from django.core.management import call_command + +from apps.slack.management.commands.slack_sync_messages import Command +from apps.slack.models import Conversation, Member, Message, Workspace + +CONSTANT_2 = 2 +CONSTANT_3 = 3 +CONSTANT_4 = 4 +TEST_TOKEN = "xoxb-test-token" # noqa: S105 +TEST_TOKEN_1 = "xoxb-token-1" # noqa: S105 +TEST_TOKEN_2 = "xoxb-token-2" # noqa: S105 +TEST_CHANNEL_ID = "C12345" +TEST_MESSAGE_TS = "1605000000.000100" +TEST_THREAD_TS = "1605000000.000200" + + +class TestSlackSyncMessagesCommand: + """Test cases for the slack_sync_messages management command.""" + + @pytest.fixture + def command(self): + """Create a Command instance for testing.""" + return Command() + + @pytest.fixture + def mock_workspace(self): + """Create a mock workspace.""" + workspace = Mock(spec=Workspace) + workspace.name = "Test Workspace" + workspace.__str__ = Mock(return_value="Test Workspace") + workspace.bot_token = TEST_TOKEN + return workspace + + @pytest.fixture + def mock_workspace_no_token(self): + """Create a mock workspace without a bot token.""" + workspace = Mock(spec=Workspace) + workspace.name = "Workspace No Token" + workspace.__str__ = Mock(return_value="Workspace No Token") + workspace.bot_token = None + return workspace + + @pytest.fixture + def mock_conversation(self): + """Create a mock conversation.""" + conversation = Mock(spec=Conversation) + conversation.name = "general" + conversation.slack_channel_id = TEST_CHANNEL_ID + conversation.workspace = Mock() + return conversation + + @pytest.fixture + def mock_member(self): + """Create a mock member.""" + member = Mock(spec=Member) + member.slack_user_id = "U12345" + return member + + @pytest.fixture + def mock_slack_history_response(self): + """Create a mock Slack conversations_history response.""" + return { + "ok": True, + "messages": [ + { + "ts": TEST_MESSAGE_TS, + "text": "Hello world!", + "user": "U12345", + "type": "message", + }, + { + "ts": TEST_THREAD_TS, + "text": "This is a thread parent", + "user": "U67890", + "type": "message", + "reply_count": 2, + "thread_ts": TEST_THREAD_TS, + }, + ], + "response_metadata": { + "next_cursor": "next-cursor-123", + }, + } + + @pytest.fixture + def mock_slack_history_response_final(self): + """Create a mock Slack conversations_history response with no next cursor.""" + return { + "ok": True, + "messages": [ + { + "ts": "1605000000.000400", + "text": "Final message", + "user": "U22222", + "type": "message", + }, + ], + "response_metadata": {}, + } + + @pytest.fixture + def mock_slack_replies_response(self): + """Create a mock Slack conversations_replies response.""" + return { + "ok": True, + "messages": [ + { + "ts": TEST_THREAD_TS, + "text": "This is a thread parent", + "user": "U67890", + "type": "message", + "reply_count": 2, + "thread_ts": TEST_THREAD_TS, + }, + { + "ts": "1605000000.000301", + "text": "First reply", + "user": "U11111", + "type": "message", + "thread_ts": TEST_THREAD_TS, + }, + { + "ts": "1605000000.000302", + "text": "Second reply", + "user": "U33333", + "type": "message", + "thread_ts": TEST_THREAD_TS, + }, + ], + } + + @pytest.fixture + def mock_user_info_response(self): + """Create a mock Slack users_info response.""" + return { + "ok": True, + "user": { + "id": "U12345", + "name": "testuser", + "profile": { + "real_name": "Test User", + "display_name": "testuser", + "email": "test@example.com", + }, + }, + } + + def test_handle_no_workspaces(self, command): + """Test handle when no workspaces exist.""" + stdout = StringIO() + with patch.object(Workspace.objects, "all") as mock_all: + mock_all.return_value.exists.return_value = False + command.stdout = stdout + command.handle(batch_size=200, delay=0.5, channel_id=None) + + output = stdout.getvalue() + assert "No workspaces found in the database" in output + + def test_handle_workspace_no_token(self, command, mock_workspace_no_token, mock_conversation): + """Test handle when workspace has no bot token.""" + mock_workspaces = Mock() + mock_workspaces.exists.return_value = True + mock_workspaces.__iter__ = Mock(return_value=iter([mock_workspace_no_token])) + + stdout = StringIO() + with patch.object(Workspace.objects, "all", return_value=mock_workspaces): + command.stdout = stdout + command.handle(batch_size=200, delay=0.5, channel_id=None) + + output = stdout.getvalue() + assert "No bot token found for Workspace No Token" in output + + @patch("apps.slack.management.commands.slack_sync_messages.WebClient") + @patch("apps.slack.management.commands.slack_sync_messages.time.sleep") + def test_handle_successful_sync( + self, + mock_sleep, + mock_web_client, + command, + mock_workspace, + mock_conversation, + mock_member, + mock_slack_history_response, + mock_slack_history_response_final, + mock_slack_replies_response, + ): + """Test successful synchronization of messages.""" + mock_workspaces = Mock() + mock_workspaces.exists.return_value = True + mock_workspaces.__iter__ = Mock(return_value=iter([mock_workspace])) + + mock_conversations = Mock() + mock_conversations.__iter__ = Mock(return_value=iter([mock_conversation])) + + mock_client = Mock() + mock_web_client.return_value = mock_client + + mock_client.conversations_history.side_effect = [ + mock_slack_history_response, + mock_slack_history_response_final, + ] + + mock_client.conversations_replies.return_value = mock_slack_replies_response + + mock_message = Mock(spec=Message) + mock_message.has_replies = True + mock_message.slack_message_id = TEST_THREAD_TS + + stdout = StringIO() + with ( + patch.object(Workspace.objects, "all", return_value=mock_workspaces), + patch.object(Conversation.objects, "filter", return_value=mock_conversations), + patch.object(Message.objects, "filter") as mock_message_filter, + patch.object(Member.objects, "get", return_value=mock_member), + patch.object(Message, "update_data", return_value=mock_message), + patch.object(Message, "bulk_save") as mock_bulk_save, + ): + mock_message_filter.return_value.order_by.return_value.first.return_value = None + command.stdout = stdout + command.handle(batch_size=200, delay=0.5, channel_id=None) + + assert mock_client.conversations_history.call_count == CONSTANT_2 + mock_bulk_save.assert_called() + + output = stdout.getvalue() + assert "Processing workspace: Test Workspace" in output + assert "Processing channel: general" in output + assert "Finished processing all workspaces" in output + + def test_create_message_from_data_channel_join_subtype(self, command, mock_conversation): + """Test _create_message_from_data with channel_join subtype.""" + message_data = { + "ts": TEST_MESSAGE_TS, + "subtype": "channel_join", + "text": "User joined channel", + } + + mock_client = Mock() + result = command._create_message_from_data( + client=mock_client, + message_data=message_data, + conversation=mock_conversation, + parent_message=None, + ) + + assert result is None + + def test_create_message_from_data_no_content(self, command, mock_conversation): + """Test _create_message_from_data with no text, attachments, or files.""" + message_data = { + "ts": TEST_MESSAGE_TS, + "user": "U12345", + } + + mock_client = Mock() + result = command._create_message_from_data( + client=mock_client, + message_data=message_data, + conversation=mock_conversation, + parent_message=None, + ) + + assert result is None + + def test_create_message_from_data_no_user(self, command, mock_conversation): + """Test _create_message_from_data with no user or bot_id.""" + message_data = { + "ts": TEST_MESSAGE_TS, + "text": "Hello world!", + } + + mock_client = Mock() + result = command._create_message_from_data( + client=mock_client, + message_data=message_data, + conversation=mock_conversation, + parent_message=None, + ) + + assert result is None + + def test_create_message_from_data_member_not_found( + self, command, mock_conversation, mock_user_info_response + ): + """Test _create_message_from_data when member is not found.""" + message_data = { + "ts": TEST_MESSAGE_TS, + "text": "Hello world!", + "user": "U12345", + } + + mock_client = Mock() + mock_client.users_info.return_value = mock_user_info_response + + stdout = StringIO() + with ( + patch.object(Member.objects, "get", side_effect=Member.DoesNotExist), + patch.object(Member, "update_data", return_value=Mock(spec=Member)), + patch.object(Message, "update_data", return_value=Mock(spec=Message)), + ): + command.stdout = stdout + result = command._create_message_from_data( + client=mock_client, + message_data=message_data, + conversation=mock_conversation, + parent_message=None, + ) + + assert result is not None + output = stdout.getvalue() + assert "Created new member: U12345" in output + + @patch("apps.slack.management.commands.slack_sync_messages.Message.update_data") + def test_create_message_from_data_regular_message( + self, mock_update_data, command, mock_conversation, mock_member + ): + """Test _create_message_from_data with regular message.""" + message_data = { + "ts": TEST_MESSAGE_TS, + "text": "Hello world!", + "user": "U12345", + } + + mock_message = Mock(spec=Message) + mock_update_data.return_value = mock_message + + mock_client = Mock() + with patch.object(Member.objects, "get", return_value=mock_member): + result = command._create_message_from_data( + client=mock_client, + message_data=message_data, + conversation=mock_conversation, + parent_message=None, + ) + + assert result is mock_message + mock_update_data.assert_called_once_with( + data=message_data, + conversation=mock_conversation, + author=mock_member, + parent_message=None, + save=False, + ) + + @patch("apps.slack.management.commands.slack_sync_messages.time.sleep") + def test_fetch_thread_replies_success( + self, mock_sleep, command, mock_conversation, mock_member, mock_slack_replies_response + ): + """Test _fetch_thread_replies successful execution.""" + mock_client = Mock() + mock_client.conversations_replies.return_value = mock_slack_replies_response + + mock_parent = Mock(spec=Message) + mock_parent.slack_message_id = TEST_THREAD_TS + + mock_reply = Mock(spec=Message) + + stdout = StringIO() + command.stdout = stdout + + with ( + patch.object(Message.objects, "filter") as mock_filter, + patch.object(Member.objects, "get", return_value=mock_member), + patch.object(Message, "update_data", return_value=mock_reply), + patch.object(Message, "bulk_save") as mock_bulk_save, + ): + mock_filter.return_value.order_by.return_value.first.return_value = None + + command._fetch_replies( + client=mock_client, + conversation=mock_conversation, + message=mock_parent, + delay=0.5, + ) + + mock_client.conversations_replies.assert_called_once() + mock_bulk_save.assert_called_once() + + def test_handle_slack_response_with_invalid_response(self, command): + """Test _handle_slack_response with invalid response.""" + stdout = StringIO() + command.stdout = stdout + + response = {"ok": False} + command._handle_slack_response(response, "conversations_history") + + output = stdout.getvalue() + assert "conversations_history API call failed" in output + + def test_handle_slack_response_with_valid_response(self, command): + """Test _handle_slack_response with valid response.""" + response = {"ok": True, "messages": []} + command._handle_slack_response(response, "conversations_history") + + def test_add_arguments(self, command): + """Test add_arguments method.""" + parser = MagicMock() + command.add_arguments(parser) + + assert parser.add_argument.call_count == CONSTANT_3 + + parser.add_argument.assert_any_call( + "--batch-size", + type=int, + default=200, + help="Number of messages to retrieve per request", + ) + + parser.add_argument.assert_any_call( + "--delay", + type=float, + default=0.5, + help="Delay between API requests in seconds", + ) + + parser.add_argument.assert_any_call( + "--channel-id", + type=str, + help="Specific channel ID to fetch messages from", + ) + + def test_management_command_via_call_command(self): + """Test running the command via Django's call_command.""" + stdout = StringIO() + + with patch.object(Workspace.objects, "all") as mock_all: + mock_all.return_value.exists.return_value = False + call_command("slack_sync_messages", stdout=stdout) + + output = stdout.getvalue() + assert "No workspaces found in the database" in output diff --git a/backend/tests/slack/models/message_test.py b/backend/tests/slack/models/message_test.py new file mode 100644 index 0000000000..3de025e261 --- /dev/null +++ b/backend/tests/slack/models/message_test.py @@ -0,0 +1,188 @@ +from unittest.mock import Mock, patch + +from apps.slack.models.conversation import Conversation +from apps.slack.models.member import Member +from apps.slack.models.message import Message + + +def create_model_mock(model_class): + mock = Mock(spec=model_class) + mock._state = Mock() + mock.pk = 1 + return mock + + +class TestMessageModel: + def test_bulk_save(self): + mock_messages = [Mock(id=None), Mock(id=1)] + with patch("apps.common.models.BulkSaveModel.bulk_save") as mock_bulk_save: + Message.bulk_save(mock_messages) + mock_bulk_save.assert_called_once_with(Message, mock_messages, fields=None) + + def test_update_data_new_message(self, mocker): + mock_conversation = create_model_mock(Conversation) + mock_author = create_model_mock(Member) + + message_data = { + "ts": "123456.789", + "text": "Test message", + } + + mocker.patch( + "apps.slack.models.message.Message.objects.get", + side_effect=Message.DoesNotExist, + ) + patched_message_save = mocker.patch("apps.slack.models.message.Message.save") + + with ( + patch.object(Message, "conversation", create=True), + patch.object(Message, "author", create=True), + ): + result = Message.update_data( + data=message_data, conversation=mock_conversation, author=mock_author, save=True + ) + + assert result is not None + assert isinstance(result, Message) + assert result.slack_message_id == "123456.789" + assert result.text == "Test message" + assert result.conversation == mock_conversation + assert result.author == mock_author + patched_message_save.assert_called_once() + + def test_update_data_existing_message(self, mocker): + mock_conversation = create_model_mock(Conversation) + mock_author = create_model_mock(Member) + + message_data = { + "ts": "123456.789", + "text": "Updated message", + } + + mock_message_instance = create_model_mock(Message) + mock_message_instance.slack_message_id = "123456.789" + mock_message_instance.text = "Updated message" + + mocker.patch( + "apps.slack.models.message.Message.objects.get", + return_value=mock_message_instance, + ) + + result = Message.update_data( + data=message_data, conversation=mock_conversation, author=mock_author, save=True + ) + + assert result is mock_message_instance + assert result.text == "Updated message" + + mock_message_instance.from_slack.assert_called_once_with( + message_data, + mock_conversation, + mock_author, + parent_message=None, + ) + mock_message_instance.save.assert_called_once() + + def test_update_data_no_save(self, mocker): + mock_conversation = create_model_mock(Conversation) + mock_author = create_model_mock(Member) + + message_data = { + "ts": "123456.789", + "text": "Test message", + } + + mocker.patch( + "apps.slack.models.message.Message.objects.get", + side_effect=Message.DoesNotExist, + ) + + patched_save_method = mocker.patch("apps.slack.models.message.Message.save") + + with ( + patch.object(Message, "conversation", create=True), + patch.object(Message, "author", create=True), + ): + result = Message.update_data( + data=message_data, conversation=mock_conversation, author=mock_author, save=False + ) + + assert result is not None + assert isinstance(result, Message) + assert result.slack_message_id == "123456.789" + assert result.text == "Test message" + assert result.conversation == mock_conversation + assert result.author == mock_author + patched_save_method.assert_not_called() + + def test_update_data_with_thread_reply(self, mocker): + mock_conversation = create_model_mock(Conversation) + mock_author = create_model_mock(Member) + mock_parent = create_model_mock(Message) + + message_data = { + "ts": "123456.789", + "text": "Reply message", + } + + mocker.patch( + "apps.slack.models.message.Message.objects.get", + side_effect=Message.DoesNotExist, + ) + patched_message_save = mocker.patch("apps.slack.models.message.Message.save") + + with ( + patch.object(Message, "conversation", create=True), + patch.object(Message, "author", create=True), + patch.object(Message, "parent_message", create=True), + ): + result = Message.update_data( + data=message_data, + conversation=mock_conversation, + author=mock_author, + parent_message=mock_parent, + save=True, + ) + + assert result is not None + assert isinstance(result, Message) + assert result.slack_message_id == "123456.789" + assert result.text == "Reply message" + assert result.parent_message == mock_parent + assert not result.has_replies + patched_message_save.assert_called_once() + + def test_update_data_with_thread_parent(self, mocker): + mock_conversation = create_model_mock(Conversation) + mock_author = create_model_mock(Member) + + message_data = { + "ts": "123456.789", + "text": "Parent message", + "reply_count": 2, + } + + mocker.patch( + "apps.slack.models.message.Message.objects.get", + side_effect=Message.DoesNotExist, + ) + patched_message_save = mocker.patch("apps.slack.models.message.Message.save") + + with ( + patch.object(Message, "conversation", create=True), + patch.object(Message, "author", create=True), + ): + result = Message.update_data( + data=message_data, conversation=mock_conversation, author=mock_author, save=True + ) + + assert result is not None + assert isinstance(result, Message) + assert result.slack_message_id == "123456.789" + assert result.text == "Parent message" + assert result.has_replies + patched_message_save.assert_called_once() + + def test_str_method(self): + message = Message(text="Short message") + assert str(message) == "Short message" diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index eeaac82621..8802f5cb56 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -81,6 +81,7 @@ owasppcitoolkit owtf pentest pentesting +pgvector pnpmrc psycopg pygithub