"""Unit tests for github_client.py.""" from unittest.mock import MagicMock, patch import pytest import requests from config import GitHubConfig from github_client import GitHubClient @patch('github_client.github_client.requests.Session') @patch('github_client.github_client.HTTPAdapter') def test_github_client_init_sets_headers_and_mount( mock_http_adapter: MagicMock, mock_session_cls: MagicMock ) -> None: """Test that GitHubClient initializes the session with correct headers and mounts the HTTP adapter. :param mock_http_adapter: :param mock_session_cls: :return: """ # noqa: E501 mock_session = MagicMock() mock_session_cls.return_value = mock_session dummy_adapter = MagicMock() mock_http_adapter.return_value = dummy_adapter # Create config with expected token cfg = GitHubConfig(github_token='dummy_token') GitHubClient(cfg) # Headers mock_session.headers.update.assert_called_once_with( { 'Authorization': 'Bearer dummy_token', 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', } ) # Adapter mounting mock_session.mount.assert_called_once_with('https://', dummy_adapter) @patch('github_client.github_client.requests.Session') def test_create_issue_success(mock_session_cls: MagicMock) -> None: """Test successful creation of an issue. :param mock_session_cls: :return: """ mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = { 'id': 12345, 'title': 'Test Issue', 'body': 'This is a test issue body.', } mock_session.post.return_value = mock_response cfg = GitHubConfig(github_token='dummy') client = GitHubClient(cfg) result = client.create_issue( repo='owner/repo', title='Test Issue', body='This is a test issue body.', assignees=['user1', 'user2'], ) expected_payload = { 'title': 'Test Issue', 'body': 'This is a test issue body.', 'assignees': ['user1', 'user2'], } assert result == { 'id': 12345, 'title': 'Test Issue', 'body': 'This is a test issue body.', } mock_session.post.assert_called_once_with( 'https://api.github.com/repos/owner/repo/issues', json=expected_payload, timeout=30, ) @patch('github_client.github_client.requests.Session') def test_create_issue_minimal(mock_session_cls: MagicMock) -> None: """Test creating an issue with minimal parameters. :param mock_session_cls: :return: """ mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = {'id': 12345, 'title': 'Minimal Issue'} mock_session.post.return_value = mock_response cfg = GitHubConfig(github_token='dummy') client = GitHubClient(cfg) result = client.create_issue(repo='owner/repo', title='Minimal Issue') expected_payload = {'title': 'Minimal Issue'} assert result == {'id': 12345, 'title': 'Minimal Issue'} mock_session.post.assert_called_once_with( 'https://api.github.com/repos/owner/repo/issues', json=expected_payload, timeout=30, ) @patch('github_client.github_client.requests.Session') def test_create_issue_http_error(mock_session_cls: MagicMock) -> None: """Test that HTTP errors are raised when creating an issue. :param mock_session_cls: :return: """ mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_response = MagicMock() mock_response.raise_for_status.side_effect = requests.HTTPError('HTTP error') mock_session.post.return_value = mock_response cfg = GitHubConfig(github_token='dummy') client = GitHubClient(cfg) with pytest.raises(requests.HTTPError): client.create_issue(repo='owner/repo', title='Test Issue') @patch('github_client.github_client.requests.Session') def test_search_text_quotes_term_as_phrase(mock_session_cls: MagicMock) -> None: """search_text_occurrences_in_org wraps the term in quotes for a phrase match.""" mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.headers = {} mock_response.json.return_value = {'items': []} mock_session.get.return_value = mock_response cfg = GitHubConfig(github_token='dummy') client = GitHubClient(cfg) client.search_text_occurrences_in_org('myorg', 'john.doe@example.com') query = mock_session.get.call_args[1]['params']['q'] assert '"john.doe@example.com"' in query assert 'org:myorg' in query assert 'language:Terraform' in query @patch('github_client.github_client.requests.Session') def test_search_text_with_repo_filter(mock_session_cls: MagicMock) -> None: """search_text_occurrences_in_org appends a fully-qualified repo filter.""" mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.headers = {} mock_response.json.return_value = {'items': []} mock_session.get.return_value = mock_response cfg = GitHubConfig(github_token='dummy') client = GitHubClient(cfg) client.search_text_occurrences_in_org('myorg', 'term1', repo_filter='myorg/my-repo') query = mock_session.get.call_args[1]['params']['q'] assert 'repo:myorg/my-repo' in query @patch('github_client.github_client.requests.Session') def test_bare_repo_filter_raises(mock_session_cls: MagicMock) -> None: """A repo_filter without an owner raises ValueError before any request.""" mock_session = MagicMock() mock_session_cls.return_value = mock_session cfg = GitHubConfig(github_token='dummy') client = GitHubClient(cfg) with pytest.raises(ValueError, match='owner/repo'): client.search_text_occurrences_in_org('myorg', 'term1', repo_filter='my-repo') mock_session.get.assert_not_called() @patch('github_client.github_client.requests.Session') def test_rate_limit_threshold_fires_at_one_remaining( mock_session_cls: MagicMock, ) -> None: """Rate limit sleep is triggered when remaining == 1 (or 0), not only < 1.""" import time as _time mock_session = MagicMock() mock_session_cls.return_value = mock_session cfg = GitHubConfig(github_token='dummy') reset_time = str(int(_time.time()) + 120) page1 = MagicMock() page1.raise_for_status.return_value = None page1.headers = {'X-RateLimit-Remaining': '1', 'X-RateLimit-Reset': reset_time} page1.json.return_value = {'items': []} # Return empty so loop ends after retry # After sleep the retried request returns empty items page2 = MagicMock() page2.raise_for_status.return_value = None page2.headers = {'X-RateLimit-Remaining': '50'} page2.json.return_value = {'items': []} mock_session.get.side_effect = [page1, page2] client = GitHubClient(cfg) with patch('github_client.github_client.time.sleep') as mock_sleep: client.search_text_occurrences_in_org('myorg', 'search_term') mock_sleep.assert_called_once() sleep_arg = mock_sleep.call_args[0][0] assert sleep_arg <= 300, 'sleep must be capped at 300 seconds' @patch('github_client.github_client.requests.Session') def test_rate_limit_past_reset_sleeps_60s(mock_session_cls: MagicMock) -> None: """When reset time is in the past, falls back to 60s sleep instead of looping.""" import time as _time mock_session = MagicMock() mock_session_cls.return_value = mock_session cfg = GitHubConfig(github_token='dummy') past_reset = str(int(_time.time()) - 300) # 5 minutes ago page1 = MagicMock() page1.raise_for_status.return_value = None page1.headers = {'X-RateLimit-Remaining': '1', 'X-RateLimit-Reset': past_reset} page1.json.return_value = {'items': []} page2 = MagicMock() page2.raise_for_status.return_value = None page2.headers = {'X-RateLimit-Remaining': '50'} page2.json.return_value = {'items': []} mock_session.get.side_effect = [page1, page2] client = GitHubClient(cfg) with patch('github_client.github_client.time.sleep') as mock_sleep: client.search_text_occurrences_in_org('myorg', 'search_term') mock_sleep.assert_called_once_with(60) @patch('github_client.github_client.requests.Session') def test_search_text_excludes_collab_repo(mock_session_cls: MagicMock) -> None: """search_text_occurrences_in_org always excludes theorchard/collab.""" mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.headers = {} mock_response.json.return_value = {'items': []} mock_session.get.return_value = mock_response cfg = GitHubConfig(github_token='dummy') client = GitHubClient(cfg) client.search_text_occurrences_in_org('myorg', 'search_term') query = mock_session.get.call_args[1]['params']['q'] assert '-repo:theorchard/collab' in query @patch('github_client.github_client.requests.Session') def test_non_json_response_raises_runtime_error( mock_session_cls: MagicMock, ) -> None: """A non-JSON GitHub response raises RuntimeError instead of crashing.""" import requests as _requests mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.headers = {} mock_response.status_code = 200 mock_response.json.side_effect = _requests.exceptions.JSONDecodeError('', '', 0) mock_session.get.return_value = mock_response cfg = GitHubConfig(github_token='dummy') client = GitHubClient(cfg) with pytest.raises(RuntimeError, match='non-JSON response'): client.search_text_occurrences_in_org('myorg', 'search_term')