diff --git a/nyx_client/nyx_client/client.py b/nyx_client/nyx_client/client.py index 4aae025..a2fdccc 100644 --- a/nyx_client/nyx_client/client.py +++ b/nyx_client/nyx_client/client.py @@ -214,7 +214,9 @@ def _nyx_patch( url=self.config.nyx_url + NYX_API_BASE_URL + endpoint, json=data if data else None, data=multipart if multipart else None, - headers=self._make_headers(headers), + headers=self._make_headers( + content_type="multipart/form-data" if multipart else "application/json", extra_headers=headers + ), ) if resp.status_code == 400: log.warning(resp.json()) diff --git a/nyx_client/tests/test_client.py b/nyx_client/tests/test_client.py index d5a62fc..f2c7663 100644 --- a/nyx_client/tests/test_client.py +++ b/nyx_client/tests/test_client.py @@ -4,7 +4,7 @@ import requests from requests_mock import ANY -from nyx_client.client import NyxClient +from nyx_client.client import NyxClient, SparqlResultType from nyx_client.configuration import BaseNyxConfig from nyx_client.data import Data @@ -12,17 +12,9 @@ @pytest.fixture(autouse=True) def mock_dotenv_values(): mock_values = { - "DID_USER_DID": "mock_user_did", - "DID_AGENT_DID": "mock_agent_did", - "DID_AGENT_KEY_NAME": "mock_agent_key", - "DID_AGENT_NAME": "mock_agent_name", - "DID_AGENT_SECRET": "mock_agent_secret", - "HOST_VERIFY_SSL": "true", "NYX_URL": "https://mock.nyx.url", - "NYX_USERNAME": "mock_username", "NYX_EMAIL": "mock@email.com", "NYX_PASSWORD": "mock_password", - "OPENAI_API_KEY": "mock_openai_key", } with patch("nyx_client.configuration.dotenv_values", return_value=mock_values): yield @@ -215,3 +207,256 @@ def test_sparql_query_constructs_data(nyx_client): assert data[0].size == 321 assert data[0].creator == "TestCreator" assert data[0].description == "Some description of sorts" + + +def test_sparql_query(requests_mock, nyx_client): + query = "SELECT * WHERE { ?s ?p ?o }" + expected_response = '{"results": {"bindings": []}}' + requests_mock.post( + "https://mock.nyx.url/api/portal/meta/sparql/global", + text=expected_response, + headers={"Content-Type": "application/sparql-results+json"}, + ) + requests_mock.post( + "https://mock.nyx.url/api/portal/meta/sparql/local", + text=expected_response, + headers={"Content-Type": "application/sparql-results+json"}, + ) + + result = nyx_client.sparql_query(query) + assert result == expected_response + + # Test with different result type + result = nyx_client.sparql_query(query, result_type=SparqlResultType.SPARQL_XML, local_only=True) + assert requests_mock.last_request.headers["Accept"] == SparqlResultType.SPARQL_XML.value + assert "local" in requests_mock.last_request.url + + +def test_search(requests_mock, nyx_client): + mock_response = [ + { + "name": "test_dataset", + "title": "Test Dataset", + "description": "Test Description", + "accessURL": "https://example.com/data", + "contentType": "text/csv", + "creator": "test_creator", + "categories": ["test_category"], + "genre": "test_genre", + } + ] + + requests_mock.get("https://mock.nyx.url/api/portal/meta/search/text", json=mock_response) + + result = nyx_client.search( + categories=["test_category"], + genre="test_genre", + creator="test_creator", + text="test", + license="MIT", + content_type="text/csv", + subscription_state="all", + timeout=5, + local_only=True, + ) + + assert len(result) == 1 + assert isinstance(result[0], Data) + assert result[0].name == "test_dataset" + + # Verify query parameters + last_request = requests_mock.last_request + assert "category=test_category" in last_request.url + assert "genre=test_genre" in last_request.url + assert "creator=test_creator" in last_request.url + assert "text=test" in last_request.url + assert "license=MIT" in last_request.url + assert "contentType=text%2Fcsv" in last_request.url + assert "include=all" in last_request.url + assert "timeout=5" in last_request.url + assert "scope=local" in last_request.url + + +def test_my_subscriptions(nyx_client): + with patch.object(nyx_client, "get_data") as mock_get_data: + mock_data = [ + Data( + name="test_sub", + title="Test Subscription", + description="Test Description", + url="https://example.com/data", + content_type="text/csv", + creator="test_creator", + org="test_org", + categories=["test_category"], + genre="test_genre", + ) + ] + mock_get_data.return_value = mock_data + + result = nyx_client.my_subscriptions( + categories=["test_category"], + genre="test_genre", + creator="test_creator", + license="MIT", + content_type="text/csv", + ) + + mock_get_data.assert_called_once_with( + categories=["test_category"], + genre="test_genre", + creator="test_creator", + license="MIT", + content_type="text/csv", + subscription_state="subscribed", + ) + assert result == mock_data + + +def test_my_data(nyx_client): + with patch.object(nyx_client, "get_data") as mock_get_data: + mock_data = [ + Data( + name="test_data", + title="My Test Data", + description="Test Description", + url="https://example.com/data", + content_type="text/csv", + creator=nyx_client.org, + org=nyx_client.org, + categories=["test_category"], + genre="test_genre", + ) + ] + mock_get_data.return_value = mock_data + + result = nyx_client.my_data( + categories=["test_category"], genre="test_genre", license="MIT", content_type="text/csv" + ) + + mock_get_data.assert_called_once_with( + categories=["test_category"], + genre="test_genre", + creator=nyx_client.org, + license="MIT", + content_type="text/csv", + subscription_state="all", + local_only=True, + ) + assert result == mock_data + + +def test_update_data(requests_mock, nyx_client): + name = "test_data" + mock_response = { + "name": name, + "title": "Updated Title", + "description": "Updated Description", + "accessURL": "https://example.com/updated", + "contentType": "text/csv", + "creator": nyx_client.org, + "categories": ["updated_category"], + "genre": "updated_genre", + } + + requests_mock.patch(f"https://mock.nyx.url/api/portal/products/{name}", json=mock_response) + + result = nyx_client.update_data( + name=name, + title="Updated Title", + description="Updated Description", + size=1000, + genre="updated_genre", + categories=["updated_category"], + download_url="https://example.com/updated", + content_type="text/csv", + preview="Test preview", + ) + + assert isinstance(result, Data) + assert result.name == name + assert result.title == "Updated Title" + assert result.description == "Updated Description" + assert result.content_type == "text/csv" + assert result.creator == nyx_client.org + + # Verify the multipart request was formed correctly + last_request = requests_mock.last_request + assert last_request.headers["Content-Type"].startswith("multipart/form-data") + + +def test_subscribe_unsubscribe(requests_mock, nyx_client): + test_data = Data( + name="test_data", + title="Test Data", + description="Test Description", + url="https://example.com/data", + content_type="text/csv", + creator="test_creator", + org="test_org", + categories=["test_category"], + genre="test_genre", + ) + + # Test subscribe + requests_mock.post("https://mock.nyx.url/api/portal/purchases/transactions", json={"status": "success"}) + + nyx_client.subscribe(test_data) + assert requests_mock.call_count == 1 + assert requests_mock.last_request.json() == {"product_name": test_data.name, "seller_org": test_data.creator} + + # Test unsubscribe + requests_mock.delete(f"https://mock.nyx.url/api/portal/purchases/transactions/{test_data.creator}/{test_data.name}") + + nyx_client.unsubscribe(test_data) + assert requests_mock.call_count == 2 + + +def test_subscribe_error(requests_mock, nyx_client): + test_data = Data( + name="test_data", + title="Test Data", + description="Description", + url="https://example.com/data", + content_type="text/csv", + creator="test_creator", + org="test_org", + categories=["test_category"], + genre="test_genre", + ) + + error_response = { + "error": "Subscription failed", + "message": "Unable to subscribe to data: insufficient permissions", + "code": "SUBSCRIPTION_ERROR", + } + + requests_mock.post("https://mock.nyx.url/api/portal/purchases/transactions", status_code=400, json=error_response) + + with pytest.raises(requests.HTTPError) as exc_info: + nyx_client.subscribe(test_data) + + # Verify the error response was received + assert exc_info.value.response.status_code == 400 + assert exc_info.value.response.json() == error_response + + +def test_unsubscribe_error(requests_mock, nyx_client): + test_data = Data( + name="test_data", + title="Test Data", + description="Description", + url="https://example.com/data", + content_type="text/csv", + creator="test_creator", + org="test_org", + categories=["test_category"], + genre="test_genre", + ) + + requests_mock.delete( + f"https://mock.nyx.url/api/portal/purchases/transactions/{test_data.creator}/{test_data.name}", status_code=404 + ) + + with pytest.raises(requests.HTTPError): + nyx_client.unsubscribe(test_data) diff --git a/nyx_client/tests/test_data.py b/nyx_client/tests/test_data.py index a340cb3..cad9aee 100644 --- a/nyx_client/tests/test_data.py +++ b/nyx_client/tests/test_data.py @@ -48,7 +48,7 @@ def test_data_str(mock_data_details): assert str(data) == "Data(Test Data, http://example.com?buyer_org=TestOrg, text/csv)" -def test_data_download(requests_mock, mock_data_details): +def test_data_as_str(requests_mock, mock_data_details): data = Data(**mock_data_details) requests_mock.get(data.url, text="Test Content") @@ -57,6 +57,15 @@ def test_data_download(requests_mock, mock_data_details): assert content == "Test Content" +def test_data_as_bytes(requests_mock, mock_data_details): + data = Data(**mock_data_details) + + requests_mock.get(data.url, text="Test Content") + + content = data.as_bytes() + assert content == b"Test Content" + + def test_nyx_data_download_failure(requests_mock, mock_data_details): data = Data(**mock_data_details) diff --git a/nyx_client/tests/test_utils.py b/nyx_client/tests/test_utils.py new file mode 100644 index 0000000..064966b --- /dev/null +++ b/nyx_client/tests/test_utils.py @@ -0,0 +1,208 @@ +from unittest.mock import Mock + +import pytest +from requests.exceptions import HTTPError + +from nyx_client.utils import auth_retry, ensure_setup + + +class MockResponse: + def __init__(self, status_code): + self.status_code = status_code + self.text = f"Error {status_code}" + + +def TestHTTPError(status_code): + """Create an HTTPError with a mock response.""" + response = MockResponse(status_code) + return HTTPError(response=response) + + +class MockNyxClient: + def __init__(self): + self._is_setup = False + self._authorise = Mock() + self.setup_called = 0 + self.auth_called = 0 + self._attempt = 0 # Track which attempt we're on + + def _setup(self): + self._is_setup = True + self.setup_called += 1 + + @ensure_setup + def test_ensure_setup(self): + return "success" + + @auth_retry + def test_auth_retry(self, should_fail_first=False, should_fail_second=False, first_status=401, second_status=None): + self.auth_called += 1 + + if self._attempt == 0 and should_fail_first: + self._attempt += 1 + raise TestHTTPError(first_status) + elif self._attempt == 1 and should_fail_second: + self._attempt += 1 + raise TestHTTPError(second_status or first_status) + + return "success" + + @ensure_setup + @auth_retry + def test_combined_decorators(self, should_fail=False, status_code=401): + self.auth_called += 1 + if should_fail: + raise TestHTTPError(status_code) + return "success" + + +def test_ensure_setup_decorator(): + client = MockNyxClient() + assert client._is_setup is False + + # First call should trigger setup + result = client.test_ensure_setup() + assert result == "success" + assert client._is_setup is True + assert client.setup_called == 1 + + # Second call should not trigger setup + result = client.test_ensure_setup() + assert result == "success" + assert client.setup_called == 1 # Setup count should not increase + + +@pytest.mark.parametrize( + "test_case", + [ + # (should_fail_first, should_fail_second, first_status, second_status, expected_auth_calls, should_raise) + (True, False, 401, None, 2, False), # Successful retry + (True, True, 401, 401, 2, True), # Failed retry + (True, True, 401, 403, 2, True), # Different error on retry + (False, False, None, None, 1, False), # No failure + (True, False, 403, None, 1, True), # Non-401 error + ], +) +def test_auth_retry_decorator(test_case): + should_fail_first, should_fail_second, first_status, second_status, expected_auth_calls, should_raise = test_case + client = MockNyxClient() + + if should_raise: + with pytest.raises(HTTPError): + client.test_auth_retry( + should_fail_first=should_fail_first, + should_fail_second=should_fail_second, + first_status=first_status, + second_status=second_status, + ) + else: + result = client.test_auth_retry( + should_fail_first=should_fail_first, + should_fail_second=should_fail_second, + first_status=first_status, + second_status=second_status, + ) + assert result == "success" + + # Verify number of auth calls + if first_status == 401 and should_fail_first: + client._authorise.assert_called_once_with(refresh=True) + assert client.auth_called == expected_auth_calls + + +@pytest.mark.parametrize( + "test_case", + [ + # (setup_fails, auth_fails, auth_status, expected_setup_calls, expected_auth_calls, should_raise) + (False, False, None, 1, 1, False), # Everything succeeds + (False, True, 401, 1, 2, True), # Auth fails with 401 + (False, True, 403, 1, 1, True), # Auth fails with non-401 + (True, False, None, 1, 0, True), # Setup fails + ], +) +def test_combined_decorators(test_case): + setup_fails, auth_fails, auth_status, expected_setup_calls, expected_auth_calls, should_raise = test_case + client = MockNyxClient() + + if setup_fails: + client._setup = Mock(side_effect=Exception("Setup failed")) + + if should_raise: + with pytest.raises((Exception, HTTPError)): + client.test_combined_decorators(should_fail=auth_fails, status_code=auth_status) + else: + result = client.test_combined_decorators(should_fail=auth_fails, status_code=auth_status) + assert result == "success" + + if not setup_fails: + assert client.setup_called == expected_setup_calls + assert client.auth_called <= expected_auth_calls + + +def test_ensure_setup_no_infinite_loop(): + client = MockNyxClient() + setup_count = 0 + max_setups = 5 # Safety limit + + def mock_setup(): + nonlocal setup_count + setup_count += 1 + if setup_count > max_setups: + pytest.fail("Potential infinite loop detected") + # Always fail setup + raise TestHTTPError(401) + + client._setup = mock_setup + + with pytest.raises(HTTPError): + client.test_ensure_setup() + + assert setup_count == 1, "Setup should only be called once even on failure" + + +class MockNyxClientWithSequence(MockNyxClient): + def __init__(self, error_sequence): + super().__init__() + self.error_sequence = error_sequence + self.call_count = 0 + + @auth_retry + def test_sequence(self): + if self.call_count >= len(self.error_sequence): + return "success" + + status_code, should_fail = self.error_sequence[self.call_count] + self.call_count += 1 + + if should_fail: + raise TestHTTPError(status_code) + return "success" + + +@pytest.mark.parametrize( + "error_sequence", + [ + [(401, True), (401, True), (401, True)], # Multiple 401s + [(401, True), (403, True)], # 401 then different error + [(401, True), (401, False)], # 401 then success + [(500, True), (401, True)], # Different error then 401 + ], +) +def test_auth_retry_error_sequences(error_sequence): + client = MockNyxClientWithSequence(error_sequence) + + if any(code != 401 for code, _ in error_sequence): + # Should fail on non-401 errors + with pytest.raises(HTTPError): + client.test_sequence() + elif all(fail for _, fail in error_sequence): + # Should fail after retry on continuous 401s + with pytest.raises(HTTPError): + client.test_sequence() + else: + # Should eventually succeed + result = client.test_sequence() + assert result == "success" + + # Verify we didn't make too many calls + assert client.call_count <= len(error_sequence)