-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtest_client.py
More file actions
380 lines (294 loc) · 14.4 KB
/
test_client.py
File metadata and controls
380 lines (294 loc) · 14.4 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
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
"""Tests for the sync ConfigClient."""
import warnings
from unittest.mock import MagicMock, patch
import grpc
import pytest
import opendecree
from opendecree.errors import DecreeError, NotFoundError, UnavailableError
from opendecree.types import FieldUpdate
from tests.conftest import FakeRpcError
def _make_patched_client(**kwargs):
"""Create a ConfigClient with mocked channel and interceptors."""
with patch("opendecree.client.create_channel") as mock_ch:
mock_channel = MagicMock()
mock_ch.return_value = mock_channel
with patch("opendecree.client.grpc.intercept_channel") as mock_intercept:
mock_intercept.return_value = mock_channel
return opendecree.ConfigClient("localhost:9090", **kwargs)
class TestConfigClientTokenWarning:
def test_warns_on_insecure_with_token(self):
with pytest.warns(UserWarning, match="insecure channel"):
_make_patched_client(token="secret", insecure=True)
def test_no_warning_on_tls_with_token(self):
creds = MagicMock(spec=grpc.ChannelCredentials)
with warnings.catch_warnings():
warnings.simplefilter("error", UserWarning)
_make_patched_client(token="secret", credentials=creds)
def test_no_warning_on_insecure_without_token(self):
with warnings.catch_warnings():
warnings.simplefilter("error", UserWarning)
_make_patched_client(insecure=True)
def test_tls_token_not_in_metadata(self):
creds = MagicMock(spec=grpc.ChannelCredentials)
with patch("opendecree.client.create_channel") as mock_ch:
mock_ch.return_value = MagicMock()
with patch("opendecree.client.grpc.intercept_channel"):
client = opendecree.ConfigClient(
"localhost:9090", token="secret", credentials=creds
)
# No interceptor metadata should contain the raw authorization header.
assert client._raw_channel is mock_ch.return_value
# Token routed to channel factory, not raw header interceptor.
assert mock_ch.call_args.kwargs.get("token") == "secret"
def test_insecure_token_in_metadata_not_channel(self):
with pytest.warns(UserWarning):
with patch("opendecree.client.create_channel") as mock_ch:
mock_ch.return_value = MagicMock()
with patch("opendecree.client.grpc.intercept_channel"):
opendecree.ConfigClient("localhost:9090", token="secret", insecure=True)
# Token should NOT be forwarded to channel factory on insecure path.
assert mock_ch.call_args.kwargs.get("token") is None
class TestConfigClientImport:
"""Test that the client is importable and has the expected API."""
def test_import(self):
assert hasattr(opendecree, "ConfigClient")
def test_version_constants(self):
assert opendecree.__version__
assert opendecree.SUPPORTED_SERVER_VERSION == ">=0.3.0,<1.0.0"
assert opendecree.PROTO_VERSION == "v1"
class TestConfigClientUnit:
"""Unit tests with mocked gRPC stubs."""
def _make_client(self):
"""Create a ConfigClient with mocked internals."""
with patch("opendecree.client.create_channel") as mock_ch:
mock_channel = MagicMock()
mock_ch.return_value = mock_channel
with patch("opendecree.client.grpc.intercept_channel") as mock_intercept:
mock_intercept.return_value = mock_channel
client = opendecree.ConfigClient(
"localhost:9090",
subject="test",
)
# Replace stub with mock.
client._stub = MagicMock()
return client
def test_get_string(self):
client = self._make_client()
from opendecree._generated.centralconfig.v1 import types_pb2
mock_resp = MagicMock()
mock_resp.value.HasField.return_value = True
mock_resp.value.value = types_pb2.TypedValue(string_value="hello")
client._stub.GetField.return_value = mock_resp
result = client.get("t1", "payments.fee")
assert result == "hello"
def test_get_int(self):
client = self._make_client()
from opendecree._generated.centralconfig.v1 import types_pb2
mock_resp = MagicMock()
mock_resp.value.HasField.return_value = True
mock_resp.value.value = types_pb2.TypedValue(integer_value=42)
client._stub.GetField.return_value = mock_resp
result = client.get("t1", "retries", int)
assert result == 42
def test_get_bool(self):
client = self._make_client()
from opendecree._generated.centralconfig.v1 import types_pb2
mock_resp = MagicMock()
mock_resp.value.HasField.return_value = True
mock_resp.value.value = types_pb2.TypedValue(bool_value=True)
client._stub.GetField.return_value = mock_resp
result = client.get("t1", "enabled", bool)
assert result is True
def test_get_float(self):
client = self._make_client()
from opendecree._generated.centralconfig.v1 import types_pb2
mock_resp = MagicMock()
mock_resp.value.HasField.return_value = True
mock_resp.value.value = types_pb2.TypedValue(number_value=3.14)
client._stub.GetField.return_value = mock_resp
result = client.get("t1", "rate", float)
assert result == pytest.approx(3.14)
def test_get_nullable_returns_none(self):
client = self._make_client()
mock_resp = MagicMock()
mock_resp.value.HasField.return_value = False
client._stub.GetField.return_value = mock_resp
result = client.get("t1", "field", str, nullable=True)
assert result is None
def test_get_not_found_raises(self):
client = self._make_client()
mock_resp = MagicMock()
mock_resp.value.HasField.return_value = False
client._stub.GetField.return_value = mock_resp
with pytest.raises(NotFoundError):
client.get("t1", "field")
def test_get_grpc_error(self):
client = self._make_client()
client._stub.GetField.side_effect = FakeRpcError(grpc.StatusCode.UNAVAILABLE, "down")
with pytest.raises(UnavailableError):
client.get("t1", "field")
def test_get_all(self):
client = self._make_client()
from opendecree._generated.centralconfig.v1 import types_pb2
cv1 = MagicMock()
cv1.field_path = "a"
cv1.HasField.return_value = True
cv1.value = types_pb2.TypedValue(string_value="1")
cv2 = MagicMock()
cv2.field_path = "b"
cv2.HasField.return_value = True
cv2.value = types_pb2.TypedValue(string_value="2")
mock_resp = MagicMock()
mock_resp.config.values = [cv1, cv2]
client._stub.GetConfig.return_value = mock_resp
result = client.get_all("t1")
assert result == {"a": "1", "b": "2"}
def test_set(self):
client = self._make_client()
client._stub.SetField.return_value = MagicMock()
client.set("t1", "payments.fee", "0.5%")
client._stub.SetField.assert_called_once()
def test_set_with_metadata(self):
client = self._make_client()
client._stub.SetField.return_value = MagicMock()
client.set(
"t1",
"payments.fee",
"0.5%",
description="fee increase",
value_description="Q2 rate",
expected_checksum="abc123",
)
req = client._stub.SetField.call_args[0][0]
assert req.description == "fee increase"
assert req.value_description == "Q2 rate"
assert req.expected_checksum == "abc123"
def test_set_many(self):
client = self._make_client()
client._stub.SetFields.return_value = MagicMock()
updates = [FieldUpdate("a", "1"), FieldUpdate("b", "2")]
client.set_many("t1", updates, description="batch")
client._stub.SetFields.assert_called_once()
def test_set_many_with_per_field_metadata(self):
client = self._make_client()
client._stub.SetFields.return_value = MagicMock()
updates = [
FieldUpdate("a", "1", expected_checksum="cs1", value_description="first"),
FieldUpdate("b", "2"),
]
client.set_many("t1", updates)
client._stub.SetFields.assert_called_once()
def test_set_null(self):
client = self._make_client()
client._stub.SetField.return_value = MagicMock()
client.set_null("t1", "payments.fee")
client._stub.SetField.assert_called_once()
def test_get_all_grpc_error(self):
client = self._make_client()
client._stub.GetConfig.side_effect = FakeRpcError(grpc.StatusCode.UNAVAILABLE, "down")
with pytest.raises(UnavailableError):
client.get_all("t1")
def test_set_grpc_error(self):
client = self._make_client()
client._stub.SetField.side_effect = FakeRpcError(grpc.StatusCode.UNAVAILABLE, "down")
with pytest.raises(UnavailableError):
client.set("t1", "payments.fee", "0.5%")
def test_set_many_grpc_error(self):
client = self._make_client()
client._stub.SetFields.side_effect = FakeRpcError(grpc.StatusCode.UNAVAILABLE, "down")
with pytest.raises(UnavailableError):
client.set_many("t1", [FieldUpdate("a", "1")])
def test_set_null_grpc_error(self):
client = self._make_client()
client._stub.SetField.side_effect = FakeRpcError(grpc.StatusCode.UNAVAILABLE, "down")
with pytest.raises(UnavailableError):
client.set_null("t1", "payments.fee")
def test_set_does_not_retry_on_deadline_exceeded(self):
client = self._make_client()
err = FakeRpcError(grpc.StatusCode.DEADLINE_EXCEEDED)
client._stub.SetField.side_effect = err
with patch("opendecree._retry.time.sleep"):
with pytest.raises(DecreeError):
client.set("t1", "payments.fee", "0.5%")
client._stub.SetField.assert_called_once()
def test_set_retries_on_deadline_exceeded_with_idempotency_key(self):
client = self._make_client()
err = FakeRpcError(grpc.StatusCode.DEADLINE_EXCEEDED)
client._stub.SetField.side_effect = [err, MagicMock()]
with patch("opendecree._retry.time.sleep"):
client.set("t1", "payments.fee", "0.5%", idempotency_key="idem-1")
assert client._stub.SetField.call_count == 2
def test_set_many_does_not_retry_on_deadline_exceeded(self):
client = self._make_client()
err = FakeRpcError(grpc.StatusCode.DEADLINE_EXCEEDED)
client._stub.SetFields.side_effect = err
with patch("opendecree._retry.time.sleep"):
with pytest.raises(DecreeError):
client.set_many("t1", [FieldUpdate("a", "1")])
client._stub.SetFields.assert_called_once()
def test_set_null_does_not_retry_on_deadline_exceeded(self):
client = self._make_client()
err = FakeRpcError(grpc.StatusCode.DEADLINE_EXCEEDED)
client._stub.SetField.side_effect = err
with patch("opendecree._retry.time.sleep"):
with pytest.raises(DecreeError):
client.set_null("t1", "payments.fee")
client._stub.SetField.assert_called_once()
def test_no_interceptor_when_no_metadata(self):
"""Client with no subject/token/role skips interceptor (line 69)."""
with patch("opendecree.client.create_channel") as mock_ch:
mock_channel = MagicMock()
mock_ch.return_value = mock_channel
with patch("opendecree.client.grpc.intercept_channel") as mock_intercept:
# Must override role="" to produce empty metadata
opendecree.ConfigClient("localhost:9090", role="")
mock_intercept.assert_not_called()
def test_context_manager(self):
with patch("opendecree.client.create_channel") as mock_ch:
mock_channel = MagicMock()
mock_ch.return_value = mock_channel
with opendecree.ConfigClient("localhost:9090") as client:
assert client is not None
mock_channel.close.assert_called_once()
def test_watch_returns_context(self):
client = self._make_client()
ctx = client.watch("t1")
assert ctx is not None
with ctx as watcher:
assert watcher is not None
def test_custom_interceptors_passed_to_intercept_channel(self):
custom = MagicMock(spec=grpc.UnaryUnaryClientInterceptor)
with patch("opendecree.client.create_channel") as mock_ch:
mock_channel = MagicMock()
mock_ch.return_value = mock_channel
with patch("opendecree.client.grpc.intercept_channel") as mock_intercept:
mock_intercept.return_value = mock_channel
opendecree.ConfigClient("localhost:9090", interceptors=[custom])
args = mock_intercept.call_args[0]
assert custom in args
def test_custom_interceptors_outermost(self):
"""User interceptors must come before AuthInterceptor."""
from opendecree._interceptors import AuthInterceptor
custom = MagicMock(spec=grpc.UnaryUnaryClientInterceptor)
with patch("opendecree.client.create_channel") as mock_ch:
mock_channel = MagicMock()
mock_ch.return_value = mock_channel
with patch("opendecree.client.grpc.intercept_channel") as mock_intercept:
mock_intercept.return_value = mock_channel
opendecree.ConfigClient("localhost:9090", subject="s", interceptors=[custom])
args = mock_intercept.call_args[0]
# args[0] is the channel; args[1:] are interceptors in order
interceptors_in_order = args[1:]
assert interceptors_in_order[0] is custom
assert isinstance(interceptors_in_order[1], AuthInterceptor)
def test_custom_interceptors_no_auth(self):
"""Custom interceptors work even when no auth metadata is set."""
custom = MagicMock(spec=grpc.UnaryUnaryClientInterceptor)
with patch("opendecree.client.create_channel") as mock_ch:
mock_channel = MagicMock()
mock_ch.return_value = mock_channel
with patch("opendecree.client.grpc.intercept_channel") as mock_intercept:
mock_intercept.return_value = mock_channel
opendecree.ConfigClient("localhost:9090", role="", interceptors=[custom])
args = mock_intercept.call_args[0]
assert args[1] is custom