diff --git a/src/visor/_transport.py b/src/visor/_transport.py index 20d5828..ce92218 100644 --- a/src/visor/_transport.py +++ b/src/visor/_transport.py @@ -1,5 +1,6 @@ from datetime import datetime from email.utils import parsedate_to_datetime +from typing import Any import httpx @@ -30,9 +31,15 @@ def _parse_retry_after(value: str | None) -> int | None: return max(0, int((retry_at - datetime.now(retry_at.tzinfo)).total_seconds())) -def _handle_response(response: httpx.Response) -> dict: # type: ignore[type-arg] +def _handle_response(response: httpx.Response) -> dict[str, Any]: if response.is_success: - return response.json() # type: ignore[no-any-return] + try: + data: Any = response.json() + except ValueError as e: + raise VisorTransportError(f"Received malformed JSON from API: {e}") from e + if not isinstance(data, dict): + raise VisorTransportError("Received non-object JSON from API") + return data try: body = response.json() @@ -86,7 +93,9 @@ def __init__( timeout=timeout, ) - async def get(self, path: str, params: dict[str, str] | None = None) -> dict: # type: ignore[type-arg] + async def get( + self, path: str, params: dict[str, str] | None = None + ) -> dict[str, Any]: try: response = await self._client.get(path, params=params or {}) except httpx.RequestError as e: @@ -110,7 +119,7 @@ def __init__( timeout=timeout, ) - def get(self, path: str, params: dict[str, str] | None = None) -> dict: # type: ignore[type-arg] + def get(self, path: str, params: dict[str, str] | None = None) -> dict[str, Any]: try: response = self._client.get(path, params=params or {}) except httpx.RequestError as e: diff --git a/src/visor/models/_base.py b/src/visor/models/_base.py index 25c8f12..97ee7bb 100644 --- a/src/visor/models/_base.py +++ b/src/visor/models/_base.py @@ -225,6 +225,9 @@ def _validate_geo_and_inventory(self) -> "ListingsFilterBase": and self.inventory_status != InventoryMode.ACTIVE ): raise ValueError("snapshot_date requires inventory_status='active'") + # NOTE: this branch is unreachable in practice — earlier checks already + # enforce sold_within_days→SOLD and snapshot_date→ACTIVE, making both + # non-None simultaneously impossible. Kept as a logical guard. if self.sold_within_days is not None and self.snapshot_date is not None: raise ValueError( "sold_within_days and snapshot_date are mutually exclusive" diff --git a/tests/test_filter_model.py b/tests/test_filter_model.py index 68bb6f6..6442da8 100644 --- a/tests/test_filter_model.py +++ b/tests/test_filter_model.py @@ -142,6 +142,38 @@ def test_valid_facets_accepted() -> None: assert params["facets"] == "make,model,price" +# --------------------------------------------------------------------------- +# snapshot_date serialization +# --------------------------------------------------------------------------- + + +def test_snapshot_date_serializes() -> None: + f = ListingsFilter(snapshot_date=date(2025, 6, 15)) + assert f.to_params()["snapshot_date"] == "2025-06-15" + + +# --------------------------------------------------------------------------- +# include serialization +# --------------------------------------------------------------------------- + + +def test_include_serializes() -> None: + f = ListingsFilter(include=["price_history", "options"]) + assert f.to_params()["include"] == "price_history,options" + + +# --------------------------------------------------------------------------- +# Empty list filters are omitted from query params +# --------------------------------------------------------------------------- + + +def test_empty_list_omitted_from_params() -> None: + # Empty lists (not None) are falsy and skipped by the comma/pipe helpers, + # so they are omitted from the serialized query string. + f = ListingsFilter(make=[]) + assert "make" not in f.to_params() + + # --------------------------------------------------------------------------- # ListingsFilterBase is accessible directly (used by downstream callers) # --------------------------------------------------------------------------- diff --git a/tests/test_transport.py b/tests/test_transport.py index 7abe671..cceae26 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,3 +1,6 @@ +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime as email_format_datetime + import httpx import pytest import respx @@ -327,3 +330,141 @@ async def test_async_empty_body_fallback_message( await transport.aclose() assert exc_info.value.message + + +# --------------------------------------------------------------------------- +# Malformed JSON on 2xx raises VisorTransportError +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_malformed_success_json_raises_transport_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 200, + content=b"not-valid-json{{{", + headers={"Content-Type": "application/json"}, + ) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorTransportError): + await transport.get("/listings") + await transport.aclose() + + +def test_sync_malformed_success_json_raises_transport_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 200, + content=b"not-valid-json{{{", + headers={"Content-Type": "application/json"}, + ) + ) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorTransportError): + transport.get("/listings") + transport.close() + + +def test_sync_non_object_success_json_raises_transport_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(return_value=httpx.Response(200, json=[1, 2, 3])) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorTransportError): + transport.get("/listings") + transport.close() + + +@pytest.mark.asyncio +async def test_async_non_object_success_json_raises_transport_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(return_value=httpx.Response(200, json=[1, 2, 3])) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorTransportError): + await transport.get("/listings") + await transport.aclose() + + +# --------------------------------------------------------------------------- +# Retry-After: HTTP-date and invalid-value coverage +# --------------------------------------------------------------------------- + + +def _future_http_date(seconds_ahead: int = 120) -> str: + """Return a valid RFC 7231 HTTP-date string for a moment in the future.""" + future = datetime.now(timezone.utc) + timedelta(seconds=seconds_ahead) + return email_format_datetime(future, usegmt=True) + + +def test_sync_rate_limit_retry_after_http_date(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": _future_http_date(120)}, + json=ERROR_BODY, + ) + ) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(RateLimitError) as exc_info: + transport.get("/listings") + transport.close() + + assert isinstance(exc_info.value.retry_after, int) + assert exc_info.value.retry_after >= 0 + + +def test_sync_rate_limit_retry_after_invalid_value(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "not-a-number-or-date"}, + json=ERROR_BODY, + ) + ) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(RateLimitError) as exc_info: + transport.get("/listings") + transport.close() + + assert exc_info.value.retry_after is None + + +@pytest.mark.asyncio +async def test_async_rate_limit_retry_after_http_date(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": _future_http_date(120)}, + json=ERROR_BODY, + ) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(RateLimitError) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert isinstance(exc_info.value.retry_after, int) + assert exc_info.value.retry_after >= 0 + + +@pytest.mark.asyncio +async def test_async_rate_limit_retry_after_invalid_value(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "not-a-number-or-date"}, + json=ERROR_BODY, + ) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(RateLimitError) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert exc_info.value.retry_after is None