-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathtest_client.py
More file actions
291 lines (227 loc) · 10.5 KB
/
Copy pathtest_client.py
File metadata and controls
291 lines (227 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
"""Tests for ``PlatformClient`` HTTP layer.
Coverage:
- URL composition honours base_url, api_path_prefix, organization_id.
- Bearer auth header present on every request.
- Non-2xx response raises ``PlatformAPIError`` with status_code + body.
- 204 / empty body returns ``None`` instead of raising on .json().
- ``get_post_schema`` parses DRF ``actions.POST`` and caches per path.
- ``close()`` shuts the underlying session; context manager works.
- ``_paginate`` follows ``next`` to exhaustion and refuses to return a short read.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from unstract.clone.client import PlatformClient
from unstract.clone.context import OrgEndpoint
from unstract.clone.exceptions import PlatformAPIError
def _endpoint() -> OrgEndpoint:
return OrgEndpoint(
base_url="https://api.example.com",
organization_id="org_abc",
platform_key="plat-key-xyz",
)
def _fake_response(status: int, payload=None, text: str = "") -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.text = text
resp.content = b"" if payload is None and not text else b"x"
resp.json.return_value = payload
return resp
def _client_with_mock(
payload=None, status: int = 200, text: str = ""
) -> tuple[PlatformClient, MagicMock]:
client = PlatformClient(_endpoint())
mock_request = MagicMock(return_value=_fake_response(status, payload, text))
client._session.request = mock_request
return client, mock_request
def test_url_composition_includes_org_and_api_prefix():
client, mock_request = _client_with_mock(payload=[])
client.list_adapters()
call = mock_request.call_args
assert call.args[0] == "GET"
assert call.args[1] == "https://api.example.com/api/v1/unstract/org_abc/adapter/"
def test_bearer_token_sent_on_session():
client, _ = _client_with_mock(payload=[])
assert client._session.headers["Authorization"] == "Bearer plat-key-xyz"
assert client._session.headers["Accept"] == "application/json"
def test_non_2xx_raises_platform_api_error_with_status_and_body():
client, _ = _client_with_mock(status=404, text="not found")
with pytest.raises(PlatformAPIError) as exc_info:
client.list_adapters()
err = exc_info.value
assert err.status_code == 404
assert "not found" in err.body
def test_500_with_long_body_truncated_to_2000_chars():
big = "x" * 5000
client, _ = _client_with_mock(status=500, text=big)
with pytest.raises(PlatformAPIError) as exc_info:
client.list_adapters()
assert len(exc_info.value.body) == 2000
def test_204_no_content_returns_none():
client = PlatformClient(_endpoint())
resp = MagicMock()
resp.status_code = 204
resp.content = b""
client._session.request = MagicMock(return_value=resp)
assert client._request("DELETE", "tag/abc/") is None
def test_get_post_schema_parses_options_and_caches():
options_body = {
"actions": {
"POST": {
"name": {"read_only": False},
"id": {"read_only": True},
"shared_to_org": {"read_only": False},
# No read_only key → treated as writable.
"description": {},
}
}
}
client, mock_request = _client_with_mock(payload=options_body)
writable = client.get_post_schema("adapter/")
assert writable == frozenset({"name", "shared_to_org", "description"})
# second call hits cache — no extra HTTP.
writable2 = client.get_post_schema("adapter/")
assert writable2 is writable
assert mock_request.call_count == 1
def test_get_post_schema_handles_missing_actions_block():
client, _ = _client_with_mock(payload={})
assert client.get_post_schema("connector/") == frozenset()
def test_close_shuts_session():
client = PlatformClient(_endpoint())
sess = client._session
sess.close = MagicMock()
client.close()
sess.close.assert_called_once()
def test_context_manager_closes_on_exit():
with PlatformClient(_endpoint()) as client:
client._session.close = MagicMock()
sess_close = client._session.close
sess_close.assert_called_once()
def test_list_endpoint_unwraps_paginated_envelope():
client, _ = _client_with_mock(payload={"results": [{"id": "a"}, {"id": "b"}]})
items = client.list_tags()
assert [i["id"] for i in items] == ["a", "b"]
def test_list_endpoint_accepts_bare_list():
client, _ = _client_with_mock(payload=[{"id": "a"}])
items = client.list_tags()
assert items == [{"id": "a"}]
def test_options_response_with_null_body_still_yields_empty_schema():
# Some deployments return 200 with no body on OPTIONS.
client, _ = _client_with_mock(payload=None, text="")
assert client.get_post_schema("pipeline/") == frozenset()
def test_get_review_settings_500_treated_as_absent():
# Backend raises DoesNotExist (-> 500) when no HITLSettings row exists.
client, _ = _client_with_mock(status=500, text="DoesNotExist")
assert client.get_review_settings("wf-1") is None
def test_get_review_settings_reraises_non_500():
# Auth / rate-limit errors must surface, not masquerade as "no settings".
client, _ = _client_with_mock(status=403, text="forbidden")
with pytest.raises(PlatformAPIError) as exc_info:
client.get_review_settings("wf-1")
assert exc_info.value.status_code == 403
def _client_with_pages(*payloads) -> tuple[PlatformClient, MagicMock]:
"""Client whose session returns each payload in turn, one per request."""
client = PlatformClient(_endpoint())
mock_request = MagicMock(
side_effect=[_fake_response(200, p) for p in payloads],
)
client._session.request = mock_request
return client, mock_request
def test_paginate_follows_next_across_pages():
page1 = {
"count": 3,
"next": "https://api.example.com/next?page=2",
"results": [1, 2],
}
page2 = {"count": 3, "next": None, "results": [3]}
client, mock_request = _client_with_pages(page1, page2)
assert client.list_tags() == [1, 2, 3]
# Second hop must GET the absolute ``next`` URL verbatim, not an org path.
assert mock_request.call_args.args[1] == "https://api.example.com/next?page=2"
def test_paginate_raises_on_short_read():
# A page set that doesn't add up means rows were dropped; a clone that
# silently copies a subset is worse than one that fails.
truncated = {"count": 9, "next": None, "results": [1, 2]}
client, _ = _client_with_pages(truncated)
with pytest.raises(PlatformAPIError, match="count=9"):
client.list_tags()
def test_paginate_raises_on_cyclic_next():
looping = {"count": 2, "next": "https://api.example.com/loop", "results": [1]}
client, _ = _client_with_pages(looping, looping, looping)
with pytest.raises(PlatformAPIError, match="looped"):
client.list_tags()
def test_paginate_rejects_offsite_next():
# A ``next`` pointing at another host must not receive the bearer key.
page1 = {"count": 3, "next": "https://evil.example.com/next", "results": [1, 2]}
client, _ = _client_with_pages(page1)
with pytest.raises(PlatformAPIError, match="left the platform host"):
client.list_tags()
def test_paginate_follows_equivalent_origin_next():
# Same host with uppercase + explicit default port must be followed,
# not rejected as off-site.
page1 = {
"count": 3,
"next": "https://API.EXAMPLE.COM:443/next?page=2",
"results": [1, 2],
}
page2 = {"count": 3, "next": None, "results": [3]}
client, _ = _client_with_pages(page1, page2)
assert client.list_tags() == [1, 2, 3]
def test_paginate_pins_next_to_configured_origin():
# A TLS-terminating proxy emits an http:// (and/or off-port) next link for
# an https:// client. Same host → followed, but the request is pinned back
# to the configured https origin so the bearer never goes over plaintext or
# an unrelated port. Only the path + query are taken from the server.
page1 = {
"count": 3,
"next": "http://api.example.com:8080/next?page=2",
"results": [1, 2],
}
page2 = {"count": 3, "next": None, "results": [3]}
client, mock_request = _client_with_pages(page1, page2)
assert client.list_tags() == [1, 2, 3]
# Second hop must go to the configured https origin, not the http:8080 the
# server returned.
assert mock_request.call_args.args[1] == "https://api.example.com/next?page=2"
def test_paginate_raises_on_non_string_next():
# A truthy non-string `next` must fail loudly, not blow up in seen.add /
# urlparse with an incidental TypeError.
page1 = {"count": 3, "next": 12345, "results": [1, 2]}
client, _ = _client_with_pages(page1)
with pytest.raises(PlatformAPIError, match="not a URL string"):
client.list_tags()
def test_paginate_empty_body_returns_empty_list():
# A 204 / empty first page means "no rows", not a malformed payload — it
# must return [] like the pre-pagination ``(result or {}).get`` guard did.
client = PlatformClient(_endpoint())
empty = MagicMock()
empty.status_code = 204
empty.content = b""
client._session.request = MagicMock(return_value=empty)
assert client.list_tags() == []
def test_paginate_raises_on_malformed_port_in_next():
# A `next` URL with a non-numeric port makes urlparse raise ValueError on
# `.port`; it must surface as PlatformAPIError, not an incidental traceback.
page1 = {
"count": 3,
"next": "https://api.example.com:notaport/next",
"results": [1, 2],
}
client, _ = _client_with_pages(page1)
with pytest.raises(PlatformAPIError, match="malformed URL"):
client.list_tags()
def test_paginate_raises_on_nonlist_results():
# `results` present but not a list must fail loudly, not corrupt rows via
# extend (character-by-character for a string, TypeError for an int).
bad = {"count": 1, "next": None, "results": "oops"}
client, _ = _client_with_pages(bad)
with pytest.raises(PlatformAPIError, match="unrecognised list payload"):
client.list_tags()
def test_paginate_raises_on_malformed_later_page():
# A later page that isn't a DRF envelope must fail loudly, not raise an
# incidental AttributeError on the next loop turn.
page1 = {"count": 3, "next": "https://api.example.com/next", "results": [1, 2]}
page2 = [3] # bare list where an envelope was expected
client, _ = _client_with_pages(page1, page2)
with pytest.raises(PlatformAPIError, match="unrecognised list payload"):
client.list_tags()