This is page 8 of 13. Use http://codebase.md/sooperset/mcp-atlassian?format=md&lines=true&page={x} to view the full context.

# Directory Structure

```
├── .devcontainer
│   ├── devcontainer.json
│   ├── Dockerfile
│   ├── post-create.sh
│   └── post-start.sh
├── .dockerignore
├── .env.example
├── .github
│   ├── ISSUE_TEMPLATE
│   │   ├── bug_report.yml
│   │   ├── config.yml
│   │   └── feature_request.yml
│   ├── pull_request_template.md
│   └── workflows
│       ├── docker-publish.yml
│       ├── lint.yml
│       ├── publish.yml
│       ├── stale.yml
│       └── tests.yml
├── .gitignore
├── .pre-commit-config.yaml
├── AGENTS.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── pyproject.toml
├── README.md
├── scripts
│   ├── oauth_authorize.py
│   └── test_with_real_data.sh
├── SECURITY.md
├── smithery.yaml
├── src
│   └── mcp_atlassian
│       ├── __init__.py
│       ├── confluence
│       │   ├── __init__.py
│       │   ├── client.py
│       │   ├── comments.py
│       │   ├── config.py
│       │   ├── constants.py
│       │   ├── labels.py
│       │   ├── pages.py
│       │   ├── search.py
│       │   ├── spaces.py
│       │   ├── users.py
│       │   ├── utils.py
│       │   └── v2_adapter.py
│       ├── exceptions.py
│       ├── jira
│       │   ├── __init__.py
│       │   ├── attachments.py
│       │   ├── boards.py
│       │   ├── client.py
│       │   ├── comments.py
│       │   ├── config.py
│       │   ├── constants.py
│       │   ├── epics.py
│       │   ├── fields.py
│       │   ├── formatting.py
│       │   ├── issues.py
│       │   ├── links.py
│       │   ├── projects.py
│       │   ├── protocols.py
│       │   ├── search.py
│       │   ├── sprints.py
│       │   ├── transitions.py
│       │   ├── users.py
│       │   └── worklog.py
│       ├── models
│       │   ├── __init__.py
│       │   ├── base.py
│       │   ├── confluence
│       │   │   ├── __init__.py
│       │   │   ├── comment.py
│       │   │   ├── common.py
│       │   │   ├── label.py
│       │   │   ├── page.py
│       │   │   ├── search.py
│       │   │   ├── space.py
│       │   │   └── user_search.py
│       │   ├── constants.py
│       │   └── jira
│       │       ├── __init__.py
│       │       ├── agile.py
│       │       ├── comment.py
│       │       ├── common.py
│       │       ├── issue.py
│       │       ├── link.py
│       │       ├── project.py
│       │       ├── search.py
│       │       ├── version.py
│       │       ├── workflow.py
│       │       └── worklog.py
│       ├── preprocessing
│       │   ├── __init__.py
│       │   ├── base.py
│       │   ├── confluence.py
│       │   └── jira.py
│       ├── servers
│       │   ├── __init__.py
│       │   ├── confluence.py
│       │   ├── context.py
│       │   ├── dependencies.py
│       │   ├── jira.py
│       │   └── main.py
│       └── utils
│           ├── __init__.py
│           ├── date.py
│           ├── decorators.py
│           ├── env.py
│           ├── environment.py
│           ├── io.py
│           ├── lifecycle.py
│           ├── logging.py
│           ├── oauth_setup.py
│           ├── oauth.py
│           ├── ssl.py
│           ├── tools.py
│           └── urls.py
├── tests
│   ├── __init__.py
│   ├── conftest.py
│   ├── fixtures
│   │   ├── __init__.py
│   │   ├── confluence_mocks.py
│   │   └── jira_mocks.py
│   ├── integration
│   │   ├── conftest.py
│   │   ├── README.md
│   │   ├── test_authentication.py
│   │   ├── test_content_processing.py
│   │   ├── test_cross_service.py
│   │   ├── test_mcp_protocol.py
│   │   ├── test_proxy.py
│   │   ├── test_real_api.py
│   │   ├── test_ssl_verification.py
│   │   ├── test_stdin_monitoring_fix.py
│   │   └── test_transport_lifecycle.py
│   ├── README.md
│   ├── test_preprocessing.py
│   ├── test_real_api_validation.py
│   ├── unit
│   │   ├── confluence
│   │   │   ├── __init__.py
│   │   │   ├── conftest.py
│   │   │   ├── test_client_oauth.py
│   │   │   ├── test_client.py
│   │   │   ├── test_comments.py
│   │   │   ├── test_config.py
│   │   │   ├── test_constants.py
│   │   │   ├── test_custom_headers.py
│   │   │   ├── test_labels.py
│   │   │   ├── test_pages.py
│   │   │   ├── test_search.py
│   │   │   ├── test_spaces.py
│   │   │   ├── test_users.py
│   │   │   ├── test_utils.py
│   │   │   └── test_v2_adapter.py
│   │   ├── jira
│   │   │   ├── conftest.py
│   │   │   ├── test_attachments.py
│   │   │   ├── test_boards.py
│   │   │   ├── test_client_oauth.py
│   │   │   ├── test_client.py
│   │   │   ├── test_comments.py
│   │   │   ├── test_config.py
│   │   │   ├── test_constants.py
│   │   │   ├── test_custom_headers.py
│   │   │   ├── test_epics.py
│   │   │   ├── test_fields.py
│   │   │   ├── test_formatting.py
│   │   │   ├── test_issues_markdown.py
│   │   │   ├── test_issues.py
│   │   │   ├── test_links.py
│   │   │   ├── test_projects.py
│   │   │   ├── test_protocols.py
│   │   │   ├── test_search.py
│   │   │   ├── test_sprints.py
│   │   │   ├── test_transitions.py
│   │   │   ├── test_users.py
│   │   │   └── test_worklog.py
│   │   ├── models
│   │   │   ├── __init__.py
│   │   │   ├── conftest.py
│   │   │   ├── test_base_models.py
│   │   │   ├── test_confluence_models.py
│   │   │   ├── test_constants.py
│   │   │   └── test_jira_models.py
│   │   ├── servers
│   │   │   ├── __init__.py
│   │   │   ├── test_confluence_server.py
│   │   │   ├── test_context.py
│   │   │   ├── test_dependencies.py
│   │   │   ├── test_jira_server.py
│   │   │   └── test_main_server.py
│   │   ├── test_exceptions.py
│   │   ├── test_main_transport_selection.py
│   │   └── utils
│   │       ├── __init__.py
│   │       ├── test_custom_headers.py
│   │       ├── test_date.py
│   │       ├── test_decorators.py
│   │       ├── test_env.py
│   │       ├── test_environment.py
│   │       ├── test_io.py
│   │       ├── test_lifecycle.py
│   │       ├── test_logging.py
│   │       ├── test_masking.py
│   │       ├── test_oauth_setup.py
│   │       ├── test_oauth.py
│   │       ├── test_ssl.py
│   │       ├── test_tools.py
│   │       └── test_urls.py
│   └── utils
│       ├── __init__.py
│       ├── assertions.py
│       ├── base.py
│       ├── factories.py
│       └── mocks.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/tests/unit/confluence/conftest.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Shared fixtures for Confluence unit tests.
  3 | 
  4 | This module provides specialized fixtures for testing Confluence-related functionality.
  5 | It builds upon the root conftest.py fixtures and integrates with the new test utilities
  6 | framework to provide efficient, reusable test fixtures with session-scoped caching.
  7 | """
  8 | 
  9 | import os
 10 | import sys
 11 | from pathlib import Path
 12 | from unittest.mock import MagicMock, patch
 13 | 
 14 | import pytest
 15 | 
 16 | # Add the root tests directory to PYTHONPATH
 17 | sys.path.append(str(Path(__file__).parent.parent.parent))
 18 | 
 19 | from fixtures.confluence_mocks import (
 20 |     MOCK_COMMENTS_RESPONSE,
 21 |     MOCK_CQL_SEARCH_RESPONSE,
 22 |     MOCK_LABELS_RESPONSE,
 23 |     MOCK_PAGE_RESPONSE,
 24 |     MOCK_PAGES_FROM_SPACE_RESPONSE,
 25 |     MOCK_SPACES_RESPONSE,
 26 | )
 27 | 
 28 | from mcp_atlassian.confluence.client import ConfluenceClient
 29 | from mcp_atlassian.confluence.config import ConfluenceConfig
 30 | from mcp_atlassian.utils.oauth import OAuthConfig
 31 | from tests.utils.factories import AuthConfigFactory, ConfluencePageFactory
 32 | from tests.utils.mocks import MockAtlassianClient, MockPreprocessor
 33 | 
 34 | # ============================================================================
 35 | # Session-Scoped Confluence Data Fixtures
 36 | # ============================================================================
 37 | 
 38 | 
 39 | @pytest.fixture(scope="session")
 40 | def session_confluence_spaces():
 41 |     """
 42 |     Session-scoped fixture providing Confluence space definitions.
 43 | 
 44 |     This expensive-to-create data is cached for the entire test session
 45 |     to improve test performance.
 46 | 
 47 |     Returns:
 48 |         List[Dict[str, Any]]: Complete Confluence space definitions
 49 |     """
 50 |     return [
 51 |         {
 52 |             "id": 12345,
 53 |             "key": "TEST",
 54 |             "name": "Test Space",
 55 |             "type": "global",
 56 |             "status": "current",
 57 |             "description": {"plain": {"value": "Test space for unit tests"}},
 58 |             "_links": {
 59 |                 "webui": "/spaces/TEST",
 60 |                 "self": "https://test.atlassian.net/wiki/rest/api/space/TEST",
 61 |             },
 62 |         },
 63 |         {
 64 |             "id": 12346,
 65 |             "key": "DEMO",
 66 |             "name": "Demo Space",
 67 |             "type": "global",
 68 |             "status": "current",
 69 |             "description": {"plain": {"value": "Demo space for testing"}},
 70 |             "_links": {
 71 |                 "webui": "/spaces/DEMO",
 72 |                 "self": "https://test.atlassian.net/wiki/rest/api/space/DEMO",
 73 |             },
 74 |         },
 75 |         {
 76 |             "id": 12347,
 77 |             "key": "SAMPLE",
 78 |             "name": "Sample Space",
 79 |             "type": "personal",
 80 |             "status": "current",
 81 |             "description": {"plain": {"value": "Sample personal space"}},
 82 |             "_links": {
 83 |                 "webui": "/spaces/SAMPLE",
 84 |                 "self": "https://test.atlassian.net/wiki/rest/api/space/SAMPLE",
 85 |             },
 86 |         },
 87 |     ]
 88 | 
 89 | 
 90 | @pytest.fixture(scope="session")
 91 | def session_confluence_content_types():
 92 |     """
 93 |     Session-scoped fixture providing Confluence content type definitions.
 94 | 
 95 |     Returns:
 96 |         List[Dict[str, Any]]: Mock Confluence content type data
 97 |     """
 98 |     return [
 99 |         {"name": "page", "type": "content"},
100 |         {"name": "blogpost", "type": "content"},
101 |         {"name": "comment", "type": "content"},
102 |         {"name": "attachment", "type": "content"},
103 |         {"name": "space", "type": "space"},
104 |         {"name": "user", "type": "user"},
105 |     ]
106 | 
107 | 
108 | @pytest.fixture(scope="session")
109 | def session_confluence_macros():
110 |     """
111 |     Session-scoped fixture providing Confluence macro definitions.
112 | 
113 |     Returns:
114 |         List[Dict[str, Any]]: Mock Confluence macro data
115 |     """
116 |     return [
117 |         {"name": "info", "hasBody": True, "bodyType": "rich-text"},
118 |         {"name": "warning", "hasBody": True, "bodyType": "rich-text"},
119 |         {"name": "note", "hasBody": True, "bodyType": "rich-text"},
120 |         {"name": "tip", "hasBody": True, "bodyType": "rich-text"},
121 |         {"name": "code", "hasBody": True, "bodyType": "plain-text"},
122 |         {"name": "toc", "hasBody": False},
123 |         {"name": "children", "hasBody": False},
124 |         {"name": "excerpt", "hasBody": True, "bodyType": "rich-text"},
125 |         {"name": "include", "hasBody": False},
126 |         {"name": "panel", "hasBody": True, "bodyType": "rich-text"},
127 |     ]
128 | 
129 | 
130 | # ============================================================================
131 | # Configuration Fixtures
132 | # ============================================================================
133 | 
134 | 
135 | @pytest.fixture
136 | def confluence_config_factory():
137 |     """
138 |     Factory for creating ConfluenceConfig instances with customizable options.
139 | 
140 |     Returns:
141 |         Callable: Function that creates ConfluenceConfig instances
142 | 
143 |     Example:
144 |         def test_config(confluence_config_factory):
145 |             config = confluence_config_factory(url="https://custom.atlassian.net/wiki")
146 |             assert "custom" in config.url
147 |     """
148 | 
149 |     def _create_config(**overrides):
150 |         defaults = {
151 |             "url": "https://example.atlassian.net/wiki",
152 |             "auth_type": "basic",
153 |             "username": "test_user",
154 |             "api_token": "test_token",
155 |         }
156 |         config_data = {**defaults, **overrides}
157 |         return ConfluenceConfig(**config_data)
158 | 
159 |     return _create_config
160 | 
161 | 
162 | @pytest.fixture
163 | def mock_config(confluence_config_factory):
164 |     """
165 |     Create a standard mock ConfluenceConfig instance.
166 | 
167 |     This fixture provides a consistent ConfluenceConfig for tests that don't
168 |     need custom configuration.
169 | 
170 |     Returns:
171 |         ConfluenceConfig: Standard test configuration
172 |     """
173 |     return confluence_config_factory()
174 | 
175 | 
176 | # ============================================================================
177 | # Environment Fixtures
178 | # ============================================================================
179 | 
180 | 
181 | @pytest.fixture
182 | def mock_env_vars():
183 |     """
184 |     Mock environment variables for testing.
185 | 
186 |     Note: This fixture is maintained for backward compatibility.
187 |     Consider using the environment fixtures from root conftest.py.
188 |     """
189 |     with patch.dict(
190 |         "os.environ",
191 |         {
192 |             "CONFLUENCE_URL": "https://example.atlassian.net/wiki",
193 |             "CONFLUENCE_USERNAME": "test_user",
194 |             "CONFLUENCE_API_TOKEN": "test_token",
195 |         },
196 |     ):
197 |         yield
198 | 
199 | 
200 | @pytest.fixture
201 | def confluence_auth_environment():
202 |     """
203 |     Fixture providing Confluence-specific authentication environment.
204 | 
205 |     This sets up environment variables specifically for Confluence authentication
206 |     and can be customized per test.
207 |     """
208 |     auth_config = AuthConfigFactory.create_basic_auth_config()
209 |     confluence_env = {
210 |         "CONFLUENCE_URL": f"{auth_config['url']}/wiki",
211 |         "CONFLUENCE_USERNAME": auth_config["username"],
212 |         "CONFLUENCE_API_TOKEN": auth_config["api_token"],
213 |     }
214 | 
215 |     with patch.dict(os.environ, confluence_env, clear=False):
216 |         yield confluence_env
217 | 
218 | 
219 | # ============================================================================
220 | # Mock Atlassian Client Fixtures
221 | # ============================================================================
222 | 
223 | 
224 | @pytest.fixture
225 | def mock_atlassian_confluence(
226 |     session_confluence_spaces, session_confluence_content_types
227 | ):
228 |     """
229 |     Enhanced mock of the Atlassian Confluence client.
230 | 
231 |     This fixture provides a comprehensive mock that uses session-scoped
232 |     data for improved performance and consistency.
233 | 
234 |     Args:
235 |         session_confluence_spaces: Session-scoped space definitions
236 |         session_confluence_content_types: Session-scoped content type data
237 | 
238 |     Returns:
239 |         MagicMock: Fully configured mock Confluence client
240 |     """
241 |     with patch("mcp_atlassian.confluence.client.Confluence") as mock:
242 |         confluence_instance = mock.return_value
243 | 
244 |         # Use original mock data to maintain backward compatibility for existing tests
245 |         confluence_instance.get_all_spaces.return_value = MOCK_SPACES_RESPONSE
246 | 
247 |         # Set up common return values using both legacy mocks and new factories
248 |         confluence_instance.get_page_by_id.return_value = MOCK_PAGE_RESPONSE
249 |         confluence_instance.get_page_by_title.return_value = MOCK_PAGE_RESPONSE
250 |         confluence_instance.get_all_pages_from_space.return_value = (
251 |             MOCK_PAGES_FROM_SPACE_RESPONSE
252 |         )
253 |         confluence_instance.get_page_comments.return_value = MOCK_COMMENTS_RESPONSE
254 |         confluence_instance.get_page_labels.return_value = MOCK_LABELS_RESPONSE
255 |         confluence_instance.cql.return_value = MOCK_CQL_SEARCH_RESPONSE
256 | 
257 |         # Enhanced responses using factories
258 |         confluence_instance.create_page.return_value = ConfluencePageFactory.create(
259 |             page_id="123456789", title="New Test Page"
260 |         )
261 | 
262 |         # Mock update_page to return None (as the actual method does)
263 |         confluence_instance.update_page.return_value = None
264 | 
265 |         # Mock delete_page to return None
266 |         confluence_instance.delete_page.return_value = None
267 | 
268 |         # Mock page history
269 |         confluence_instance.get_page_history.return_value = {
270 |             "results": [
271 |                 {
272 |                     "version": {"number": 1},
273 |                     "when": "2023-01-01T12:00:00.000Z",
274 |                     "by": {"displayName": "Test User"},
275 |                     "message": "Initial version",
276 |                 }
277 |             ]
278 |         }
279 | 
280 |         # Mock page ancestors
281 |         confluence_instance.get_page_ancestors.return_value = [
282 |             ConfluencePageFactory.create(page_id="parent123", title="Parent Page")
283 |         ]
284 | 
285 |         # Mock page children
286 |         confluence_instance.get_page_child_by_type.return_value = {
287 |             "results": [
288 |                 ConfluencePageFactory.create(page_id="child123", title="Child Page")
289 |             ]
290 |         }
291 | 
292 |         yield confluence_instance
293 | 
294 | 
295 | @pytest.fixture
296 | def enhanced_mock_confluence_client():
297 |     """
298 |     Enhanced mock Confluence client using the new factory system.
299 | 
300 |     This provides a more flexible mock that can be easily customized
301 |     and integrates with the factory system.
302 | 
303 |     Returns:
304 |         MagicMock: Enhanced mock Confluence client with factory integration
305 |     """
306 |     return MockAtlassianClient.create_confluence_client()
307 | 
308 | 
309 | @pytest.fixture
310 | def mock_atlassian_confluence_with_session_data(
311 |     session_confluence_spaces, session_confluence_content_types
312 | ):
313 |     """
314 |     Alternative mock using session-scoped data for new tests.
315 | 
316 |     This fixture is recommended for new tests as it uses the efficient
317 |     session-scoped data. Existing tests should continue using
318 |     mock_atlassian_confluence for compatibility.
319 | 
320 |     Args:
321 |         session_confluence_spaces: Session-scoped space definitions
322 |         session_confluence_content_types: Session-scoped content type data
323 | 
324 |     Returns:
325 |         MagicMock: Mock Confluence client with session-scoped data
326 |     """
327 |     with patch("mcp_atlassian.confluence.client.Confluence") as mock:
328 |         confluence_instance = mock.return_value
329 | 
330 |         # Use session-scoped data for improved performance
331 |         confluence_instance.get_all_spaces.return_value = {
332 |             "results": session_confluence_spaces,
333 |             "size": len(session_confluence_spaces),
334 |         }
335 | 
336 |         # Enhanced responses using factories
337 |         confluence_instance.get_page_by_id.return_value = ConfluencePageFactory.create()
338 |         confluence_instance.get_page_by_title.return_value = (
339 |             ConfluencePageFactory.create()
340 |         )
341 |         confluence_instance.create_page.return_value = ConfluencePageFactory.create(
342 |             page_id="123456789", title="New Test Page"
343 |         )
344 | 
345 |         # Use session data for content types
346 |         confluence_instance.get_content_types.return_value = (
347 |             session_confluence_content_types
348 |         )
349 | 
350 |         yield confluence_instance
351 | 
352 | 
353 | # ============================================================================
354 | # Preprocessor Fixtures
355 | # ============================================================================
356 | 
357 | 
358 | @pytest.fixture
359 | def mock_preprocessor():
360 |     """
361 |     Mock the TextPreprocessor with enhanced functionality.
362 | 
363 |     This fixture provides a preprocessor mock that can be customized
364 |     for testing different content processing scenarios.
365 | 
366 |     Returns:
367 |         MagicMock: Mock preprocessor with common methods
368 |     """
369 |     preprocessor_instance = MagicMock()
370 | 
371 |     # Default processing behavior
372 |     preprocessor_instance.process_html_content.return_value = (
373 |         "<p>Processed HTML</p>",
374 |         "Processed Markdown",
375 |     )
376 | 
377 |     # Additional processing methods
378 |     preprocessor_instance.clean_html.return_value = "<p>Clean HTML</p>"
379 |     preprocessor_instance.html_to_markdown.return_value = "# Markdown Content"
380 |     preprocessor_instance.markdown_to_html.return_value = "<h1>HTML Content</h1>"
381 | 
382 |     yield preprocessor_instance
383 | 
384 | 
385 | @pytest.fixture
386 | def preprocessor_factory():
387 |     """
388 |     Factory for creating preprocessor mocks with different behaviors.
389 | 
390 |     Returns:
391 |         Dict[str, Callable]: Factory functions for different preprocessor types
392 | 
393 |     Example:
394 |         def test_preprocessing(preprocessor_factory):
395 |             html_processor = preprocessor_factory["html_to_markdown"]()
396 |             markdown_processor = preprocessor_factory["markdown_to_html"]()
397 |     """
398 |     return {
399 |         "html_to_markdown": MockPreprocessor.create_html_to_markdown,
400 |         "markdown_to_html": MockPreprocessor.create_markdown_to_html,
401 |     }
402 | 
403 | 
404 | # ============================================================================
405 | # Client Instance Fixtures
406 | # ============================================================================
407 | 
408 | 
409 | @pytest.fixture
410 | def oauth_confluence_client(mock_preprocessor):
411 |     """
412 |     Create a ConfluenceClient instance configured for OAuth authentication.
413 | 
414 |     This fixture provides a Confluence client configured with OAuth settings
415 |     for testing OAuth-specific functionality.
416 | 
417 |     Args:
418 |         mock_preprocessor: Mock text preprocessor
419 | 
420 |     Returns:
421 |         ConfluenceClient: OAuth-configured client instance
422 |     """
423 |     # Create OAuth configuration
424 |     oauth_config = OAuthConfig(
425 |         client_id="test-client-id",
426 |         client_secret="test-client-secret",
427 |         redirect_uri="http://localhost:8080/callback",
428 |         scope="read:confluence-content write:confluence-content",
429 |         cloud_id="test-cloud-id",
430 |     )
431 | 
432 |     # Convert to ConfluenceConfig format (use .atlassian.net URL to make is_cloud return True)
433 |     config = ConfluenceConfig(
434 |         url="https://test.atlassian.net/wiki",
435 |         auth_type="oauth",
436 |         oauth_config=oauth_config,
437 |     )
438 | 
439 |     # Mock the OAuth session setup and Confluence client
440 |     with patch(
441 |         "mcp_atlassian.confluence.client.configure_oauth_session"
442 |     ) as mock_oauth_session:
443 |         with patch(
444 |             "mcp_atlassian.confluence.client.Confluence"
445 |         ) as mock_confluence_class:
446 |             with patch(
447 |                 "mcp_atlassian.preprocessing.TextPreprocessor"
448 |             ) as mock_text_preprocessor:
449 |                 # Mock OAuth session configuration to succeed
450 |                 mock_oauth_session.return_value = True
451 | 
452 |                 mock_text_preprocessor.return_value = mock_preprocessor
453 | 
454 |                 # Create the mock Confluence instance
455 |                 mock_confluence_instance = MagicMock()
456 |                 mock_confluence_class.return_value = mock_confluence_instance
457 | 
458 |                 # Set up OAuth-specific mock responses
459 |                 mock_confluence_instance.get_all_spaces.return_value = (
460 |                     MOCK_SPACES_RESPONSE
461 |                 )
462 |                 mock_confluence_instance.get_page_by_id.return_value = (
463 |                     MOCK_PAGE_RESPONSE
464 |                 )
465 |                 mock_confluence_instance.create_page.return_value = (
466 |                     ConfluencePageFactory.create(
467 |                         page_id="v2_123456789", title="OAuth Test Page"
468 |                     )
469 |                 )
470 | 
471 |                 # Mock the session to have OAuth characteristics
472 |                 mock_session = MagicMock()
473 |                 mock_confluence_instance._session = mock_session
474 | 
475 |                 # Create the client with OAuth config
476 |                 client = ConfluenceClient(config=config)
477 |                 client.confluence = mock_confluence_instance
478 |                 client.preprocessor = mock_preprocessor
479 | 
480 |                 yield client
481 | 
482 | 
483 | @pytest.fixture
484 | def confluence_client(mock_config, mock_atlassian_confluence, mock_preprocessor):
485 |     """
486 |     Create a ConfluenceClient instance with mocked dependencies.
487 | 
488 |     This fixture provides a fully functional ConfluenceClient with mocked
489 |     Atlassian API calls and content preprocessing for testing.
490 | 
491 |     Args:
492 |         mock_config: Mock configuration
493 |         mock_atlassian_confluence: Mock Atlassian client
494 |         mock_preprocessor: Mock text preprocessor
495 | 
496 |     Returns:
497 |         ConfluenceClient: Configured client instance
498 |     """
499 |     # Create a client with a mocked configuration
500 |     with patch(
501 |         "mcp_atlassian.preprocessing.TextPreprocessor"
502 |     ) as mock_text_preprocessor:
503 |         mock_text_preprocessor.return_value = mock_preprocessor
504 | 
505 |         client = ConfluenceClient(config=mock_config)
506 |         # Replace the actual Confluence instance with our mock
507 |         client.confluence = mock_atlassian_confluence
508 |         # Replace the actual preprocessor with our mock
509 |         client.preprocessor = mock_preprocessor
510 |         yield client
511 | 
512 | 
513 | # ============================================================================
514 | # Specialized Test Data Fixtures
515 | # ============================================================================
516 | 
517 | 
518 | @pytest.fixture
519 | def make_confluence_page_with_content():
520 |     """
521 |     Factory fixture for creating Confluence pages with rich content.
522 | 
523 |     Returns:
524 |         Callable: Function that creates page data with content
525 | 
526 |     Example:
527 |         def test_page_content(make_confluence_page_with_content):
528 |             page = make_confluence_page_with_content(
529 |                 title="Rich Page",
530 |                 content="<h1>Header</h1><p>Content</p>",
531 |                 labels=["test", "content"]
532 |             )
533 |     """
534 | 
535 |     def _create_page_with_content(
536 |         title: str = "Test Page",
537 |         content: str = "<p>Test content</p>",
538 |         labels: list[str] = None,
539 |         **overrides,
540 |     ):
541 |         labels = labels or ["test"]
542 |         page = ConfluencePageFactory.create(title=title, **overrides)
543 | 
544 |         # Add rich content
545 |         page["body"]["storage"]["value"] = content
546 | 
547 |         # Add labels
548 |         page["metadata"] = {
549 |             "labels": {"results": [{"name": label} for label in labels]}
550 |         }
551 | 
552 |         # Add version info
553 |         page["version"]["message"] = f"Updated {title}"
554 | 
555 |         return page
556 | 
557 |     return _create_page_with_content
558 | 
559 | 
560 | @pytest.fixture
561 | def make_confluence_search_results():
562 |     """
563 |     Factory fixture for creating Confluence search results.
564 | 
565 |     Returns:
566 |         Callable: Function that creates CQL search results
567 | 
568 |     Example:
569 |         def test_search(make_confluence_search_results):
570 |             results = make_confluence_search_results(
571 |                 pages=["Page 1", "Page 2"],
572 |                 total=2
573 |             )
574 |     """
575 | 
576 |     def _create_search_results(pages: list[str] = None, total: int = None, **overrides):
577 |         if pages is None:
578 |             pages = ["Test Page 1", "Test Page 2", "Test Page 3"]
579 |         if total is None:
580 |             total = len(pages)
581 | 
582 |         page_objects = [
583 |             ConfluencePageFactory.create(page_id=str(i), title=title)
584 |             for i, title in enumerate(pages, 1)
585 |         ]
586 | 
587 |         defaults = {
588 |             "results": page_objects,
589 |             "totalSize": total,
590 |             "start": 0,
591 |             "limit": 25,
592 |         }
593 | 
594 |         return {**defaults, **overrides}
595 | 
596 |     return _create_search_results
597 | 
598 | 
599 | @pytest.fixture
600 | def make_confluence_space():
601 |     """
602 |     Factory fixture for creating Confluence spaces.
603 | 
604 |     Returns:
605 |         Callable: Function that creates space data
606 | 
607 |     Example:
608 |         def test_space(make_confluence_space):
609 |             space = make_confluence_space(
610 |                 key="CUSTOM",
611 |                 name="Custom Space",
612 |                 type="personal"
613 |             )
614 |     """
615 | 
616 |     def _create_space(
617 |         key: str = "TEST",
618 |         name: str = "Test Space",
619 |         space_type: str = "global",
620 |         **overrides,
621 |     ):
622 |         defaults = {
623 |             "id": 12345,
624 |             "key": key,
625 |             "name": name,
626 |             "type": space_type,
627 |             "status": "current",
628 |             "description": {"plain": {"value": f"{name} for testing"}},
629 |             "_links": {
630 |                 "webui": f"/spaces/{key}",
631 |                 "self": f"https://test.atlassian.net/wiki/rest/api/space/{key}",
632 |             },
633 |         }
634 | 
635 |         return {**defaults, **overrides}
636 | 
637 |     return _create_space
638 | 
639 | 
640 | # ============================================================================
641 | # Integration Test Fixtures
642 | # ============================================================================
643 | 
644 | 
645 | @pytest.fixture
646 | def confluence_integration_client(session_auth_configs):
647 |     """
648 |     Create a ConfluenceClient for integration testing.
649 | 
650 |     This fixture creates a client that can be used for integration tests
651 |     when real API credentials are available.
652 | 
653 |     Args:
654 |         session_auth_configs: Session-scoped auth configurations
655 | 
656 |     Returns:
657 |         Optional[ConfluenceClient]: Real client if credentials available, None otherwise
658 |     """
659 |     # Check if integration test environment variables are set
660 |     required_vars = ["CONFLUENCE_URL", "CONFLUENCE_USERNAME", "CONFLUENCE_API_TOKEN"]
661 |     if not all(os.environ.get(var) for var in required_vars):
662 |         pytest.skip("Integration test environment variables not set")
663 | 
664 |     config = ConfluenceConfig(
665 |         url=os.environ["CONFLUENCE_URL"],
666 |         auth_type="basic",
667 |         username=os.environ["CONFLUENCE_USERNAME"],
668 |         api_token=os.environ["CONFLUENCE_API_TOKEN"],
669 |     )
670 | 
671 |     return ConfluenceClient(config=config)
672 | 
673 | 
674 | # ============================================================================
675 | # Parameterized Fixtures
676 | # ============================================================================
677 | 
678 | 
679 | @pytest.fixture
680 | def parametrized_confluence_content_type(request):
681 |     """
682 |     Parametrized fixture for testing with different Confluence content types.
683 | 
684 |     Use with pytest.mark.parametrize to test functionality across
685 |     different content types.
686 | 
687 |     Example:
688 |         @pytest.mark.parametrize("parametrized_confluence_content_type",
689 |                                ["page", "blogpost"], indirect=True)
690 |         def test_content_types(parametrized_confluence_content_type):
691 |             # Test runs once for each content type
692 |             pass
693 |     """
694 |     content_type = request.param
695 |     return ConfluencePageFactory.create(type=content_type)
696 | 
697 | 
698 | @pytest.fixture
699 | def parametrized_confluence_space_type(request):
700 |     """
701 |     Parametrized fixture for testing with different Confluence space types.
702 | 
703 |     Use with pytest.mark.parametrize to test functionality across
704 |     different space types.
705 |     """
706 |     space_type = request.param
707 |     return {
708 |         "key": "TEST",
709 |         "name": "Test Space",
710 |         "type": space_type,
711 |         "status": "current",
712 |     }
713 | 
```

--------------------------------------------------------------------------------
/tests/fixtures/jira_mocks.py:
--------------------------------------------------------------------------------

```python
  1 | MOCK_JIRA_ISSUE_RESPONSE = {
  2 |     "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
  3 |     "id": "12345",
  4 |     "self": "https://example.atlassian.net/rest/api/2/issue/12345",
  5 |     "key": "PROJ-123",
  6 |     "fields": {
  7 |         "summary": "Test Issue Summary",
  8 |         "description": "This is a test issue description",
  9 |         "created": "2024-01-01T10:00:00.000+0000",
 10 |         "updated": "2024-01-02T15:30:00.000+0000",
 11 |         "status": {
 12 |             "self": "https://example.atlassian.net/rest/api/2/status/3",
 13 |             "description": "This issue is currently being worked on.",
 14 |             "iconUrl": "https://example.atlassian.net/images/icons/statuses/inprogress.png",
 15 |             "name": "In Progress",
 16 |             "id": "3",
 17 |             "statusCategory": {
 18 |                 "self": "https://example.atlassian.net/rest/api/2/statuscategory/4",
 19 |                 "id": 4,
 20 |                 "key": "indeterminate",
 21 |                 "colorName": "yellow",
 22 |                 "name": "In Progress",
 23 |             },
 24 |         },
 25 |         "issuetype": {
 26 |             "self": "https://example.atlassian.net/rest/api/2/issuetype/10001",
 27 |             "id": "10001",
 28 |             "description": "A task that needs to be done.",
 29 |             "iconUrl": "https://example.atlassian.net/secure/viewavatar?size=xsmall&avatarId=10318&avatarType=issuetype",
 30 |             "name": "Task",
 31 |             "subtask": False,
 32 |         },
 33 |         "priority": {
 34 |             "self": "https://example.atlassian.net/rest/api/2/priority/3",
 35 |             "iconUrl": "https://example.atlassian.net/images/icons/priorities/medium.svg",
 36 |             "name": "Medium",
 37 |             "id": "3",
 38 |         },
 39 |         "assignee": {
 40 |             "self": "https://example.atlassian.net/rest/api/2/user?accountId=123",
 41 |             "accountId": "123",
 42 |             "emailAddress": "test@example.com",
 43 |             "avatarUrls": {
 44 |                 "48x48": "https://secure.gravatar.com/avatar/123?d=https%3A%2F%2Favatar.example.com%2Fdefault.png",
 45 |             },
 46 |             "displayName": "Test User",
 47 |             "active": True,
 48 |             "timeZone": "UTC",
 49 |         },
 50 |         "reporter": {
 51 |             "self": "https://example.atlassian.net/rest/api/2/user?accountId=456",
 52 |             "accountId": "456",
 53 |             "displayName": "Reporter User",
 54 |             "active": True,
 55 |         },
 56 |         "labels": ["test-label"],
 57 |         "components": [{"name": "Backend"}],
 58 |         "fixVersions": [{"name": "v1.0"}],
 59 |         "attachment": [
 60 |             {
 61 |                 "id": "10000",
 62 |                 "filename": "test_attachment.txt",
 63 |                 "size": 1024,
 64 |                 "mimeType": "text/plain",
 65 |                 "content": "https://example.atlassian.net/secure/attachment/10000/test_attachment.txt",
 66 |             }
 67 |         ],
 68 |         "comment": {
 69 |             "comments": [
 70 |                 {
 71 |                     "id": "10001",
 72 |                     "author": {"displayName": "Commenter User"},
 73 |                     "body": "This is a test comment",
 74 |                     "created": "2024-01-01T12:00:00.000+0000",
 75 |                     "updated": "2024-01-01T12:00:00.000+0000",
 76 |                 }
 77 |             ],
 78 |             "maxResults": 1,
 79 |             "total": 1,
 80 |             "startAt": 0,
 81 |         },
 82 |         "timetracking": {
 83 |             "originalEstimate": "1d",
 84 |             "remainingEstimate": "4h",
 85 |             "timeSpent": "4h",
 86 |             "originalEstimateSeconds": 28800,
 87 |             "remainingEstimateSeconds": 14400,
 88 |             "timeSpentSeconds": 14400,
 89 |         },
 90 |         "project": {
 91 |             "id": "10000",
 92 |             "key": "PROJ",
 93 |             "name": "Test Project",
 94 |             "self": "https://example.atlassian.net/rest/api/2/project/10000",
 95 |             "avatarUrls": {
 96 |                 "48x48": "https://example.atlassian.net/secure/projectavatar?size=large&pid=10000"
 97 |             },
 98 |         },
 99 |         "resolution": {
100 |             "self": "https://example.atlassian.net/rest/api/2/resolution/10000",
101 |             "id": "10000",
102 |             "description": "Work has been completed on this issue.",
103 |             "name": "Fixed",
104 |         },
105 |         "duedate": "2024-12-31",
106 |         "resolutiondate": "2024-01-15T11:00:00.000+0000",
107 |         "parent": {
108 |             "id": "12344",
109 |             "key": "PROJ-122",
110 |             "fields": {"summary": "Parent Issue Summary"},
111 |         },
112 |         "subtasks": [
113 |             {
114 |                 "id": "12346",
115 |                 "key": "PROJ-124",
116 |                 "fields": {"summary": "Subtask 1 Summary"},
117 |             }
118 |         ],
119 |         "security": {"name": "Internal", "id": "10001"},
120 |         "worklog": {"startAt": 0, "maxResults": 20, "total": 0, "worklogs": []},
121 |         # Custom fields for testing
122 |         "customfield_10011": "Epic Name Example",  # Epic Name
123 |         "customfield_10014": "EPIC-KEY-1",  # Epic Link
124 |         "customfield_10001": "Custom Text Field Value",
125 |         "customfield_10002": {"value": "Custom Select Value"},
126 |         "customfield_10003": [
127 |             {"value": "Custom MultiSelect 1"},
128 |             {"value": "Custom MultiSelect 2"},
129 |         ],
130 |     },
131 |     "names": {
132 |         "customfield_10011": "Epic Name",
133 |         "customfield_10014": "Epic Link",
134 |         "customfield_10001": "My Custom Text Field",
135 |         "customfield_10002": "My Custom Select",
136 |         "customfield_10003": "My Custom MultiSelect",
137 |     },
138 | }
139 | 
140 | MOCK_JIRA_JQL_RESPONSE = {
141 |     "expand": "schema,names",
142 |     "startAt": 0,
143 |     "maxResults": 5,
144 |     "total": 34,
145 |     "issues": [
146 |         {
147 |             "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
148 |             "id": "12345",
149 |             "self": "https://example.atlassian.net/rest/api/2/issue/12345",
150 |             "key": "PROJ-123",
151 |             "fields": {
152 |                 "parent": {
153 |                     "id": "12340",
154 |                     "key": "PROJ-120",
155 |                     "self": "https://example.atlassian.net/rest/api/2/issue/12340",
156 |                     "fields": {
157 |                         "summary": "Parent Epic Summary",
158 |                         "status": {
159 |                             "self": "https://example.atlassian.net/rest/api/2/status/10000",
160 |                             "description": "",
161 |                             "iconUrl": "https://example.atlassian.net/",
162 |                             "name": "In Progress",
163 |                             "id": "10000",
164 |                             "statusCategory": {
165 |                                 "self": "https://example.atlassian.net/rest/api/2/statuscategory/4",
166 |                                 "id": 4,
167 |                                 "key": "indeterminate",
168 |                                 "colorName": "yellow",
169 |                                 "name": "In Progress",
170 |                             },
171 |                         },
172 |                         "priority": {
173 |                             "self": "https://example.atlassian.net/rest/api/2/priority/3",
174 |                             "iconUrl": "https://example.atlassian.net/images/icons/priorities/medium.svg",
175 |                             "name": "Medium",
176 |                             "id": "3",
177 |                         },
178 |                         "issuetype": {
179 |                             "self": "https://example.atlassian.net/rest/api/2/issuetype/10001",
180 |                             "id": "10001",
181 |                             "description": "Epics track large pieces of work.",
182 |                             "iconUrl": "https://example.atlassian.net/images/icons/issuetypes/epic.svg",
183 |                             "name": "Epic",
184 |                             "subtask": False,
185 |                             "hierarchyLevel": 1,
186 |                         },
187 |                     },
188 |                 },
189 |                 "summary": "Test Issue Summary",
190 |                 "description": "This is a test issue description",
191 |                 "created": "2024-01-01T10:00:00.000+0000",
192 |                 "updated": "2024-01-02T15:30:00.000+0000",
193 |                 "duedate": "2024-12-31",
194 |                 "priority": {
195 |                     "self": "https://example.atlassian.net/rest/api/2/priority/3",
196 |                     "iconUrl": "https://example.atlassian.net/images/icons/priorities/medium.svg",
197 |                     "name": "Medium",
198 |                     "id": "3",
199 |                 },
200 |                 "status": {
201 |                     "self": "https://example.atlassian.net/rest/api/2/status/10000",
202 |                     "description": "",
203 |                     "iconUrl": "https://example.atlassian.net/",
204 |                     "name": "In Progress",
205 |                     "id": "10000",
206 |                     "statusCategory": {
207 |                         "self": "https://example.atlassian.net/rest/api/2/statuscategory/4",
208 |                         "id": 4,
209 |                         "key": "indeterminate",
210 |                         "colorName": "yellow",
211 |                         "name": "In Progress",
212 |                     },
213 |                 },
214 |                 "issuetype": {
215 |                     "self": "https://example.atlassian.net/rest/api/2/issuetype/10000",
216 |                     "id": "10000",
217 |                     "description": "A task that needs to be done.",
218 |                     "iconUrl": "https://example.atlassian.net/images/icons/issuetypes/task.svg",
219 |                     "name": "Task",
220 |                     "subtask": False,
221 |                     "hierarchyLevel": 0,
222 |                 },
223 |                 "project": {
224 |                     "self": "https://example.atlassian.net/rest/api/2/project/10000",
225 |                     "id": "10000",
226 |                     "key": "PROJ",
227 |                     "name": "Test Project",
228 |                     "projectTypeKey": "software",
229 |                     "simplified": True,
230 |                 },
231 |                 "comment": {
232 |                     "comments": [
233 |                         {
234 |                             "self": "https://example.atlassian.net/rest/api/2/issue/12345/comment/10000",
235 |                             "id": "10000",
236 |                             "author": {"displayName": "Comment User", "active": True},
237 |                             "body": "This is a test comment",
238 |                             "created": "2024-01-01T12:00:00.000+0000",
239 |                             "updated": "2024-01-01T12:00:00.000+0000",
240 |                         }
241 |                     ],
242 |                     "maxResults": 1,
243 |                     "total": 1,
244 |                     "startAt": 0,
245 |                 },
246 |             },
247 |         }
248 |     ],
249 |     "names": {
250 |         "customfield_10011": "Epic Name",
251 |         "customfield_10014": "Epic Link",
252 |     },
253 | }
254 | 
255 | # Generic mock Jira comments data without any company-specific information
256 | MOCK_JIRA_COMMENTS = {
257 |     "startAt": 0,
258 |     "maxResults": 100,
259 |     "total": 5,
260 |     "comments": [
261 |         {
262 |             "self": "https://example.atlassian.net/rest/api/2/issue/10001/comment/10101",
263 |             "id": "10101",
264 |             "author": {
265 |                 "self": "https://example.atlassian.net/rest/api/2/user?accountId=account-id-1",
266 |                 "accountId": "account-id-1",
267 |                 "avatarUrls": {
268 |                     "48x48": "https://avatar.example.com/avatar/user1_48.png",
269 |                     "24x24": "https://avatar.example.com/avatar/user1_24.png",
270 |                     "16x16": "https://avatar.example.com/avatar/user1_16.png",
271 |                     "32x32": "https://avatar.example.com/avatar/user1_32.png",
272 |                 },
273 |                 "displayName": "John Smith",
274 |                 "active": True,
275 |                 "timeZone": "UTC",
276 |                 "accountType": "atlassian",
277 |             },
278 |             "body": "I've analyzed this issue and found that we need to update the configuration settings.",
279 |             "updateAuthor": {
280 |                 "self": "https://example.atlassian.net/rest/api/2/user?accountId=account-id-1",
281 |                 "accountId": "account-id-1",
282 |                 "avatarUrls": {
283 |                     "48x48": "https://avatar.example.com/avatar/user1_48.png",
284 |                     "24x24": "https://avatar.example.com/avatar/user1_24.png",
285 |                     "16x16": "https://avatar.example.com/avatar/user1_16.png",
286 |                     "32x32": "https://avatar.example.com/avatar/user1_32.png",
287 |                 },
288 |                 "displayName": "John Smith",
289 |                 "active": True,
290 |                 "timeZone": "UTC",
291 |                 "accountType": "atlassian",
292 |             },
293 |             "created": "2023-01-15T09:14:01.240+0000",
294 |             "updated": "2023-01-15T09:14:15.433+0000",
295 |             "jsdPublic": True,
296 |         },
297 |         {
298 |             "self": "https://example.atlassian.net/rest/api/2/issue/10001/comment/10102",
299 |             "id": "10102",
300 |             "author": {
301 |                 "self": "https://example.atlassian.net/rest/api/2/user?accountId=account-id-2",
302 |                 "accountId": "account-id-2",
303 |                 "avatarUrls": {
304 |                     "48x48": "https://avatar.example.com/avatar/user2_48.png",
305 |                     "24x24": "https://avatar.example.com/avatar/user2_24.png",
306 |                     "16x16": "https://avatar.example.com/avatar/user2_16.png",
307 |                     "32x32": "https://avatar.example.com/avatar/user2_32.png",
308 |                 },
309 |                 "displayName": "Jane Doe",
310 |                 "active": True,
311 |                 "timeZone": "America/New_York",
312 |                 "accountType": "atlassian",
313 |             },
314 |             "body": "I agree with John. Let's schedule a meeting to discuss the implementation details.",
315 |             "updateAuthor": {
316 |                 "self": "https://example.atlassian.net/rest/api/2/user?accountId=account-id-2",
317 |                 "accountId": "account-id-2",
318 |                 "avatarUrls": {
319 |                     "48x48": "https://avatar.example.com/avatar/user2_48.png",
320 |                     "24x24": "https://avatar.example.com/avatar/user2_24.png",
321 |                     "16x16": "https://avatar.example.com/avatar/user2_16.png",
322 |                     "32x32": "https://avatar.example.com/avatar/user2_32.png",
323 |                 },
324 |                 "displayName": "Jane Doe",
325 |                 "active": True,
326 |                 "timeZone": "America/New_York",
327 |                 "accountType": "atlassian",
328 |             },
329 |             "created": "2023-01-15T14:35:28.392+0000",
330 |             "updated": "2023-01-15T14:35:28.392+0000",
331 |             "jsdPublic": True,
332 |         },
333 |         {
334 |             "self": "https://example.atlassian.net/rest/api/2/issue/10001/comment/10103",
335 |             "id": "10103",
336 |             "author": {
337 |                 "self": "https://example.atlassian.net/rest/api/2/user?accountId=account-id-3",
338 |                 "accountId": "account-id-3",
339 |                 "avatarUrls": {
340 |                     "48x48": "https://avatar.example.com/avatar/user3_48.png",
341 |                     "24x24": "https://avatar.example.com/avatar/user3_24.png",
342 |                     "16x16": "https://avatar.example.com/avatar/user3_16.png",
343 |                     "32x32": "https://avatar.example.com/avatar/user3_32.png",
344 |                 },
345 |                 "displayName": "Robert Johnson",
346 |                 "active": True,
347 |                 "timeZone": "Europe/London",
348 |                 "accountType": "atlassian",
349 |             },
350 |             "body": "I've created a draft implementation. Please review the code changes in the linked PR.",
351 |             "updateAuthor": {
352 |                 "self": "https://example.atlassian.net/rest/api/2/user?accountId=account-id-3",
353 |                 "accountId": "account-id-3",
354 |                 "avatarUrls": {
355 |                     "48x48": "https://avatar.example.com/avatar/user3_48.png",
356 |                     "24x24": "https://avatar.example.com/avatar/user3_24.png",
357 |                     "16x16": "https://avatar.example.com/avatar/user3_16.png",
358 |                     "32x32": "https://avatar.example.com/avatar/user3_32.png",
359 |                 },
360 |                 "displayName": "Robert Johnson",
361 |                 "active": True,
362 |                 "timeZone": "Europe/London",
363 |                 "accountType": "atlassian",
364 |             },
365 |             "created": "2023-01-18T10:47:53.672+0000",
366 |             "updated": "2023-01-18T11:01:55.589+0000",
367 |             "jsdPublic": True,
368 |         },
369 |         {
370 |             "self": "https://example.atlassian.net/rest/api/2/issue/10001/comment/10104",
371 |             "id": "10104",
372 |             "author": {
373 |                 "self": "https://example.atlassian.net/rest/api/2/user?accountId=account-id-1",
374 |                 "accountId": "account-id-1",
375 |                 "avatarUrls": {
376 |                     "48x48": "https://avatar.example.com/avatar/user1_48.png",
377 |                     "24x24": "https://avatar.example.com/avatar/user1_24.png",
378 |                     "16x16": "https://avatar.example.com/avatar/user1_16.png",
379 |                     "32x32": "https://avatar.example.com/avatar/user1_32.png",
380 |                 },
381 |                 "displayName": "John Smith",
382 |                 "active": True,
383 |                 "timeZone": "UTC",
384 |                 "accountType": "atlassian",
385 |             },
386 |             "body": "The code looks good. I've left some minor suggestions in the PR review.",
387 |             "updateAuthor": {
388 |                 "self": "https://example.atlassian.net/rest/api/2/user?accountId=account-id-1",
389 |                 "accountId": "account-id-1",
390 |                 "avatarUrls": {
391 |                     "48x48": "https://avatar.example.com/avatar/user1_48.png",
392 |                     "24x24": "https://avatar.example.com/avatar/user1_24.png",
393 |                     "16x16": "https://avatar.example.com/avatar/user1_16.png",
394 |                     "32x32": "https://avatar.example.com/avatar/user1_32.png",
395 |                 },
396 |                 "displayName": "John Smith",
397 |                 "active": True,
398 |                 "timeZone": "UTC",
399 |                 "accountType": "atlassian",
400 |             },
401 |             "created": "2023-01-19T15:20:02.083+0000",
402 |             "updated": "2023-01-19T15:20:02.083+0000",
403 |             "jsdPublic": True,
404 |         },
405 |         {
406 |             "self": "https://example.atlassian.net/rest/api/2/issue/10001/comment/10105",
407 |             "id": "10105",
408 |             "author": {
409 |                 "self": "https://example.atlassian.net/rest/api/2/user?accountId=account-id-3",
410 |                 "accountId": "account-id-3",
411 |                 "avatarUrls": {
412 |                     "48x48": "https://avatar.example.com/avatar/user3_48.png",
413 |                     "24x24": "https://avatar.example.com/avatar/user3_24.png",
414 |                     "16x16": "https://avatar.example.com/avatar/user3_16.png",
415 |                     "32x32": "https://avatar.example.com/avatar/user3_32.png",
416 |                 },
417 |                 "displayName": "Robert Johnson",
418 |                 "active": True,
419 |                 "timeZone": "Europe/London",
420 |                 "accountType": "atlassian",
421 |             },
422 |             "body": "I've addressed all the feedback and merged the PR. Issue can be closed.",
423 |             "updateAuthor": {
424 |                 "self": "https://example.atlassian.net/rest/api/2/user?accountId=account-id-3",
425 |                 "accountId": "account-id-3",
426 |                 "avatarUrls": {
427 |                     "48x48": "https://avatar.example.com/avatar/user3_48.png",
428 |                     "24x24": "https://avatar.example.com/avatar/user3_24.png",
429 |                     "16x16": "https://avatar.example.com/avatar/user3_16.png",
430 |                     "32x32": "https://avatar.example.com/avatar/user3_32.png",
431 |                 },
432 |                 "displayName": "Robert Johnson",
433 |                 "active": True,
434 |                 "timeZone": "Europe/London",
435 |                 "accountType": "atlassian",
436 |             },
437 |             "created": "2023-01-20T11:10:38.167+0000",
438 |             "updated": "2023-01-20T11:10:38.167+0000",
439 |             "jsdPublic": True,
440 |         },
441 |     ],
442 | }
443 | 
444 | # Create a simplified version for test use
445 | MOCK_JIRA_COMMENTS_SIMPLIFIED = {
446 |     "startAt": 0,
447 |     "maxResults": 100,
448 |     "total": MOCK_JIRA_COMMENTS["total"],
449 |     "comments": [
450 |         {
451 |             "id": comment["id"],
452 |             "author": {"displayName": comment["author"]["displayName"]},
453 |             "body": comment["body"],
454 |             "created": comment["created"],
455 |             "updated": comment["updated"],
456 |         }
457 |         for comment in MOCK_JIRA_COMMENTS["comments"][:3]  # Just use first 3 comments
458 |     ],
459 | }
460 | 
461 | # Create simplified versions of the mock responses
462 | MOCK_JIRA_ISSUE_RESPONSE_SIMPLIFIED = {
463 |     "id": "12345",
464 |     "self": "https://example.atlassian.net/rest/api/2/issue/12345",
465 |     "key": "PROJ-123",
466 |     "fields": {
467 |         "summary": "Test Issue Summary",
468 |         "description": "This is a test issue description",
469 |         "created": "2024-01-01T10:00:00.000+0000",
470 |         "updated": "2024-01-02T15:30:00.000+0000",
471 |         "duedate": "2024-12-31",
472 |         "priority": {
473 |             "name": "Medium",
474 |             "id": "3",
475 |         },
476 |         "status": {
477 |             "name": "In Progress",
478 |             "id": "10000",
479 |             "statusCategory": {
480 |                 "id": 4,
481 |                 "key": "indeterminate",
482 |                 "name": "In Progress",
483 |             },
484 |         },
485 |         "issuetype": {
486 |             "id": "10000",
487 |             "name": "Task",
488 |             "subtask": False,
489 |         },
490 |         "project": {
491 |             "id": "10000",
492 |             "key": "PROJ",
493 |             "name": "Test Project",
494 |         },
495 |         "comment": {
496 |             "comments": [
497 |                 {
498 |                     "id": "10000",
499 |                     "author": {"displayName": "Comment User"},
500 |                     "body": "This is a test comment",
501 |                     "created": "2024-01-01T12:00:00.000+0000",
502 |                     "updated": "2024-01-01T12:00:00.000+0000",
503 |                 }
504 |             ],
505 |             "total": 1,
506 |         },
507 |         "labels": ["test-label"],
508 |         "fixVersions": [{"name": "v1.0"}],
509 |         "attachment": [
510 |             {
511 |                 "id": "10000",
512 |                 "filename": "test_attachment.txt",
513 |                 "author": {"displayName": "Test User"},
514 |                 "created": "2024-01-01T10:00:00.000+0000",
515 |                 "size": 1024,
516 |                 "mimeType": "text/plain",
517 |                 "content": "https://example.atlassian.net/secure/attachment/10000/test_attachment.txt",
518 |             }
519 |         ],
520 |         "timetracking": {
521 |             "originalEstimate": "1d",
522 |             "remainingEstimate": "4h",
523 |             "timeSpent": "4h",
524 |         },
525 |         "custom_fields": {
526 |             "customfield_10011": "My Awesome Epic Name",
527 |             "customfield_10014": "EPIC-123",
528 |             "customfield_10001": "Simple string value",
529 |             "customfield_10002": {"value": "Option value"},
530 |             "customfield_10003": [{"value": "Item 1"}, {"value": "Item 2"}],
531 |         },
532 |     },
533 | }
534 | 
535 | MOCK_JIRA_JQL_RESPONSE_SIMPLIFIED = {
536 |     "startAt": 0,
537 |     "maxResults": 5,
538 |     "total": 34,
539 |     "issues": [
540 |         {
541 |             "id": "12345",
542 |             "key": "PROJ-123",
543 |             "fields": {
544 |                 "parent": {
545 |                     "id": "12340",
546 |                     "key": "PROJ-120",
547 |                     "fields": {
548 |                         "summary": "Parent Epic Summary",
549 |                         "status": {
550 |                             "name": "In Progress",
551 |                         },
552 |                         "issuetype": {
553 |                             "name": "Epic",
554 |                             "subtask": False,
555 |                         },
556 |                     },
557 |                 },
558 |                 "summary": "Test Issue Summary",
559 |                 "description": "This is a test issue description",
560 |                 "created": "2024-01-01T10:00:00.000+0000",
561 |                 "updated": "2024-01-02T15:30:00.000+0000",
562 |                 "status": {
563 |                     "name": "In Progress",
564 |                 },
565 |                 "issuetype": {
566 |                     "name": "Task",
567 |                     "subtask": False,
568 |                 },
569 |                 "project": {
570 |                     "key": "PROJ",
571 |                     "name": "Test Project",
572 |                 },
573 |                 "comment": {
574 |                     "comments": [
575 |                         {
576 |                             "id": "10000",
577 |                             "author": {"displayName": "Comment User"},
578 |                             "body": "This is a test comment",
579 |                             "created": "2024-01-01T12:00:00.000+0000",
580 |                         }
581 |                     ],
582 |                     "total": 1,
583 |                 },
584 |             },
585 |         }
586 |     ],
587 | }
588 | 
```

--------------------------------------------------------------------------------
/tests/unit/models/test_confluence_models.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Tests for the Confluence Pydantic models.
  3 | 
  4 | These tests validate the conversion of Confluence API responses to structured models
  5 | and the simplified dictionary conversion for API responses.
  6 | """
  7 | 
  8 | import pytest
  9 | 
 10 | from src.mcp_atlassian.models import (
 11 |     ConfluenceAttachment,
 12 |     ConfluenceComment,
 13 |     ConfluenceLabel,
 14 |     ConfluencePage,
 15 |     ConfluenceSearchResult,
 16 |     ConfluenceSpace,
 17 |     ConfluenceUser,
 18 |     ConfluenceVersion,
 19 | )
 20 | from src.mcp_atlassian.models.constants import EMPTY_STRING
 21 | 
 22 | # Optional: Import real API client for optional real-data testing
 23 | try:
 24 |     from src.mcp_atlassian.confluence.client import ConfluenceClient  # noqa: F401
 25 | except ImportError:
 26 |     pass
 27 | 
 28 | 
 29 | class TestConfluenceAttachment:
 30 |     """Tests for the ConfluenceAttachment model."""
 31 | 
 32 |     def test_from_api_response_with_valid_data(self):
 33 |         """Test creating a ConfluenceAttachment from valid API data."""
 34 |         attachment_data = {
 35 |             "id": "att105348",
 36 |             "type": "attachment",
 37 |             "status": "current",
 38 |             "title": "random_geometric_image.svg",
 39 |             "extensions": {"mediaType": "application/binary", "fileSize": 1098},
 40 |         }
 41 | 
 42 |         attachment = ConfluenceAttachment.from_api_response(attachment_data)
 43 | 
 44 |         assert attachment.id == "att105348"
 45 |         assert attachment.title == "random_geometric_image.svg"
 46 |         assert attachment.type == "attachment"
 47 |         assert attachment.status == "current"
 48 |         assert attachment.media_type == "application/binary"
 49 |         assert attachment.file_size == 1098
 50 | 
 51 |     def test_from_api_response_with_empty_data(self):
 52 |         """Test creating a ConfluenceAttachment from empty data."""
 53 |         attachment = ConfluenceAttachment.from_api_response({})
 54 | 
 55 |         # Should use default values
 56 |         assert attachment.id is None
 57 |         assert attachment.title is None
 58 |         assert attachment.type is None
 59 |         assert attachment.status is None
 60 |         assert attachment.media_type is None
 61 |         assert attachment.file_size is None
 62 | 
 63 |     def test_from_api_response_with_none_data(self):
 64 |         """Test creating a ConfluenceAttachment from None data."""
 65 |         attachment = ConfluenceAttachment.from_api_response(None)
 66 | 
 67 |         # Should use default values
 68 |         assert attachment.id is None
 69 |         assert attachment.title is None
 70 |         assert attachment.type is None
 71 |         assert attachment.status is None
 72 |         assert attachment.media_type is None
 73 |         assert attachment.file_size is None
 74 | 
 75 |     def test_to_simplified_dict(self):
 76 |         """Test converting ConfluenceAttachment to a simplified dictionary."""
 77 |         attachment = ConfluenceAttachment(
 78 |             id="att105348",
 79 |             title="random_geometric_image.svg",
 80 |             type="attachment",
 81 |             status="current",
 82 |             media_type="application/binary",
 83 |             file_size=1098,
 84 |         )
 85 | 
 86 |         simplified = attachment.to_simplified_dict()
 87 | 
 88 |         assert isinstance(simplified, dict)
 89 |         assert simplified["id"] == "att105348"
 90 |         assert simplified["title"] == "random_geometric_image.svg"
 91 |         assert simplified["type"] == "attachment"
 92 |         assert simplified["status"] == "current"
 93 |         assert simplified["media_type"] == "application/binary"
 94 |         assert simplified["file_size"] == 1098
 95 | 
 96 | 
 97 | class TestConfluenceUser:
 98 |     """Tests for the ConfluenceUser model."""
 99 | 
100 |     def test_from_api_response_with_valid_data(self):
101 |         """Test creating a ConfluenceUser from valid API data."""
102 |         user_data = {
103 |             "accountId": "user123",
104 |             "displayName": "Test User",
105 |             "email": "test@example.com",
106 |             "profilePicture": {
107 |                 "path": "/wiki/aa-avatar/user123",
108 |                 "width": 48,
109 |                 "height": 48,
110 |             },
111 |             "accountStatus": "active",
112 |             "locale": "en_US",
113 |         }
114 | 
115 |         user = ConfluenceUser.from_api_response(user_data)
116 | 
117 |         assert user.account_id == "user123"
118 |         assert user.display_name == "Test User"
119 |         assert user.email == "test@example.com"
120 |         assert user.profile_picture == "/wiki/aa-avatar/user123"
121 |         assert user.is_active is True
122 |         assert user.locale == "en_US"
123 | 
124 |     def test_from_api_response_with_empty_data(self):
125 |         """Test creating a ConfluenceUser from empty data."""
126 |         user = ConfluenceUser.from_api_response({})
127 | 
128 |         # Should use default values
129 |         assert user.account_id is None
130 |         assert user.display_name == "Unassigned"
131 |         assert user.email is None
132 |         assert user.profile_picture is None
133 |         assert user.is_active is True
134 |         assert user.locale is None
135 | 
136 |     def test_from_api_response_with_none_data(self):
137 |         """Test creating a ConfluenceUser from None data."""
138 |         user = ConfluenceUser.from_api_response(None)
139 | 
140 |         # Should use default values
141 |         assert user.account_id is None
142 |         assert user.display_name == "Unassigned"
143 |         assert user.email is None
144 |         assert user.profile_picture is None
145 |         assert user.is_active is True
146 |         assert user.locale is None
147 | 
148 |     def test_to_simplified_dict(self):
149 |         """Test converting ConfluenceUser to a simplified dictionary."""
150 |         user = ConfluenceUser(
151 |             account_id="user123",
152 |             display_name="Test User",
153 |             email="test@example.com",
154 |             profile_picture="/wiki/aa-avatar/user123",
155 |             is_active=True,
156 |             locale="en_US",
157 |         )
158 | 
159 |         simplified = user.to_simplified_dict()
160 | 
161 |         assert isinstance(simplified, dict)
162 |         assert simplified["display_name"] == "Test User"
163 |         assert simplified["email"] == "test@example.com"
164 |         assert simplified["profile_picture"] == "/wiki/aa-avatar/user123"
165 |         assert "account_id" not in simplified  # Not included in simplified dict
166 |         assert "locale" not in simplified  # Not included in simplified dict
167 | 
168 | 
169 | class TestConfluenceSpace:
170 |     """Tests for the ConfluenceSpace model."""
171 | 
172 |     def test_from_api_response_with_valid_data(self):
173 |         """Test creating a ConfluenceSpace from valid API data."""
174 |         space_data = {
175 |             "id": "123456",
176 |             "key": "TEST",
177 |             "name": "Test Space",
178 |             "type": "global",
179 |             "status": "current",
180 |         }
181 | 
182 |         space = ConfluenceSpace.from_api_response(space_data)
183 | 
184 |         assert space.id == "123456"
185 |         assert space.key == "TEST"
186 |         assert space.name == "Test Space"
187 |         assert space.type == "global"
188 |         assert space.status == "current"
189 | 
190 |     def test_from_api_response_with_empty_data(self):
191 |         """Test creating a ConfluenceSpace from empty data."""
192 |         space = ConfluenceSpace.from_api_response({})
193 | 
194 |         # Should use default values
195 |         assert space.id == "0"
196 |         assert space.key == ""
197 |         assert space.name == "Unknown"
198 |         assert space.type == "global"
199 |         assert space.status == "current"
200 | 
201 |     def test_to_simplified_dict(self):
202 |         """Test converting ConfluenceSpace to a simplified dictionary."""
203 |         space = ConfluenceSpace(
204 |             id="123456", key="TEST", name="Test Space", type="global", status="current"
205 |         )
206 | 
207 |         simplified = space.to_simplified_dict()
208 | 
209 |         assert isinstance(simplified, dict)
210 |         assert simplified["key"] == "TEST"
211 |         assert simplified["name"] == "Test Space"
212 |         assert simplified["type"] == "global"
213 |         assert simplified["status"] == "current"
214 |         assert "id" not in simplified  # Not included in simplified dict
215 | 
216 | 
217 | class TestConfluenceVersion:
218 |     """Tests for the ConfluenceVersion model."""
219 | 
220 |     def test_from_api_response_with_valid_data(self):
221 |         """Test creating a ConfluenceVersion from valid API data."""
222 |         version_data = {
223 |             "number": 5,
224 |             "when": "2024-01-01T09:00:00.000Z",
225 |             "message": "Updated content",
226 |             "by": {
227 |                 "accountId": "user123",
228 |                 "displayName": "Test User",
229 |                 "email": "test@example.com",
230 |             },
231 |         }
232 | 
233 |         version = ConfluenceVersion.from_api_response(version_data)
234 | 
235 |         assert version.number == 5
236 |         assert version.when == "2024-01-01T09:00:00.000Z"
237 |         assert version.message == "Updated content"
238 |         assert version.by is not None
239 |         assert version.by.display_name == "Test User"
240 | 
241 |     def test_from_api_response_with_empty_data(self):
242 |         """Test creating a ConfluenceVersion from empty data."""
243 |         version = ConfluenceVersion.from_api_response({})
244 | 
245 |         # Should use default values
246 |         assert version.number == 0
247 |         assert version.when == ""
248 |         assert version.message is None
249 |         assert version.by is None
250 | 
251 |     def test_to_simplified_dict(self):
252 |         """Test converting ConfluenceVersion to a simplified dictionary."""
253 |         version = ConfluenceVersion(
254 |             number=5,
255 |             when="2024-01-01T09:00:00.000Z",
256 |             message="Updated content",
257 |             by=ConfluenceUser(account_id="user123", display_name="Test User"),
258 |         )
259 | 
260 |         simplified = version.to_simplified_dict()
261 | 
262 |         assert isinstance(simplified, dict)
263 |         assert simplified["number"] == 5
264 |         assert simplified["when"] == "2024-01-01 09:00:00"  # Formatted timestamp
265 |         assert simplified["message"] == "Updated content"
266 |         assert simplified["by"] == "Test User"
267 | 
268 | 
269 | class TestConfluenceComment:
270 |     """Tests for the ConfluenceComment model."""
271 | 
272 |     def test_from_api_response_with_valid_data(self, confluence_comments_data):
273 |         """Test creating a ConfluenceComment from valid API data."""
274 |         comment_data = confluence_comments_data["results"][0]
275 | 
276 |         comment = ConfluenceComment.from_api_response(comment_data)
277 | 
278 |         assert comment.id == "456789123"
279 |         assert comment.title == "Re: Technical Design Document"
280 |         assert comment.body != ""  # Body should be populated from "value" field
281 |         assert comment.author is not None
282 |         assert comment.author.display_name == "John Doe"
283 |         assert comment.type == "comment"
284 | 
285 |     def test_from_api_response_with_empty_data(self):
286 |         """Test creating a ConfluenceComment from empty data."""
287 |         comment = ConfluenceComment.from_api_response({})
288 | 
289 |         # Should use default values
290 |         assert comment.id == "0"
291 |         assert comment.title is None
292 |         assert comment.body == ""
293 |         assert comment.created == ""
294 |         assert comment.updated == ""
295 |         assert comment.author is None
296 |         assert comment.type == "comment"
297 | 
298 |     def test_to_simplified_dict(self):
299 |         """Test converting ConfluenceComment to a simplified dictionary."""
300 |         comment = ConfluenceComment(
301 |             id="456789123",
302 |             title="Test Comment",
303 |             body="This is a test comment",
304 |             created="2024-01-01T10:00:00.000Z",
305 |             updated="2024-01-01T10:00:00.000Z",
306 |             author=ConfluenceUser(account_id="user123", display_name="Comment Author"),
307 |             type="comment",
308 |         )
309 | 
310 |         simplified = comment.to_simplified_dict()
311 | 
312 |         assert isinstance(simplified, dict)
313 |         assert simplified["id"] == "456789123"
314 |         assert simplified["title"] == "Test Comment"
315 |         assert simplified["body"] == "This is a test comment"
316 |         assert simplified["created"] == "2024-01-01 10:00:00"  # Formatted timestamp
317 |         assert simplified["updated"] == "2024-01-01 10:00:00"  # Formatted timestamp
318 |         assert simplified["author"] == "Comment Author"
319 | 
320 | 
321 | class TestConfluenceLabel:
322 |     """Tests for the ConfluenceLabel model."""
323 | 
324 |     def test_from_api_response_with_valid_data(self, confluence_labels_data):
325 |         """Test creating a ConfluenceLabel from valid API data."""
326 |         label_data = confluence_labels_data["results"][0]
327 | 
328 |         label = ConfluenceLabel.from_api_response(label_data)
329 | 
330 |         assert label.id == "456789123"
331 |         assert label.name == "meeting-notes"
332 |         assert label.prefix == "global"
333 |         assert label.label == "meeting-notes"
334 |         assert label.type == "label"
335 | 
336 |     def test_from_api_response_with_empty_data(self):
337 |         """Test creating a ConfluenceLabel from empty data."""
338 |         label = ConfluenceLabel.from_api_response({})
339 | 
340 |         # Should use default values
341 |         assert label.id == "0"
342 |         assert label.name == EMPTY_STRING
343 |         assert label.prefix == "global"
344 |         assert label.label == EMPTY_STRING
345 |         assert label.type == "label"
346 | 
347 |     def test_to_simplified_dict(self):
348 |         """Test converting ConfluenceLabel to a simplified dictionary."""
349 |         label = ConfluenceLabel(
350 |             id="456789123",
351 |             name="test",
352 |             prefix="my",
353 |             label="test",
354 |             type="label",
355 |         )
356 | 
357 |         simplified = label.to_simplified_dict()
358 | 
359 |         assert isinstance(simplified, dict)
360 |         assert simplified["id"] == "456789123"
361 |         assert simplified["name"] == "test"
362 |         assert simplified["prefix"] == "my"
363 |         assert simplified["label"] == "test"
364 | 
365 | 
366 | class TestConfluencePage:
367 |     """Tests for the ConfluencePage model."""
368 | 
369 |     def test_from_api_response_with_valid_data(self, confluence_page_data):
370 |         """Test creating a ConfluencePage from valid API data."""
371 |         page = ConfluencePage.from_api_response(confluence_page_data)
372 | 
373 |         assert page.id == "987654321"
374 |         assert page.title == "Example Meeting Notes"
375 |         assert page.type == "page"
376 |         assert page.status == "current"
377 | 
378 |         # Verify nested objects
379 |         assert page.space is not None
380 |         assert page.space.key == "PROJ"
381 |         assert page.space.name == "Project Space"
382 | 
383 |         assert page.version is not None
384 |         assert page.version.number == 1
385 |         assert page.version.by is not None
386 |         assert page.version.by.display_name == "Example User (Unlicensed)"
387 | 
388 |         # Content extraction depends on the implementation
389 |         # If it's not extracting from the mock data, let's skip this check
390 |         # assert "<h2>" in page.content
391 | 
392 |         # Check timestamps
393 |         assert page.version.when == "2024-01-01T09:00:00.000Z"
394 | 
395 |     def test_from_api_response_with_empty_data(self):
396 |         """Test creating a ConfluencePage from empty data."""
397 |         page = ConfluencePage.from_api_response({})
398 | 
399 |         # Should use default values
400 |         assert page.id == "0"
401 |         assert page.title == ""
402 |         assert page.type == "page"
403 |         assert page.status == "current"
404 |         assert page.space is None
405 |         assert page.content == ""
406 |         assert page.content_format == "view"
407 |         assert page.created == ""
408 |         assert page.updated == ""
409 |         assert page.author is None
410 |         assert page.version is None
411 |         assert len(page.ancestors) == 0
412 |         assert isinstance(page.children, dict)
413 |         assert page.url is None
414 | 
415 |     def test_from_api_response_with_search_result(self, confluence_search_data):
416 |         """Test creating a ConfluencePage from search result content."""
417 |         content_data = confluence_search_data["results"][0]["content"]
418 | 
419 |         page = ConfluencePage.from_api_response(content_data)
420 | 
421 |         assert page.id == "123456789"
422 |         assert page.title == "2024-01-01: Team Progress Meeting 01"
423 |         assert page.type == "page"
424 |         assert page.status == "current"
425 | 
426 |     def test_to_simplified_dict(self, confluence_page_data):
427 |         """Test converting ConfluencePage to a simplified dictionary."""
428 |         page = ConfluencePage.from_api_response(confluence_page_data)
429 | 
430 |         simplified = page.to_simplified_dict()
431 | 
432 |         assert isinstance(simplified, dict)
433 |         assert simplified["id"] == "987654321"
434 |         assert simplified["title"] == "Example Meeting Notes"
435 | 
436 |         # The keys in the simplified dict depend on the implementation
437 |         # Let's check for space information in a more flexible way
438 |         assert page.space is not None
439 |         assert page.space.key == "PROJ"
440 | 
441 |         # Check space information - could be a string or a dict
442 |         if "space_key" in simplified:
443 |             assert simplified["space_key"] == "PROJ"
444 |         elif "space" in simplified:
445 |             # The space field might be a dictionary with key and name fields
446 |             if isinstance(simplified["space"], dict):
447 |                 assert simplified["space"]["key"] == "PROJ"
448 |                 assert simplified["space"]["name"] == "Project Space"
449 |             # Or it might be a string with just the key
450 |             else:
451 |                 assert (
452 |                     simplified["space"] == "PROJ"
453 |                     or simplified["space"] == "Project Space"
454 |                 )
455 | 
456 |         # Check version is included
457 |         assert "version" in simplified
458 |         assert simplified["version"] == 1
459 | 
460 |         # URL should be included
461 |         assert "url" in simplified
462 | 
463 |     def test_from_api_response_with_expandable_space(self):
464 |         """Test creating a ConfluencePage from data with space info in _expandable."""
465 |         page_data = {
466 |             "id": "123456",
467 |             "title": "Test Page",
468 |             "_expandable": {"space": "/rest/api/space/TEST"},
469 |         }
470 | 
471 |         page = ConfluencePage.from_api_response(
472 |             page_data, base_url="https://confluence.example.com", is_cloud=True
473 |         )
474 | 
475 |         assert page.space is not None
476 |         assert page.space.key == "TEST"
477 |         assert page.space.name == "Space TEST"
478 |         assert page.url == "https://confluence.example.com/spaces/TEST/pages/123456"
479 | 
480 |     def test_from_api_response_with_missing_space(self):
481 |         """Test creating a ConfluencePage with no space information."""
482 |         page_data = {"id": "123456", "title": "Test Page"}
483 | 
484 |         page = ConfluencePage.from_api_response(
485 |             page_data, base_url="https://confluence.example.com", is_cloud=True
486 |         )
487 | 
488 |         assert page.space is not None
489 |         assert page.space.key == ""  # Default from ConfluenceSpace
490 |         assert page.url == "https://confluence.example.com/spaces/unknown/pages/123456"
491 | 
492 |     def test_from_api_response_with_empty_space_data(self):
493 |         """Test creating a ConfluencePage with empty space data."""
494 |         page_data = {
495 |             "id": "123456",
496 |             "title": "Test Page",
497 |             "space": {},  # Empty space data
498 |         }
499 | 
500 |         page = ConfluencePage.from_api_response(
501 |             page_data, base_url="https://confluence.example.com", is_cloud=True
502 |         )
503 | 
504 |         assert page.space is not None
505 |         assert page.space.key == ""  # Default from ConfluenceSpace
506 |         assert page.url == "https://confluence.example.com/spaces/unknown/pages/123456"
507 | 
508 |     def test_from_api_response_url_construction_without_base_url(self):
509 |         """Test that URL is None when base_url is not provided."""
510 |         page_data = {
511 |             "id": "123456",
512 |             "title": "Test Page",
513 |             "space": {"key": "TEST", "name": "Test Space"},
514 |         }
515 | 
516 |         page = ConfluencePage.from_api_response(page_data)  # No base_url provided
517 | 
518 |         assert page.url is None
519 |         assert page.space is not None
520 |         assert page.space.key == "TEST"
521 | 
522 |     def test_url_construction_cloud_format(self):
523 |         """Test URL construction in cloud format."""
524 |         page_data = {
525 |             "id": "123456",
526 |             "title": "Test Page",
527 |             "space": {"key": "TEST", "name": "Test Space"},
528 |         }
529 | 
530 |         page = ConfluencePage.from_api_response(
531 |             page_data, base_url="https://example.atlassian.net/wiki", is_cloud=True
532 |         )
533 | 
534 |         assert page.url == "https://example.atlassian.net/wiki/spaces/TEST/pages/123456"
535 | 
536 |     def test_url_construction_server_format(self):
537 |         """Test URL construction in server format."""
538 |         page_data = {
539 |             "id": "123456",
540 |             "title": "Test Page",
541 |             "space": {"key": "TEST", "name": "Test Space"},
542 |         }
543 | 
544 |         page = ConfluencePage.from_api_response(
545 |             page_data, base_url="https://wiki.corp.example.com", is_cloud=False
546 |         )
547 | 
548 |         assert (
549 |             page.url
550 |             == "https://wiki.corp.example.com/pages/viewpage.action?pageId=123456"
551 |         )
552 | 
553 | 
554 | class TestConfluenceSearchResult:
555 |     """Tests for the ConfluenceSearchResult model."""
556 | 
557 |     def test_from_api_response_with_valid_data(self, confluence_search_data):
558 |         """Test creating a ConfluenceSearchResult from valid API data."""
559 |         search_result = ConfluenceSearchResult.from_api_response(confluence_search_data)
560 | 
561 |         assert search_result.total_size == 1
562 |         assert search_result.start == 0
563 |         assert search_result.limit == 50
564 |         assert search_result.cql_query == "parent = 123456789"
565 |         assert search_result.search_duration == 156
566 | 
567 |         assert len(search_result.results) == 1
568 | 
569 |         # Verify that results are properly converted to ConfluencePage objects
570 |         page = search_result.results[0]
571 |         assert isinstance(page, ConfluencePage)
572 |         assert page.id == "123456789"
573 |         assert page.title == "2024-01-01: Team Progress Meeting 01"
574 | 
575 |     def test_from_api_response_with_empty_data(self):
576 |         """Test creating a ConfluenceSearchResult from empty data."""
577 |         search_result = ConfluenceSearchResult.from_api_response({})
578 | 
579 |         # Should use default values
580 |         assert search_result.total_size == 0
581 |         assert search_result.start == 0
582 |         assert search_result.limit == 0
583 |         assert search_result.cql_query is None
584 |         assert search_result.search_duration is None
585 |         assert len(search_result.results) == 0
586 | 
587 | 
588 | class TestRealConfluenceData:
589 |     """Tests using real Confluence data (only run if environment is configured)."""
590 | 
591 |     def test_real_confluence_page(
592 |         self, use_real_confluence_data, default_confluence_page_id
593 |     ):
594 |         """Test with real Confluence page data from the API."""
595 |         if not use_real_confluence_data:
596 |             pytest.skip("Real Confluence data testing is disabled")
597 | 
598 |         try:
599 |             # Initialize the Confluence client
600 |             from src.mcp_atlassian.confluence.client import ConfluenceClient
601 |             from src.mcp_atlassian.confluence.config import ConfluenceConfig
602 |             from src.mcp_atlassian.confluence.pages import PagesMixin
603 | 
604 |             # Use the from_env method to create the config
605 |             config = ConfluenceConfig.from_env()
606 |             confluence_client = ConfluenceClient(config=config)
607 |             pages_client = PagesMixin(config=config)
608 | 
609 |             # Use the provided page ID from environment or fixture
610 |             page_id = default_confluence_page_id
611 | 
612 |             # Get page data directly from the Confluence API
613 |             page_data = confluence_client.confluence.get_page_by_id(
614 |                 page_id=page_id, expand="body.storage,version,space,children.attachment"
615 |             )
616 | 
617 |             # Convert to model
618 |             from src.mcp_atlassian.models import ConfluencePage
619 | 
620 |             page = ConfluencePage.from_api_response(page_data)
621 | 
622 |             # Verify basic properties
623 |             assert page.id == page_id
624 |             assert page.title is not None
625 |             assert page.space is not None
626 |             assert page.space.key is not None
627 |             assert page.attachments is not None
628 | 
629 |             # Verify that to_simplified_dict works
630 |             simplified = page.to_simplified_dict()
631 |             assert isinstance(simplified, dict)
632 |             assert simplified["id"] == page_id
633 | 
634 |             # Get and test comments if available
635 |             try:
636 |                 from src.mcp_atlassian.models import ConfluenceComment
637 | 
638 |                 comments_data = confluence_client.confluence.get_page_comments(
639 |                     page_id=page_id, expand="body.view,version"
640 |                 )
641 | 
642 |                 if comments_data and comments_data.get("results"):
643 |                     comment_data = comments_data["results"][0]
644 |                     comment = ConfluenceComment.from_api_response(comment_data)
645 | 
646 |                     assert comment.id is not None
647 |                     assert comment.body is not None
648 | 
649 |                     # Test simplified dict
650 |                     comment_dict = comment.to_simplified_dict()
651 |                     assert isinstance(comment_dict, dict)
652 |                     assert "body" in comment_dict
653 |             except Exception as e:
654 |                 print(f"Comments test skipped: {e}")
655 | 
656 |             print(
657 |                 f"Successfully tested real Confluence page {page_id} in space {page.space.key}"
658 |             )
659 |         except ImportError as e:
660 |             pytest.skip(f"Could not import Confluence client: {e}")
661 |         except Exception as e:
662 |             pytest.fail(f"Error testing real Confluence page: {e}")
663 | 
```

--------------------------------------------------------------------------------
/tests/integration/test_cross_service.py:
--------------------------------------------------------------------------------

```python
  1 | """Integration tests for cross-service functionality between Jira and Confluence."""
  2 | 
  3 | import os
  4 | from unittest.mock import MagicMock, patch
  5 | 
  6 | import pytest
  7 | from requests.sessions import Session
  8 | 
  9 | from mcp_atlassian.confluence import ConfluenceConfig
 10 | from mcp_atlassian.jira import JiraConfig
 11 | from mcp_atlassian.servers.context import MainAppContext
 12 | from mcp_atlassian.servers.dependencies import (
 13 |     _create_user_config_for_fetcher,
 14 |     get_confluence_fetcher,
 15 |     get_jira_fetcher,
 16 | )
 17 | from mcp_atlassian.servers.main import AtlassianMCP, main_lifespan
 18 | from mcp_atlassian.utils.environment import get_available_services
 19 | from mcp_atlassian.utils.ssl import configure_ssl_verification
 20 | from tests.utils.factories import (
 21 |     ConfluencePageFactory,
 22 |     JiraIssueFactory,
 23 | )
 24 | from tests.utils.mocks import MockAtlassianClient, MockEnvironment, MockFastMCP
 25 | 
 26 | 
 27 | @pytest.mark.integration
 28 | class TestCrossServiceUserResolution:
 29 |     """Test user resolution across Jira and Confluence services."""
 30 | 
 31 |     def test_shared_user_email_resolution(self):
 32 |         """Test that user email is resolved consistently across services."""
 33 |         user_email = "test@example.com"
 34 | 
 35 |         # Create mock clients
 36 |         jira_client = MockAtlassianClient.create_jira_client()
 37 |         confluence_client = MockAtlassianClient.create_confluence_client()
 38 | 
 39 |         # Mock user resolution
 40 |         jira_client.user.return_value = {
 41 |             "emailAddress": user_email,
 42 |             "displayName": "Test User",
 43 |             "accountId": "123456",
 44 |         }
 45 | 
 46 |         confluence_client.get_user.return_value = {
 47 |             "email": user_email,
 48 |             "displayName": "Test User",
 49 |             "accountId": "123456",
 50 |         }
 51 | 
 52 |         # Verify consistent user resolution
 53 |         jira_user = jira_client.user("123456")
 54 |         confluence_user = confluence_client.get_user("123456")
 55 | 
 56 |         assert jira_user["emailAddress"] == confluence_user["email"]
 57 |         assert jira_user["displayName"] == confluence_user["displayName"]
 58 |         assert jira_user["accountId"] == confluence_user["accountId"]
 59 | 
 60 |     @pytest.mark.anyio
 61 |     async def test_user_context_propagation(self):
 62 |         """Test that user context is properly propagated between services."""
 63 |         with MockEnvironment.oauth_env() as env:
 64 |             # Create configurations
 65 |             jira_config = JiraConfig.from_env()
 66 |             confluence_config = ConfluenceConfig.from_env()
 67 | 
 68 |             # Create user-specific configurations
 69 |             user_token = "test-user-token"
 70 |             user_email = "test@example.com"
 71 | 
 72 |             credentials = {
 73 |                 "user_email_context": user_email,
 74 |                 "oauth_access_token": user_token,
 75 |             }
 76 | 
 77 |             # Create user configs for both services
 78 |             user_jira_config = _create_user_config_for_fetcher(
 79 |                 base_config=jira_config, auth_type="oauth", credentials=credentials
 80 |             )
 81 | 
 82 |             user_confluence_config = _create_user_config_for_fetcher(
 83 |                 base_config=confluence_config,
 84 |                 auth_type="oauth",
 85 |                 credentials=credentials,
 86 |             )
 87 | 
 88 |             # Verify consistent OAuth configuration
 89 |             assert user_jira_config.oauth_config.access_token == user_token
 90 |             assert user_confluence_config.oauth_config.access_token == user_token
 91 |             assert user_jira_config.username == user_email
 92 |             assert user_confluence_config.username == user_email
 93 | 
 94 | 
 95 | @pytest.mark.integration
 96 | class TestSharedAuthentication:
 97 |     """Test shared authentication context between services."""
 98 | 
 99 |     def test_oauth_shared_configuration(self):
100 |         """Test that OAuth configuration is shared between services."""
101 |         with MockEnvironment.oauth_env() as env:
102 |             # Both services should use the same OAuth configuration
103 |             jira_config = JiraConfig.from_env()
104 |             confluence_config = ConfluenceConfig.from_env()
105 | 
106 |             assert (
107 |                 jira_config.oauth_config.client_id
108 |                 == confluence_config.oauth_config.client_id
109 |             )
110 |             assert (
111 |                 jira_config.oauth_config.client_secret
112 |                 == confluence_config.oauth_config.client_secret
113 |             )
114 |             assert (
115 |                 jira_config.oauth_config.cloud_id
116 |                 == confluence_config.oauth_config.cloud_id
117 |             )
118 |             assert (
119 |                 jira_config.oauth_config.scope == confluence_config.oauth_config.scope
120 |             )
121 | 
122 |     def test_basic_auth_shared_configuration(self):
123 |         """Test that basic auth configuration can be shared between services."""
124 |         with MockEnvironment.basic_auth_env() as env:
125 |             # Both services should use consistent authentication
126 |             jira_config = JiraConfig.from_env()
127 |             confluence_config = ConfluenceConfig.from_env()
128 | 
129 |             assert jira_config.username == confluence_config.username
130 |             assert jira_config.api_token == confluence_config.api_token
131 |             assert jira_config.auth_type == confluence_config.auth_type
132 | 
133 |     @pytest.mark.anyio
134 |     async def test_authentication_context_in_request(self):
135 |         """Test authentication context is properly maintained in request state."""
136 |         request = MockFastMCP.create_request()
137 |         request.state.user_atlassian_auth_type = "oauth"
138 |         request.state.user_atlassian_token = "test-oauth-token"
139 |         request.state.user_atlassian_email = "test@example.com"
140 | 
141 |         with patch(
142 |             "mcp_atlassian.servers.dependencies.get_http_request", return_value=request
143 |         ):
144 |             # Create mock context with lifespan data
145 |             ctx = MockFastMCP.create_context()
146 |             ctx.request_context = MagicMock()
147 |             ctx.request_context.lifespan_context = {
148 |                 "app_lifespan_context": MainAppContext(
149 |                     full_jira_config=JiraConfig.from_env(),
150 |                     full_confluence_config=ConfluenceConfig.from_env(),
151 |                     read_only=False,
152 |                     enabled_tools=None,
153 |                 )
154 |             }
155 | 
156 |             # Mock the fetcher creation
157 |             with (
158 |                 patch("mcp_atlassian.jira.JiraFetcher") as mock_jira_fetcher,
159 |                 patch(
160 |                     "mcp_atlassian.confluence.ConfluenceFetcher"
161 |                 ) as mock_confluence_fetcher,
162 |             ):
163 |                 # Mock the current user validation
164 |                 mock_jira_instance = MagicMock()
165 |                 mock_jira_instance.get_current_user_account_id.return_value = "user123"
166 |                 mock_jira_fetcher.return_value = mock_jira_instance
167 | 
168 |                 mock_confluence_instance = MagicMock()
169 |                 mock_confluence_instance.get_current_user_info.return_value = {
170 |                     "email": "test@example.com",
171 |                     "displayName": "Test User",
172 |                 }
173 |                 mock_confluence_fetcher.return_value = mock_confluence_instance
174 | 
175 |                 # Get fetchers - should use the same auth context
176 |                 jira_fetcher = await get_jira_fetcher(ctx)
177 |                 confluence_fetcher = await get_confluence_fetcher(ctx)
178 | 
179 |                 # Verify both fetchers were created with user-specific config
180 |                 assert request.state.jira_fetcher is not None
181 |                 assert request.state.confluence_fetcher is not None
182 | 
183 | 
184 | @pytest.mark.integration
185 | class TestCrossServiceErrorHandling:
186 |     """Test error handling and propagation across services."""
187 | 
188 |     @pytest.mark.anyio
189 |     async def test_jira_failure_does_not_affect_confluence(self):
190 |         """Test that Jira failure doesn't prevent Confluence from working."""
191 |         with MockEnvironment.basic_auth_env():
192 |             app = AtlassianMCP("Test MCP")
193 | 
194 |             # Mock Jira to fail during initialization
195 |             with patch(
196 |                 "mcp_atlassian.jira.config.JiraConfig.from_env"
197 |             ) as mock_jira_config:
198 |                 mock_jira_config.side_effect = Exception("Jira config failed")
199 | 
200 |                 # But Confluence should still work
201 |                 async with main_lifespan(app) as lifespan_data:
202 |                     context = lifespan_data["app_lifespan_context"]
203 | 
204 |                     assert context.full_jira_config is None
205 |                     assert context.full_confluence_config is not None
206 | 
207 |     @pytest.mark.anyio
208 |     async def test_confluence_failure_does_not_affect_jira(self):
209 |         """Test that Confluence failure doesn't prevent Jira from working."""
210 |         with MockEnvironment.basic_auth_env():
211 |             app = AtlassianMCP("Test MCP")
212 | 
213 |             # Mock Confluence to fail during initialization
214 |             with patch(
215 |                 "mcp_atlassian.confluence.config.ConfluenceConfig.from_env"
216 |             ) as mock_conf_config:
217 |                 mock_conf_config.side_effect = Exception("Confluence config failed")
218 | 
219 |                 # But Jira should still work
220 |                 async with main_lifespan(app) as lifespan_data:
221 |                     context = lifespan_data["app_lifespan_context"]
222 | 
223 |                     assert context.full_jira_config is not None
224 |                     assert context.full_confluence_config is None
225 | 
226 |     def test_error_propagation_in_user_config_creation(self):
227 |         """Test error propagation when creating user-specific configurations."""
228 |         base_config = JiraConfig.from_env()
229 | 
230 |         # Test missing OAuth token
231 |         with pytest.raises(ValueError, match="OAuth access token missing"):
232 |             _create_user_config_for_fetcher(
233 |                 base_config=base_config,
234 |                 auth_type="oauth",
235 |                 credentials={"user_email_context": "test@example.com"},
236 |             )
237 | 
238 |         # Test missing PAT token
239 |         with pytest.raises(ValueError, match="PAT missing"):
240 |             _create_user_config_for_fetcher(
241 |                 base_config=base_config,
242 |                 auth_type="pat",
243 |                 credentials={"user_email_context": "test@example.com"},
244 |             )
245 | 
246 |         # Test invalid auth type
247 |         with pytest.raises(ValueError, match="Unsupported auth_type"):
248 |             _create_user_config_for_fetcher(
249 |                 base_config=base_config, auth_type="invalid", credentials={}
250 |             )
251 | 
252 | 
253 | @pytest.mark.integration
254 | class TestSharedSSLProxyConfiguration:
255 |     """Test shared SSL and proxy configuration between services."""
256 | 
257 |     def test_ssl_configuration_shared(self):
258 |         """Test that SSL configuration is applied consistently."""
259 |         with MockEnvironment.basic_auth_env():
260 |             # Set SSL verification to false for both services
261 |             with patch.dict(
262 |                 os.environ,
263 |                 {"JIRA_SSL_VERIFY": "false", "CONFLUENCE_SSL_VERIFY": "false"},
264 |             ):
265 |                 jira_config = JiraConfig.from_env()
266 |                 confluence_config = ConfluenceConfig.from_env()
267 | 
268 |                 assert jira_config.ssl_verify is False
269 |                 assert confluence_config.ssl_verify is False
270 | 
271 |                 # Test SSL adapter configuration
272 |                 jira_session = Session()
273 |                 confluence_session = Session()
274 | 
275 |                 configure_ssl_verification(
276 |                     "Jira", jira_config.url, jira_session, ssl_verify=False
277 |                 )
278 |                 configure_ssl_verification(
279 |                     "Confluence",
280 |                     confluence_config.url,
281 |                     confluence_session,
282 |                     ssl_verify=False,
283 |                 )
284 | 
285 |                 # Extract domains
286 |                 jira_domain = jira_config.url.split("://")[1].split("/")[0]
287 |                 confluence_domain = confluence_config.url.split("://")[1].split("/")[0]
288 | 
289 |                 # Both should have SSL ignore adapters
290 |                 assert f"https://{jira_domain}" in jira_session.adapters
291 |                 assert f"https://{confluence_domain}" in confluence_session.adapters
292 | 
293 |     def test_proxy_configuration_shared(self):
294 |         """Test that proxy configuration is shared between services."""
295 |         proxy_config = {
296 |             "HTTP_PROXY": "http://proxy.example.com:8080",
297 |             "HTTPS_PROXY": "https://proxy.example.com:8443",
298 |             "NO_PROXY": "localhost,127.0.0.1",
299 |         }
300 | 
301 |         with MockEnvironment.basic_auth_env():
302 |             with patch.dict(os.environ, proxy_config):
303 |                 jira_config = JiraConfig.from_env()
304 |                 confluence_config = ConfluenceConfig.from_env()
305 | 
306 |                 # Both services should have the same proxy configuration
307 |                 assert jira_config.http_proxy == proxy_config["HTTP_PROXY"]
308 |                 assert jira_config.https_proxy == proxy_config["HTTPS_PROXY"]
309 |                 assert jira_config.no_proxy == proxy_config["NO_PROXY"]
310 | 
311 |                 assert confluence_config.http_proxy == proxy_config["HTTP_PROXY"]
312 |                 assert confluence_config.https_proxy == proxy_config["HTTPS_PROXY"]
313 |                 assert confluence_config.no_proxy == proxy_config["NO_PROXY"]
314 | 
315 | 
316 | @pytest.mark.integration
317 | class TestConcurrentServiceInitialization:
318 |     """Test concurrent initialization of both services."""
319 | 
320 |     @pytest.mark.anyio
321 |     async def test_concurrent_service_startup(self):
322 |         """Test that both services can be initialized concurrently."""
323 |         with MockEnvironment.basic_auth_env():
324 |             app = AtlassianMCP("Test MCP")
325 | 
326 |             # Track initialization order
327 |             init_order = []
328 | 
329 |             def mock_jira_init(*args, **kwargs):
330 |                 init_order.append("jira_start")
331 |                 # Can't use async sleep in sync function, just append both immediately
332 |                 init_order.append("jira_end")
333 |                 return MagicMock(is_auth_configured=MagicMock(return_value=True))
334 | 
335 |             def mock_confluence_init(*args, **kwargs):
336 |                 init_order.append("confluence_start")
337 |                 init_order.append("confluence_end")
338 |                 return MagicMock(is_auth_configured=MagicMock(return_value=True))
339 | 
340 |             with (
341 |                 patch(
342 |                     "mcp_atlassian.jira.config.JiraConfig.from_env",
343 |                     side_effect=mock_jira_init,
344 |                 ),
345 |                 patch(
346 |                     "mcp_atlassian.confluence.config.ConfluenceConfig.from_env",
347 |                     side_effect=mock_confluence_init,
348 |                 ),
349 |             ):
350 |                 async with main_lifespan(app) as lifespan_data:
351 |                     context = lifespan_data["app_lifespan_context"]
352 | 
353 |                     # Both services should be initialized
354 |                     assert context.full_jira_config is not None
355 |                     assert context.full_confluence_config is not None
356 | 
357 |                     # Verify concurrent initialization (interleaved order)
358 |                     assert "jira_start" in init_order
359 |                     assert "confluence_start" in init_order
360 | 
361 |     @pytest.mark.anyio
362 |     async def test_parallel_fetcher_creation(self):
363 |         """Test that fetchers can be created in parallel for both services."""
364 |         with MockEnvironment.oauth_env():
365 |             # Create mock request with user context
366 |             request = MockFastMCP.create_request()
367 |             request.state.user_atlassian_auth_type = "oauth"
368 |             request.state.user_atlassian_token = "test-token"
369 |             request.state.user_atlassian_email = "test@example.com"
370 | 
371 |             # Create context
372 |             ctx = MockFastMCP.create_context()
373 |             ctx.request_context = MagicMock()
374 |             ctx.request_context.lifespan_context = {
375 |                 "app_lifespan_context": MainAppContext(
376 |                     full_jira_config=JiraConfig.from_env(),
377 |                     full_confluence_config=ConfluenceConfig.from_env(),
378 |                     read_only=False,
379 |                     enabled_tools=None,
380 |                 )
381 |             }
382 | 
383 |             with (
384 |                 patch(
385 |                     "mcp_atlassian.servers.dependencies.get_http_request",
386 |                     return_value=request,
387 |                 ),
388 |                 patch("mcp_atlassian.jira.JiraFetcher") as mock_jira_fetcher,
389 |                 patch(
390 |                     "mcp_atlassian.confluence.ConfluenceFetcher"
391 |                 ) as mock_confluence_fetcher,
392 |             ):
393 |                 # Mock fetcher instances
394 |                 mock_jira_instance = MagicMock()
395 |                 mock_jira_instance.get_current_user_account_id.return_value = "user123"
396 |                 mock_jira_fetcher.return_value = mock_jira_instance
397 | 
398 |                 mock_confluence_instance = MagicMock()
399 |                 mock_confluence_instance.get_current_user_info.return_value = {
400 |                     "email": "test@example.com",
401 |                     "displayName": "Test User",
402 |                 }
403 |                 mock_confluence_fetcher.return_value = mock_confluence_instance
404 | 
405 |                 # Create fetchers in parallel using anyio for backend compatibility
406 |                 import anyio
407 | 
408 |                 async def fetch_jira():
409 |                     return await get_jira_fetcher(ctx)
410 | 
411 |                 async def fetch_confluence():
412 |                     return await get_confluence_fetcher(ctx)
413 | 
414 |                 # Wait for both using anyio task group
415 |                 async with anyio.create_task_group() as tg:
416 |                     jira_future = None
417 |                     confluence_future = None
418 | 
419 |                     async def set_jira():
420 |                         nonlocal jira_future
421 |                         jira_future = await fetch_jira()
422 | 
423 |                     async def set_confluence():
424 |                         nonlocal confluence_future
425 |                         confluence_future = await fetch_confluence()
426 | 
427 |                     tg.start_soon(set_jira)
428 |                     tg.start_soon(set_confluence)
429 | 
430 |                 jira_fetcher = jira_future
431 |                 confluence_fetcher = confluence_future
432 | 
433 |                 # Both should be created successfully
434 |                 assert jira_fetcher is not None
435 |                 assert confluence_fetcher is not None
436 |                 assert request.state.jira_fetcher is jira_fetcher
437 |                 assert request.state.confluence_fetcher is confluence_fetcher
438 | 
439 | 
440 | @pytest.mark.integration
441 | class TestServiceAvailabilityDetection:
442 |     """Test service availability detection and handling."""
443 | 
444 |     def test_detect_no_services_configured(self):
445 |         """Test detection when no services are configured."""
446 |         with MockEnvironment.clean_env():
447 |             services = get_available_services()
448 |             assert services["jira"] is False
449 |             assert services["confluence"] is False
450 | 
451 |     def test_detect_only_jira_configured(self):
452 |         """Test detection when only Jira is configured."""
453 |         with patch.dict(
454 |             os.environ,
455 |             {
456 |                 "JIRA_URL": "https://test.atlassian.net",
457 |                 "JIRA_USERNAME": "test@example.com",
458 |                 "JIRA_API_TOKEN": "test-token",
459 |             },
460 |             clear=True,
461 |         ):  # Clear environment to ensure isolation
462 |             services = get_available_services()
463 |             assert services["jira"] is True
464 |             assert services["confluence"] is False
465 | 
466 |     def test_detect_only_confluence_configured(self):
467 |         """Test detection when only Confluence is configured."""
468 |         with patch.dict(
469 |             os.environ,
470 |             {
471 |                 "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
472 |                 "CONFLUENCE_USERNAME": "test@example.com",
473 |                 "CONFLUENCE_API_TOKEN": "test-token",
474 |             },
475 |             clear=True,
476 |         ):  # Clear environment to ensure isolation
477 |             services = get_available_services()
478 |             assert services["jira"] is False
479 |             assert services["confluence"] is True
480 | 
481 |     def test_detect_both_services_configured(self):
482 |         """Test detection when both services are configured."""
483 |         with MockEnvironment.basic_auth_env():
484 |             services = get_available_services()
485 |             assert services["jira"] is True
486 |             assert services["confluence"] is True
487 | 
488 |     def test_partial_configuration_detection(self):
489 |         """Test detection with partial configuration (URL but no auth)."""
490 |         with patch.dict(
491 |             os.environ,
492 |             {
493 |                 "JIRA_URL": "https://test.atlassian.net",
494 |                 "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
495 |                 # No authentication credentials
496 |             },
497 |             clear=True,
498 |         ):  # Clear environment to ensure isolation
499 |             services = get_available_services()
500 |             assert services["jira"] is False
501 |             assert services["confluence"] is False
502 | 
503 |     @pytest.mark.anyio
504 |     async def test_service_availability_in_lifespan(self):
505 |         """Test that service availability is properly reflected in lifespan context."""
506 |         # Test with only Jira configured
507 |         with patch.dict(
508 |             os.environ,
509 |             {
510 |                 "JIRA_URL": "https://test.atlassian.net",
511 |                 "JIRA_USERNAME": "test@example.com",
512 |                 "JIRA_API_TOKEN": "test-token",
513 |             },
514 |             clear=True,
515 |         ):
516 |             app = AtlassianMCP("Test MCP")
517 | 
518 |             async with main_lifespan(app) as lifespan_data:
519 |                 context = lifespan_data["app_lifespan_context"]
520 | 
521 |                 # Only Jira should be configured
522 |                 assert context.full_jira_config is not None
523 |                 assert context.full_confluence_config is None
524 | 
525 |     def test_oauth_precedence_over_basic_auth(self):
526 |         """Test that OAuth configuration takes precedence over basic auth."""
527 |         # Set both OAuth and basic auth environment variables
528 |         with patch.dict(
529 |             os.environ,
530 |             {
531 |                 # OAuth configuration
532 |                 "ATLASSIAN_OAUTH_CLIENT_ID": "oauth-client",
533 |                 "ATLASSIAN_OAUTH_CLIENT_SECRET": "oauth-secret",
534 |                 "ATLASSIAN_OAUTH_REDIRECT_URI": "http://localhost:8080",
535 |                 "ATLASSIAN_OAUTH_SCOPE": "read:jira-work write:jira-work",
536 |                 "ATLASSIAN_OAUTH_CLOUD_ID": "cloud-123",
537 |                 # Basic auth configuration
538 |                 "JIRA_URL": "https://test.atlassian.net",
539 |                 "JIRA_USERNAME": "basic@example.com",
540 |                 "JIRA_API_TOKEN": "basic-token",
541 |                 "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
542 |                 "CONFLUENCE_USERNAME": "basic@example.com",
543 |                 "CONFLUENCE_API_TOKEN": "basic-token",
544 |             },
545 |         ):
546 |             services = get_available_services()
547 |             assert services["jira"] is True
548 |             assert services["confluence"] is True
549 | 
550 |             # Verify OAuth is used
551 |             jira_config = JiraConfig.from_env()
552 |             confluence_config = ConfluenceConfig.from_env()
553 | 
554 |             assert jira_config.auth_type == "oauth"
555 |             assert confluence_config.auth_type == "oauth"
556 |             assert jira_config.oauth_config is not None
557 |             assert confluence_config.oauth_config is not None
558 | 
559 | 
560 | @pytest.mark.integration
561 | class TestCrossServiceDataSharing:
562 |     """Test data sharing and references between services."""
563 | 
564 |     def test_jira_issue_confluence_page_link(self):
565 |         """Test linking between Jira issues and Confluence pages."""
566 |         # Create test data
567 |         jira_issue = JiraIssueFactory.create(
568 |             key="TEST-123",
569 |             fields={
570 |                 "summary": "Test Issue",
571 |                 "description": "See documentation at https://test.atlassian.net/wiki/spaces/TEST/pages/123456",
572 |             },
573 |         )
574 | 
575 |         confluence_page = ConfluencePageFactory.create(
576 |             page_id="123456",
577 |             title="Test Documentation",
578 |             body={
579 |                 "storage": {
580 |                     "value": "<p>Related to <a href='https://test.atlassian.net/browse/TEST-123'>TEST-123</a></p>"
581 |                 }
582 |             },
583 |         )
584 | 
585 |         # Verify cross-references exist
586 |         assert "123456" in jira_issue["fields"]["description"]
587 |         assert "TEST-123" in confluence_page["body"]["storage"]["value"]
588 | 
589 |     def test_shared_user_mentions(self):
590 |         """Test that user mentions work consistently across services."""
591 |         user_account_id = "557058:c4b6b2f1-2f5f-4b85-b033-4cedbe2d2e17"
592 | 
593 |         # Jira mention format
594 |         jira_mention = f"[~accountid:{user_account_id}]"
595 | 
596 |         # Confluence mention format
597 |         confluence_mention = (
598 |             f'<ac:link><ri:user ri:account-id="{user_account_id}" /></ac:link>'
599 |         )
600 | 
601 |         # Create content with mentions
602 |         jira_comment = {"body": f"Hey {jira_mention}, please review this issue."}
603 | 
604 |         confluence_content = {
605 |             "body": {
606 |                 "storage": {
607 |                     "value": f"<p>Hey {confluence_mention}, please review this page.</p>"
608 |                 }
609 |             }
610 |         }
611 | 
612 |         # Verify mentions are present
613 |         assert user_account_id in jira_comment["body"]
614 |         assert user_account_id in confluence_content["body"]["storage"]["value"]
615 | 
```

--------------------------------------------------------------------------------
/tests/unit/jira/test_users.py:
--------------------------------------------------------------------------------

```python
  1 | """Tests for the Jira users module."""
  2 | 
  3 | from unittest.mock import MagicMock, patch
  4 | 
  5 | import pytest
  6 | import requests
  7 | 
  8 | from mcp_atlassian.jira.config import JiraConfig
  9 | from mcp_atlassian.jira.users import UsersMixin
 10 | 
 11 | 
 12 | class TestUsersMixin:
 13 |     """Tests for the UsersMixin class."""
 14 | 
 15 |     @pytest.fixture
 16 |     def users_mixin(self, jira_client):
 17 |         """Create a UsersMixin instance with mocked dependencies."""
 18 |         mixin = UsersMixin(config=jira_client.config)
 19 |         mixin.jira = jira_client.jira
 20 |         return mixin
 21 | 
 22 |     def test_get_current_user_account_id_cached(self, users_mixin):
 23 |         """Test that get_current_user_account_id returns cached value if available."""
 24 |         # Set cached value
 25 |         users_mixin._current_user_account_id = "cached-account-id"
 26 | 
 27 |         # Call the method
 28 |         account_id = users_mixin.get_current_user_account_id()
 29 | 
 30 |         # Verify result
 31 |         assert account_id == "cached-account-id"
 32 |         # Verify the API wasn't called
 33 |         users_mixin.jira.myself.assert_not_called()
 34 | 
 35 |     def test_get_current_user_account_id_from_api(self, users_mixin):
 36 |         """Test that get_current_user_account_id calls the API if no cached value."""
 37 |         # Ensure no cached value
 38 |         users_mixin._current_user_account_id = None
 39 | 
 40 |         # Mock the self.jira.myself() method
 41 |         users_mixin.jira.myself = MagicMock(
 42 |             return_value={"accountId": "test-account-id"}
 43 |         )
 44 | 
 45 |         # Call the method
 46 |         account_id = users_mixin.get_current_user_account_id()
 47 | 
 48 |         # Verify result
 49 |         assert account_id == "test-account-id"
 50 |         # Verify self.jira.myself was called
 51 |         users_mixin.jira.myself.assert_called_once()
 52 | 
 53 |     def test_get_current_user_account_id_data_center_timestamp_issue(self, users_mixin):
 54 |         """Test that get_current_user_account_id handles Jira Data Center with problematic timestamps."""
 55 |         # Ensure no cached value
 56 |         users_mixin._current_user_account_id = None
 57 | 
 58 |         # Mock the self.jira.myself() method
 59 |         users_mixin.jira.myself = MagicMock(
 60 |             return_value={
 61 |                 "key": "jira-dc-user",
 62 |                 "name": "DC User",
 63 |                 "created": "9999-12-31T23:59:59.999+0000",
 64 |                 "lastLogin": "0000-01-01T00:00:00.000+0000",
 65 |             }
 66 |         )
 67 | 
 68 |         # Call the method
 69 |         account_id = users_mixin.get_current_user_account_id()
 70 | 
 71 |         # Verify result - should extract key without timestamp parsing issues
 72 |         assert account_id == "jira-dc-user"
 73 |         # Verify self.jira.myself was called
 74 |         users_mixin.jira.myself.assert_called_once()
 75 | 
 76 |     def test_get_current_user_account_id_error(self, users_mixin):
 77 |         """Test that get_current_user_account_id handles errors."""
 78 |         # Ensure no cached value
 79 |         users_mixin._current_user_account_id = None
 80 | 
 81 |         # Mock the self.jira.myself() method to raise an exception
 82 |         users_mixin.jira.myself = MagicMock(
 83 |             side_effect=requests.RequestException("API error")
 84 |         )
 85 | 
 86 |         # Call the method and verify it raises the expected exception
 87 |         with pytest.raises(
 88 |             Exception, match="Unable to get current user account ID: API error"
 89 |         ):
 90 |             users_mixin.get_current_user_account_id()
 91 | 
 92 |         # Verify self.jira.myself was called
 93 |         users_mixin.jira.myself.assert_called_once()
 94 | 
 95 |     def test_get_current_user_account_id_jira_data_center_key(self, users_mixin):
 96 |         """Test that get_current_user_account_id falls back to 'key' for Jira Data Center."""
 97 |         # Ensure no cached value
 98 |         users_mixin._current_user_account_id = None
 99 | 
100 |         # Mock the self.jira.myself() response with a Jira Data Center response
101 |         users_mixin.jira.myself = MagicMock(
102 |             return_value={"key": "jira-data-center-key", "name": "Test User"}
103 |         )
104 | 
105 |         # Call the method
106 |         account_id = users_mixin.get_current_user_account_id()
107 | 
108 |         # Verify result
109 |         assert account_id == "jira-data-center-key"
110 |         # Verify self.jira.myself was called
111 |         users_mixin.jira.myself.assert_called_once()
112 | 
113 |     def test_get_current_user_account_id_jira_data_center_name(self, users_mixin):
114 |         """Test that get_current_user_account_id falls back to 'name' when no 'key' or 'accountId'."""
115 |         # Ensure no cached value
116 |         users_mixin._current_user_account_id = None
117 | 
118 |         # Mock the self.jira.myself() response with a Jira Data Center response
119 |         users_mixin.jira.myself = MagicMock(
120 |             return_value={"name": "jira-data-center-name"}
121 |         )
122 | 
123 |         # Call the method
124 |         account_id = users_mixin.get_current_user_account_id()
125 | 
126 |         # Verify result
127 |         assert account_id == "jira-data-center-name"
128 |         # Verify self.jira.myself was called
129 |         users_mixin.jira.myself.assert_called_once()
130 | 
131 |     def test_get_current_user_account_id_no_identifiers(self, users_mixin):
132 |         """Test that get_current_user_account_id raises error when no identifiers are found."""
133 |         # Ensure no cached value
134 |         users_mixin._current_user_account_id = None
135 | 
136 |         # Mock the self.jira.myself() response with no identifiers
137 |         users_mixin.jira.myself = MagicMock(return_value={"someField": "someValue"})
138 | 
139 |         # Call the method and verify it raises the expected exception
140 |         with pytest.raises(
141 |             Exception,
142 |             match="Unable to get current user account ID: Could not find accountId, key, or name in user data",
143 |         ):
144 |             users_mixin.get_current_user_account_id()
145 | 
146 |         # Verify self.jira.myself was called
147 |         users_mixin.jira.myself.assert_called_once()
148 | 
149 |     def test_get_account_id_already_account_id(self, users_mixin):
150 |         """Test that _get_account_id returns the input if it looks like an account ID."""
151 |         # Call the method with a string that looks like an account ID
152 |         account_id = users_mixin._get_account_id("5abcdef1234567890")
153 | 
154 |         # Verify result
155 |         assert account_id == "5abcdef1234567890"
156 |         # Verify no lookups were performed
157 |         users_mixin.jira.user_find_by_user_string.assert_not_called()
158 | 
159 |     def test_get_account_id_direct_lookup(self, users_mixin):
160 |         """Test that _get_account_id uses direct lookup."""
161 |         # Mock both methods to avoid AttributeError
162 |         with (
163 |             patch.object(
164 |                 users_mixin, "_lookup_user_directly", return_value="direct-account-id"
165 |             ) as mock_direct,
166 |             patch.object(
167 |                 users_mixin, "_lookup_user_by_permissions"
168 |             ) as mock_permissions,
169 |         ):
170 |             # Call the method
171 |             account_id = users_mixin._get_account_id("username")
172 | 
173 |             # Verify result
174 |             assert account_id == "direct-account-id"
175 |             # Verify direct lookup was called
176 |             mock_direct.assert_called_once_with("username")
177 |             # Verify permissions lookup wasn't called
178 |             mock_permissions.assert_not_called()
179 | 
180 |     def test_get_account_id_permissions_lookup(self, users_mixin):
181 |         """Test that _get_account_id falls back to permissions lookup."""
182 |         # Mock direct lookup to return None
183 |         with (
184 |             patch.object(
185 |                 users_mixin, "_lookup_user_directly", return_value=None
186 |             ) as mock_direct,
187 |             patch.object(
188 |                 users_mixin,
189 |                 "_lookup_user_by_permissions",
190 |                 return_value="permissions-account-id",
191 |             ) as mock_permissions,
192 |         ):
193 |             # Call the method
194 |             account_id = users_mixin._get_account_id("username")
195 | 
196 |             # Verify result
197 |             assert account_id == "permissions-account-id"
198 |             # Verify both lookups were called
199 |             mock_direct.assert_called_once_with("username")
200 |             mock_permissions.assert_called_once_with("username")
201 | 
202 |     def test_get_account_id_not_found(self, users_mixin):
203 |         """Test that _get_account_id raises ValueError if user not found."""
204 |         # Mock both lookups to return None
205 |         with (
206 |             patch.object(users_mixin, "_lookup_user_directly", return_value=None),
207 |             patch.object(users_mixin, "_lookup_user_by_permissions", return_value=None),
208 |         ):
209 |             # Call the method and verify it raises the expected exception
210 |             with pytest.raises(
211 |                 ValueError, match="Could not find account ID for user: testuser"
212 |             ):
213 |                 users_mixin._get_account_id("testuser")
214 | 
215 |     def test_lookup_user_directly(self, users_mixin):
216 |         """Test _lookup_user_directly when user is found."""
217 |         # Mock the API response
218 |         users_mixin.jira.user_find_by_user_string.return_value = [
219 |             {
220 |                 "accountId": "direct-account-id",
221 |                 "displayName": "Test User",
222 |                 "emailAddress": "test@example.com",
223 |             }
224 |         ]
225 | 
226 |         # Mock config.is_cloud to return True
227 |         users_mixin.config = MagicMock()
228 |         users_mixin.config.is_cloud = True
229 | 
230 |         # Call the method
231 |         account_id = users_mixin._lookup_user_directly("Test User")
232 | 
233 |         # Verify result
234 |         assert account_id == "direct-account-id"
235 |         # Verify API call with query parameter for Cloud
236 |         users_mixin.jira.user_find_by_user_string.assert_called_once_with(
237 |             query="Test User", start=0, limit=1
238 |         )
239 | 
240 |     def test_lookup_user_directly_server_dc(self, users_mixin):
241 |         """Test _lookup_user_directly for Server/DC when user is found."""
242 |         # Mock the API response
243 |         users_mixin.jira.user_find_by_user_string.return_value = [
244 |             {
245 |                 "key": "server-user-key",
246 |                 "name": "server-user-name",
247 |                 "displayName": "Test User",
248 |                 "emailAddress": "test@example.com",
249 |             }
250 |         ]
251 | 
252 |         # Mock config.is_cloud to return False for Server/DC
253 |         users_mixin.config = MagicMock()
254 |         users_mixin.config.is_cloud = False
255 | 
256 |         # Call the method
257 |         account_id = users_mixin._lookup_user_directly("Test User")
258 | 
259 |         # Verify result - should now return name instead of key for Server/DC
260 |         assert account_id == "server-user-name"
261 |         # Verify API call with username parameter for Server/DC
262 |         users_mixin.jira.user_find_by_user_string.assert_called_once_with(
263 |             username="Test User", start=0, limit=1
264 |         )
265 | 
266 |     def test_lookup_user_directly_server_dc_key_fallback(self, users_mixin):
267 |         """Test _lookup_user_directly for Server/DC falls back to key when name is not available."""
268 |         # Mock the API response
269 |         users_mixin.jira.user_find_by_user_string.return_value = [
270 |             {
271 |                 "key": "server-user-key",  # Only key, no name
272 |                 "displayName": "Test User",
273 |                 "emailAddress": "test@example.com",
274 |             }
275 |         ]
276 | 
277 |         # Mock config.is_cloud to return False for Server/DC
278 |         users_mixin.config = MagicMock()
279 |         users_mixin.config.is_cloud = False
280 | 
281 |         # Call the method
282 |         account_id = users_mixin._lookup_user_directly("Test User")
283 | 
284 |         # Verify result - should fallback to key when name is missing
285 |         assert account_id == "server-user-key"
286 |         # Verify API call with username parameter for Server/DC
287 |         users_mixin.jira.user_find_by_user_string.assert_called_once_with(
288 |             username="Test User", start=0, limit=1
289 |         )
290 | 
291 |     def test_lookup_user_directly_not_found(self, users_mixin):
292 |         """Test _lookup_user_directly when user is not found."""
293 |         # Mock empty API response
294 |         users_mixin.jira.user_find_by_user_string.return_value = []
295 | 
296 |         # Mock config.is_cloud to return True (default case)
297 |         users_mixin.config = MagicMock()
298 |         users_mixin.config.is_cloud = True
299 | 
300 |         # Call the method
301 |         account_id = users_mixin._lookup_user_directly("nonexistent")
302 | 
303 |         # Verify result
304 |         assert account_id is None
305 | 
306 |     def test_lookup_user_directly_jira_data_center_key(self, users_mixin):
307 |         """Test _lookup_user_directly when only 'key' is available (Data Center)."""
308 |         # Mock the API response for Jira Data Center (has key but no accountId)
309 |         users_mixin.jira.user_find_by_user_string.return_value = [
310 |             {
311 |                 "key": "data-center-key",
312 |                 "displayName": "Test User",
313 |                 "emailAddress": "test@example.com",
314 |             }
315 |         ]
316 | 
317 |         # Mock config.is_cloud to return False for Server/DC
318 |         users_mixin.config = MagicMock()
319 |         users_mixin.config.is_cloud = False
320 | 
321 |         # Call the method
322 |         account_id = users_mixin._lookup_user_directly("Test User")
323 | 
324 |         # Verify result
325 |         assert account_id == "data-center-key"
326 |         # Verify API call
327 |         users_mixin.jira.user_find_by_user_string.assert_called_once_with(
328 |             username="Test User", start=0, limit=1
329 |         )
330 | 
331 |     def test_lookup_user_directly_jira_data_center_name(self, users_mixin):
332 |         """Test _lookup_user_directly when only 'name' is available (Data Center)."""
333 |         # Mock the API response for Jira Data Center (has name but no accountId or key)
334 |         users_mixin.jira.user_find_by_user_string.return_value = [
335 |             {
336 |                 "name": "data-center-name",
337 |                 "displayName": "Test User",
338 |                 "emailAddress": "test@example.com",
339 |             }
340 |         ]
341 | 
342 |         # Mock config.is_cloud to return False for Server/DC
343 |         users_mixin.config = MagicMock()
344 |         users_mixin.config.is_cloud = False
345 | 
346 |         # Call the method
347 |         account_id = users_mixin._lookup_user_directly("Test User")
348 | 
349 |         # Verify result
350 |         assert account_id == "data-center-name"
351 |         # Verify API call
352 |         users_mixin.jira.user_find_by_user_string.assert_called_once_with(
353 |             username="Test User", start=0, limit=1
354 |         )
355 | 
356 |     def test_lookup_user_directly_error(self, users_mixin):
357 |         """Test _lookup_user_directly when API call fails."""
358 |         # Mock API call to raise exception
359 |         users_mixin.jira.user_find_by_user_string.side_effect = Exception("API error")
360 | 
361 |         # Call the method
362 |         account_id = users_mixin._lookup_user_directly("error")
363 | 
364 |         # Verify result
365 |         assert account_id is None
366 | 
367 |     def test_lookup_user_by_permissions(self, users_mixin):
368 |         """Test _lookup_user_by_permissions when user is found."""
369 |         # Mock requests.get
370 |         with patch("requests.get") as mock_get:
371 |             mock_response = MagicMock()
372 |             mock_response.status_code = 200
373 |             mock_response.json.return_value = {
374 |                 "users": [{"accountId": "permissions-account-id"}]
375 |             }
376 |             mock_get.return_value = mock_response
377 | 
378 |             # Call the method
379 |             account_id = users_mixin._lookup_user_by_permissions("username")
380 | 
381 |             # Verify result
382 |             assert account_id == "permissions-account-id"
383 |             # Verify API call
384 |             mock_get.assert_called_once()
385 |             assert mock_get.call_args[0][0].endswith("/user/permission/search")
386 |             assert mock_get.call_args[1]["params"] == {
387 |                 "query": "username",
388 |                 "permissions": "BROWSE",
389 |             }
390 | 
391 |     def test_lookup_user_by_permissions_not_found(self, users_mixin):
392 |         """Test _lookup_user_by_permissions when user is not found."""
393 |         # Mock requests.get
394 |         with patch("requests.get") as mock_get:
395 |             mock_response = MagicMock()
396 |             mock_response.status_code = 200
397 |             mock_response.json.return_value = {"users": []}
398 |             mock_get.return_value = mock_response
399 | 
400 |             # Call the method
401 |             account_id = users_mixin._lookup_user_by_permissions("nonexistent")
402 | 
403 |             # Verify result
404 |             assert account_id is None
405 | 
406 |     def test_lookup_user_by_permissions_jira_data_center(self, users_mixin):
407 |         """Test _lookup_user_by_permissions when both 'key' and 'name' are available (Data Center)."""
408 |         # Mock requests.get
409 |         with patch("requests.get") as mock_get:
410 |             mock_response = MagicMock()
411 |             mock_response.status_code = 200
412 |             mock_response.json.return_value = {
413 |                 "users": [
414 |                     {
415 |                         "key": "data-center-permissions-key",
416 |                         "name": "data-center-permissions-name",
417 |                     }
418 |                 ]
419 |             }
420 |             mock_get.return_value = mock_response
421 | 
422 |             # Mock config.is_cloud to return False for Server/DC
423 |             users_mixin.config = MagicMock()
424 |             users_mixin.config.is_cloud = False
425 | 
426 |             # Call the method
427 |             account_id = users_mixin._lookup_user_by_permissions("username")
428 | 
429 |             # Verify result - should prioritize name for Server/DC
430 |             assert account_id == "data-center-permissions-name"
431 |             # Verify API call
432 |             mock_get.assert_called_once()
433 |             assert mock_get.call_args[0][0].endswith("/user/permission/search")
434 |             assert mock_get.call_args[1]["params"] == {
435 |                 "query": "username",
436 |                 "permissions": "BROWSE",
437 |             }
438 | 
439 |     def test_lookup_user_by_permissions_jira_data_center_key_fallback(
440 |         self, users_mixin
441 |     ):
442 |         """Test _lookup_user_by_permissions when only 'key' is available (Data Center)."""
443 |         # Mock requests.get
444 |         with patch("requests.get") as mock_get:
445 |             mock_response = MagicMock()
446 |             mock_response.status_code = 200
447 |             mock_response.json.return_value = {
448 |                 "users": [{"key": "data-center-permissions-key"}]
449 |             }
450 |             mock_get.return_value = mock_response
451 | 
452 |             # Mock config.is_cloud to return False for Server/DC
453 |             users_mixin.config = MagicMock()
454 |             users_mixin.config.is_cloud = False
455 | 
456 |             # Call the method
457 |             account_id = users_mixin._lookup_user_by_permissions("username")
458 | 
459 |             # Verify result - should fallback to key when name is missing
460 |             assert account_id == "data-center-permissions-key"
461 |             # Verify API call
462 |             mock_get.assert_called_once()
463 |             assert mock_get.call_args[0][0].endswith("/user/permission/search")
464 |             assert mock_get.call_args[1]["params"] == {
465 |                 "query": "username",
466 |                 "permissions": "BROWSE",
467 |             }
468 | 
469 |     def test_lookup_user_by_permissions_error(self, users_mixin):
470 |         """Test _lookup_user_by_permissions when API call fails."""
471 |         # Mock requests.get to raise exception
472 |         with patch("requests.get", side_effect=Exception("API error")):
473 |             # Call the method
474 |             account_id = users_mixin._lookup_user_by_permissions("error")
475 | 
476 |             # Verify result
477 |             assert account_id is None
478 | 
479 |     def test_lookup_user_by_permissions_jira_data_center_name_only(self, users_mixin):
480 |         """Test _lookup_user_by_permissions when only 'name' is available (Data Center)."""
481 |         # Mock requests.get
482 |         with patch("requests.get") as mock_get:
483 |             mock_response = MagicMock()
484 |             mock_response.status_code = 200
485 |             mock_response.json.return_value = {
486 |                 "users": [{"name": "data-center-permissions-name"}]
487 |             }
488 |             mock_get.return_value = mock_response
489 | 
490 |             # Mock config.is_cloud to return False for Server/DC
491 |             users_mixin.config = MagicMock()
492 |             users_mixin.config.is_cloud = False
493 | 
494 |             # Call the method
495 |             account_id = users_mixin._lookup_user_by_permissions("username")
496 | 
497 |             # Verify result - should use name when that's all that's available
498 |             assert account_id == "data-center-permissions-name"
499 |             # Verify API call
500 |             mock_get.assert_called_once()
501 |             assert mock_get.call_args[0][0].endswith("/user/permission/search")
502 |             assert mock_get.call_args[1]["params"] == {
503 |                 "query": "username",
504 |                 "permissions": "BROWSE",
505 |             }
506 | 
507 |     def test_get_user_profile_by_identifier_cloud_account_id(self, users_mixin):
508 |         """Test get_user_profile_by_identifier with Cloud and accountId."""
509 |         users_mixin.config = MagicMock(spec=JiraConfig)
510 |         users_mixin.config.is_cloud = True
511 | 
512 |         with patch(
513 |             "src.mcp_atlassian.jira.users.JiraUser.from_api_response"
514 |         ) as mock_from_api_response:
515 |             mock_user_instance = MagicMock()
516 |             mock_from_api_response.return_value = mock_user_instance
517 |             mock_response_data = {
518 |                 "accountId": "5b10ac8d82e05b22cc7d4ef5",
519 |                 "displayName": "Cloud User",
520 |                 "emailAddress": "cloud@example.com",
521 |                 "active": True,
522 |             }
523 |             users_mixin.jira.user = MagicMock(return_value=mock_response_data)
524 |             test_account_id = "5b10ac8d82e05b22cc7d4ef5"
525 |             user = users_mixin.get_user_profile_by_identifier(test_account_id)
526 |             assert user == mock_user_instance
527 |             users_mixin.jira.user.assert_called_once_with(account_id=test_account_id)
528 |             mock_from_api_response.assert_called_once_with(mock_response_data)
529 | 
530 |     def test_get_user_profile_by_identifier_server_username(self, users_mixin):
531 |         """Test get_user_profile_by_identifier with Server/DC and username."""
532 |         users_mixin.config = MagicMock(spec=JiraConfig)
533 |         users_mixin.config.is_cloud = False
534 | 
535 |         with patch(
536 |             "src.mcp_atlassian.jira.users.JiraUser.from_api_response"
537 |         ) as mock_from_api_response:
538 |             mock_user_instance = MagicMock()
539 |             mock_from_api_response.return_value = mock_user_instance
540 |             mock_response_data = {
541 |                 "name": "server_user",
542 |                 "displayName": "Server User",
543 |                 "emailAddress": "server@example.com",
544 |                 "active": True,
545 |             }
546 |             users_mixin.jira.user = MagicMock(return_value=mock_response_data)
547 |             user = users_mixin.get_user_profile_by_identifier("server_user")
548 |             assert user == mock_user_instance
549 |             users_mixin.jira.user.assert_called_once_with(username="server_user")
550 |             mock_from_api_response.assert_called_once_with(mock_response_data)
551 | 
552 |     def test_get_user_profile_by_identifier_cloud_email(self, users_mixin):
553 |         """Test get_user_profile_by_identifier with Cloud and email."""
554 |         users_mixin.config = MagicMock(spec=JiraConfig)
555 |         users_mixin.config.is_cloud = True
556 |         users_mixin._lookup_user_directly = MagicMock(
557 |             return_value="5b10ac8d82e05b22cc7d4ef5"
558 |         )
559 |         with patch(
560 |             "src.mcp_atlassian.jira.users.JiraUser.from_api_response"
561 |         ) as mock_from_api_response:
562 |             mock_user_instance = MagicMock()
563 |             mock_from_api_response.return_value = mock_user_instance
564 |             mock_response_data = {
565 |                 "accountId": "5b10ac8d82e05b22cc7d4ef5",
566 |                 "displayName": "Email User",
567 |                 "emailAddress": "email@example.com",
568 |                 "active": True,
569 |             }
570 |             users_mixin.jira.user = MagicMock(return_value=mock_response_data)
571 |             user = users_mixin.get_user_profile_by_identifier("email@example.com")
572 |             assert user == mock_user_instance
573 |             users_mixin.jira.user.assert_called_once_with(
574 |                 account_id="5b10ac8d82e05b22cc7d4ef5"
575 |             )
576 |             users_mixin._lookup_user_directly.assert_called_once_with(
577 |                 "email@example.com"
578 |             )
579 |             mock_from_api_response.assert_called_once_with(mock_response_data)
580 | 
581 |     def test_get_user_profile_by_identifier_not_found(self, users_mixin):
582 |         """Test get_user_profile_by_identifier when user is not found (404 or cannot resolve)."""
583 |         users_mixin.config = MagicMock(spec=JiraConfig)
584 |         users_mixin.config.is_cloud = True
585 |         users_mixin._lookup_user_directly = MagicMock(return_value=None)
586 |         users_mixin._lookup_user_by_permissions = MagicMock(return_value=None)
587 |         # Simulate the identifier cannot be resolved to an account ID
588 |         with pytest.raises(
589 |             ValueError, match="Could not determine how to look up user 'nonexistent'."
590 |         ):
591 |             users_mixin.get_user_profile_by_identifier("nonexistent")
592 | 
593 |     def test_get_user_profile_by_identifier_permission_error(self, users_mixin):
594 |         """Test get_user_profile_by_identifier with a permission error (403)."""
595 |         users_mixin.config = MagicMock(spec=JiraConfig)
596 |         users_mixin.config.is_cloud = True
597 |         users_mixin._get_account_id = MagicMock(
598 |             return_value="account-id-for-restricted"
599 |         )
600 |         mock_response = MagicMock(spec=requests.Response)
601 |         mock_response.status_code = 403
602 |         http_error = requests.exceptions.HTTPError(response=mock_response)
603 |         users_mixin.jira.user = MagicMock(side_effect=http_error)
604 |         from mcp_atlassian.exceptions import MCPAtlassianAuthenticationError
605 | 
606 |         with pytest.raises(
607 |             MCPAtlassianAuthenticationError,
608 |             match="Permission denied accessing user 'restricted_user'.",
609 |         ):
610 |             users_mixin.get_user_profile_by_identifier("restricted_user")
611 | 
612 |     def test_get_user_profile_by_identifier_api_error(self, users_mixin):
613 |         """Test get_user_profile_by_identifier with a generic API error."""
614 |         # Mock config
615 |         users_mixin.config = MagicMock(spec=JiraConfig)
616 |         users_mixin.config.is_cloud = True
617 |         # Mock resolution methods to succeed
618 |         users_mixin._get_account_id = MagicMock(return_value="account-id-for-error")
619 | 
620 |         # Mock API to raise a generic exception
621 |         users_mixin.jira.user = MagicMock(side_effect=Exception("Network Timeout"))
622 | 
623 |         # Call method and assert generic Exception
624 |         with pytest.raises(
625 |             Exception, match="Error processing user profile for 'error_user'"
626 |         ):
627 |             users_mixin.get_user_profile_by_identifier("error_user")
628 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/servers/confluence.py:
--------------------------------------------------------------------------------

```python
  1 | """Confluence FastMCP server instance and tool definitions."""
  2 | 
  3 | import json
  4 | import logging
  5 | from typing import Annotated
  6 | 
  7 | from fastmcp import Context, FastMCP
  8 | from pydantic import BeforeValidator, Field
  9 | 
 10 | from mcp_atlassian.exceptions import MCPAtlassianAuthenticationError
 11 | from mcp_atlassian.servers.dependencies import get_confluence_fetcher
 12 | from mcp_atlassian.utils.decorators import (
 13 |     check_write_access,
 14 | )
 15 | 
 16 | logger = logging.getLogger(__name__)
 17 | 
 18 | confluence_mcp = FastMCP(
 19 |     name="Confluence MCP Service",
 20 |     description="Provides tools for interacting with Atlassian Confluence.",
 21 | )
 22 | 
 23 | 
 24 | @confluence_mcp.tool(tags={"confluence", "read"})
 25 | async def search(
 26 |     ctx: Context,
 27 |     query: Annotated[
 28 |         str,
 29 |         Field(
 30 |             description=(
 31 |                 "Search query - can be either a simple text (e.g. 'project documentation') or a CQL query string. "
 32 |                 "Simple queries use 'siteSearch' by default, to mimic the WebUI search, with an automatic fallback "
 33 |                 "to 'text' search if not supported. Examples of CQL:\n"
 34 |                 "- Basic search: 'type=page AND space=DEV'\n"
 35 |                 "- Personal space search: 'space=\"~username\"' (note: personal space keys starting with ~ must be quoted)\n"
 36 |                 "- Search by title: 'title~\"Meeting Notes\"'\n"
 37 |                 "- Use siteSearch: 'siteSearch ~ \"important concept\"'\n"
 38 |                 "- Use text search: 'text ~ \"important concept\"'\n"
 39 |                 "- Recent content: 'created >= \"2023-01-01\"'\n"
 40 |                 "- Content with specific label: 'label=documentation'\n"
 41 |                 "- Recently modified content: 'lastModified > startOfMonth(\"-1M\")'\n"
 42 |                 "- Content modified this year: 'creator = currentUser() AND lastModified > startOfYear()'\n"
 43 |                 "- Content you contributed to recently: 'contributor = currentUser() AND lastModified > startOfWeek()'\n"
 44 |                 "- Content watched by user: 'watcher = \"user@domain.com\" AND type = page'\n"
 45 |                 '- Exact phrase in content: \'text ~ "\\"Urgent Review Required\\"" AND label = "pending-approval"\'\n'
 46 |                 '- Title wildcards: \'title ~ "Minutes*" AND (space = "HR" OR space = "Marketing")\'\n'
 47 |                 'Note: Special identifiers need proper quoting in CQL: personal space keys (e.g., "~username"), '
 48 |                 "reserved words, numeric IDs, and identifiers with special characters."
 49 |             )
 50 |         ),
 51 |     ],
 52 |     limit: Annotated[
 53 |         int,
 54 |         Field(
 55 |             description="Maximum number of results (1-50)",
 56 |             default=10,
 57 |             ge=1,
 58 |             le=50,
 59 |         ),
 60 |     ] = 10,
 61 |     spaces_filter: Annotated[
 62 |         str | None,
 63 |         Field(
 64 |             description=(
 65 |                 "(Optional) Comma-separated list of space keys to filter results by. "
 66 |                 "Overrides the environment variable CONFLUENCE_SPACES_FILTER if provided. "
 67 |                 "Use empty string to disable filtering."
 68 |             ),
 69 |             default=None,
 70 |         ),
 71 |     ] = None,
 72 | ) -> str:
 73 |     """Search Confluence content using simple terms or CQL.
 74 | 
 75 |     Args:
 76 |         ctx: The FastMCP context.
 77 |         query: Search query - can be simple text or a CQL query string.
 78 |         limit: Maximum number of results (1-50).
 79 |         spaces_filter: Comma-separated list of space keys to filter by.
 80 | 
 81 |     Returns:
 82 |         JSON string representing a list of simplified Confluence page objects.
 83 |     """
 84 |     confluence_fetcher = await get_confluence_fetcher(ctx)
 85 |     # Check if the query is a simple search term or already a CQL query
 86 |     if query and not any(
 87 |         x in query for x in ["=", "~", ">", "<", " AND ", " OR ", "currentUser()"]
 88 |     ):
 89 |         original_query = query
 90 |         try:
 91 |             query = f'siteSearch ~ "{original_query}"'
 92 |             logger.info(
 93 |                 f"Converting simple search term to CQL using siteSearch: {query}"
 94 |             )
 95 |             pages = confluence_fetcher.search(
 96 |                 query, limit=limit, spaces_filter=spaces_filter
 97 |             )
 98 |         except Exception as e:
 99 |             logger.warning(f"siteSearch failed ('{e}'), falling back to text search.")
100 |             query = f'text ~ "{original_query}"'
101 |             logger.info(f"Falling back to text search with CQL: {query}")
102 |             pages = confluence_fetcher.search(
103 |                 query, limit=limit, spaces_filter=spaces_filter
104 |             )
105 |     else:
106 |         pages = confluence_fetcher.search(
107 |             query, limit=limit, spaces_filter=spaces_filter
108 |         )
109 |     search_results = [page.to_simplified_dict() for page in pages]
110 |     return json.dumps(search_results, indent=2, ensure_ascii=False)
111 | 
112 | 
113 | @confluence_mcp.tool(tags={"confluence", "read"})
114 | async def get_page(
115 |     ctx: Context,
116 |     page_id: Annotated[
117 |         str | None,
118 |         Field(
119 |             description=(
120 |                 "Confluence page ID (numeric ID, can be found in the page URL). "
121 |                 "For example, in the URL 'https://example.atlassian.net/wiki/spaces/TEAM/pages/123456789/Page+Title', "
122 |                 "the page ID is '123456789'. "
123 |                 "Provide this OR both 'title' and 'space_key'. If page_id is provided, title and space_key will be ignored."
124 |             ),
125 |             default=None,
126 |         ),
127 |     ] = None,
128 |     title: Annotated[
129 |         str | None,
130 |         Field(
131 |             description=(
132 |                 "The exact title of the Confluence page. Use this with 'space_key' if 'page_id' is not known."
133 |             ),
134 |             default=None,
135 |         ),
136 |     ] = None,
137 |     space_key: Annotated[
138 |         str | None,
139 |         Field(
140 |             description=(
141 |                 "The key of the Confluence space where the page resides (e.g., 'DEV', 'TEAM'). Required if using 'title'."
142 |             ),
143 |             default=None,
144 |         ),
145 |     ] = None,
146 |     include_metadata: Annotated[
147 |         bool,
148 |         Field(
149 |             description="Whether to include page metadata such as creation date, last update, version, and labels.",
150 |             default=True,
151 |         ),
152 |     ] = True,
153 |     convert_to_markdown: Annotated[
154 |         bool,
155 |         Field(
156 |             description=(
157 |                 "Whether to convert page to markdown (true) or keep it in raw HTML format (false). "
158 |                 "Raw HTML can reveal macros (like dates) not visible in markdown, but CAUTION: "
159 |                 "using HTML significantly increases token usage in AI responses."
160 |             ),
161 |             default=True,
162 |         ),
163 |     ] = True,
164 | ) -> str:
165 |     """Get content of a specific Confluence page by its ID, or by its title and space key.
166 | 
167 |     Args:
168 |         ctx: The FastMCP context.
169 |         page_id: Confluence page ID. If provided, 'title' and 'space_key' are ignored.
170 |         title: The exact title of the page. Must be used with 'space_key'.
171 |         space_key: The key of the space. Must be used with 'title'.
172 |         include_metadata: Whether to include page metadata.
173 |         convert_to_markdown: Convert content to markdown (true) or keep raw HTML (false).
174 | 
175 |     Returns:
176 |         JSON string representing the page content and/or metadata, or an error if not found or parameters are invalid.
177 |     """
178 |     confluence_fetcher = await get_confluence_fetcher(ctx)
179 |     page_object = None
180 | 
181 |     if page_id:
182 |         if title or space_key:
183 |             logger.warning(
184 |                 "page_id was provided; title and space_key parameters will be ignored."
185 |             )
186 |         try:
187 |             page_object = confluence_fetcher.get_page_content(
188 |                 page_id, convert_to_markdown=convert_to_markdown
189 |             )
190 |         except Exception as e:
191 |             logger.error(f"Error fetching page by ID '{page_id}': {e}")
192 |             return json.dumps(
193 |                 {"error": f"Failed to retrieve page by ID '{page_id}': {e}"},
194 |                 indent=2,
195 |                 ensure_ascii=False,
196 |             )
197 |     elif title and space_key:
198 |         page_object = confluence_fetcher.get_page_by_title(
199 |             space_key, title, convert_to_markdown=convert_to_markdown
200 |         )
201 |         if not page_object:
202 |             return json.dumps(
203 |                 {
204 |                     "error": f"Page with title '{title}' not found in space '{space_key}'."
205 |                 },
206 |                 indent=2,
207 |                 ensure_ascii=False,
208 |             )
209 |     else:
210 |         raise ValueError(
211 |             "Either 'page_id' OR both 'title' and 'space_key' must be provided."
212 |         )
213 | 
214 |     if not page_object:
215 |         return json.dumps(
216 |             {"error": "Page not found with the provided identifiers."},
217 |             indent=2,
218 |             ensure_ascii=False,
219 |         )
220 | 
221 |     if include_metadata:
222 |         result = {"metadata": page_object.to_simplified_dict()}
223 |     else:
224 |         result = {"content": {"value": page_object.content}}
225 | 
226 |     return json.dumps(result, indent=2, ensure_ascii=False)
227 | 
228 | 
229 | @confluence_mcp.tool(tags={"confluence", "read"})
230 | async def get_page_children(
231 |     ctx: Context,
232 |     parent_id: Annotated[
233 |         str,
234 |         Field(
235 |             description="The ID of the parent page whose children you want to retrieve"
236 |         ),
237 |     ],
238 |     expand: Annotated[
239 |         str,
240 |         Field(
241 |             description="Fields to expand in the response (e.g., 'version', 'body.storage')",
242 |             default="version",
243 |         ),
244 |     ] = "version",
245 |     limit: Annotated[
246 |         int,
247 |         Field(
248 |             description="Maximum number of child pages to return (1-50)",
249 |             default=25,
250 |             ge=1,
251 |             le=50,
252 |         ),
253 |     ] = 25,
254 |     include_content: Annotated[
255 |         bool,
256 |         Field(
257 |             description="Whether to include the page content in the response",
258 |             default=False,
259 |         ),
260 |     ] = False,
261 |     convert_to_markdown: Annotated[
262 |         bool,
263 |         Field(
264 |             description="Whether to convert page content to markdown (true) or keep it in raw HTML format (false). Only relevant if include_content is true.",
265 |             default=True,
266 |         ),
267 |     ] = True,
268 |     start: Annotated[
269 |         int,
270 |         Field(description="Starting index for pagination (0-based)", default=0, ge=0),
271 |     ] = 0,
272 | ) -> str:
273 |     """Get child pages of a specific Confluence page.
274 | 
275 |     Args:
276 |         ctx: The FastMCP context.
277 |         parent_id: The ID of the parent page.
278 |         expand: Fields to expand.
279 |         limit: Maximum number of child pages.
280 |         include_content: Whether to include page content.
281 |         convert_to_markdown: Convert content to markdown if include_content is true.
282 |         start: Starting index for pagination.
283 | 
284 |     Returns:
285 |         JSON string representing a list of child page objects.
286 |     """
287 |     confluence_fetcher = await get_confluence_fetcher(ctx)
288 |     if include_content and "body" not in expand:
289 |         expand = f"{expand},body.storage" if expand else "body.storage"
290 | 
291 |     try:
292 |         pages = confluence_fetcher.get_page_children(
293 |             page_id=parent_id,
294 |             start=start,
295 |             limit=limit,
296 |             expand=expand,
297 |             convert_to_markdown=convert_to_markdown,
298 |         )
299 |         child_pages = [page.to_simplified_dict() for page in pages]
300 |         result = {
301 |             "parent_id": parent_id,
302 |             "count": len(child_pages),
303 |             "limit_requested": limit,
304 |             "start_requested": start,
305 |             "results": child_pages,
306 |         }
307 |     except Exception as e:
308 |         logger.error(
309 |             f"Error getting/processing children for page ID {parent_id}: {e}",
310 |             exc_info=True,
311 |         )
312 |         result = {"error": f"Failed to get child pages: {e}"}
313 | 
314 |     return json.dumps(result, indent=2, ensure_ascii=False)
315 | 
316 | 
317 | @confluence_mcp.tool(tags={"confluence", "read"})
318 | async def get_comments(
319 |     ctx: Context,
320 |     page_id: Annotated[
321 |         str,
322 |         Field(
323 |             description=(
324 |                 "Confluence page ID (numeric ID, can be parsed from URL, "
325 |                 "e.g. from 'https://example.atlassian.net/wiki/spaces/TEAM/pages/123456789/Page+Title' "
326 |                 "-> '123456789')"
327 |             )
328 |         ),
329 |     ],
330 | ) -> str:
331 |     """Get comments for a specific Confluence page.
332 | 
333 |     Args:
334 |         ctx: The FastMCP context.
335 |         page_id: Confluence page ID.
336 | 
337 |     Returns:
338 |         JSON string representing a list of comment objects.
339 |     """
340 |     confluence_fetcher = await get_confluence_fetcher(ctx)
341 |     comments = confluence_fetcher.get_page_comments(page_id)
342 |     formatted_comments = [comment.to_simplified_dict() for comment in comments]
343 |     return json.dumps(formatted_comments, indent=2, ensure_ascii=False)
344 | 
345 | 
346 | @confluence_mcp.tool(tags={"confluence", "read"})
347 | async def get_labels(
348 |     ctx: Context,
349 |     page_id: Annotated[
350 |         str,
351 |         Field(
352 |             description=(
353 |                 "Confluence page ID (numeric ID, can be parsed from URL, "
354 |                 "e.g. from 'https://example.atlassian.net/wiki/spaces/TEAM/pages/123456789/Page+Title' "
355 |                 "-> '123456789')"
356 |             )
357 |         ),
358 |     ],
359 | ) -> str:
360 |     """Get labels for a specific Confluence page.
361 | 
362 |     Args:
363 |         ctx: The FastMCP context.
364 |         page_id: Confluence page ID.
365 | 
366 |     Returns:
367 |         JSON string representing a list of label objects.
368 |     """
369 |     confluence_fetcher = await get_confluence_fetcher(ctx)
370 |     labels = confluence_fetcher.get_page_labels(page_id)
371 |     formatted_labels = [label.to_simplified_dict() for label in labels]
372 |     return json.dumps(formatted_labels, indent=2, ensure_ascii=False)
373 | 
374 | 
375 | @confluence_mcp.tool(tags={"confluence", "write"})
376 | @check_write_access
377 | async def add_label(
378 |     ctx: Context,
379 |     page_id: Annotated[str, Field(description="The ID of the page to update")],
380 |     name: Annotated[str, Field(description="The name of the label")],
381 | ) -> str:
382 |     """Add label to an existing Confluence page.
383 | 
384 |     Args:
385 |         ctx: The FastMCP context.
386 |         page_id: The ID of the page to update.
387 |         name: The name of the label.
388 | 
389 |     Returns:
390 |         JSON string representing the updated list of label objects for the page.
391 | 
392 |     Raises:
393 |         ValueError: If in read-only mode or Confluence client is unavailable.
394 |     """
395 |     confluence_fetcher = await get_confluence_fetcher(ctx)
396 |     labels = confluence_fetcher.add_page_label(page_id, name)
397 |     formatted_labels = [label.to_simplified_dict() for label in labels]
398 |     return json.dumps(formatted_labels, indent=2, ensure_ascii=False)
399 | 
400 | 
401 | @confluence_mcp.tool(tags={"confluence", "write"})
402 | @check_write_access
403 | async def create_page(
404 |     ctx: Context,
405 |     space_key: Annotated[
406 |         str,
407 |         Field(
408 |             description="The key of the space to create the page in (usually a short uppercase code like 'DEV', 'TEAM', or 'DOC')"
409 |         ),
410 |     ],
411 |     title: Annotated[str, Field(description="The title of the page")],
412 |     content: Annotated[
413 |         str,
414 |         Field(
415 |             description="The content of the page. Format depends on content_format parameter. Can be Markdown (default), wiki markup, or storage format"
416 |         ),
417 |     ],
418 |     parent_id: Annotated[
419 |         str | None,
420 |         Field(
421 |             description="(Optional) parent page ID. If provided, this page will be created as a child of the specified page",
422 |             default=None,
423 |         ),
424 |         BeforeValidator(lambda x: str(x) if x is not None else None),
425 |     ] = None,
426 |     content_format: Annotated[
427 |         str,
428 |         Field(
429 |             description="(Optional) The format of the content parameter. Options: 'markdown' (default), 'wiki', or 'storage'. Wiki format uses Confluence wiki markup syntax",
430 |             default="markdown",
431 |         ),
432 |     ] = "markdown",
433 |     enable_heading_anchors: Annotated[
434 |         bool,
435 |         Field(
436 |             description="(Optional) Whether to enable automatic heading anchor generation. Only applies when content_format is 'markdown'",
437 |             default=False,
438 |         ),
439 |     ] = False,
440 | ) -> str:
441 |     """Create a new Confluence page.
442 | 
443 |     Args:
444 |         ctx: The FastMCP context.
445 |         space_key: The key of the space.
446 |         title: The title of the page.
447 |         content: The content of the page (format depends on content_format).
448 |         parent_id: Optional parent page ID.
449 |         content_format: The format of the content ('markdown', 'wiki', or 'storage').
450 |         enable_heading_anchors: Whether to enable heading anchors (markdown only).
451 | 
452 |     Returns:
453 |         JSON string representing the created page object.
454 | 
455 |     Raises:
456 |         ValueError: If in read-only mode, Confluence client is unavailable, or invalid content_format.
457 |     """
458 |     confluence_fetcher = await get_confluence_fetcher(ctx)
459 | 
460 |     # Validate content_format
461 |     if content_format not in ["markdown", "wiki", "storage"]:
462 |         raise ValueError(
463 |             f"Invalid content_format: {content_format}. Must be 'markdown', 'wiki', or 'storage'"
464 |         )
465 | 
466 |     # Determine parameters based on content format
467 |     if content_format == "markdown":
468 |         is_markdown = True
469 |         content_representation = None  # Will be converted to storage
470 |     else:
471 |         is_markdown = False
472 |         content_representation = content_format  # Pass 'wiki' or 'storage' directly
473 | 
474 |     page = confluence_fetcher.create_page(
475 |         space_key=space_key,
476 |         title=title,
477 |         body=content,
478 |         parent_id=parent_id,
479 |         is_markdown=is_markdown,
480 |         enable_heading_anchors=enable_heading_anchors
481 |         if content_format == "markdown"
482 |         else False,
483 |         content_representation=content_representation,
484 |     )
485 |     result = page.to_simplified_dict()
486 |     return json.dumps(
487 |         {"message": "Page created successfully", "page": result},
488 |         indent=2,
489 |         ensure_ascii=False,
490 |     )
491 | 
492 | 
493 | @confluence_mcp.tool(tags={"confluence", "write"})
494 | @check_write_access
495 | async def update_page(
496 |     ctx: Context,
497 |     page_id: Annotated[str, Field(description="The ID of the page to update")],
498 |     title: Annotated[str, Field(description="The new title of the page")],
499 |     content: Annotated[
500 |         str,
501 |         Field(
502 |             description="The new content of the page. Format depends on content_format parameter"
503 |         ),
504 |     ],
505 |     is_minor_edit: Annotated[
506 |         bool, Field(description="Whether this is a minor edit", default=False)
507 |     ] = False,
508 |     version_comment: Annotated[
509 |         str | None, Field(description="Optional comment for this version", default=None)
510 |     ] = None,
511 |     parent_id: Annotated[
512 |         str | None,
513 |         Field(description="Optional the new parent page ID", default=None),
514 |         BeforeValidator(lambda x: str(x) if x is not None else None),
515 |     ] = None,
516 |     content_format: Annotated[
517 |         str,
518 |         Field(
519 |             description="(Optional) The format of the content parameter. Options: 'markdown' (default), 'wiki', or 'storage'. Wiki format uses Confluence wiki markup syntax",
520 |             default="markdown",
521 |         ),
522 |     ] = "markdown",
523 |     enable_heading_anchors: Annotated[
524 |         bool,
525 |         Field(
526 |             description="(Optional) Whether to enable automatic heading anchor generation. Only applies when content_format is 'markdown'",
527 |             default=False,
528 |         ),
529 |     ] = False,
530 | ) -> str:
531 |     """Update an existing Confluence page.
532 | 
533 |     Args:
534 |         ctx: The FastMCP context.
535 |         page_id: The ID of the page to update.
536 |         title: The new title of the page.
537 |         content: The new content of the page (format depends on content_format).
538 |         is_minor_edit: Whether this is a minor edit.
539 |         version_comment: Optional comment for this version.
540 |         parent_id: Optional new parent page ID.
541 |         content_format: The format of the content ('markdown', 'wiki', or 'storage').
542 |         enable_heading_anchors: Whether to enable heading anchors (markdown only).
543 | 
544 |     Returns:
545 |         JSON string representing the updated page object.
546 | 
547 |     Raises:
548 |         ValueError: If Confluence client is not configured, available, or invalid content_format.
549 |     """
550 |     confluence_fetcher = await get_confluence_fetcher(ctx)
551 | 
552 |     # Validate content_format
553 |     if content_format not in ["markdown", "wiki", "storage"]:
554 |         raise ValueError(
555 |             f"Invalid content_format: {content_format}. Must be 'markdown', 'wiki', or 'storage'"
556 |         )
557 | 
558 |     # Determine parameters based on content format
559 |     if content_format == "markdown":
560 |         is_markdown = True
561 |         content_representation = None  # Will be converted to storage
562 |     else:
563 |         is_markdown = False
564 |         content_representation = content_format  # Pass 'wiki' or 'storage' directly
565 | 
566 |     updated_page = confluence_fetcher.update_page(
567 |         page_id=page_id,
568 |         title=title,
569 |         body=content,
570 |         is_minor_edit=is_minor_edit,
571 |         version_comment=version_comment,
572 |         is_markdown=is_markdown,
573 |         parent_id=parent_id,
574 |         enable_heading_anchors=enable_heading_anchors
575 |         if content_format == "markdown"
576 |         else False,
577 |         content_representation=content_representation,
578 |     )
579 |     page_data = updated_page.to_simplified_dict()
580 |     return json.dumps(
581 |         {"message": "Page updated successfully", "page": page_data},
582 |         indent=2,
583 |         ensure_ascii=False,
584 |     )
585 | 
586 | 
587 | @confluence_mcp.tool(tags={"confluence", "write"})
588 | @check_write_access
589 | async def delete_page(
590 |     ctx: Context,
591 |     page_id: Annotated[str, Field(description="The ID of the page to delete")],
592 | ) -> str:
593 |     """Delete an existing Confluence page.
594 | 
595 |     Args:
596 |         ctx: The FastMCP context.
597 |         page_id: The ID of the page to delete.
598 | 
599 |     Returns:
600 |         JSON string indicating success or failure.
601 | 
602 |     Raises:
603 |         ValueError: If Confluence client is not configured or available.
604 |     """
605 |     confluence_fetcher = await get_confluence_fetcher(ctx)
606 |     try:
607 |         result = confluence_fetcher.delete_page(page_id=page_id)
608 |         if result:
609 |             response = {
610 |                 "success": True,
611 |                 "message": f"Page {page_id} deleted successfully",
612 |             }
613 |         else:
614 |             response = {
615 |                 "success": False,
616 |                 "message": f"Unable to delete page {page_id}. API request completed but deletion unsuccessful.",
617 |             }
618 |     except Exception as e:
619 |         logger.error(f"Error deleting Confluence page {page_id}: {str(e)}")
620 |         response = {
621 |             "success": False,
622 |             "message": f"Error deleting page {page_id}",
623 |             "error": str(e),
624 |         }
625 | 
626 |     return json.dumps(response, indent=2, ensure_ascii=False)
627 | 
628 | 
629 | @confluence_mcp.tool(tags={"confluence", "write"})
630 | @check_write_access
631 | async def add_comment(
632 |     ctx: Context,
633 |     page_id: Annotated[
634 |         str, Field(description="The ID of the page to add a comment to")
635 |     ],
636 |     content: Annotated[
637 |         str, Field(description="The comment content in Markdown format")
638 |     ],
639 | ) -> str:
640 |     """Add a comment to a Confluence page.
641 | 
642 |     Args:
643 |         ctx: The FastMCP context.
644 |         page_id: The ID of the page to add a comment to.
645 |         content: The comment content in Markdown format.
646 | 
647 |     Returns:
648 |         JSON string representing the created comment.
649 | 
650 |     Raises:
651 |         ValueError: If in read-only mode or Confluence client is unavailable.
652 |     """
653 |     confluence_fetcher = await get_confluence_fetcher(ctx)
654 |     try:
655 |         comment = confluence_fetcher.add_comment(page_id=page_id, content=content)
656 |         if comment:
657 |             comment_data = comment.to_simplified_dict()
658 |             response = {
659 |                 "success": True,
660 |                 "message": "Comment added successfully",
661 |                 "comment": comment_data,
662 |             }
663 |         else:
664 |             response = {
665 |                 "success": False,
666 |                 "message": f"Unable to add comment to page {page_id}. API request completed but comment creation unsuccessful.",
667 |             }
668 |     except Exception as e:
669 |         logger.error(f"Error adding comment to Confluence page {page_id}: {str(e)}")
670 |         response = {
671 |             "success": False,
672 |             "message": f"Error adding comment to page {page_id}",
673 |             "error": str(e),
674 |         }
675 | 
676 |     return json.dumps(response, indent=2, ensure_ascii=False)
677 | 
678 | 
679 | @confluence_mcp.tool(tags={"confluence", "read"})
680 | async def search_user(
681 |     ctx: Context,
682 |     query: Annotated[
683 |         str,
684 |         Field(
685 |             description=(
686 |                 "Search query - a CQL query string for user search. "
687 |                 "Examples of CQL:\n"
688 |                 "- Basic user lookup by full name: 'user.fullname ~ \"First Last\"'\n"
689 |                 'Note: Special identifiers need proper quoting in CQL: personal space keys (e.g., "~username"), '
690 |                 "reserved words, numeric IDs, and identifiers with special characters."
691 |             )
692 |         ),
693 |     ],
694 |     limit: Annotated[
695 |         int,
696 |         Field(
697 |             description="Maximum number of results (1-50)",
698 |             default=10,
699 |             ge=1,
700 |             le=50,
701 |         ),
702 |     ] = 10,
703 | ) -> str:
704 |     """Search Confluence users using CQL.
705 | 
706 |     Args:
707 |         ctx: The FastMCP context.
708 |         query: Search query - a CQL query string for user search.
709 |         limit: Maximum number of results (1-50).
710 | 
711 |     Returns:
712 |         JSON string representing a list of simplified Confluence user search result objects.
713 |     """
714 |     confluence_fetcher = await get_confluence_fetcher(ctx)
715 | 
716 |     # If the query doesn't look like CQL, wrap it as a user fullname search
717 |     if query and not any(
718 |         x in query for x in ["=", "~", ">", "<", " AND ", " OR ", "user."]
719 |     ):
720 |         # Simple search term - search by fullname
721 |         query = f'user.fullname ~ "{query}"'
722 |         logger.info(f"Converting simple search term to user CQL: {query}")
723 | 
724 |     try:
725 |         user_results = confluence_fetcher.search_user(query, limit=limit)
726 |         search_results = [user.to_simplified_dict() for user in user_results]
727 |         return json.dumps(search_results, indent=2, ensure_ascii=False)
728 |     except MCPAtlassianAuthenticationError as e:
729 |         logger.error(f"Authentication error during user search: {e}", exc_info=False)
730 |         return json.dumps(
731 |             {
732 |                 "error": "Authentication failed. Please check your credentials.",
733 |                 "details": str(e),
734 |             },
735 |             indent=2,
736 |             ensure_ascii=False,
737 |         )
738 |     except Exception as e:
739 |         logger.error(f"Error searching users: {str(e)}")
740 |         return json.dumps(
741 |             {
742 |                 "error": f"An unexpected error occurred while searching for users: {str(e)}"
743 |             },
744 |             indent=2,
745 |             ensure_ascii=False,
746 |         )
747 | 
```

--------------------------------------------------------------------------------
/tests/integration/test_authentication.py:
--------------------------------------------------------------------------------

```python
  1 | """Integration tests for authentication functionality."""
  2 | 
  3 | import json
  4 | import time
  5 | from unittest.mock import MagicMock, Mock, patch
  6 | 
  7 | import pytest
  8 | import requests
  9 | from requests.exceptions import HTTPError
 10 | 
 11 | from mcp_atlassian.confluence.client import ConfluenceClient
 12 | from mcp_atlassian.confluence.config import ConfluenceConfig
 13 | from mcp_atlassian.exceptions import MCPAtlassianAuthenticationError
 14 | from mcp_atlassian.jira.client import JiraClient
 15 | from mcp_atlassian.jira.config import JiraConfig
 16 | from mcp_atlassian.utils.oauth import OAuthConfig, configure_oauth_session
 17 | from tests.utils.mocks import MockEnvironment
 18 | 
 19 | 
 20 | @pytest.mark.integration
 21 | class TestOAuthTokenRefreshFlow:
 22 |     """Test OAuth token refresh flow with expiration handling."""
 23 | 
 24 |     def test_oauth_token_refresh_on_expiration(self):
 25 |         """Test automatic token refresh when access token is expired."""
 26 |         with MockEnvironment.oauth_env() as oauth_env:
 27 |             # Create OAuth config with expired token
 28 |             oauth_config = OAuthConfig(
 29 |                 client_id=oauth_env["ATLASSIAN_OAUTH_CLIENT_ID"],
 30 |                 client_secret=oauth_env["ATLASSIAN_OAUTH_CLIENT_SECRET"],
 31 |                 redirect_uri=oauth_env["ATLASSIAN_OAUTH_REDIRECT_URI"],
 32 |                 scope=oauth_env["ATLASSIAN_OAUTH_SCOPE"],
 33 |                 cloud_id=oauth_env["ATLASSIAN_OAUTH_CLOUD_ID"],
 34 |                 access_token="expired-access-token",
 35 |                 refresh_token="valid-refresh-token",
 36 |                 expires_at=time.time() - 3600,  # Expired 1 hour ago
 37 |             )
 38 | 
 39 |             # Mock the token refresh endpoint
 40 |             with patch("requests.post") as mock_post:
 41 |                 mock_response = Mock()
 42 |                 mock_response.ok = True
 43 |                 mock_response.json.return_value = {
 44 |                     "access_token": "new-access-token",
 45 |                     "refresh_token": "new-refresh-token",
 46 |                     "expires_in": 3600,
 47 |                 }
 48 |                 mock_post.return_value = mock_response
 49 | 
 50 |                 # Ensure valid token should trigger refresh
 51 |                 assert oauth_config.is_token_expired is True
 52 |                 result = oauth_config.ensure_valid_token()
 53 | 
 54 |                 assert result is True
 55 |                 assert oauth_config.access_token == "new-access-token"
 56 |                 assert oauth_config.refresh_token == "new-refresh-token"
 57 |                 assert oauth_config.expires_at > time.time()
 58 | 
 59 |                 # Verify the refresh token request
 60 |                 mock_post.assert_called_once()
 61 |                 call_args = mock_post.call_args
 62 |                 assert call_args[0][0] == "https://auth.atlassian.com/oauth/token"
 63 |                 assert call_args[1]["data"]["grant_type"] == "refresh_token"
 64 |                 assert call_args[1]["data"]["refresh_token"] == "valid-refresh-token"
 65 | 
 66 |     def test_oauth_token_refresh_failure_handling(self):
 67 |         """Test handling of token refresh failures."""
 68 |         with MockEnvironment.oauth_env() as oauth_env:
 69 |             # Create OAuth config with expired token
 70 |             oauth_config = OAuthConfig(
 71 |                 client_id=oauth_env["ATLASSIAN_OAUTH_CLIENT_ID"],
 72 |                 client_secret=oauth_env["ATLASSIAN_OAUTH_CLIENT_SECRET"],
 73 |                 redirect_uri=oauth_env["ATLASSIAN_OAUTH_REDIRECT_URI"],
 74 |                 scope=oauth_env["ATLASSIAN_OAUTH_SCOPE"],
 75 |                 cloud_id=oauth_env["ATLASSIAN_OAUTH_CLOUD_ID"],
 76 |                 access_token="expired-access-token",
 77 |                 refresh_token="invalid-refresh-token",
 78 |                 expires_at=time.time() - 3600,
 79 |             )
 80 | 
 81 |             # Mock the token refresh endpoint to fail
 82 |             with patch("requests.post") as mock_post:
 83 |                 mock_response = Mock()
 84 |                 mock_response.ok = False
 85 |                 mock_response.raise_for_status.side_effect = HTTPError(
 86 |                     "401 Unauthorized"
 87 |                 )
 88 |                 mock_post.return_value = mock_response
 89 | 
 90 |                 # Ensure valid token should fail
 91 |                 result = oauth_config.ensure_valid_token()
 92 |                 assert result is False
 93 | 
 94 |     def test_oauth_token_expiry_margin(self):
 95 |         """Test that tokens are refreshed before actual expiration."""
 96 |         with MockEnvironment.oauth_env():
 97 |             # Create OAuth config with token expiring in 4 minutes (within margin)
 98 |             oauth_config = OAuthConfig(
 99 |                 client_id="test-client",
100 |                 client_secret="test-secret",
101 |                 redirect_uri="http://localhost:8080",
102 |                 scope="read:jira",
103 |                 access_token="almost-expired-token",
104 |                 refresh_token="valid-refresh-token",
105 |                 expires_at=time.time() + 240,  # 4 minutes from now
106 |             )
107 | 
108 |             # Token should be considered expired due to margin
109 |             assert oauth_config.is_token_expired is True
110 | 
111 |             # Create token expiring in 10 minutes (outside margin)
112 |             oauth_config.expires_at = time.time() + 600
113 |             assert oauth_config.is_token_expired is False
114 | 
115 | 
116 | @pytest.mark.integration
117 | class TestBasicAuthValidation:
118 |     """Test basic authentication validation against real endpoints."""
119 | 
120 |     @patch("mcp_atlassian.jira.client.Jira")
121 |     def test_jira_basic_auth_success(self, mock_jira_class):
122 |         """Test successful Jira basic authentication."""
123 |         with MockEnvironment.basic_auth_env() as auth_env:
124 |             # Create mock Jira instance
125 |             mock_jira = MagicMock()
126 |             mock_jira_class.return_value = mock_jira
127 | 
128 |             # Create Jira client
129 |             config = JiraConfig.from_env()
130 |             client = JiraClient(config)
131 | 
132 |             # Verify Jira was initialized with correct params
133 |             mock_jira_class.assert_called_once_with(
134 |                 url=auth_env["JIRA_URL"],
135 |                 username=auth_env["JIRA_USERNAME"],
136 |                 password=auth_env["JIRA_API_TOKEN"],
137 |                 cloud=True,  # Assuming cloud by default
138 |                 verify_ssl=True,
139 |             )
140 | 
141 |     @patch("mcp_atlassian.confluence.client.Confluence")
142 |     def test_confluence_basic_auth_success(self, mock_confluence_class):
143 |         """Test successful Confluence basic authentication."""
144 |         with MockEnvironment.basic_auth_env() as auth_env:
145 |             # Create mock Confluence instance
146 |             mock_confluence = MagicMock()
147 |             mock_confluence_class.return_value = mock_confluence
148 | 
149 |             # Create Confluence client
150 |             config = ConfluenceConfig.from_env()
151 |             client = ConfluenceClient(config)
152 | 
153 |             # Verify Confluence was initialized with correct params
154 |             mock_confluence_class.assert_called_once_with(
155 |                 url=auth_env["CONFLUENCE_URL"],
156 |                 username=auth_env["CONFLUENCE_USERNAME"],
157 |                 password=auth_env["CONFLUENCE_API_TOKEN"],
158 |                 cloud=True,
159 |                 verify_ssl=True,
160 |             )
161 | 
162 |     def test_basic_auth_with_invalid_credentials(self):
163 |         """Test basic authentication with invalid credentials."""
164 |         with MockEnvironment.clean_env():
165 |             with patch.dict(
166 |                 "os.environ",
167 |                 {
168 |                     "JIRA_URL": "https://test.atlassian.net",
169 |                     "JIRA_USERNAME": "invalid@example.com",
170 |                     "JIRA_API_TOKEN": "invalid-token",
171 |                 },
172 |             ):
173 |                 with patch("mcp_atlassian.jira.client.Jira") as mock_jira_class:
174 |                     # Make Jira constructor raise authentication error
175 |                     mock_jira_class.side_effect = HTTPError("401 Unauthorized")
176 | 
177 |                     config = JiraConfig.from_env()
178 |                     with pytest.raises(HTTPError):
179 |                         JiraClient(config)
180 | 
181 | 
182 | @pytest.mark.integration
183 | class TestPATTokenValidation:
184 |     """Test Personal Access Token (PAT) validation and precedence."""
185 | 
186 |     @patch("mcp_atlassian.jira.client.Jira")
187 |     def test_jira_pat_token_success(self, mock_jira_class):
188 |         """Test successful Jira PAT authentication."""
189 |         # Clear existing auth env vars first
190 |         with MockEnvironment.clean_env():
191 |             with patch.dict(
192 |                 "os.environ",
193 |                 {
194 |                     "JIRA_URL": "https://jira.company.com",  # Server URL for PAT
195 |                     "JIRA_PERSONAL_TOKEN": "test-personal-access-token",
196 |                 },
197 |             ):
198 |                 # Create mock Jira instance
199 |                 mock_jira = MagicMock()
200 |                 mock_jira_class.return_value = mock_jira
201 | 
202 |                 # Create Jira client
203 |                 config = JiraConfig.from_env()
204 |                 client = JiraClient(config)
205 | 
206 |                 # Verify Jira was initialized with PAT token
207 |                 mock_jira_class.assert_called_once_with(
208 |                     url="https://jira.company.com",
209 |                     token="test-personal-access-token",
210 |                     cloud=False,  # Server instance
211 |                     verify_ssl=True,
212 |                 )
213 | 
214 |     @patch("mcp_atlassian.confluence.client.Confluence")
215 |     def test_confluence_pat_token_success(self, mock_confluence_class):
216 |         """Test successful Confluence PAT authentication."""
217 |         # Clear existing auth env vars first
218 |         with MockEnvironment.clean_env():
219 |             with patch.dict(
220 |                 "os.environ",
221 |                 {
222 |                     "CONFLUENCE_URL": "https://confluence.company.com",  # Server URL for PAT
223 |                     "CONFLUENCE_PERSONAL_TOKEN": "test-personal-access-token",
224 |                 },
225 |             ):
226 |                 # Create mock Confluence instance
227 |                 mock_confluence = MagicMock()
228 |                 mock_confluence_class.return_value = mock_confluence
229 | 
230 |                 # Create Confluence client
231 |                 config = ConfluenceConfig.from_env()
232 |                 client = ConfluenceClient(config)
233 | 
234 |                 # Verify Confluence was initialized with PAT token
235 |                 mock_confluence_class.assert_called_once_with(
236 |                     url="https://confluence.company.com",
237 |                     token="test-personal-access-token",
238 |                     cloud=False,  # Server instance
239 |                     verify_ssl=True,
240 |                 )
241 | 
242 |     def test_pat_token_precedence_over_basic_auth(self):
243 |         """Test that PAT token takes precedence over basic auth when both are present."""
244 |         with MockEnvironment.clean_env():
245 |             with patch.dict(
246 |                 "os.environ",
247 |                 {
248 |                     "JIRA_URL": "https://jira.company.com",  # Server URL for PAT
249 |                     "JIRA_USERNAME": "user@example.com",
250 |                     "JIRA_API_TOKEN": "basic-api-token",
251 |                     "JIRA_PERSONAL_TOKEN": "personal-access-token",
252 |                 },
253 |             ):
254 |                 config = JiraConfig.from_env()
255 |                 assert config.auth_type == "pat"
256 |                 assert config.personal_token == "personal-access-token"
257 | 
258 | 
259 | @pytest.mark.integration
260 | class TestAuthenticationFailureRecovery:
261 |     """Test authentication failure recovery patterns."""
262 | 
263 |     def test_oauth_to_basic_auth_fallback(self):
264 |         """Test fallback from OAuth to basic auth when OAuth fails."""
265 |         # Set up environment with both OAuth and basic auth but incomplete OAuth
266 |         with MockEnvironment.clean_env():
267 |             # Mock token loading to return empty (no stored tokens)
268 |             with patch(
269 |                 "mcp_atlassian.utils.oauth.OAuthConfig.load_tokens", return_value={}
270 |             ):
271 |                 with patch.dict(
272 |                     "os.environ",
273 |                     {
274 |                         # OAuth config - incomplete (missing cloud_id)
275 |                         "ATLASSIAN_OAUTH_CLIENT_ID": "test-client",
276 |                         "ATLASSIAN_OAUTH_CLIENT_SECRET": "test-secret",
277 |                         "ATLASSIAN_OAUTH_REDIRECT_URI": "http://localhost:8080",
278 |                         "ATLASSIAN_OAUTH_SCOPE": "read:jira",
279 |                         # Basic auth config
280 |                         "JIRA_URL": "https://test.atlassian.net",
281 |                         "JIRA_USERNAME": "user@example.com",
282 |                         "JIRA_API_TOKEN": "api-token",
283 |                     },
284 |                 ):
285 |                     # Without cloud_id, OAuth config is incomplete and should fallback to basic
286 |                     config = JiraConfig.from_env()
287 |                     assert config.auth_type == "basic"  # Falls back to basic auth
288 | 
289 |                     # Now add cloud_id to complete OAuth config
290 |                     with patch.dict(
291 |                         "os.environ", {"ATLASSIAN_OAUTH_CLOUD_ID": "test-cloud-id"}
292 |                     ):
293 |                         config = JiraConfig.from_env()
294 |                         assert config.auth_type == "oauth"
295 | 
296 |                         # OAuth should fail without valid tokens
297 |                         with pytest.raises(
298 |                             MCPAtlassianAuthenticationError,
299 |                             match="Failed to configure OAuth session",
300 |                         ):
301 |                             JiraClient(config)
302 | 
303 |     def test_authentication_retry_on_401(self):
304 |         """Test retry behavior on 401 authentication errors."""
305 |         with MockEnvironment.oauth_env():
306 |             oauth_config = OAuthConfig(
307 |                 client_id="test-client",
308 |                 client_secret="test-secret",
309 |                 redirect_uri="http://localhost:8080",
310 |                 scope="read:jira",
311 |                 cloud_id="test-cloud",
312 |                 access_token="expired-token",
313 |                 refresh_token="valid-refresh-token",
314 |                 expires_at=time.time() - 3600,
315 |             )
316 | 
317 |             session = requests.Session()
318 | 
319 |             # Mock token refresh to succeed
320 |             with patch.object(oauth_config, "refresh_access_token") as mock_refresh:
321 |                 mock_refresh.return_value = True
322 |                 oauth_config.access_token = "new-token"
323 | 
324 |                 result = configure_oauth_session(session, oauth_config)
325 |                 assert result is True
326 |                 assert session.headers["Authorization"] == "Bearer new-token"
327 |                 mock_refresh.assert_called_once()
328 | 
329 | 
330 | @pytest.mark.integration
331 | class TestTokenExpirationAndRetry:
332 |     """Test token expiration and automatic retry."""
333 | 
334 |     def test_automatic_token_refresh_in_session(self):
335 |         """Test that expired tokens are automatically refreshed in session."""
336 |         with MockEnvironment.oauth_env():
337 |             # Create OAuth config with soon-to-expire token
338 |             oauth_config = OAuthConfig(
339 |                 client_id="test-client",
340 |                 client_secret="test-secret",
341 |                 redirect_uri="http://localhost:8080",
342 |                 scope="read:jira",
343 |                 cloud_id="test-cloud",
344 |                 access_token="expiring-token",
345 |                 refresh_token="valid-refresh-token",
346 |                 expires_at=time.time() + 100,  # Expires in 100 seconds (within margin)
347 |             )
348 | 
349 |             session = requests.Session()
350 | 
351 |             # Mock the refresh token call
352 |             with patch("requests.post") as mock_post:
353 |                 mock_response = Mock()
354 |                 mock_response.ok = True
355 |                 mock_response.json.return_value = {
356 |                     "access_token": "refreshed-token",
357 |                     "expires_in": 3600,
358 |                 }
359 |                 mock_post.return_value = mock_response
360 | 
361 |                 # Configure session should refresh the token
362 |                 result = configure_oauth_session(session, oauth_config)
363 |                 assert result is True
364 |                 assert oauth_config.access_token == "refreshed-token"
365 |                 assert session.headers["Authorization"] == "Bearer refreshed-token"
366 | 
367 |     def test_token_storage_and_retrieval(self):
368 |         """Test token storage in keyring and retrieval."""
369 |         client_id = "test-client-storage"
370 | 
371 |         # Mock keyring operations
372 |         with (
373 |             patch("keyring.set_password") as mock_set,
374 |             patch("keyring.get_password") as mock_get,
375 |         ):
376 |             # Create OAuth config and save tokens
377 |             oauth_config = OAuthConfig(
378 |                 client_id=client_id,
379 |                 client_secret="test-secret",
380 |                 redirect_uri="http://localhost:8080",
381 |                 scope="read:jira",
382 |                 cloud_id="test-cloud",
383 |                 access_token="stored-token",
384 |                 refresh_token="stored-refresh",
385 |                 expires_at=time.time() + 3600,
386 |             )
387 | 
388 |             # Save tokens
389 |             oauth_config._save_tokens()
390 | 
391 |             # Verify keyring was called
392 |             mock_set.assert_called_once()
393 |             service_name, username, token_json = mock_set.call_args[0]
394 |             assert service_name == "mcp-atlassian-oauth"
395 |             assert username == f"oauth-{client_id}"
396 | 
397 |             # Parse stored token data
398 |             stored_data = json.loads(token_json)
399 |             assert stored_data["access_token"] == "stored-token"
400 |             assert stored_data["refresh_token"] == "stored-refresh"
401 |             assert stored_data["cloud_id"] == "test-cloud"
402 | 
403 |             # Test token retrieval
404 |             mock_get.return_value = token_json
405 |             loaded_data = OAuthConfig.load_tokens(client_id)
406 |             assert loaded_data["access_token"] == "stored-token"
407 |             assert loaded_data["refresh_token"] == "stored-refresh"
408 | 
409 | 
410 | @pytest.mark.integration
411 | class TestMixedAuthenticationScenarios:
412 |     """Test mixed authentication scenarios and fallback patterns."""
413 | 
414 |     def test_oauth_with_direct_access_token(self):
415 |         """Test OAuth config with only access token (no refresh token)."""
416 |         session = requests.Session()
417 | 
418 |         # Create OAuth config with only access token
419 |         oauth_config = OAuthConfig(
420 |             client_id="test-client",
421 |             client_secret="test-secret",
422 |             redirect_uri="http://localhost:8080",
423 |             scope="read:jira",
424 |             access_token="direct-access-token",
425 |             # No refresh_token, no expires_at
426 |         )
427 | 
428 |         # Should use token directly without refresh attempt
429 |         result = configure_oauth_session(session, oauth_config)
430 |         assert result is True
431 |         assert session.headers["Authorization"] == "Bearer direct-access-token"
432 | 
433 |     def test_environment_detection_priority(self):
434 |         """Test authentication method detection priority from environment."""
435 |         # Test with all auth methods present - OAuth should take precedence
436 |         with MockEnvironment.clean_env():
437 |             with patch.dict(
438 |                 "os.environ",
439 |                 {
440 |                     # OAuth
441 |                     "ATLASSIAN_OAUTH_CLIENT_ID": "oauth-client",
442 |                     "ATLASSIAN_OAUTH_CLIENT_SECRET": "oauth-secret",
443 |                     "ATLASSIAN_OAUTH_REDIRECT_URI": "http://localhost:8080",
444 |                     "ATLASSIAN_OAUTH_SCOPE": "read:jira",
445 |                     "ATLASSIAN_OAUTH_CLOUD_ID": "test-cloud-id",
446 |                     # PAT
447 |                     "JIRA_PERSONAL_TOKEN": "personal-token",
448 |                     # Basic
449 |                     "JIRA_URL": "https://test.atlassian.net",
450 |                     "JIRA_USERNAME": "user@example.com",
451 |                     "JIRA_API_TOKEN": "api-token",
452 |                 },
453 |             ):
454 |                 config = JiraConfig.from_env()
455 |                 assert config.auth_type == "oauth"
456 | 
457 |         # Test with PAT and basic - PAT should take precedence (for server)
458 |         with MockEnvironment.clean_env():
459 |             with patch.dict(
460 |                 "os.environ",
461 |                 {
462 |                     "JIRA_URL": "https://jira.company.com",  # Server URL
463 |                     "JIRA_PERSONAL_TOKEN": "personal-token",
464 |                     "JIRA_USERNAME": "user@example.com",
465 |                     "JIRA_API_TOKEN": "api-token",
466 |                 },
467 |             ):
468 |                 config = JiraConfig.from_env()
469 |                 assert config.auth_type == "pat"
470 | 
471 |         # Test with only basic auth
472 |         with MockEnvironment.clean_env():
473 |             with patch.dict(
474 |                 "os.environ",
475 |                 {
476 |                     "JIRA_URL": "https://test.atlassian.net",
477 |                     "JIRA_USERNAME": "user@example.com",
478 |                     "JIRA_API_TOKEN": "api-token",
479 |                 },
480 |             ):
481 |                 config = JiraConfig.from_env()
482 |                 assert config.auth_type == "basic"
483 | 
484 |     def test_cloud_vs_server_authentication(self):
485 |         """Test authentication differences between cloud and server instances."""
486 |         # Cloud instance (default)
487 |         with MockEnvironment.clean_env():
488 |             with patch.dict(
489 |                 "os.environ",
490 |                 {
491 |                     "JIRA_URL": "https://example.atlassian.net",
492 |                     "JIRA_USERNAME": "user@example.com",
493 |                     "JIRA_API_TOKEN": "api-token",
494 |                 },
495 |             ):
496 |                 config = JiraConfig.from_env()
497 |                 assert config.is_cloud is True
498 | 
499 |         # Server instance
500 |         with MockEnvironment.clean_env():
501 |             with patch.dict(
502 |                 "os.environ",
503 |                 {
504 |                     "JIRA_URL": "https://jira.company.com",
505 |                     "JIRA_USERNAME": "user@example.com",
506 |                     "JIRA_API_TOKEN": "api-token",
507 |                 },
508 |             ):
509 |                 config = JiraConfig.from_env()
510 |                 assert config.is_cloud is False
511 | 
512 | 
513 | @pytest.mark.integration
514 | class TestJiraConfluenceAuthFlows:
515 |     """Test authentication flows for both Jira and Confluence services."""
516 | 
517 |     @patch("mcp_atlassian.confluence.client.Confluence")
518 |     @patch("mcp_atlassian.jira.client.Jira")
519 |     def test_shared_oauth_config_both_services(
520 |         self, mock_jira_class, mock_confluence_class
521 |     ):
522 |         """Test that both services can share the same OAuth configuration."""
523 |         with MockEnvironment.oauth_env():
524 |             # Mock cloud ID retrieval
525 |             with patch("requests.get") as mock_get:
526 |                 mock_response = Mock()
527 |                 mock_response.ok = True
528 |                 mock_response.json.return_value = [{"id": "test-cloud-id"}]
529 |                 mock_get.return_value = mock_response
530 | 
531 |                 # Create OAuth config
532 |                 oauth_config = OAuthConfig.from_env()
533 |                 oauth_config.access_token = "shared-token"
534 |                 oauth_config.cloud_id = "test-cloud-id"
535 | 
536 |                 # Create both clients with same OAuth config
537 |                 jira_config = JiraConfig(
538 |                     url="https://test.atlassian.net",
539 |                     auth_type="oauth",
540 |                     oauth_config=oauth_config,
541 |                 )
542 |                 confluence_config = ConfluenceConfig(
543 |                     url="https://test.atlassian.net/wiki",
544 |                     auth_type="oauth",
545 |                     oauth_config=oauth_config,
546 |                 )
547 | 
548 |                 # Initialize clients
549 |                 jira_client = JiraClient(jira_config)
550 |                 confluence_client = ConfluenceClient(confluence_config)
551 | 
552 |                 # Verify both were initialized with OAuth URLs
553 |                 jira_url = mock_jira_class.call_args[1]["url"]
554 |                 confluence_url = mock_confluence_class.call_args[1]["url"]
555 | 
556 |                 assert jira_url == "https://api.atlassian.com/ex/jira/test-cloud-id"
557 |                 assert (
558 |                     confluence_url
559 |                     == "https://api.atlassian.com/ex/confluence/test-cloud-id"
560 |                 )
561 | 
562 |     def test_service_specific_auth_override(self):
563 |         """Test that service-specific auth overrides shared configuration."""
564 |         with MockEnvironment.clean_env():
565 |             # Mock token loading to return empty (no stored tokens)
566 |             with patch(
567 |                 "mcp_atlassian.utils.oauth.OAuthConfig.load_tokens", return_value={}
568 |             ):
569 |                 # Test case 1: Only service-specific auth (no OAuth config)
570 |                 with patch.dict(
571 |                     "os.environ",
572 |                     {
573 |                         # Jira-specific PAT for server
574 |                         "JIRA_URL": "https://jira.company.com",
575 |                         "JIRA_PERSONAL_TOKEN": "jira-pat-token",
576 |                         # Confluence basic auth
577 |                         "CONFLUENCE_URL": "https://confluence.atlassian.net",
578 |                         "CONFLUENCE_USERNAME": "conf-user@example.com",
579 |                         "CONFLUENCE_API_TOKEN": "conf-api-token",
580 |                     },
581 |                 ):
582 |                     # Jira uses PAT (server instance)
583 |                     jira_config = JiraConfig.from_env()
584 |                     assert jira_config.auth_type == "pat"
585 | 
586 |                     # Confluence uses basic auth (cloud instance)
587 |                     confluence_config = ConfluenceConfig.from_env()
588 |                     assert confluence_config.auth_type == "basic"
589 | 
590 |                 # Test case 2: OAuth takes precedence when fully configured
591 |                 with patch.dict(
592 |                     "os.environ",
593 |                     {
594 |                         # Shared OAuth config
595 |                         "ATLASSIAN_OAUTH_CLIENT_ID": "shared-client",
596 |                         "ATLASSIAN_OAUTH_CLIENT_SECRET": "shared-secret",
597 |                         "ATLASSIAN_OAUTH_REDIRECT_URI": "http://localhost:8080",
598 |                         "ATLASSIAN_OAUTH_SCOPE": "read:jira read:confluence",
599 |                         "ATLASSIAN_OAUTH_CLOUD_ID": "test-cloud-id",
600 |                         # Service-specific auth also present
601 |                         "JIRA_URL": "https://jira.company.com",
602 |                         "JIRA_USERNAME": "jira-user@example.com",
603 |                         "JIRA_API_TOKEN": "jira-token",
604 |                         "CONFLUENCE_URL": "https://confluence.atlassian.net",
605 |                     },
606 |                 ):
607 |                     # OAuth takes precedence for both when cloud_id is present
608 |                     jira_config = JiraConfig.from_env()
609 |                     assert jira_config.auth_type == "oauth"
610 | 
611 |                     confluence_config = ConfluenceConfig.from_env()
612 |                     assert confluence_config.auth_type == "oauth"
613 | 
614 |     def test_ssl_and_proxy_with_authentication(self):
615 |         """Test SSL verification and proxy settings work with authentication."""
616 |         with MockEnvironment.clean_env():
617 |             with patch.dict(
618 |                 "os.environ",
619 |                 {
620 |                     "JIRA_URL": "https://test.atlassian.net",
621 |                     "JIRA_USERNAME": "user@example.com",
622 |                     "JIRA_API_TOKEN": "api-token",
623 |                     "JIRA_SSL_VERIFY": "false",
624 |                     "HTTPS_PROXY": "http://proxy.company.com:8080",
625 |                 },
626 |             ):
627 |                 config = JiraConfig.from_env()
628 |                 assert config.ssl_verify is False
629 |                 assert config.https_proxy == "http://proxy.company.com:8080"
630 | 
631 |                 with patch("mcp_atlassian.jira.client.Jira") as mock_jira:
632 |                     client = JiraClient(config)
633 |                     # Verify SSL verification was disabled
634 |                     mock_jira.assert_called_with(
635 |                         url="https://test.atlassian.net",
636 |                         username="user@example.com",
637 |                         password="api-token",
638 |                         cloud=True,
639 |                         verify_ssl=False,
640 |                     )
641 | 
```

