From 9a6501fa006abb956d354ceac2764d9ade91900b Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Fri, 12 Jun 2026 18:05:32 -0500 Subject: [PATCH 1/3] Add client and exception test coverage (step 8) 59 new tests across three files. No SDK implementation changes. tests/test_client_listings.py (18 tests): separator quirks (pipe for assembly_location, plus for exclude_assembly_location), geo filters, range filters, multi-value comma serialization, sort and fields projection, sold inventory mode, and include param presence/absence. tests/test_client_dealers.py (13 tests): all DealerFilter params (dealer_id, country, type, q, make, state, limit/offset) and dealer_inventory routing with ListingsFilter pass-through. tests/test_exceptions.py (28 tests): full exception class hierarchy, VisorAPIError attribute storage and str format, RateLimitError retry_after with/without header, VisorTransportError from ConnectError and TimeoutException, error body edge cases. Full suite: 170 passed. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_client_dealers.py | 225 +++++++++++++++++++++ tests/test_client_listings.py | 355 ++++++++++++++++++++++++++++++++++ tests/test_exceptions.py | 231 ++++++++++++++++++++++ 3 files changed, 811 insertions(+) create mode 100644 tests/test_client_dealers.py create mode 100644 tests/test_client_listings.py create mode 100644 tests/test_exceptions.py diff --git a/tests/test_client_dealers.py b/tests/test_client_dealers.py new file mode 100644 index 0000000..e0b47e0 --- /dev/null +++ b/tests/test_client_dealers.py @@ -0,0 +1,225 @@ +"""Tests for AsyncVisorClient dealer endpoints — focused on filter serialization.""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from visor._client import AsyncVisorClient +from visor.models.dealers import DealerFilter +from visor.models.listings import ListingsFilter + +API_BASE = "https://api.visor.vin/v1" + +PAGINATION = {"limit": 50, "offset": 0, "total": 1, "next_offset": None} +LISTING_SUMMARY = {"id": "abc123", "vin": "4T1DAACKXTU765422"} +LISTINGS_PAGE = {"data": [LISTING_SUMMARY], "pagination": PAGINATION, "meta": {}} + +DEALER_SUMMARY = { + "dealer_id": "d1", + "name": "Test Dealer", + "city": "Austin", + "state": "TX", + "country": "US", + "type": "franchise", + "listing_count": 42, +} +DEALERS_PAGE = {"data": [DEALER_SUMMARY], "pagination": PAGINATION, "meta": {}} +DEALER_DETAIL = {**DEALER_SUMMARY, "phone": None, "address": None} + + +# --------------------------------------------------------------------------- +# DealerFilter default +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_search_dealers_default_sends_limit_and_offset() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers").mock( + return_value=httpx.Response(200, json=DEALERS_PAGE) + ) + async with AsyncVisorClient(api_key="test") as client: + await client.search_dealers() + url = str(route.calls[0].request.url) + assert "limit=50" in url + assert "offset=0" in url + + +# --------------------------------------------------------------------------- +# DealerFilter param serialization +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_search_dealers_dealer_id_comma_joined() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers").mock( + return_value=httpx.Response(200, json=DEALERS_PAGE) + ) + f = DealerFilter(dealer_id=["d1", "d2", "d3"]) + async with AsyncVisorClient(api_key="test") as client: + await client.search_dealers(f) + url = str(route.calls[0].request.url) + assert "dealer_id=" in url + assert "d1" in url + assert "d2" in url + assert "d3" in url + + +@pytest.mark.asyncio +async def test_search_dealers_country_filter() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers").mock( + return_value=httpx.Response(200, json=DEALERS_PAGE) + ) + f = DealerFilter(country="CA") + async with AsyncVisorClient(api_key="test") as client: + await client.search_dealers(f) + url = str(route.calls[0].request.url) + assert "country=CA" in url + + +@pytest.mark.asyncio +async def test_search_dealers_type_franchise() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers").mock( + return_value=httpx.Response(200, json=DEALERS_PAGE) + ) + f = DealerFilter(type="franchise") + async with AsyncVisorClient(api_key="test") as client: + await client.search_dealers(f) + url = str(route.calls[0].request.url) + assert "type=franchise" in url + + +@pytest.mark.asyncio +async def test_search_dealers_type_independent() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers").mock( + return_value=httpx.Response(200, json=DEALERS_PAGE) + ) + f = DealerFilter(type="independent") + async with AsyncVisorClient(api_key="test") as client: + await client.search_dealers(f) + url = str(route.calls[0].request.url) + assert "type=independent" in url + + +@pytest.mark.asyncio +async def test_search_dealers_q_text_search() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers").mock( + return_value=httpx.Response(200, json=DEALERS_PAGE) + ) + f = DealerFilter(q="autonation") + async with AsyncVisorClient(api_key="test") as client: + await client.search_dealers(f) + url = str(route.calls[0].request.url) + assert "q=autonation" in url + + +@pytest.mark.asyncio +async def test_search_dealers_make_comma_joined() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers").mock( + return_value=httpx.Response(200, json=DEALERS_PAGE) + ) + f = DealerFilter(make=["Toyota", "Honda"]) + async with AsyncVisorClient(api_key="test") as client: + await client.search_dealers(f) + url = str(route.calls[0].request.url) + assert "make=" in url + assert "Toyota" in url + assert "Honda" in url + + +@pytest.mark.asyncio +async def test_search_dealers_state_comma_joined() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers").mock( + return_value=httpx.Response(200, json=DEALERS_PAGE) + ) + f = DealerFilter(state=["TX", "CA", "NY"]) + async with AsyncVisorClient(api_key="test") as client: + await client.search_dealers(f) + url = str(route.calls[0].request.url) + assert "state=" in url + assert "TX" in url + assert "CA" in url + + +@pytest.mark.asyncio +async def test_search_dealers_pagination_params() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers").mock( + return_value=httpx.Response(200, json=DEALERS_PAGE) + ) + f = DealerFilter(limit=10, offset=20) + async with AsyncVisorClient(api_key="test") as client: + await client.search_dealers(f) + url = str(route.calls[0].request.url) + assert "limit=10" in url + assert "offset=20" in url + + +# --------------------------------------------------------------------------- +# dealer_inventory +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dealer_inventory_default_filter_sends_limit() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers/d1/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + async with AsyncVisorClient(api_key="test") as client: + await client.dealer_inventory("d1") + url = str(route.calls[0].request.url) + assert "limit=50" in url + + +@pytest.mark.asyncio +async def test_dealer_inventory_combined_make_year_price_filter() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers/d1/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(make=["Toyota"], year=[2024, 2025], max_price=45000) + async with AsyncVisorClient(api_key="test") as client: + await client.dealer_inventory("d1", f) + url = str(route.calls[0].request.url) + assert "make=Toyota" in url + assert "year=" in url + assert "max_price=45000" in url + + +@pytest.mark.asyncio +async def test_dealer_inventory_routes_to_correct_dealer() -> None: + """Different dealer IDs produce different request paths.""" + with respx.mock(base_url=API_BASE) as mock: + route_a = mock.get("/dealers/alpha/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + route_b = mock.get("/dealers/beta/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + async with AsyncVisorClient(api_key="test") as client: + await client.dealer_inventory("alpha") + await client.dealer_inventory("beta") + assert route_a.called + assert route_b.called + + +@pytest.mark.asyncio +async def test_dealer_inventory_with_none_uses_default_filter() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/dealers/d1/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + async with AsyncVisorClient(api_key="test") as client: + await client.dealer_inventory("d1", None) + url = str(route.calls[0].request.url) + assert "limit=50" in url diff --git a/tests/test_client_listings.py b/tests/test_client_listings.py new file mode 100644 index 0000000..afd86d3 --- /dev/null +++ b/tests/test_client_listings.py @@ -0,0 +1,355 @@ +"""Tests for AsyncVisorClient listing endpoints — focused on filter serialization.""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from visor._client import AsyncVisorClient +from visor.models._base import BBox, InventoryMode, SortOrder +from visor.models.facets import FacetsFilter +from visor.models.listings import ListingsFilter + +API_BASE = "https://api.visor.vin/v1" + +PAGINATION = {"limit": 50, "offset": 0, "total": 1, "next_offset": None} +LISTING_SUMMARY = {"id": "abc123", "vin": "4T1DAACKXTU765422"} +LISTINGS_PAGE = {"data": [LISTING_SUMMARY], "pagination": PAGINATION, "meta": {}} + +DEALER_REF = {"dealer_id": "d1", "name": "Dealer", "city": "Austin", "state": "TX"} +VEHICLE_BUILD = {"year": 2022, "make": "Toyota", "model": "Camry"} +LISTING_DETAIL = { + "id": "abc123", + "vin": "4T1DAACKXTU765422", + "status": "active", + "inventory_type": "new", + "dealer": DEALER_REF, + "vehicle": {"vin": "4T1DAACKXTU765422", "status": "active", "build": VEHICLE_BUILD}, +} +VIN_DETAIL = { + "vin": "4T1DAACKXTU765422", + "status": "active", + "build": VEHICLE_BUILD, + "latest_listing": None, +} +FACETS_RESPONSE = { + "data": { + "total": 100, + "facets": {"make": [{"value": "Toyota", "count": 50}]}, + "range_facets": {}, + "stats": {}, + }, + "meta": { + "facets": ["make"], + "metric": "count", + "sort": "-count", + "minimum_metric_count": 1, + }, +} + + +# --------------------------------------------------------------------------- +# Separator quirks (critical per CLAUDE.md) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_assembly_location_pipe_separated() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(assembly_location=["US", "MX"]) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "assembly_location=US%7CMX" in url or "assembly_location=US|MX" in url + + +@pytest.mark.asyncio +async def test_exclude_assembly_location_plus_separated() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(exclude_assembly_location=["JP", "DE"]) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert ( + "exclude_assembly_location=JP%2BDE" in url + or "exclude_assembly_location=JP+DE" in url + ) + + +@pytest.mark.asyncio +async def test_normal_list_field_comma_separated() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(drivetrain=["AWD", "FWD"]) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "drivetrain=" in url + assert "AWD" in url + assert "FWD" in url + + +# --------------------------------------------------------------------------- +# Multi-value list serialization +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_multiple_makes_all_present_in_url() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(make=["Toyota", "Honda", "Ford"]) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "make=" in url + assert "Toyota" in url + assert "Honda" in url + assert "Ford" in url + + +@pytest.mark.asyncio +async def test_year_integers_serialized_in_url() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(year=[2023, 2024, 2025]) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "year=" in url + assert "2023" in url + assert "2025" in url + + +# --------------------------------------------------------------------------- +# Geo filters +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_postal_code_and_radius_in_url() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(postal_code="78701", radius=50.0) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "postal_code=78701" in url + assert "radius=50.0" in url + + +@pytest.mark.asyncio +async def test_bbox_serialized_as_csv_west_south_east_north() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(bbox=BBox(west=-97.0, south=30.0, east=-96.0, north=31.0)) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "bbox=" in url + assert "-97.0" in url + assert "30.0" in url + assert "31.0" in url + + +# --------------------------------------------------------------------------- +# Range filters +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_min_max_price_in_url() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(min_price=20000, max_price=50000) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "min_price=20000" in url + assert "max_price=50000" in url + + +@pytest.mark.asyncio +async def test_min_max_mileage_in_url() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(min_mileage=0, max_mileage=30000) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "min_mileage=0" in url + assert "max_mileage=30000" in url + + +# --------------------------------------------------------------------------- +# Sort and fields projection +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_sort_param_serialized_in_url() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(sort=SortOrder.PRICE_DESC) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "sort=" in url + assert "-price" in url + + +@pytest.mark.asyncio +async def test_fields_projection_serialized_in_url() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter(fields=["id", "vin", "price"]) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "fields=" in url + assert "price" in url + + +# --------------------------------------------------------------------------- +# Inventory status / sold filters +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_sold_inventory_with_sold_within_days() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + f = ListingsFilter( + inventory_status=InventoryMode.SOLD, + sold_within_days=30, + ) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(f) + url = str(route.calls[0].request.url) + assert "inventory_status=sold" in url + assert "sold_within_days=30" in url + + +@pytest.mark.asyncio +async def test_active_inventory_status_omitted_from_url() -> None: + """Default inventory_status=active is omitted to keep URLs clean.""" + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=LISTINGS_PAGE) + ) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_listings(ListingsFilter()) + url = str(route.calls[0].request.url) + assert "inventory_status" not in url + + +# --------------------------------------------------------------------------- +# get_listing — include presence/absence +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_listing_no_include_omits_param() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/listings/abc123").mock( + return_value=httpx.Response(200, json={"data": LISTING_DETAIL}) + ) + async with AsyncVisorClient(api_key="test") as client: + await client.get_listing("abc123") + url = str(route.calls[0].request.url) + assert "include" not in url + + +# --------------------------------------------------------------------------- +# lookup_vin — include presence/absence +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_lookup_vin_no_include_omits_param() -> None: + vin = "4T1DAACKXTU765422" + with respx.mock(base_url=API_BASE) as mock: + route = mock.get(f"/vins/{vin}").mock( + return_value=httpx.Response(200, json={"data": VIN_DETAIL}) + ) + async with AsyncVisorClient(api_key="test") as client: + await client.lookup_vin(vin) + url = str(route.calls[0].request.url) + assert "include" not in url + + +# --------------------------------------------------------------------------- +# filter_facets — facets-specific params +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_facets_facet_value_limit_in_url() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/facets").mock( + return_value=httpx.Response(200, json=FACETS_RESPONSE) + ) + f = FacetsFilter(facets=["make"], facet_value_limit=10) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_facets(f) + url = str(route.calls[0].request.url) + assert "facet_value_limit=10" in url + + +@pytest.mark.asyncio +async def test_filter_facets_combined_listing_and_facet_params() -> None: + """FacetsFilter inherits ListingsFilterBase — listing filters pass through.""" + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/facets").mock( + return_value=httpx.Response(200, json=FACETS_RESPONSE) + ) + f = FacetsFilter(facets=["make"], make=["Toyota"], state=["TX"]) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_facets(f) + url = str(route.calls[0].request.url) + assert "facets=" in url + assert "make=Toyota" in url + assert "state=TX" in url + + +@pytest.mark.asyncio +async def test_filter_facets_multiple_facet_names_in_url() -> None: + with respx.mock(base_url=API_BASE) as mock: + route = mock.get("/facets").mock( + return_value=httpx.Response(200, json=FACETS_RESPONSE) + ) + f = FacetsFilter(facets=["make", "model", "year"]) + async with AsyncVisorClient(api_key="test") as client: + await client.filter_facets(f) + url = str(route.calls[0].request.url) + assert "facets=" in url + assert "make" in url + assert "model" in url + assert "year" in url diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..a887fc2 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,231 @@ +"""Tests for the exception hierarchy and dispatch behavior.""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from visor._client import AsyncVisorClient +from visor.exceptions import ( + AuthError, + ForbiddenError, + NotFoundError, + PaymentRequiredError, + RateLimitError, + ValidationError, + VisorAPIError, + VisorError, + VisorTransportError, +) + +API_BASE = "https://api.visor.vin/v1" +LISTINGS_PAGE = { + "data": [], + "pagination": {"limit": 50, "offset": 0, "total": 0, "next_offset": None}, + "meta": {}, +} +ERROR_BODY = {"error": {"code": "some_error", "message": "something went wrong"}} + + +# --------------------------------------------------------------------------- +# Exception hierarchy +# --------------------------------------------------------------------------- + + +def test_visor_api_error_is_visor_error() -> None: + assert issubclass(VisorAPIError, VisorError) + + +def test_auth_error_is_visor_api_error() -> None: + assert issubclass(AuthError, VisorAPIError) + assert issubclass(AuthError, VisorError) + + +def test_forbidden_error_is_visor_api_error() -> None: + assert issubclass(ForbiddenError, VisorAPIError) + + +def test_not_found_error_is_visor_api_error() -> None: + assert issubclass(NotFoundError, VisorAPIError) + + +def test_validation_error_is_visor_api_error() -> None: + assert issubclass(ValidationError, VisorAPIError) + + +def test_payment_required_error_is_visor_api_error() -> None: + assert issubclass(PaymentRequiredError, VisorAPIError) + + +def test_rate_limit_error_is_visor_api_error() -> None: + assert issubclass(RateLimitError, VisorAPIError) + + +def test_transport_error_is_visor_error() -> None: + assert issubclass(VisorTransportError, VisorError) + + +def test_transport_error_is_not_api_error() -> None: + assert not issubclass(VisorTransportError, VisorAPIError) + + +# --------------------------------------------------------------------------- +# VisorAPIError attributes and string format +# --------------------------------------------------------------------------- + + +def test_visor_api_error_attributes_stored() -> None: + exc = VisorAPIError(404, "not_found", "Resource not found") + assert exc.status_code == 404 + assert exc.error_code == "not_found" + assert exc.message == "Resource not found" + + +def test_visor_api_error_str_contains_all_parts() -> None: + exc = VisorAPIError(400, "validation_error", "bad param") + s = str(exc) + assert "400" in s + assert "validation_error" in s + assert "bad param" in s + + +def test_rate_limit_error_retry_after_stored() -> None: + exc = RateLimitError(429, "rate_limited", "slow down", retry_after=60) + assert exc.retry_after == 60 + assert exc.status_code == 429 + assert exc.error_code == "rate_limited" + assert exc.message == "slow down" + + +def test_rate_limit_error_retry_after_defaults_to_none() -> None: + exc = RateLimitError(429, "rate_limited", "slow down") + assert exc.retry_after is None + + +# --------------------------------------------------------------------------- +# Exception dispatch through client — status codes +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,exc_class", + [ + (400, ValidationError), + (401, AuthError), + (402, PaymentRequiredError), + (403, ForbiddenError), + (404, NotFoundError), + (429, RateLimitError), + (500, VisorAPIError), + (503, VisorAPIError), + ], +) +async def test_status_code_dispatches_correct_exception( + status: int, exc_class: type[VisorAPIError] +) -> None: + with respx.mock(base_url=API_BASE) as mock: + mock.get("/listings").mock(return_value=httpx.Response(status, json=ERROR_BODY)) + async with AsyncVisorClient(api_key="test") as client: + with pytest.raises(exc_class): + await client.filter_listings() + + +# --------------------------------------------------------------------------- +# RateLimitError retry_after header parsing +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_rate_limit_retry_after_from_integer_header() -> None: + with respx.mock(base_url=API_BASE) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 429, headers={"Retry-After": "30"}, json=ERROR_BODY + ) + ) + async with AsyncVisorClient(api_key="test") as client: + with pytest.raises(RateLimitError) as exc_info: + await client.filter_listings() + assert exc_info.value.retry_after == 30 + + +@pytest.mark.asyncio +async def test_rate_limit_retry_after_none_when_header_absent() -> None: + with respx.mock(base_url=API_BASE) as mock: + mock.get("/listings").mock(return_value=httpx.Response(429, json=ERROR_BODY)) + async with AsyncVisorClient(api_key="test") as client: + with pytest.raises(RateLimitError) as exc_info: + await client.filter_listings() + assert exc_info.value.retry_after is None + + +# --------------------------------------------------------------------------- +# Network-level failures → VisorTransportError +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_connect_error_raises_transport_error() -> None: + with respx.mock(base_url=API_BASE) as mock: + mock.get("/listings").mock(side_effect=httpx.ConnectError("refused")) + async with AsyncVisorClient(api_key="test") as client: + with pytest.raises(VisorTransportError): + await client.filter_listings() + + +@pytest.mark.asyncio +async def test_timeout_raises_transport_error() -> None: + with respx.mock(base_url=API_BASE) as mock: + mock.get("/listings").mock(side_effect=httpx.TimeoutException("timed out")) + async with AsyncVisorClient(api_key="test") as client: + with pytest.raises(VisorTransportError): + await client.filter_listings() + + +# --------------------------------------------------------------------------- +# Malformed / missing error body edge cases +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_non_json_response_body_falls_back_to_unknown_error() -> None: + with respx.mock(base_url=API_BASE) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 502, + content=b"Bad Gateway", + headers={"Content-Type": "text/html"}, + ) + ) + async with AsyncVisorClient(api_key="test") as client: + with pytest.raises(VisorAPIError) as exc_info: + await client.filter_listings() + assert exc_info.value.error_code == "unknown_error" + assert "Bad Gateway" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_error_body_missing_error_key_falls_back_to_unknown() -> None: + with respx.mock(base_url=API_BASE) as mock: + mock.get("/listings").mock( + return_value=httpx.Response(500, json={"message": "internal error"}) + ) + async with AsyncVisorClient(api_key="test") as client: + with pytest.raises(VisorAPIError) as exc_info: + await client.filter_listings() + assert exc_info.value.error_code == "unknown_error" + + +@pytest.mark.asyncio +async def test_error_body_with_string_error_field_falls_back_to_unknown() -> None: + """{"error": "string"} — error is not a dict, must not crash.""" + with respx.mock(base_url=API_BASE) as mock: + mock.get("/listings").mock( + return_value=httpx.Response(500, json={"error": "something broke"}) + ) + async with AsyncVisorClient(api_key="test") as client: + with pytest.raises(VisorAPIError) as exc_info: + await client.filter_listings() + assert exc_info.value.error_code == "unknown_error" From 800c8d6ff82651d95b125b1c33e141aa34b9922f Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Fri, 12 Jun 2026 21:34:08 -0500 Subject: [PATCH 2/3] Tighten list-serialization and unmapped-status assertions Multi-value list tests now assert the exact comma-joined param value via url.params[field] == a,b,c rather than checking each value individually in the raw URL string. Fixes: test_multiple_makes, test_year_integers, test_filter_facets_multiple_facet_names, test_search_dealers_dealer_id, test_search_dealers_make, test_search_dealers_state. Unmapped HTTP status tests (500, 503) split out of the mapped-subclass parametrize and now assert type(exc_info.value) is VisorAPIError to confirm the base class is raised, not an accidental subclass match. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_client_dealers.py | 19 ++++++------------- tests/test_client_listings.py | 20 ++++++-------------- tests/test_exceptions.py | 15 ++++++++++++--- 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/tests/test_client_dealers.py b/tests/test_client_dealers.py index e0b47e0..f3300f6 100644 --- a/tests/test_client_dealers.py +++ b/tests/test_client_dealers.py @@ -61,11 +61,8 @@ async def test_search_dealers_dealer_id_comma_joined() -> None: f = DealerFilter(dealer_id=["d1", "d2", "d3"]) async with AsyncVisorClient(api_key="test") as client: await client.search_dealers(f) - url = str(route.calls[0].request.url) - assert "dealer_id=" in url - assert "d1" in url - assert "d2" in url - assert "d3" in url + params = route.calls[0].request.url.params + assert params["dealer_id"] == "d1,d2,d3" @pytest.mark.asyncio @@ -129,10 +126,8 @@ async def test_search_dealers_make_comma_joined() -> None: f = DealerFilter(make=["Toyota", "Honda"]) async with AsyncVisorClient(api_key="test") as client: await client.search_dealers(f) - url = str(route.calls[0].request.url) - assert "make=" in url - assert "Toyota" in url - assert "Honda" in url + params = route.calls[0].request.url.params + assert params["make"] == "Toyota,Honda" @pytest.mark.asyncio @@ -144,10 +139,8 @@ async def test_search_dealers_state_comma_joined() -> None: f = DealerFilter(state=["TX", "CA", "NY"]) async with AsyncVisorClient(api_key="test") as client: await client.search_dealers(f) - url = str(route.calls[0].request.url) - assert "state=" in url - assert "TX" in url - assert "CA" in url + params = route.calls[0].request.url.params + assert params["state"] == "TX,CA,NY" @pytest.mark.asyncio diff --git a/tests/test_client_listings.py b/tests/test_client_listings.py index afd86d3..7741d16 100644 --- a/tests/test_client_listings.py +++ b/tests/test_client_listings.py @@ -112,11 +112,8 @@ async def test_multiple_makes_all_present_in_url() -> None: f = ListingsFilter(make=["Toyota", "Honda", "Ford"]) async with AsyncVisorClient(api_key="test") as client: await client.filter_listings(f) - url = str(route.calls[0].request.url) - assert "make=" in url - assert "Toyota" in url - assert "Honda" in url - assert "Ford" in url + params = route.calls[0].request.url.params + assert params["make"] == "Toyota,Honda,Ford" @pytest.mark.asyncio @@ -128,10 +125,8 @@ async def test_year_integers_serialized_in_url() -> None: f = ListingsFilter(year=[2023, 2024, 2025]) async with AsyncVisorClient(api_key="test") as client: await client.filter_listings(f) - url = str(route.calls[0].request.url) - assert "year=" in url - assert "2023" in url - assert "2025" in url + params = route.calls[0].request.url.params + assert params["year"] == "2023,2024,2025" # --------------------------------------------------------------------------- @@ -348,8 +343,5 @@ async def test_filter_facets_multiple_facet_names_in_url() -> None: f = FacetsFilter(facets=["make", "model", "year"]) async with AsyncVisorClient(api_key="test") as client: await client.filter_facets(f) - url = str(route.calls[0].request.url) - assert "facets=" in url - assert "make" in url - assert "model" in url - assert "year" in url + params = route.calls[0].request.url.params + assert params["facets"] == "make,model,year" diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index a887fc2..2de5fde 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -118,11 +118,9 @@ def test_rate_limit_error_retry_after_defaults_to_none() -> None: (403, ForbiddenError), (404, NotFoundError), (429, RateLimitError), - (500, VisorAPIError), - (503, VisorAPIError), ], ) -async def test_status_code_dispatches_correct_exception( +async def test_mapped_status_dispatches_correct_subclass( status: int, exc_class: type[VisorAPIError] ) -> None: with respx.mock(base_url=API_BASE) as mock: @@ -132,6 +130,17 @@ async def test_status_code_dispatches_correct_exception( await client.filter_listings() +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [500, 503]) +async def test_unmapped_status_raises_base_visor_api_error(status: int) -> None: + with respx.mock(base_url=API_BASE) as mock: + mock.get("/listings").mock(return_value=httpx.Response(status, json=ERROR_BODY)) + async with AsyncVisorClient(api_key="test") as client: + with pytest.raises(VisorAPIError) as exc_info: + await client.filter_listings() + assert type(exc_info.value) is VisorAPIError + + # --------------------------------------------------------------------------- # RateLimitError retry_after header parsing # --------------------------------------------------------------------------- From 6402e45594ec5597ddc97ec860d48b9d26138af1 Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Fri, 12 Jun 2026 21:37:04 -0500 Subject: [PATCH 3/3] Rename test to reflect exact param assertion test_multiple_makes_all_present_in_url -> test_multiple_makes_comma_joined_in_param matches what the test now actually checks. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_client_listings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_client_listings.py b/tests/test_client_listings.py index 7741d16..f4ff359 100644 --- a/tests/test_client_listings.py +++ b/tests/test_client_listings.py @@ -104,7 +104,7 @@ async def test_normal_list_field_comma_separated() -> None: @pytest.mark.asyncio -async def test_multiple_makes_all_present_in_url() -> None: +async def test_multiple_makes_comma_joined_in_param() -> None: with respx.mock(base_url=API_BASE) as mock: route = mock.get("/listings").mock( return_value=httpx.Response(200, json=LISTINGS_PAGE)