From 4b07d13e656009c44afbbce0b7d23249a47ad56c Mon Sep 17 00:00:00 2001 From: Abhishek Enaguthi Date: Wed, 8 Jul 2026 00:13:00 -0700 Subject: [PATCH] test: expand unit coverage for retries, serializers, and mocked HTTP resources Closes #23 for the non-UID2 SDK surface. UID2 already has 17 tests in test_uid2.py; this adds coverage for retry/backoff utilities, serializer helpers, HTTP client lifecycle, and mocked resource clients with no network. --- tests/unit/test_httpclient.py | 46 +++++++ tests/unit/test_resources_mocked.py | 183 ++++++++++++++++++++++++++++ tests/unit/test_retries.py | 151 +++++++++++++++++++++++ tests/unit/test_serializers.py | 88 +++++++++++++ 4 files changed, 468 insertions(+) create mode 100644 tests/unit/test_httpclient.py create mode 100644 tests/unit/test_resources_mocked.py create mode 100644 tests/unit/test_retries.py create mode 100644 tests/unit/test_serializers.py diff --git a/tests/unit/test_httpclient.py b/tests/unit/test_httpclient.py new file mode 100644 index 0000000..33f4b71 --- /dev/null +++ b/tests/unit/test_httpclient.py @@ -0,0 +1,46 @@ +"""Unit tests for HTTP client lifecycle helpers. No network.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ttd_data.httpclient import close_clients + + +class _Owner: + client = object() + async_client = object() + + +def test_close_clients_clears_owner_references(): + owner = _Owner() + sync = MagicMock() + async_client = MagicMock() + async_client.aclose = AsyncMock() + + close_clients(owner, sync, sync_client_supplied=False, async_client=async_client, async_client_supplied=False) + + assert owner.client is None + assert owner.async_client is None + sync.close.assert_called_once() + + +def test_close_clients_skips_supplied_sync_client(): + owner = _Owner() + sync = MagicMock() + + close_clients(owner, sync, sync_client_supplied=True, async_client=None, async_client_supplied=True) + + sync.close.assert_not_called() + + +def test_close_clients_swallows_sync_close_errors(): + owner = _Owner() + sync = MagicMock() + sync.close.side_effect = RuntimeError("already closed") + + close_clients(owner, sync, sync_client_supplied=False, async_client=None, async_client_supplied=True) + + assert owner.client is None diff --git a/tests/unit/test_resources_mocked.py b/tests/unit/test_resources_mocked.py new file mode 100644 index 0000000..dcabff7 --- /dev/null +++ b/tests/unit/test_resources_mocked.py @@ -0,0 +1,183 @@ +"""Mocked HTTP resource-client tests for BaseDataClient. No network.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +from datetime import datetime, timezone + +import httpx +import pytest + +from ttd_data import models +from ttd_data.sdk import BaseDataClient +from ttd_data.uid2 import UserIdType + + +def _mock_transport( + expected_paths: List[str], + captured: Dict[str, Any], +) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + captured.setdefault("requests", []).append(request) + assert request.url.path in expected_paths + return httpx.Response(200, json={"FailedLines": []}) + + return httpx.MockTransport(handler) + + +def test_advertiser_ingest_posts_json_with_auth_header(): + captured: Dict[str, Any] = {} + client = BaseDataClient( + client=httpx.Client(transport=_mock_transport(["/data/advertiser"], captured)), + server_url="https://api.test", + ) + + response = client.advertiser.ingest_advertiser_data( + ttd_auth="secret-token", + advertiser_id="adv-123", + items=[ + models.AdvertiserDataItem( + tdid="df2df528-e032-4851-b7c6-99287c7d6bcd", + data=[models.AdvertiserData(name="loyalty_members")], + ) + ], + ) + + request = captured["requests"][0] + assert request.method == "POST" + assert request.headers["ttd-auth"] == "secret-token" + body = json.loads(request.content.decode()) + assert body["AdvertiserId"] == "adv-123" + assert response.advertiser_data_server_response is not None + + +def test_third_party_ingest_hits_expected_path(): + captured: Dict[str, Any] = {} + client = BaseDataClient( + client=httpx.Client(transport=_mock_transport(["/data/thirdparty"], captured)), + server_url="https://api.test", + ) + + client.third_party.ingest_third_party_data( + ttd_auth="secret-token", + data_provider_id="provider-9", + items=[ + models.ThirdPartyDataItem( + tdid="df2df528-e032-4851-b7c6-99287c7d6bcd", + data=[models.ThirdPartyData(name="segment_a")], + ) + ], + ) + + request = captured["requests"][0] + assert request.url.path == "/data/thirdparty" + body = json.loads(request.content.decode()) + assert body["DataProviderId"] == "provider-9" + + +def test_offline_conversion_ingest_hits_expected_path(): + captured: Dict[str, Any] = {} + client = BaseDataClient( + client=httpx.Client( + transport=_mock_transport(["/providerapi/offlineconversion"], captured) + ), + server_url="https://api.test", + ) + + client.offline_conversion.ingest_offline_conversion_data( + ttd_auth="secret-token", + data_provider_id="provider-9", + items=[ + models.OfflineConversionDataItem( + tracking_tag_id="tag-1", + timestamp_utc=datetime(2025, 1, 1, tzinfo=timezone.utc), + user_id_array=[ + [UserIdType.TDID, "df2df528-e032-4851-b7c6-99287c7d6bcd"], + ], + ) + ], + ) + + request = captured["requests"][0] + assert request.url.path == "/providerapi/offlineconversion" + + +def test_deletion_opt_out_advertiser_hits_expected_path(): + captured: Dict[str, Any] = {} + client = BaseDataClient( + client=httpx.Client( + transport=_mock_transport(["/data/deletion-optout/advertiser"], captured) + ), + server_url="https://api.test", + ) + + client.deletion_opt_out.data_subject_request_advertiser_data( + ttd_auth="secret-token", + advertiser_id="adv-123", + items=[ + models.PartnerDsrDataItem( + tdid="df2df528-e032-4851-b7c6-99287c7d6bcd", + request_type=models.PartnerDsrRequestType.DELETION, + ) + ], + ) + + request = captured["requests"][0] + assert request.method == "POST" + assert request.url.path == "/data/deletion-optout/advertiser" + + +def test_custom_http_headers_are_forwarded(): + captured: Dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"FailedLines": []}) + + client = BaseDataClient( + client=httpx.Client(transport=httpx.MockTransport(handler)), + server_url="https://api.test", + ) + + client.advertiser.ingest_advertiser_data( + ttd_auth="secret-token", + advertiser_id="adv-123", + http_headers={"X-Trace": "abc"}, + items=[ + models.AdvertiserDataItem( + tdid="df2df528-e032-4851-b7c6-99287c7d6bcd", + data=[models.AdvertiserData(name="loyalty_members")], + ) + ], + ) + + assert captured["request"].headers["x-trace"] == "abc" + + +def test_server_url_override_is_used(): + captured: Dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + return httpx.Response(200, json={"FailedLines": []}) + + client = BaseDataClient( + client=httpx.Client(transport=httpx.MockTransport(handler)), + server_url="https://default.test", + ) + + client.advertiser.ingest_advertiser_data( + ttd_auth="secret-token", + advertiser_id="adv-123", + server_url="https://override.test", + items=[ + models.AdvertiserDataItem( + tdid="df2df528-e032-4851-b7c6-99287c7d6bcd", + data=[models.AdvertiserData(name="loyalty_members")], + ) + ], + ) + + assert captured["url"].startswith("https://override.test/data/advertiser") diff --git a/tests/unit/test_retries.py b/tests/unit/test_retries.py new file mode 100644 index 0000000..292d352 --- /dev/null +++ b/tests/unit/test_retries.py @@ -0,0 +1,151 @@ +"""Unit tests for HTTP retry utilities (`ttd_data.utils.retries`). No network.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import httpx +import pytest + +from ttd_data.utils.retries import ( + BackoffStrategy, + PermanentError, + Retries, + RetryConfig, + TemporaryError, + _parse_retry_after_header, + _parse_retry_after_ms_header, + _get_sleep_interval, + retry, + retry_with_backoff, +) + + +def test_backoff_strategy_rejects_negative_jitter(): + with pytest.raises(ValueError, match="jitter_ms must be >= 0"): + BackoffStrategy(500, 60000, 1.5, 3600000, jitter_ms=-1) + + +def test_backoff_strategy_accepts_zero_jitter(): + strategy = BackoffStrategy(500, 60000, 1.5, 3600000, jitter_ms=0) + assert strategy.jitter_ms == 0 + + +def test_parse_retry_after_header_seconds(): + response = httpx.Response(503, headers={"retry-after": "2.5"}) + assert _parse_retry_after_header(response) == 2500 + + +def test_parse_retry_after_header_missing(): + response = httpx.Response(503) + assert _parse_retry_after_header(response) is None + + +def test_parse_retry_after_header_invalid(): + response = httpx.Response(503, headers={"retry-after": "not-a-date-or-number"}) + assert _parse_retry_after_header(response) is None + + +def test_parse_retry_after_ms_header(): + response = httpx.Response(503, headers={"retry-after-ms": "1500"}) + assert _parse_retry_after_ms_header(response) == 1500 + + +def test_parse_retry_after_ms_header_negative_ignored(): + response = httpx.Response(503, headers={"retry-after-ms": "-1"}) + assert _parse_retry_after_ms_header(response) is None + + +def test_temporary_error_carries_retry_after(): + response = httpx.Response(503, headers={"retry-after": "1"}) + err = TemporaryError(response) + assert err.retry_after == 1000 + + +def test_get_sleep_interval_uses_retry_after_on_temporary_error(): + response = httpx.Response(503, headers={"retry-after": "3"}) + err = TemporaryError(response) + assert _get_sleep_interval(err, 500, 60000, 1.5, 0) == 3.0 + + +def test_get_sleep_interval_exponential_backoff_capped(monkeypatch): + monkeypatch.setattr("ttd_data.utils.retries.random.uniform", lambda _a, _b: 0) + sleep = _get_sleep_interval(RuntimeError("boom"), 1000, 2000, 2.0, 3) + assert sleep == 2.0 + + +def test_retries_uses_status_codes_override(): + config = RetryConfig( + "backoff", + BackoffStrategy(1, 50, 1.1, 100), + retry_connection_errors=False, + status_codes_override=["418"], + ) + retries = Retries(config, ["500"]) + assert retries.status_codes == ["418"] + + +def test_retry_none_strategy_calls_func_once(): + config = RetryConfig("none", BackoffStrategy(1, 50, 1.1, 100), False) + retries = Retries(config, ["503"]) + calls = {"n": 0} + + def func(): + calls["n"] += 1 + return httpx.Response(200) + + result = retry(func, retries) + assert result.status_code == 200 + assert calls["n"] == 1 + + +def test_retry_raises_temporary_error_for_matching_status(monkeypatch): + monkeypatch.setattr("ttd_data.utils.retries.time.sleep", lambda _s: None) + config = RetryConfig("backoff", BackoffStrategy(1, 10, 1.0, 50), False) + retries = Retries(config, ["503"]) + attempts = {"n": 0} + + def func(): + attempts["n"] += 1 + if attempts["n"] == 1: + return httpx.Response(503) + return httpx.Response(200) + + result = retry(func, retries) + assert result.status_code == 200 + assert attempts["n"] == 2 + + +def test_retry_raises_permanent_error_on_connection_failure(): + config = RetryConfig("backoff", BackoffStrategy(1, 10, 1.0, 50), False) + retries = Retries(config, ["503"]) + + def func(): + raise httpx.ConnectError("connection refused") + + with pytest.raises(httpx.ConnectError, match="connection refused"): + retry(func, retries) + + +def test_retry_with_backoff_reraises_permanent_error_inner(): + def func(): + raise PermanentError(ValueError("nope")) + + with pytest.raises(ValueError, match="nope"): + retry_with_backoff(func, max_elapsed_time=100) + + +def test_retry_matches_5xx_wildcard_status_code(monkeypatch): + monkeypatch.setattr("ttd_data.utils.retries.time.sleep", lambda _s: None) + config = RetryConfig("backoff", BackoffStrategy(1, 10, 1.0, 50), False) + retries = Retries(config, ["5XX"]) + attempts = {"n": 0} + + def func(): + attempts["n"] += 1 + if attempts["n"] == 1: + return httpx.Response(502) + return httpx.Response(200) + + result = retry(func, retries) + assert result.status_code == 200 diff --git a/tests/unit/test_serializers.py b/tests/unit/test_serializers.py new file mode 100644 index 0000000..d0cc8e9 --- /dev/null +++ b/tests/unit/test_serializers.py @@ -0,0 +1,88 @@ +"""Unit tests for serializer helpers and datetime parsing. No network.""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from ttd_data.types.basemodel import Unset +from ttd_data.utils.datetimes import parse_datetime +from ttd_data.utils.serializers import ( + serialize_decimal, + serialize_float, + serialize_int, + validate_const, + validate_decimal, + validate_float, + validate_int, +) + + +def test_validate_decimal_from_string(): + assert validate_decimal("12.5") == Decimal("12.5") + + +def test_validate_decimal_passthrough(): + value = Decimal("3.14") + assert validate_decimal(value) is value + + +def test_validate_decimal_rejects_invalid_type(): + with pytest.raises(ValueError, match="Expected string, int or float"): + validate_decimal({"bad": True}) + + +def test_serialize_decimal_as_string(): + assert serialize_decimal(True)(Decimal("1.25")) == "1.25" + + +def test_serialize_decimal_as_float(): + assert serialize_decimal(False)(Decimal("1.25")) == 1.25 + + +def test_serialize_decimal_rejects_non_decimal(): + with pytest.raises(ValueError, match="Expected Decimal"): + serialize_decimal(False)(1.25) + + +def test_validate_int_from_string(): + assert validate_int("42") == 42 + + +def test_serialize_int_as_string(): + assert serialize_int(True)(7) == "7" + + +def test_validate_float_from_string(): + assert validate_float("2.5") == 2.5 + + +def test_serialize_float_as_string(): + assert serialize_float(True)(2.5) == "2.5" + + +def test_validate_const_enforces_value(): + validator = validate_const("fixed") + assert validator("fixed") == "fixed" + with pytest.raises(ValueError, match="Expected fixed"): + validator("other") + + +def test_parse_datetime_rfc3339_with_z_suffix(): + dt = parse_datetime("2025-06-01T12:30:00Z") + assert dt.year == 2025 + assert dt.month == 6 + assert dt.day == 1 + assert dt.hour == 12 + assert dt.minute == 30 + + +def test_parse_datetime_with_offset(): + dt = parse_datetime("2025-06-01T12:30:00+00:00") + assert dt.tzinfo is not None + + +def test_serialize_int_preserves_unset(): + sentinel = Unset() + assert serialize_int(False)(sentinel) is sentinel