|
| 1 | +import importlib |
| 2 | +from unittest.mock import MagicMock, patch |
| 3 | + |
| 4 | +import httpx |
| 5 | +import pytest |
| 6 | + |
| 7 | + |
| 8 | +@pytest.fixture(autouse=True) |
| 9 | +def _set_required_env(monkeypatch): |
| 10 | + """Ensure required env vars exist to avoid import-time errors.""" |
| 11 | + monkeypatch.setenv("FLOW_RESULTS_API_KEY", "test-key") |
| 12 | + monkeypatch.setenv("FLOW_RESULTS_API_BASE_URL", "http://test-api.com") |
| 13 | + |
| 14 | + |
| 15 | +@pytest.fixture |
| 16 | +def flow_results_httpx_module(_set_required_env): |
| 17 | + """Import the module under test.""" |
| 18 | + return importlib.import_module( |
| 19 | + "rdw_ingestion_tools.api.flow_results.extensions.httpx" |
| 20 | + ) |
| 21 | + |
| 22 | + |
| 23 | +@pytest.fixture |
| 24 | +def mock_client(monkeypatch, flow_results_httpx_module): |
| 25 | + """ |
| 26 | + Patch the Client symbol used in the module under test |
| 27 | + and return the mock client. |
| 28 | + """ |
| 29 | + client = MagicMock() |
| 30 | + client.__enter__.return_value = client |
| 31 | + client.__exit__.return_value = False |
| 32 | + |
| 33 | + monkeypatch.setattr( |
| 34 | + flow_results_httpx_module, "Client", lambda *args, **kwargs: client |
| 35 | + ) |
| 36 | + return client |
| 37 | + |
| 38 | + |
| 39 | +@pytest.fixture |
| 40 | +def make_response(): |
| 41 | + """ |
| 42 | + Factory to construct a mock Response with a provided JSON payload |
| 43 | + and optional status error. |
| 44 | + """ |
| 45 | + |
| 46 | + def _factory(json_data: dict, status_error: Exception | None = None): |
| 47 | + resp = MagicMock() |
| 48 | + resp.json.return_value = json_data |
| 49 | + if status_error is None: |
| 50 | + resp.raise_for_status.return_value = None |
| 51 | + else: |
| 52 | + resp.raise_for_status.side_effect = status_error |
| 53 | + return resp |
| 54 | + |
| 55 | + return _factory |
| 56 | + |
| 57 | + |
| 58 | +def test_get_ids(flow_results_httpx_module, mock_client, make_response): |
| 59 | + json_payload = { |
| 60 | + "data": [ |
| 61 | + {"id": "f-1"}, |
| 62 | + {"id": "f-2"}, |
| 63 | + ] |
| 64 | + } |
| 65 | + mock_client.get.return_value = make_response(json_payload) |
| 66 | + |
| 67 | + result = list(flow_results_httpx_module.get_ids(mock_client, org="acme", page=1)) |
| 68 | + |
| 69 | + assert result == ["f-1", "f-2"] |
| 70 | + mock_client.get.assert_called_once_with("", params={"org": "acme", "page": 1}) |
| 71 | + |
| 72 | + |
| 73 | +def test_get_ids_error(flow_results_httpx_module, mock_client, make_response): |
| 74 | + req = httpx.Request("GET", "http://example.invalid") |
| 75 | + resp = httpx.Response(500, request=req) |
| 76 | + status_err = httpx.HTTPStatusError("Error", request=req, response=resp) |
| 77 | + mock_client.get.return_value = make_response({"data": []}, status_error=status_err) |
| 78 | + |
| 79 | + with pytest.raises(httpx.HTTPStatusError): |
| 80 | + list(flow_results_httpx_module.get_ids(mock_client)) |
| 81 | + |
| 82 | + |
| 83 | +def test_get_paginated_single_page( |
| 84 | + flow_results_httpx_module, mock_client, make_response |
| 85 | +): |
| 86 | + json_payload = { |
| 87 | + "data": { |
| 88 | + "attributes": {"responses": [[{"a": 1}], [{"b": 2}]]}, |
| 89 | + "relationships": {"links": {"next": None}}, |
| 90 | + } |
| 91 | + } |
| 92 | + # Accessing ["next"] on a dict with None value won't raise AttributeError, |
| 93 | + # but our code only checks AttributeError. To keep a single page, we'll instead |
| 94 | + # make the relationships object raise AttributeError on __getitem__ |
| 95 | + # in the pagination test. |
| 96 | + # For single-page, just ensure we don't provide a usable "next" URL |
| 97 | + # and call count is 1. |
| 98 | + mock_client.get.return_value = make_response(json_payload) |
| 99 | + |
| 100 | + result = list(flow_results_httpx_module.get_paginated(mock_client, "/packages/123")) |
| 101 | + |
| 102 | + assert result == [[{"a": 1}], [{"b": 2}]] |
| 103 | + mock_client.get.assert_called_once_with("/packages/123", params={}) |
| 104 | + |
| 105 | + |
| 106 | +def test_get_paginated_pagination( |
| 107 | + flow_results_httpx_module, mock_client, make_response |
| 108 | +): |
| 109 | + # First page returns a next URL |
| 110 | + first_page = { |
| 111 | + "data": { |
| 112 | + "attributes": {"responses": [[{"a": 1}]]}, |
| 113 | + "relationships": { |
| 114 | + "links": {"next": "https://api.example.com/packages/next-token"} |
| 115 | + }, |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + # Second page should trigger the break via AttributeError when accessing ["next"] |
| 120 | + bad_links = MagicMock() |
| 121 | + bad_links.__getitem__.side_effect = AttributeError |
| 122 | + second_page = { |
| 123 | + "data": { |
| 124 | + "attributes": {"responses": [[{"c": 3}]]}, |
| 125 | + "relationships": bad_links, |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + mock_client.get.side_effect = [ |
| 130 | + make_response(first_page), |
| 131 | + make_response(second_page), |
| 132 | + ] |
| 133 | + |
| 134 | + result = list( |
| 135 | + flow_results_httpx_module.get_paginated(mock_client, "/packages/start", q="x") |
| 136 | + ) |
| 137 | + |
| 138 | + assert result == [[{"a": 1}], [{"c": 3}]] |
| 139 | + assert mock_client.get.call_count == 2 |
| 140 | + mock_client.get.assert_any_call("/packages/start", params={"q": "x"}) |
| 141 | + # The second call should use the path part after "packages/" |
| 142 | + mock_client.get.assert_any_call("next-token", params={"q": "x"}) |
| 143 | + |
| 144 | + |
| 145 | +def test_get_paginated_kwargs_propagation( |
| 146 | + flow_results_httpx_module, mock_client, make_response |
| 147 | +): |
| 148 | + json_payload = { |
| 149 | + "data": { |
| 150 | + "attributes": {"responses": []}, |
| 151 | + "relationships": {"links": {"next": None}}, |
| 152 | + } |
| 153 | + } |
| 154 | + mock_client.get.return_value = make_response(json_payload) |
| 155 | + |
| 156 | + list( |
| 157 | + flow_results_httpx_module.get_paginated( |
| 158 | + mock_client, "/packages/xyz", limit=50, cursor="abc" |
| 159 | + ) |
| 160 | + ) |
| 161 | + |
| 162 | + mock_client.get.assert_called_once_with( |
| 163 | + "/packages/xyz", params={"limit": 50, "cursor": "abc"} |
| 164 | + ) |
| 165 | + |
| 166 | + |
| 167 | +def test_get_paginated_retry_mechanism( |
| 168 | + flow_results_httpx_module, mock_client, make_response |
| 169 | +): |
| 170 | + """Test that get_paginated uses RetryTransport for resilient HTTP requests.""" |
| 171 | + # Create a mock response |
| 172 | + json_payload = { |
| 173 | + "data": { |
| 174 | + "attributes": {"responses": [[{"a": 1}], [{"b": 2}]]}, |
| 175 | + "relationships": {"links": {"next": None}}, |
| 176 | + } |
| 177 | + } |
| 178 | + mock_response = make_response(json_payload) |
| 179 | + mock_client.get.return_value = mock_response |
| 180 | + |
| 181 | + # Mock the RetryTransport using the correct import path |
| 182 | + with patch( |
| 183 | + "rdw_ingestion_tools.api.flow_results.client.Client", |
| 184 | + return_value=mock_client, |
| 185 | + ): |
| 186 | + # Call the function |
| 187 | + result = list( |
| 188 | + flow_results_httpx_module.get_paginated( |
| 189 | + mock_client, "http://test-api.com/data" |
| 190 | + ) |
| 191 | + ) |
| 192 | + |
| 193 | + # Assertions |
| 194 | + assert len(result) == 2 |
| 195 | + assert result == [[{"a": 1}], [{"b": 2}]] |
| 196 | + |
| 197 | + # Verify the call was made with correct parameters |
| 198 | + mock_client.get.assert_called_once_with("http://test-api.com/data", params={}) |
| 199 | + |
| 200 | + |
| 201 | +def test_make_client_uses_retrytransport(): |
| 202 | + with patch("rdw_ingestion_tools.api.flow_results.client.RetryTransport") as mock_rt: |
| 203 | + from rdw_ingestion_tools.api.flow_results.client import make_client |
| 204 | + |
| 205 | + _ = make_client() |
| 206 | + assert mock_rt.call_count == 1 |
0 commit comments