"""Unit tests for the SplitIOClient module.""" import json from unittest.mock import MagicMock, patch from config import SPLITIO_BASE_URL from exceptions import ( HTTPRequestError, InvalidContentTypeError, InvalidResponseError, JSONParsingError, RateLimitExceededError, UserNotFoundError ) import pytest from splitio_client import SplitIOClient mock_flag_definition = { 'name': 'my-flag', 'description': '[ABC-123] test flag', 'owners': ['owner@example.com'], 'tags': ['team-a'], 'trafficType': 'user' } mock_definition_data = {'treatments': [], 'rules': [], 'defaultTreatment': 'off'} mock_flag_definition_response = { **mock_flag_definition, **mock_definition_data } def get_mock_response(status_code=200, json_data=None, content_type='application/json', headers=None, text='OK'): """Create a mock response object.""" mock_resp = MagicMock() mock_resp.status_code = status_code mock_resp.headers = headers or {'Content-Type': content_type} mock_resp.text = text mock_resp.json.return_value = json_data return mock_resp @pytest.fixture def client(): """Fixture to create a SplitIOClient instance for testing.""" return SplitIOClient( account_identifier='test_account_identifier', workspace_id='test_workspace', headers={'x-api-key': 'test token'} ) def test_init_with_valid_workspace_id(client): """Test that SplitIOClient initializes with a valid workspace_id.""" assert client.account_identifier == 'test_account_identifier' assert client.workspace_id == 'test_workspace' assert 'x-api-key' in client.session.headers def test_init_without_account_identifier_raises_value_error(): """Test that SplitIOClient raises ValueError if account_identifier is not set.""" with pytest.raises(ValueError, match='account_identifier must be set'): SplitIOClient(account_identifier=None) def test_init_without_workspace_id_raises_value_error(): """Test that SplitIOClient raises ValueError if workspace_id is not set.""" with pytest.raises(ValueError, match='workspace_id must be set'): SplitIOClient(account_identifier='test_account_identifier', workspace_id=None) @patch('splitio_client.requests.Session.request') def test_request_with_custom_retry_successful_request(mock_request, client): """Test that _request_with_custom_retry returns JSON data on success.""" mock_request.return_value = get_mock_response(json_data={'key': 'value'}) result = client._request_with_custom_retry('GET', 'https://example.com') assert result == {'key': 'value'} @patch('splitio_client.requests.Session.request') def test_request_with_custom_retry_on_429_then_success(mock_request, client): """Test that _request_with_custom_retry retries on 429 and succeeds.""" resp_429 = get_mock_response(429, headers={ 'Content-Type': 'application/json', 'X-RateLimit-Reset-Seconds-Org': '1', 'X-RateLimit-Reset-Seconds-IP': '2' }) resp_200 = get_mock_response(json_data={'ok': True}) mock_request.side_effect = [resp_429, resp_200] with patch('time.sleep') as sleep_mock: result = client._request_with_custom_retry('GET', 'https://example.com') assert result == {'ok': True} assert sleep_mock.called @patch('splitio_client.requests.Session.request') def test_request_with_custom_retry_exceeds_then_raises(mock_request, client): """Test that _request_with_custom_retry raises RateLimitExceededError after retries.""" resp_429 = get_mock_response(429, headers={ 'Content-Type': 'application/json', 'X-RateLimit-Reset-Seconds-Org': '0', 'X-RateLimit-Reset-Seconds-IP': '0' }) mock_request.side_effect = [resp_429] * 6 with patch('time.sleep'), pytest.raises(RateLimitExceededError): client._request_with_custom_retry('GET', 'https://example.com', retries=5) @patch('splitio_client.requests.Session.request') def test_request_with_custom_retry_raises_http_error(mock_request, client): """Test that _request_with_custom_retry raises HTTPRequestError for HTTP errors.""" mock_request.return_value = get_mock_response(404, text='Not Found') with pytest.raises(HTTPRequestError, match='HTTP 404: Not Found'): client._request_with_custom_retry('GET', 'https://example.com') @patch('splitio_client.requests.Session.request') def test_request_with_custom_retry_raises_invalid_content_type_error(mock_request, client): """Test that _request_with_custom_retry raises InvalidContentTypeError for unexpected content type.""" mock_request.return_value = get_mock_response(content_type='text/html') with pytest.raises(InvalidContentTypeError, match='Unexpected content type: text/html'): client._request_with_custom_retry('GET', 'https://example.com') @patch('splitio_client.requests.Session.request') def test_request_with_custom_retry_raises_json_parsing_error(mock_request, client): """Test that _request_with_custom_retry raises JSONParsingError for invalid JSON.""" mock_resp = get_mock_response() mock_resp.json.side_effect = ValueError('No JSON could be decoded') mock_request.return_value = mock_resp with pytest.raises(JSONParsingError, match='Failed to parse JSON'): client._request_with_custom_retry('GET', 'https://example.com') @patch('splitio_client.requests.Session.request') def test_request_with_custom_retry_raises_invalid_response_error(mock_request, client): """Test that _request_with_custom_retry raises InvalidResponseError for unexpected JSON type.""" mock_request.return_value = get_mock_response(json_data='not_a_dict_or_list') with pytest.raises(InvalidResponseError, match='Unexpected JSON format'): client._request_with_custom_retry('GET', 'https://example.com') @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_environments_success(mock_request_with_retry, client, mock_environments): """Test that get_environments returns a list of traffic types.""" mock_request_with_retry.return_value = mock_environments result = client.get_environments() assert result == mock_environments mock_request_with_retry.assert_called_once() mock_request_with_retry.assert_called_with( 'GET', f'{SPLITIO_BASE_URL}/environments/ws/{client.workspace_id}' ) @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_environments_invalid_response(mock_request_with_retry, client, mock_environments): """Test that get_environments raises InvalidResponseError on unexpected response.""" mock_response = { 'data': mock_environments } mock_request_with_retry.return_value = mock_response with pytest.raises(InvalidResponseError, match='Expected a list of dicts for environments.'): client.get_environments() @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_traffic_types_success(mock_request_with_retry, client, mock_traffic_types): """Test that get_traffic_types returns a list of traffic types.""" mock_request_with_retry.return_value = mock_traffic_types result = client.get_traffic_types() assert result == mock_traffic_types mock_request_with_retry.assert_called_once() mock_request_with_retry.assert_called_with( 'GET', f'{SPLITIO_BASE_URL}/trafficTypes/ws/{client.workspace_id}' ) @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_traffic_types_invalid_response(mock_request_with_retry, client, mock_traffic_types): """Test that get_traffic_types raises InvalidResponseError on unexpected response.""" mock_response = { 'data': mock_traffic_types } mock_request_with_retry.return_value = mock_response with pytest.raises(InvalidResponseError, match='Expected a list of dicts for traffic types.'): client.get_traffic_types() @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_all_feature_flags_single_page(mock_request_with_retry, client, mock_feature_flags): """Test that get_all_feature_flags returns users from a single page.""" mock_data = {'objects': mock_feature_flags} mock_request_with_retry.return_value = mock_data feature_flags = client.get_all_feature_flags() assert len(feature_flags) == 2 assert feature_flags == mock_feature_flags mock_request_with_retry.assert_called_once() @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_all_feature_flags_multiple_pages(mock_request_with_retry, client, paginate_mock_response): """Test that get_all_feature_flags handles multiple pages correctly.""" mock_feature_flags = [{'name': f'flag_{i}'} for i in range(123)] pages = list(paginate_mock_response(mock_feature_flags, key='objects')) mock_request_with_retry.side_effect = pages feature_flags = client.get_all_feature_flags() assert feature_flags == mock_feature_flags assert mock_request_with_retry.call_count == 3 @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_all_feature_flags_invalid_response_type(mock_request_with_retry, client, mock_feature_flags): """Test that get_all_feature_flags raises InvalidResponseError on unexpected response.""" mock_request_with_retry.return_value = mock_feature_flags with pytest.raises(InvalidResponseError, match='Expected a dicts for feature flags.'): client.get_all_feature_flags() @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_all_groups_single_page(mock_request_with_retry, client, mock_groups): """Test that get_all_groups returns users from a single page.""" mock_data = { 'status': 'SUCCESS', 'data': { 'totalPages': 1, 'totalItems': 2, 'pageItemCount': 2, 'pageSize': 50, 'content': mock_groups }, 'metaData': None, } mock_request_with_retry.return_value = mock_data groups = client.get_all_groups() assert len(groups) == 2 assert groups == mock_groups mock_request_with_retry.assert_called_once() @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_all_groups_multiple_pages(mock_request_with_retry, client): """Test that get_all_groups handles multiple pages correctly.""" mock_groups_page1 = [{'name': f'group_{i}'} for i in range(50)] mock_groups_page2 = [{'name': f'group_{i}'} for i in range(50, 85)] # Mock two paginated API responses mock_request_with_retry.side_effect = [ { 'status': 'SUCCESS', 'data': { 'totalPages': 2, 'totalItems': 85, 'pageItemCount': 50, 'pageSize': 50, 'content': mock_groups_page1, }, 'metaData': None, }, { 'status': 'SUCCESS', 'data': { 'totalPages': 2, 'totalItems': 85, 'pageItemCount': 35, 'pageSize': 50, 'content': mock_groups_page2, }, 'metaData': None, }, ] groups = client.get_all_groups() assert len(groups) == 85 assert groups == mock_groups_page1 + mock_groups_page2 assert mock_request_with_retry.call_count == 2 @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_all_groups_invalid_response_type(mock_request_with_retry, client, mock_groups): """Test that get_all_groups raises InvalidResponseError on unexpected response.""" mock_request_with_retry.return_value = mock_groups with pytest.raises(InvalidResponseError, match='Expected a dicts for groups.'): client.get_all_groups() def test_get_all_users_returns_cached(client): """Test that get_all_users returns cached users if available.""" client._cached_users = [{'id': 'u1', 'email': 'a@example.com'}] result = client.get_all_users() assert result == client._cached_users @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_all_users_single_page(mock_request_with_retry, client, mock_users): """Test that get_all_users returns users from a single page.""" mock_data = { 'status': 'SUCCESS', 'data': { 'totalPages': 1, 'totalItems': 2, 'pageItemCount': 2, 'pageSize': 50, 'content': [ { 'user': mock_users[0] }, { 'user': mock_users[1] } ], }, 'metaData': None, } mock_request_with_retry.return_value = mock_data client._cached_users = None users = client.get_all_users() assert len(users) == 2 assert users == mock_users mock_request_with_retry.assert_called_once() @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_all_users_multiple_pages(mock_request_with_retry, client, mock_users): """Test that get_all_users handles multiple pages correctly.""" mock_request_with_retry.side_effect = [ { 'status': 'SUCCESS', 'data': { 'totalPages': 2, 'totalItems': 2, 'pageItemCount': 1, 'pageSize': 1, 'content': [{'user': mock_users[0]}], }, 'metaData': None, }, { 'status': 'SUCCESS', 'data': { 'totalPages': 2, 'totalItems': 2, 'pageItemCount': 1, 'pageSize': 1, 'content': [{'user': mock_users[1]}], }, 'metaData': None, }, ] client._cached_users = None users = client.get_all_users() assert len(users) == 2 assert users == mock_users assert mock_request_with_retry.call_count == 2 @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_get_all_users_invalid_response_type(mock_request_with_retry, client, mock_users): """Test that get_all_users raises InvalidResponseError on unexpected response.""" client._cached_users = None mock_request_with_retry.return_value = mock_users with pytest.raises(InvalidResponseError, match='Expected dict response'): client.get_all_users() def test_resolve_owner_by_email(client, mock_users): """Test that resolve_owner can find user by email.""" client._cached_users = mock_users result = client.resolve_owner('user1@example.com') assert result == {'id': '1', 'type': 'user'} def test_resolve_owner_by_name(client, mock_users): """Test that resolve_owner can find user by name.""" client._cached_users = mock_users result = client.resolve_owner('User 2') assert result == {'id': '2', 'type': 'user'} def test_resolve_owner_user_not_found(client, mock_users): """Test that resolve_owner raises UserNotFoundError if user is not found.""" client._cached_users = mock_users with pytest.raises(UserNotFoundError) as excinfo: client.resolve_owner('nonexistent@example.com') assert str(excinfo.value) == "User 'nonexistent@example.com' not found in Split.io" def test_resolve_owner_user_object_missing_keys(client): """Test that resolve_owner raises UserNotFoundError if user object is missing keys.""" client._cached_users = [ {'id': '001'} ] with pytest.raises(UserNotFoundError): client.resolve_owner('someone@example.com') @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_create_feature_flag_success(mock_request_with_retry, client): """Test that create_feature_flag successfully creates a flag.""" metadata = {'description': 'Test flag', 'owners': [{'id': '123', 'type': 'user'}]} flag_name = 'my-flag' expected_payload = { 'name': flag_name, 'description': 'Test flag', 'owners': [{'id': '123', 'type': 'user'}] } mock_request_with_retry.return_value = mock_flag_definition result = client.create_feature_flag(flag_name, 'user', metadata) assert result == mock_flag_definition mock_request_with_retry.assert_called_once_with( 'POST', f'{SPLITIO_BASE_URL}/splits/ws/{client.workspace_id}/trafficTypes/user', data=json.dumps(expected_payload) ) @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_create_feature_flag_invalid_response(mock_request_with_retry, client): """Test that create_feature_flag raises InvalidResponseError on unexpected response.""" mock_request_with_retry.return_value = ['unexpected_list'] metadata = {'description': 'Test flag', 'owners': ['alice@example.com']} with pytest.raises(InvalidResponseError, match='Expected dict response from create feature flag API.'): client.create_feature_flag('my-flag', 'user', metadata) @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_patch_feature_flag_success(mock_request_with_retry, client): """Test that patch_feature_flag successfully patches a flag.""" flag_name = 'my-flag' patch_ops = [{'op': 'replace', 'path': '/description', 'value': 'Updated description'}] expected_url = f'{SPLITIO_BASE_URL}/splits/ws/{client.workspace_id}/{flag_name}' mock_response = mock_flag_definition mock_request_with_retry.return_value = mock_response response = client.patch_feature_flag(flag_name, patch_ops) mock_request_with_retry.assert_called_once_with( 'PATCH', expected_url, data=pytest.approx(json.dumps(patch_ops)) ) assert response == mock_response @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_patch_feature_flag_invalid_response_type(mock_request_with_retry, client): """Test that patch_feature_flag raises InvalidResponseError on unexpected response.""" mock_request_with_retry.return_value = ['not', 'a', 'dict'] with pytest.raises(InvalidResponseError, match='Expected dict response from feature flag patch API.'): client.patch_feature_flag('test_flag', [{'op': 'replace', 'path': '/description', 'value': 'desc'}]) @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_patch_feature_flag_empty_patch(mock_request_with_retry, client): """Test that patch_feature_flag can handle an empty patch operation.""" flag_name = 'my-flag' expected_url = f'{SPLITIO_BASE_URL}/splits/ws/{client.workspace_id}/{flag_name}' mock_request_with_retry.return_value = mock_flag_definition response = client.patch_feature_flag(flag_name, []) assert response == mock_flag_definition mock_request_with_retry.assert_called_once() mock_request_with_retry.assert_called_once_with( 'PATCH', expected_url, data=pytest.approx(json.dumps([])) ) @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_create_flag_definition_success(mock_request_with_retry, client): """Test that create_flag_definition successfully patches a flag.""" flag_name = 'my-flag' test_environment_id = 'test-env-id' expected_url = f'{SPLITIO_BASE_URL}/splits/ws/{client.workspace_id}/{flag_name}/environments/{test_environment_id}' mock_response = mock_flag_definition_response mock_request_with_retry.return_value = mock_response response = client.create_flag_definition(flag_name, test_environment_id, mock_definition_data) mock_request_with_retry.assert_called_once_with( 'POST', expected_url, data=pytest.approx(json.dumps(mock_definition_data)) ) assert response == mock_response @patch('splitio_client.SplitIOClient._request_with_custom_retry') def test_create_flag_definition_invalid_response_type(mock_request_with_retry, client): """Test that create_flag_definition raises InvalidResponseError on unexpected response.""" mock_request_with_retry.return_value = ['not', 'a', 'dict'] with pytest.raises(InvalidResponseError, match='Expected dict response from post definition API.'): client.create_flag_definition('test_flag', 'test_env_id', mock_definition_data)