Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/visor/_transport.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import datetime
from email.utils import parsedate_to_datetime
from typing import Any

import httpx

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/visor/models/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 32 additions & 0 deletions tests/test_filter_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
141 changes: 141 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading