Skip to content

Commit 7a330cf

Browse files
zeevdrclaude
andcommitted
test: raise unit test coverage to 100% across all SDK modules
Adds targeted tests for every previously uncovered branch, covering the 23 missing statements that were keeping coverage below perfection: - _channel.py: invoke the _token_call_credentials inner callback directly - _convert.py: hit the case-_ fallback in typed_value_to_string via a fake TypedValue - _retry.py: async deadline tests — loop-top break (line 116) and sleep-clipping (line 130) - async_client.py: insecure-token UserWarning, get_server_version() caching, and the check_version=True lazy-compat path - async_watcher.py: spurious-wake path in changes(), stream loop body (process_change + stopped-mid-iteration return), and AioRpcError while _stopped=True - watcher.py: queue-cond wait when queue is empty, stream loop body (process_change + stopped-mid-iteration return), and RpcError while stop_event set All 282 tests pass; total coverage 100% (927/927 statements). Closes #112 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8e6f1d5 commit 7a330cf

6 files changed

Lines changed: 396 additions & 0 deletions

File tree

sdk/tests/test_async_client.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,68 @@ def test_multiple_custom_interceptors_preserved(self):
317317
mock_ch.return_value = MagicMock()
318318
AsyncConfigClient("localhost:9090", interceptors=[a, b])
319319
assert mock_ch.call_args.kwargs["interceptors"] == [a, b]
320+
321+
def test_insecure_token_emits_warning(self):
322+
import warnings
323+
324+
with patch("opendecree.async_client.create_aio_channel") as mock_ch:
325+
mock_ch.return_value = MagicMock()
326+
with warnings.catch_warnings(record=True) as w:
327+
warnings.simplefilter("always")
328+
AsyncConfigClient("localhost:9090", insecure=True, token="tok")
329+
assert len(w) == 1
330+
assert issubclass(w[0].category, UserWarning)
331+
assert "cleartext" in str(w[0].message)
332+
333+
@pytest.mark.asyncio
334+
async def test_get_server_version(self):
335+
client = self._make_client()
336+
from opendecree.types import ServerVersion
337+
338+
sv = ServerVersion(version="0.2.0", commit="abc123")
339+
with patch(
340+
"opendecree.async_client.async_fetch_server_version",
341+
new_callable=AsyncMock,
342+
return_value=sv,
343+
) as mock_fetch:
344+
result = await client.get_server_version()
345+
assert result == sv
346+
mock_fetch.assert_called_once()
347+
# Second call uses cache — fetch not called again
348+
with patch(
349+
"opendecree.async_client.async_fetch_server_version",
350+
new_callable=AsyncMock,
351+
) as mock_fetch2:
352+
result2 = await client.get_server_version()
353+
assert result2 == sv
354+
mock_fetch2.assert_not_called()
355+
356+
@pytest.mark.asyncio
357+
async def test_check_version_true_triggers_compat_check_on_first_call(self):
358+
with patch("opendecree.async_client.create_aio_channel") as mock_ch:
359+
mock_ch.return_value = MagicMock()
360+
client = AsyncConfigClient("localhost:9090", check_version=True)
361+
client._stub = MagicMock()
362+
363+
from opendecree._generated.centralconfig.v1 import types_pb2
364+
365+
mock_resp = MagicMock()
366+
mock_resp.value.HasField.return_value = True
367+
mock_resp.value.value = types_pb2.TypedValue(string_value="hello")
368+
client._stub.GetField = AsyncMock(return_value=mock_resp)
369+
370+
from opendecree.types import ServerVersion
371+
372+
sv = ServerVersion(version="0.2.0", commit="abc")
373+
with patch(
374+
"opendecree.async_client.async_fetch_server_version",
375+
new_callable=AsyncMock,
376+
return_value=sv,
377+
):
378+
with patch("opendecree.async_client.check_version_compatible") as mock_check:
379+
result = await client.get("t1", "some.field")
380+
381+
mock_check.assert_called_once_with("0.2.0")
382+
assert result == "hello"
383+
# version_checked flag prevents second check
384+
assert client._version_checked is True

sdk/tests/test_async_watcher.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,30 @@ def test_default_max_queue_size(self):
195195
assert f._max_queue_size == _DEFAULT_MAX_QUEUE_SIZE
196196
assert _DEFAULT_MAX_QUEUE_SIZE == 1024
197197

198+
@pytest.mark.asyncio
199+
async def test_changes_spurious_wake_skipped(self):
200+
f = AsyncWatchedField("x", str, "")
201+
f._load_initial("a")
202+
203+
# Set event without putting anything in the queue → spurious wake
204+
f._queue_event.set()
205+
206+
async def _collect_first():
207+
async for c in f.changes():
208+
return c
209+
210+
task = asyncio.create_task(_collect_first())
211+
# Yield twice so the task can process the spurious wake (clears event, loops back)
212+
await asyncio.sleep(0)
213+
await asyncio.sleep(0)
214+
215+
# Now deliver a real change + stop
216+
change = Change(field_path="x", old_value="a", new_value="b", version=1)
217+
f._update("b", change)
218+
219+
result = await asyncio.wait_for(task, timeout=2.0)
220+
assert result.new_value == "b"
221+
198222
@pytest.mark.asyncio
199223
async def test_changes_iterator_after_overflow(self):
200224
f = AsyncWatchedField("x", str, "", max_queue_size=2)
@@ -410,6 +434,81 @@ async def empty_stream():
410434
_, sub_kwargs = stub.Subscribe.call_args
411435
assert sub_kwargs.get("metadata") == auth_meta
412436

437+
@pytest.mark.asyncio
438+
async def test_processes_stream_response(self):
439+
from opendecree._generated.centralconfig.v1 import types_pb2
440+
441+
w = self._make_watcher()
442+
fee = w.field("fee", float, default=0.0)
443+
444+
response = MagicMock()
445+
response.change.field_path = "fee"
446+
response.change.HasField.side_effect = lambda name: name in ("old_value", "new_value")
447+
response.change.old_value = types_pb2.TypedValue(string_value="0.0")
448+
response.change.new_value = types_pb2.TypedValue(string_value="1.5")
449+
response.change.version = 1
450+
response.change.changed_by = ""
451+
452+
async def stream_with_one_response():
453+
yield response
454+
# Stream ends normally (server closed)
455+
456+
w._stub.Subscribe = MagicMock(return_value=stream_with_one_response())
457+
458+
await w.start()
459+
await asyncio.sleep(0.2)
460+
await w.stop()
461+
462+
assert fee.value == pytest.approx(1.5)
463+
464+
@pytest.mark.asyncio
465+
async def test_stream_loop_returns_when_stopped_during_iteration(self):
466+
from opendecree._generated.centralconfig.v1 import types_pb2
467+
468+
w = self._make_watcher()
469+
w.field("fee", float, default=0.0)
470+
471+
response = MagicMock()
472+
response.change.field_path = "fee"
473+
response.change.HasField.side_effect = lambda name: name in ("old_value", "new_value")
474+
response.change.old_value = types_pb2.TypedValue(string_value="0.0")
475+
response.change.new_value = types_pb2.TypedValue(string_value="1.5")
476+
response.change.version = 1
477+
response.change.changed_by = ""
478+
479+
async def stream_already_stopped():
480+
w._stopped = True # mark stopped before the loop body runs
481+
yield response
482+
483+
w._stub.Subscribe = MagicMock(return_value=stream_already_stopped())
484+
485+
await w.start()
486+
await asyncio.sleep(0.2)
487+
488+
assert w._task is not None
489+
assert w._task.done()
490+
await w.stop()
491+
492+
@pytest.mark.asyncio
493+
async def test_stream_aiorpc_error_while_stopped_exits_cleanly(self):
494+
w = self._make_watcher()
495+
w.field("fee", float, default=0.0)
496+
497+
async def error_after_stop():
498+
w._stopped = True # mark stopped before raising
499+
raise FakeRpcError(grpc.StatusCode.UNAVAILABLE, "gone")
500+
yield # makes it an async generator
501+
502+
w._stub.Subscribe = MagicMock(return_value=error_after_stop())
503+
504+
await w.start()
505+
await asyncio.sleep(0.2)
506+
507+
# Task should have exited cleanly (stopped=True path)
508+
assert w._task is not None
509+
assert w._task.done()
510+
await w.stop()
511+
413512
@pytest.mark.asyncio
414513
async def test_task_name_sanitizes_control_chars(self):
415514
stub = MagicMock()

sdk/tests/test_channel.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,25 @@ def test_keepalive_override(self):
181181
opts = dict(kwargs["options"])
182182
assert opts["grpc.keepalive_time_ms"] == 60000
183183
assert opts["grpc.keepalive_timeout_ms"] == 5000
184+
185+
186+
def test_token_call_credentials_callback_injects_bearer():
187+
from opendecree._channel import _token_call_credentials
188+
189+
captured = []
190+
191+
def fake_metadata_call_creds(fn):
192+
captured.append(fn)
193+
return MagicMock(spec=grpc.CallCredentials)
194+
195+
with patch(
196+
"opendecree._channel.grpc.metadata_call_credentials",
197+
side_effect=fake_metadata_call_creds,
198+
):
199+
_token_call_credentials("secret-tok")
200+
201+
# Invoke the captured inner callback directly
202+
received = []
203+
captured[0](None, lambda meta, err: received.append((meta, err)))
204+
assert received[0][0] == [("authorization", "Bearer secret-tok")]
205+
assert received[0][1] is None

sdk/tests/test_convert.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,26 @@ def test_typed_value_to_string_time():
276276
tv = types_pb2.TypedValue(time_value=t)
277277
result = typed_value_to_string(tv)
278278
assert "2023" in result # RFC 3339 format
279+
280+
281+
def test_typed_value_to_string_unknown_kind():
282+
"""The case _ fallback returns str(val) for unrecognised kind strings."""
283+
from unittest.mock import patch
284+
285+
from opendecree._convert import typed_value_to_string
286+
from opendecree._generated.centralconfig.v1 import types_pb2
287+
288+
class FakeTV:
289+
def WhichOneof(self, name): # noqa: N802
290+
return "exotic_kind"
291+
292+
def __getattr__(self, name):
293+
return "exotic_value"
294+
295+
fake_instance = FakeTV()
296+
297+
# Patch the TypedValue class inside the generated module so isinstance passes.
298+
with patch.object(types_pb2, "TypedValue", FakeTV):
299+
result = typed_value_to_string(fake_instance)
300+
301+
assert result == "exotic_value"

sdk/tests/test_retry.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,55 @@ async def fake_sleep(s: float) -> None:
224224
await async_with_retry(RetryConfig(max_attempts=3, total_timeout=0.1), fn)
225225

226226
assert slept == []
227+
228+
229+
@pytest.mark.asyncio
230+
async def test_async_deadline_already_passed_before_second_attempt():
231+
"""Async loop-top deadline check stops further attempts once budget is exhausted."""
232+
err = FakeRpcError(grpc.StatusCode.UNAVAILABLE)
233+
call_count = 0
234+
235+
async def fn() -> str:
236+
nonlocal call_count
237+
call_count += 1
238+
raise err
239+
240+
slept: list[float] = []
241+
242+
async def fake_sleep(s: float) -> None:
243+
slept.append(s)
244+
245+
with patch("opendecree._retry.asyncio.sleep", side_effect=fake_sleep):
246+
# monotonic: [deadline_start, loop-top-0 ok, remaining ok, loop-top-1 expired]
247+
with patch("opendecree._retry.time.monotonic", side_effect=[0.0, 0.0, 0.05, 0.2]):
248+
with pytest.raises(grpc.aio.AioRpcError):
249+
await async_with_retry(RetryConfig(max_attempts=3, total_timeout=0.1), fn)
250+
251+
assert call_count == 1 # second attempt blocked by loop-top break
252+
253+
254+
@pytest.mark.asyncio
255+
async def test_async_deadline_clips_sleep():
256+
"""Async sleep is clipped to remaining budget so total wall time stays bounded."""
257+
err = FakeRpcError(grpc.StatusCode.UNAVAILABLE)
258+
call_count = 0
259+
260+
async def fn() -> str:
261+
nonlocal call_count
262+
call_count += 1
263+
if call_count == 1:
264+
raise err
265+
return "ok"
266+
267+
slept: list[float] = []
268+
269+
async def fake_sleep(s: float) -> None:
270+
slept.append(s)
271+
272+
with patch("opendecree._retry.asyncio.sleep", side_effect=fake_sleep):
273+
# monotonic: [deadline_start=0.0, loop-top-0=0.0, remaining-check=0.05, loop-top-1=0.05]
274+
with patch("opendecree._retry.time.monotonic", side_effect=[0.0, 0.0, 0.05, 0.05]):
275+
result = await async_with_retry(RetryConfig(max_attempts=3, total_timeout=0.1), fn)
276+
277+
assert result == "ok"
278+
assert slept[0] <= 0.05 + 1e-9 # sleep clipped to remaining budget

0 commit comments

Comments
 (0)