From 7cf4671c4348fa2c72d732ad5cf4eb541549139b Mon Sep 17 00:00:00 2001 From: Brennon Church Date: Wed, 16 Jul 2025 11:25:02 -0700 Subject: [PATCH] feat: Add test infrastructure post refactoring (issue #24) - Add development dependencies to pyproject.toml (pytest, flake8, mypy, black, isort) - Configure tool settings in pyproject.toml for all linters - Create .flake8 configuration file with appropriate settings - Add comprehensive test structure with fixtures in conftest.py - Add unit tests for all 4 tool modules (core, message, channel, user) - Add utility tests and integration test framework - Create GitHub Actions workflow for CI/CD with non-blocking linting - Update .gitignore with missing Python patterns (*.pyc, .mypy_cache/) - Configure all linters in notify-only mode as requested - Total of 28 tools confirmed in repository --- .flake8 | 18 +++ .github/workflows/test.yml | 101 +++++++++++++++++ .gitignore | 2 + pyproject.toml | 83 ++++++++++++++ tests/__init__.py | 1 + tests/conftest.py | 178 ++++++++++++++++++++++++++++++ tests/test_channel_management.py | 140 ++++++++++++++++++++++++ tests/test_core.py | 181 +++++++++++++++++++++++++++++++ tests/test_integration.py | 53 +++++++++ tests/test_message_management.py | 141 ++++++++++++++++++++++++ tests/test_user_management.py | 155 ++++++++++++++++++++++++++ tests/test_utils.py | 107 ++++++++++++++++++ 12 files changed, 1160 insertions(+) create mode 100644 .flake8 create mode 100644 .github/workflows/test.yml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_channel_management.py create mode 100644 tests/test_core.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_message_management.py create mode 100644 tests/test_user_management.py create mode 100644 tests/test_utils.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..84954bd --- /dev/null +++ b/.flake8 @@ -0,0 +1,18 @@ +[flake8] +max-line-length = 120 +extend-ignore = E203, W503 +exclude = + .git, + __pycache__, + .venv, + venv, + build, + dist, + *.egg-info, + .mypy_cache, + .pytest_cache, + htmlcov +per-file-ignores = + __init__.py:F401 + tests/*.py:D100,D101,D102,D103 +max-complexity = 10 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ed531cd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,101 @@ +name: Test and Lint + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests with pytest + run: | + pytest tests/ -v --tb=short --cov=src/slack_mcp --cov-report=term-missing --cov-report=html + + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: htmlcov/ + if: always() + + # Linting checks (non-blocking) + - name: Run flake8 linting + run: | + flake8 src/ --count --statistics || true + continue-on-error: true + + - name: Run mypy type checking + run: | + mypy src/ || true + continue-on-error: true + + - name: Check code formatting with black + run: | + black --check src/ || true + continue-on-error: true + + - name: Check import sorting with isort + run: | + isort --check-only src/ || true + continue-on-error: true + + # Post linting results as comment (for PRs) + - name: Comment linting results + uses: actions/github-script@v7 + if: github.event_name == 'pull_request' + with: + script: | + const output = ` + ## 🔍 Linting Results (Non-blocking) + + The following linting checks were run. These are informational only and will not block the merge: + + - **flake8**: Check the job logs for style issues + - **mypy**: Check the job logs for type checking issues + - **black**: Check the job logs for formatting suggestions + - **isort**: Check the job logs for import sorting suggestions + + To run these locally: + \`\`\`bash + pip install -e ".[dev]" + flake8 src/ + mypy src/ + black --check src/ + isort --check-only src/ + \`\`\` + `; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: output + }) + continue-on-error: true diff --git a/.gitignore b/.gitignore index c128b86..421bbe0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Python __pycache__/ *.py[cod] +*.pyc *$py.class *.so .Python @@ -68,6 +69,7 @@ htmlcov/ .pytest_cache/ .tox/ .cache +.mypy_cache/ # Jupyter .ipynb_checkpoints diff --git a/pyproject.toml b/pyproject.toml index 2859ec7..65cf2e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,3 +16,86 @@ dependencies = [ "python-dotenv>=1.1.1", "slack-sdk>=3.35.0", ] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=4.1.0", + "flake8>=7.0.0", + "mypy>=1.8.0", + "black>=24.0.0", + "isort>=5.13.0", +] + +[tool.black] +line-length = 120 +target-version = ['py312'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +line_length = 120 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true + +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +disallow_incomplete_defs = false +check_untyped_defs = true +disallow_untyped_decorators = false +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +follow_imports = "normal" +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +addopts = "-v --tb=short --strict-markers" +asyncio_mode = "auto" +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", +] + +[tool.coverage.run] +source = ["src"] +omit = [ + "*/tests/*", + "*/test_*.py", + "*/__init__.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0acc0a9 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for Slack MCP Server.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..49a5a5f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,178 @@ +"""Pytest configuration and shared fixtures.""" + +import asyncio +from typing import Generator, AsyncGenerator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import pytest_asyncio +from slack_sdk import WebClient +from slack_sdk.web.async_client import AsyncWebClient + + +@pytest.fixture(scope="session") +def event_loop(): + """Create an instance of the default event loop for the test session.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +def mock_slack_client(): + """Mock Slack WebClient for testing.""" + client = MagicMock(spec=WebClient) + client.auth_test.return_value = { + "ok": True, + "url": "https://test.slack.com/", + "team": "Test Team", + "user": "test_user", + "team_id": "T1234567890", + "user_id": "U1234567890", + "bot_id": "B1234567890" + } + return client + + +@pytest_asyncio.fixture +async def mock_async_slack_client(): + """Mock Async Slack WebClient for testing.""" + client = AsyncMock(spec=AsyncWebClient) + client.auth_test.return_value = { + "ok": True, + "url": "https://test.slack.com/", + "team": "Test Team", + "user": "test_user", + "team_id": "T1234567890", + "user_id": "U1234567890", + "bot_id": "B1234567890" + } + return client + + +@pytest.fixture +def mock_mcp_server(): + """Mock MCP server instance.""" + server = MagicMock() + server.tool = MagicMock() + return server + + +@pytest.fixture +def sample_channel_response(): + """Sample channel response from Slack API.""" + return { + "ok": True, + "channel": { + "id": "C1234567890", + "name": "general", + "is_channel": True, + "is_group": False, + "is_im": False, + "is_mpim": False, + "is_private": False, + "created": 1360782804, + "is_archived": False, + "is_general": True, + "unlinked": 0, + "name_normalized": "general", + "is_shared": False, + "is_org_shared": False, + "is_member": True, + "is_pending_ext_shared": False, + "pending_shared": [], + "context_team_id": "T1234567890", + "parent_conversation": None, + "creator": "U1234567890", + "is_ext_shared": False, + "shared_team_ids": ["T1234567890"], + "pending_connected_team_ids": [], + "topic": { + "value": "Company-wide announcements and work-based matters", + "creator": "U1234567890", + "last_set": 1360782804 + }, + "purpose": { + "value": "This channel is for team-wide communication and announcements.", + "creator": "U1234567890", + "last_set": 1360782804 + }, + "num_members": 100 + } + } + + +@pytest.fixture +def sample_user_response(): + """Sample user response from Slack API.""" + return { + "ok": True, + "user": { + "id": "U1234567890", + "team_id": "T1234567890", + "name": "test_user", + "deleted": False, + "color": "9f69e7", + "real_name": "Test User", + "tz": "America/Los_Angeles", + "tz_label": "Pacific Standard Time", + "tz_offset": -28800, + "profile": { + "avatar_hash": "g1234567890", + "status_text": "Working from home", + "status_emoji": ":house_with_garden:", + "real_name": "Test User", + "display_name": "testuser", + "real_name_normalized": "Test User", + "display_name_normalized": "testuser", + "email": "test@example.com", + "image_original": "https://avatars.slack-edge.com/test_original.jpg", + "image_24": "https://avatars.slack-edge.com/test_24.jpg", + "image_32": "https://avatars.slack-edge.com/test_32.jpg", + "image_48": "https://avatars.slack-edge.com/test_48.jpg", + "image_72": "https://avatars.slack-edge.com/test_72.jpg", + "image_192": "https://avatars.slack-edge.com/test_192.jpg", + "image_512": "https://avatars.slack-edge.com/test_512.jpg", + "team": "T1234567890" + }, + "is_admin": False, + "is_owner": False, + "is_primary_owner": False, + "is_restricted": False, + "is_ultra_restricted": False, + "is_bot": False, + "is_app_user": False, + "updated": 1234567890 + } + } + + +@pytest.fixture +def sample_message_response(): + """Sample message response from Slack API.""" + return { + "ok": True, + "channel": "C1234567890", + "ts": "1234567890.123456", + "message": { + "bot_id": "B1234567890", + "type": "message", + "text": "Hello, World!", + "user": "U1234567890", + "ts": "1234567890.123456", + "team": "T1234567890", + "bot_profile": { + "id": "B1234567890", + "deleted": False, + "name": "Test Bot", + "updated": 1234567890, + "app_id": "A1234567890", + "icons": { + "image_36": "https://a.slack-edge.com/test_36.png", + "image_48": "https://a.slack-edge.com/test_48.png", + "image_72": "https://a.slack-edge.com/test_72.png" + }, + "team_id": "T1234567890" + } + } + } diff --git a/tests/test_channel_management.py b/tests/test_channel_management.py new file mode 100644 index 0000000..aeb35cb --- /dev/null +++ b/tests/test_channel_management.py @@ -0,0 +1,140 @@ +"""Unit tests for channel management tools.""" + +import pytest +from unittest.mock import patch, AsyncMock, MagicMock +from slack_sdk.errors import SlackApiError + +from slack_mcp.tools import channel_management + + +class TestChannelManagementTools: + """Test cases for channel management Slack MCP tools.""" + + def test_register_tools(self, mock_mcp_server): + """Test that all channel management tools are properly registered.""" + channel_management.register_tools(mock_mcp_server) + + # Verify tool decorator was called for each tool + assert mock_mcp_server.tool.call_count >= 9 # At least 9 channel management tools + + # Check that specific tools were registered + tool_names = [call[0][0] for call in mock_mcp_server.tool.call_args_list] + expected_tools = [ + "create_channel", + "archive_channel", + "join_channel", + "leave_channel", + "invite_to_channel", + "remove_from_channel", + "set_channel_topic", + "set_channel_purpose", + "list_channel_members" + ] + for tool in expected_tools: + assert tool in tool_names + + @pytest.mark.asyncio + @patch('slack_mcp.tools.channel_management.init_async_client') + @patch('slack_mcp.tools.channel_management.make_slack_request') + @patch('slack_mcp.tools.channel_management.validate_slack_token') + async def test_create_channel_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test creating a channel successfully.""" + # Setup mocks + mock_validate.return_value = None + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + mock_make_request.return_value = { + "ok": True, + "channel": { + "id": "C123NEW", + "name": "new-channel", + "is_channel": True, + "is_private": False, + "created": 1234567890 + } + } + + # Register tools and get the function + channel_management.register_tools(mock_mcp_server) + create_channel_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "create_channel": + create_channel_func = call[0][1] + break + + # Test creating channel + result = await create_channel_func( + name="new-channel", + is_private=False + ) + + assert "✅ **Channel Created Successfully**" in result + assert "Name: #new-channel" in result + assert "ID: C123NEW" in result + + @pytest.mark.asyncio + @patch('slack_mcp.tools.channel_management.init_async_client') + @patch('slack_mcp.tools.channel_management.make_slack_request') + @patch('slack_mcp.tools.channel_management.validate_slack_token') + async def test_archive_channel_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test archiving a channel successfully.""" + # Setup mocks + mock_validate.return_value = None + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + mock_make_request.return_value = { + "ok": True + } + + # Register tools and get the function + channel_management.register_tools(mock_mcp_server) + archive_channel_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "archive_channel": + archive_channel_func = call[0][1] + break + + # Test archiving channel + result = await archive_channel_func(channel="C123") + + assert "✅ **Channel Archived Successfully**" in result + assert "Channel C123" in result + + @pytest.mark.asyncio + @patch('slack_mcp.tools.channel_management.init_async_client') + @patch('slack_mcp.tools.channel_management.make_slack_request') + @patch('slack_mcp.tools.channel_management.validate_slack_token') + async def test_invite_to_channel_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test inviting users to a channel successfully.""" + # Setup mocks + mock_validate.return_value = None + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + mock_make_request.return_value = { + "ok": True, + "channel": { + "id": "C123", + "name": "general" + } + } + + # Register tools and get the function + channel_management.register_tools(mock_mcp_server) + invite_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "invite_to_channel": + invite_func = call[0][1] + break + + # Test inviting users + result = await invite_func( + channel="C123", + users="U123,U456" + ) + + assert "✅ **Users Invited Successfully**" in result + assert "Users: U123,U456" in result + assert "Channel: general (C123)" in result diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..1c84c2c --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,181 @@ +"""Unit tests for core tools.""" + +import pytest +from unittest.mock import patch, AsyncMock, MagicMock +from slack_sdk.errors import SlackApiError + +from slack_mcp.tools import core +from slack_mcp.utils.client import set_slack_client + + +class TestCoreTools: + """Test cases for core Slack MCP tools.""" + + def test_register_tools(self, mock_mcp_server): + """Test that all core tools are properly registered.""" + core.register_tools(mock_mcp_server) + + # Verify tool decorator was called for each tool + assert mock_mcp_server.tool.call_count >= 7 # At least 7 core tools + + # Check that specific tools were registered + tool_names = [call[0][0] for call in mock_mcp_server.tool.call_args_list] + expected_tools = [ + "set_slack_token", + "test_slack_connection", + "send_message", + "list_channels", + "get_channel_info", + "get_user_info" + ] + for tool in expected_tools: + assert tool in tool_names + + @patch('slack_mcp.tools.core.WebClient') + @patch('slack_mcp.tools.core.set_slack_client') + def test_set_slack_token_valid(self, mock_set_client, mock_webclient_class, mock_mcp_server): + """Test setting a valid Slack token.""" + # Setup mock + mock_client = MagicMock() + mock_webclient_class.return_value = mock_client + mock_client.auth_test.return_value = { + "ok": True, + "bot_id": "B123", + "user_id": "U123", + "team": "Test Team", + "team_id": "T123" + } + + # Register tools and get the function + core.register_tools(mock_mcp_server) + set_token_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "set_slack_token": + set_token_func = call[0][1] + break + + # Test valid token + result = set_token_func("xoxb-valid-token") + + assert result["success"] is True + assert "bot_id" in result + assert result["bot_id"] == "B123" + mock_set_client.assert_called_once_with(mock_client) + + def test_set_slack_token_invalid_format(self, mock_mcp_server): + """Test setting an invalid token format.""" + # Register tools and get the function + core.register_tools(mock_mcp_server) + set_token_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "set_slack_token": + set_token_func = call[0][1] + break + + # Test invalid token format + result = set_token_func("invalid-token") + + assert result["success"] is False + assert "Invalid token format" in result["error"] + + @pytest.mark.asyncio + @patch('slack_mcp.tools.core.init_async_client') + @patch('slack_mcp.tools.core.make_slack_request') + @patch('slack_mcp.tools.core.validate_slack_token') + async def test_test_slack_connection_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test successful Slack connection test.""" + # Setup mocks + mock_validate.return_value = None # No exception + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + # Mock API responses + mock_make_request.side_effect = [ + { # auth_test response + "ok": True, + "bot_id": "B123", + "user_id": "U123", + "team": "Test Team", + "team_id": "T123", + "url": "https://test.slack.com" + }, + { # team_info response + "ok": True, + "team": { + "name": "Test Team", + "domain": "testteam", + "email_domain": "testteam.com", + "icon": {"image_132": "https://test.com/icon.png"} + } + } + ] + + # Register tools and get the function + core.register_tools(mock_mcp_server) + test_conn_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "test_slack_connection": + test_conn_func = call[0][1] + break + + # Test connection + result = await test_conn_func() + + assert "✅ **Slack Connection Successful**" in result + assert "Bot ID: B123" in result + assert "Team: Test Team" in result + assert "Workspace Information" in result + + @pytest.mark.asyncio + @patch('slack_mcp.tools.core.init_async_client') + @patch('slack_mcp.tools.core.make_slack_request') + @patch('slack_mcp.tools.core.validate_slack_token') + async def test_send_message_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test sending a message successfully.""" + # Setup mocks + mock_validate.return_value = None + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + mock_make_request.return_value = { + "ok": True, + "channel": "C123", + "ts": "1234567890.123456" + } + + # Register tools and get the function + core.register_tools(mock_mcp_server) + send_msg_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "send_message": + send_msg_func = call[0][1] + break + + # Test sending message + result = await send_msg_func(channel="#general", text="Hello, World!") + + assert "✅ **Message Sent Successfully**" in result + assert "Channel: C123" in result + assert "Hello, World!" in result + + @pytest.mark.asyncio + @patch('slack_mcp.tools.core.validate_slack_token') + async def test_send_message_no_token(self, mock_validate, mock_mcp_server): + """Test sending a message without a token.""" + # Setup mock to raise ValueError + mock_validate.side_effect = ValueError("No token configured") + + # Register tools and get the function + core.register_tools(mock_mcp_server) + send_msg_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "send_message": + send_msg_func = call[0][1] + break + + # Test sending message without token + result = await send_msg_func(channel="#general", text="Hello") + + assert "❌ Configuration Error" in result + assert "No token configured" in result + diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..ee955ac --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,53 @@ +"""Integration tests for the entire Slack MCP server.""" + +import pytest +from unittest.mock import MagicMock, AsyncMock, patch + + +@pytest.mark.integration +class TestIntegrationSlackMCP: + """Integration test cases for Slack MCP server functionality.""" + + @pytest.mark.asyncio + async def test_mcp_end_to_end(self, mock_mcp_server, mock_async_slack_client, sample_channel_response, sample_user_response, sample_message_response): + """End-to-end integration test for the Slack MCP server.""" + + # Assign mocked Slack client + with patch('slack_mcp_server.mcp') as mcp_mock: + with patch('slack_mcp_server.WebClient', return_value=mock_async_slack_client): + # Initialize mocked Slack client + from slack_mcp.utils.client import set_slack_client + set_slack_client(mock_async_slack_client) + + # Setup mock responses + mock_async_slack_client.conversations_info.return_value = sample_channel_response + mock_async_slack_client.users_info.return_value = sample_user_response + mock_async_slack_client.chat_postMessage.return_value = sample_message_response + + # ACT - Perform MCP operations + core_tool_result = await mock_mcp_server.perform_tool_action('get_channel_info', channel='C1234567890') + + # ASSERT - Validate response of core tools + assert "\u2714 **Channel Information**" in core_tool_result, "Channel information tool failed." + assert "Channel ID: C1234567890" in core_tool_result + assert "Channel Name: #general" in core_tool_result + + user_tool_result = await mock_mcp_server.perform_tool_action('get_user_info', user='U1234567890') + assert "\u2714 **User Information**" in user_tool_result, "User information tool failed." + assert "User ID: U1234567890" in user_tool_result + assert "Real Name: Test User" in user_tool_result + + # Simulate sending a message + message_tool_result = await mock_mcp_server.perform_tool_action( + 'send_message', + channel='C1234567890', + text="Test message sent during integration test" + ) + assert "\u2714 **Message Sent Successfully**" in message_tool_result, "Message send tool failed." + assert "Message: Test message sent during integration test" in message_tool_result + assert "Channel: C1234567890" in message_tool_result + + # Ensure all tools were correctly triggered + mcp_mock.perform_tool_action.assert_any_call('get_channel_info', channel='C1234567890') + mcp_mock.perform_tool_action.assert_any_call('get_user_info', user='U1234567890') + mcp_mock.perform_tool_action.assert_any_call('send_message', channel='C1234567890', text="Test message sent during integration test") diff --git a/tests/test_message_management.py b/tests/test_message_management.py new file mode 100644 index 0000000..de129b6 --- /dev/null +++ b/tests/test_message_management.py @@ -0,0 +1,141 @@ +"""Unit tests for message management tools.""" + +import pytest +from unittest.mock import patch, AsyncMock, MagicMock +from slack_sdk.errors import SlackApiError + +from slack_mcp.tools import message_management + + +class TestMessageManagementTools: + """Test cases for message management Slack MCP tools.""" + + def test_register_tools(self, mock_mcp_server): + """Test that all message management tools are properly registered.""" + message_management.register_tools(mock_mcp_server) + + # Verify tool decorator was called for each tool + assert mock_mcp_server.tool.call_count >= 8 # At least 8 message management tools + + # Check that specific tools were registered + tool_names = [call[0][0] for call in mock_mcp_server.tool.call_args_list] + expected_tools = [ + "update_message", + "delete_message", + "pin_message", + "unpin_message", + "get_message_permalink", + "schedule_message", + "get_thread_replies", + "send_direct_message" + ] + for tool in expected_tools: + assert tool in tool_names + + @pytest.mark.asyncio + @patch('slack_mcp.tools.message_management.init_async_client') + @patch('slack_mcp.tools.message_management.make_slack_request') + @patch('slack_mcp.tools.message_management.validate_slack_token') + async def test_update_message_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test updating a message successfully.""" + # Setup mocks + mock_validate.return_value = None + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + mock_make_request.return_value = { + "ok": True, + "channel": "C123", + "ts": "1234567890.123456", + "text": "Updated message" + } + + # Register tools and get the function + message_management.register_tools(mock_mcp_server) + update_msg_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "update_message": + update_msg_func = call[0][1] + break + + # Test updating message + result = await update_msg_func( + channel="C123", + ts="1234567890.123456", + text="Updated message" + ) + + assert "✅ **Message Updated Successfully**" in result + assert "Channel: C123" in result + assert "Updated message" in result + + @pytest.mark.asyncio + @patch('slack_mcp.tools.message_management.init_async_client') + @patch('slack_mcp.tools.message_management.make_slack_request') + @patch('slack_mcp.tools.message_management.validate_slack_token') + async def test_delete_message_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test deleting a message successfully.""" + # Setup mocks + mock_validate.return_value = None + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + mock_make_request.return_value = { + "ok": True, + "channel": "C123", + "ts": "1234567890.123456" + } + + # Register tools and get the function + message_management.register_tools(mock_mcp_server) + delete_msg_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "delete_message": + delete_msg_func = call[0][1] + break + + # Test deleting message + result = await delete_msg_func( + channel="C123", + ts="1234567890.123456" + ) + + assert "✅ **Message Deleted Successfully**" in result + assert "Channel: C123" in result + + @pytest.mark.asyncio + @patch('slack_mcp.tools.message_management.init_async_client') + @patch('slack_mcp.tools.message_management.make_slack_request') + @patch('slack_mcp.tools.message_management.validate_slack_token') + async def test_schedule_message_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test scheduling a message successfully.""" + # Setup mocks + mock_validate.return_value = None + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + mock_make_request.return_value = { + "ok": True, + "scheduled_message_id": "Q1234567890", + "channel": "C123", + "post_at": 1735689600 + } + + # Register tools and get the function + message_management.register_tools(mock_mcp_server) + schedule_msg_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "schedule_message": + schedule_msg_func = call[0][1] + break + + # Test scheduling message + result = await schedule_msg_func( + channel="C123", + text="Future message", + post_at=1735689600 + ) + + assert "✅ **Message Scheduled Successfully**" in result + assert "Scheduled ID: Q1234567890" in result + assert "Channel: C123" in result diff --git a/tests/test_user_management.py b/tests/test_user_management.py new file mode 100644 index 0000000..c3a1191 --- /dev/null +++ b/tests/test_user_management.py @@ -0,0 +1,155 @@ +"""Unit tests for user management tools.""" + +import pytest +from unittest.mock import patch, AsyncMock, MagicMock +from slack_sdk.errors import SlackApiError + +from slack_mcp.tools import user_management + + +class TestUserManagementTools: + """Test cases for user management Slack MCP tools.""" + + def test_register_tools(self, mock_mcp_server): + """Test that all user management tools are properly registered.""" + user_management.register_tools(mock_mcp_server) + + # Verify tool decorator was called for each tool + assert mock_mcp_server.tool.call_count >= 4 # At least 4 user management tools + + # Check that specific tools were registered + tool_names = [call[0][0] for call in mock_mcp_server.tool.call_args_list] + expected_tools = [ + "list_workspace_members", + "get_user_presence", + "get_user_timezone", + "search_slack_users" + ] + for tool in expected_tools: + assert tool in tool_names + + @pytest.mark.asyncio + @patch('slack_mcp.tools.user_management.init_async_client') + @patch('slack_mcp.tools.user_management.make_slack_request') + @patch('slack_mcp.tools.user_management.validate_slack_token') + async def test_list_workspace_members_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test listing workspace members successfully.""" + # Setup mocks + mock_validate.return_value = None + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + mock_make_request.return_value = { + "ok": True, + "members": [ + { + "id": "U123", + "name": "testuser1", + "real_name": "Test User 1", + "is_bot": False, + "is_admin": False + }, + { + "id": "U456", + "name": "testuser2", + "real_name": "Test User 2", + "is_bot": False, + "is_admin": True + } + ] + } + + # Register tools and get the function + user_management.register_tools(mock_mcp_server) + list_members_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "list_workspace_members": + list_members_func = call[0][1] + break + + # Test listing members + result = await list_members_func(limit=100) + + assert "👥 **Workspace Members**" in result + assert "Total members: 2" in result + assert "Test User 1" in result + assert "Test User 2" in result + + @pytest.mark.asyncio + @patch('slack_mcp.tools.user_management.init_async_client') + @patch('slack_mcp.tools.user_management.make_slack_request') + @patch('slack_mcp.tools.user_management.validate_slack_token') + async def test_get_user_presence_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test getting user presence successfully.""" + # Setup mocks + mock_validate.return_value = None + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + mock_make_request.return_value = { + "ok": True, + "presence": "active", + "online": True, + "auto_away": False, + "manual_away": False, + "connection_count": 1, + "last_activity": 1234567890 + } + + # Register tools and get the function + user_management.register_tools(mock_mcp_server) + get_presence_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "get_user_presence": + get_presence_func = call[0][1] + break + + # Test getting presence + result = await get_presence_func(user="U123") + + assert "🟢 **User Presence**" in result + assert "Status: active" in result + assert "Online: Yes" in result + + @pytest.mark.asyncio + @patch('slack_mcp.tools.user_management.init_async_client') + @patch('slack_mcp.tools.user_management.make_slack_request') + @patch('slack_mcp.tools.user_management.validate_slack_token') + async def test_search_slack_users_success(self, mock_validate, mock_make_request, mock_init_client, mock_mcp_server): + """Test searching for users successfully.""" + # Setup mocks + mock_validate.return_value = None + mock_async_client = AsyncMock() + mock_init_client.return_value = mock_async_client + + # Mock users.list response for search + mock_make_request.return_value = { + "ok": True, + "members": [ + { + "id": "U123", + "name": "john_doe", + "real_name": "John Doe", + "profile": { + "email": "john@example.com", + "display_name": "John" + }, + "is_bot": False + } + ] + } + + # Register tools and get the function + user_management.register_tools(mock_mcp_server) + search_users_func = None + for call in mock_mcp_server.tool.call_args_list: + if call[0][0] == "search_slack_users": + search_users_func = call[0][1] + break + + # Test searching users + result = await search_users_func(query="john") + + assert "🔍 **Search Results**" in result + assert "John Doe" in result + assert "@john_doe" in result diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..c5a6235 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,107 @@ +"""Unit tests for utility functions.""" + +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from slack_mcp.utils.client import ( + validate_slack_token, + init_async_client, + set_slack_client, + get_slack_client +) +from slack_mcp.utils.errors import make_slack_request + + +class TestClientUtils: + """Test cases for client utility functions.""" + + def test_validate_slack_token_no_client(self): + """Test validating token when no client is set.""" + # Clear any existing client + set_slack_client(None) + + with pytest.raises(ValueError, match="No Slack token configured"): + validate_slack_token() + + def test_validate_slack_token_with_client(self): + """Test validating token when client is set.""" + mock_client = MagicMock(spec=WebClient) + set_slack_client(mock_client) + + # Should not raise an exception + validate_slack_token() + + # Clean up + set_slack_client(None) + + def test_get_and_set_slack_client(self): + """Test getting and setting the Slack client.""" + # Initially should be None + assert get_slack_client() is None + + # Set a client + mock_client = MagicMock(spec=WebClient) + set_slack_client(mock_client) + + # Should return the same client + assert get_slack_client() == mock_client + + # Clean up + set_slack_client(None) + + @pytest.mark.asyncio + async def test_init_async_client_success(self): + """Test initializing async client successfully.""" + mock_client = MagicMock(spec=WebClient) + mock_client.token = "xoxb-test-token" + set_slack_client(mock_client) + + async_client = await init_async_client() + assert async_client is not None + assert async_client.token == "xoxb-test-token" + + # Clean up + set_slack_client(None) + + @pytest.mark.asyncio + async def test_init_async_client_no_token(self): + """Test initializing async client without token.""" + set_slack_client(None) + + async_client = await init_async_client() + assert async_client is None + + +class TestErrorUtils: + """Test cases for error handling utilities.""" + + @pytest.mark.asyncio + async def test_make_slack_request_success(self): + """Test making a successful Slack API request.""" + mock_method = AsyncMock(return_value={"ok": True, "data": "test"}) + + result = await make_slack_request(mock_method, arg1="value1") + + assert result == {"ok": True, "data": "test"} + mock_method.assert_called_once_with(arg1="value1") + + @pytest.mark.asyncio + async def test_make_slack_request_api_error(self): + """Test handling Slack API errors.""" + mock_response = {"ok": False, "error": "channel_not_found"} + mock_method = AsyncMock(side_effect=SlackApiError("Error", mock_response)) + + result = await make_slack_request(mock_method, channel="C123") + + assert result is None + + @pytest.mark.asyncio + async def test_make_slack_request_general_error(self): + """Test handling general errors.""" + mock_method = AsyncMock(side_effect=Exception("Network error")) + + result = await make_slack_request(mock_method) + + assert result is None