diff --git a/pyproject.toml b/pyproject.toml index fea2df1..68fb41f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,3 +78,8 @@ python_version = "3.10" [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +markers = [ + "integration: live API tests, requires VISOR_API_KEY", + "release_gate: stable live tests required before publishing", + "manual: live tests that require special account state or credentials", +] diff --git a/src/visor/models/_base.py b/src/visor/models/_base.py index 97ee7bb..ece1f56 100644 --- a/src/visor/models/_base.py +++ b/src/visor/models/_base.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Any, NamedTuple -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, model_validator class VisorRequestModel(BaseModel): @@ -25,7 +25,7 @@ class VisorResponseModel(BaseModel): class VehicleOption(VisorResponseModel): code: str name: str - msrp: int | None = None + msrp: float | None = None class VehicleBuild(VisorResponseModel): @@ -52,7 +52,7 @@ class VehicleBuild(VisorResponseModel): window_sticker_verified: bool = False base_msrp: int | None = None combined_msrp: int | None = None - options: list[VehicleOption] = Field(default_factory=list) + options: list[VehicleOption] | None = None class VehicleRecord(VisorResponseModel): diff --git a/src/visor/models/listings.py b/src/visor/models/listings.py index 4db8c15..a029876 100644 --- a/src/visor/models/listings.py +++ b/src/visor/models/listings.py @@ -174,7 +174,7 @@ class ListingDetail(VisorResponseModel): last_checked_at: str | None = None dealer: DealerRef vehicle: VehicleRecord - price_history: list[PriceHistoryEntry] = Field(default_factory=list) + price_history: list[PriceHistoryEntry] | None = None class ListingSnapshot(VisorResponseModel): @@ -196,7 +196,7 @@ class ListingSnapshot(VisorResponseModel): sold_date: date | None = None last_checked_at: str | None = None dealer: DealerRef - price_history: list[PriceHistoryEntry] = Field(default_factory=list) + price_history: list[PriceHistoryEntry] | None = None class ListingsPage(VisorResponseModel): diff --git a/tests/conftest.py b/tests/conftest.py index d6a9629..35f6f18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,16 @@ import pytest import respx + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption( + "--run-integration", + action="store_true", + default=False, + help="Run live Visor API integration tests", + ) + + API_BASE = "https://api.visor.vin/v1" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..a800f54 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,160 @@ +import asyncio +import functools +import os +import threading +import time +from typing import Any + +import pytest +import pytest_asyncio + +from visor import AsyncVisorClient, VisorClient +from visor.exceptions import RateLimitError + +# --------------------------------------------------------------------------- +# Live-request throttle +# Applied to the transport layer of every integration client to prevent +# exhausting the per-key rate limit mid-suite. Controlled via +# VISOR_INTEGRATION_MIN_INTERVAL_SECONDS (default 10.5 s). +# --------------------------------------------------------------------------- + +_last_request_time: float = 0.0 +_request_lock = threading.Lock() + + +def _min_interval() -> float: + try: + return float(os.environ.get("VISOR_INTEGRATION_MIN_INTERVAL_SECONDS", "10.5")) + except ValueError: + return 10.5 + + +def _throttle_sync(fn: Any) -> Any: + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + global _last_request_time + interval = _min_interval() + with _request_lock: + now = time.monotonic() + wait = max(0.0, _last_request_time + interval - now) + _last_request_time = now + wait + if wait: + time.sleep(wait) + try: + return fn(*args, **kwargs) + except RateLimitError as exc: + delay = exc.retry_after if exc.retry_after is not None else interval + time.sleep(delay) + with _request_lock: + _last_request_time = time.monotonic() + return fn(*args, **kwargs) + + return wrapper + + +def _throttle_async(fn: Any) -> Any: + @functools.wraps(fn) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + global _last_request_time + interval = _min_interval() + with _request_lock: + now = time.monotonic() + wait = max(0.0, _last_request_time + interval - now) + _last_request_time = now + wait + if wait: + await asyncio.sleep(wait) + try: + return await fn(*args, **kwargs) + except RateLimitError as exc: + delay = exc.retry_after if exc.retry_after is not None else interval + await asyncio.sleep(delay) + with _request_lock: + _last_request_time = time.monotonic() + return await fn(*args, **kwargs) + + return wrapper + + +# --------------------------------------------------------------------------- +# Pytest configuration +# --------------------------------------------------------------------------- + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", "integration: mark test as requiring a live API key" + ) + config.addinivalue_line( + "markers", "release_gate: stable live tests required before publishing" + ) + config.addinivalue_line( + "markers", "manual: live tests that require special credentials/account state" + ) + + +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: + if config.getoption("--run-integration"): + return + skip_integration = pytest.mark.skip( + reason="live integration test; pass --run-integration to run" + ) + for item in items: + if "integration" in item.keywords: + item.add_marker(skip_integration) + + +# --------------------------------------------------------------------------- +# Session-scoped fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def api_key() -> str: + key = os.environ.get("VISOR_API_KEY") + if not key: + pytest.skip("VISOR_API_KEY not set — skipping integration tests") + return key + + +@pytest.fixture(scope="session") +def client(api_key: str) -> VisorClient: + with VisorClient(api_key=api_key) as c: + c._transport.get = _throttle_sync(c._transport.get) # type: ignore[method-assign] + yield c # type: ignore[misc] + + +@pytest_asyncio.fixture +async def async_client(api_key: str) -> AsyncVisorClient: + async with AsyncVisorClient(api_key=api_key) as c: + c._transport.get = _throttle_async(c._transport.get) # type: ignore[method-assign] + yield c # type: ignore[misc] + + +@pytest.fixture(scope="session") +def _sample_listing(client: VisorClient) -> Any: + from visor import ListingsFilter + + page = client.filter_listings(ListingsFilter(limit=1)) + assert page.data, "No listings returned — cannot run integration tests" + return page.data[0] + + +@pytest.fixture(scope="session") +def sample_listing_id(_sample_listing: Any) -> str: + return str(_sample_listing.id) + + +@pytest.fixture(scope="session") +def sample_vin(_sample_listing: Any) -> str: + return str(_sample_listing.vin) + + +@pytest.fixture(scope="session") +def sample_dealer_id(client: VisorClient) -> str: + from visor import DealerFilter + + page = client.search_dealers(DealerFilter(limit=1)) + assert page.data, "No dealers returned — cannot run dealer integration tests" + return page.data[0].dealer_id diff --git a/tests/integration/test_int_dealers.py b/tests/integration/test_int_dealers.py new file mode 100644 index 0000000..a51e2a3 --- /dev/null +++ b/tests/integration/test_int_dealers.py @@ -0,0 +1,36 @@ +import pytest + +from visor import DealerFilter, VisorClient +from visor.models.dealers import DealerDetail, DealersPage +from visor.models.listings import ListingsPage + +pytestmark = [pytest.mark.integration, pytest.mark.release_gate] + + +def test_search_dealers_default(client: VisorClient) -> None: + page = client.search_dealers() + assert isinstance(page, DealersPage) + assert len(page.data) > 0 + assert page.pagination.total > 0 + + +def test_search_dealers_by_state(client: VisorClient) -> None: + page = client.search_dealers(DealerFilter(state=["TX"], limit=5)) + assert len(page.data) > 0 + for dealer in page.data: + assert dealer.state == "TX" + + +def test_get_dealer(client: VisorClient, sample_dealer_id: str) -> None: + dealer = client.get_dealer(sample_dealer_id) + assert isinstance(dealer, DealerDetail) + assert dealer.dealer_id == sample_dealer_id + assert dealer.name is not None + assert dealer.city is not None + assert dealer.state is not None + + +def test_dealer_inventory(client: VisorClient, sample_dealer_id: str) -> None: + page = client.dealer_inventory(sample_dealer_id) + assert isinstance(page, ListingsPage) + assert page.pagination is not None diff --git a/tests/integration/test_int_errors.py b/tests/integration/test_int_errors.py new file mode 100644 index 0000000..50f447b --- /dev/null +++ b/tests/integration/test_int_errors.py @@ -0,0 +1,59 @@ +import pytest + +from visor import ( + AuthError, + ForbiddenError, + NotFoundError, + VisorAPIError, + VisorClient, + VisorTransportError, +) + +pytestmark = pytest.mark.integration + + +@pytest.mark.release_gate +def test_auth_error_bad_key() -> None: + # Live invalid-key behavior varies: the API itself may return 401 AuthError, + # or the Cloudflare WAF layer may intercept first and return 403 ForbiddenError. + # Both are correct SDK behavior; assert the response is one of the two. + with ( + VisorClient(api_key="vsr_live_thisisnotarealkey") as bad_client, + pytest.raises((AuthError, ForbiddenError)) as exc_info, + ): + bad_client.filter_listings() + exc = exc_info.value + assert exc.status_code in (401, 403) + assert exc.message + assert str(exc.status_code) in str(exc) + + +@pytest.mark.release_gate +def test_not_found_listing(client: VisorClient) -> None: + # Live API may return 404 NotFoundError or 503 VisorAPIError (data_unavailable) + # for non-existent listing IDs. Both are acceptable; the SDK maps them correctly. + with pytest.raises(VisorAPIError) as exc_info: + client.get_listing("00000000000000000000000000000000") + exc = exc_info.value + assert exc.status_code in (404, 503) + if exc.status_code == 503: + assert exc.error_code == "data_unavailable" + + +@pytest.mark.release_gate +def test_not_found_dealer(client: VisorClient) -> None: + with pytest.raises(NotFoundError) as exc_info: + client.get_dealer("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status_code == 404 + + +def test_transport_error_unreachable_host() -> None: + with ( + VisorClient( + api_key="test", + base_url="https://this-host-does-not-exist.visor.vin/v1", + timeout=2.0, + ) as bad_client, + pytest.raises(VisorTransportError), + ): + bad_client.filter_listings() diff --git a/tests/integration/test_int_facets.py b/tests/integration/test_int_facets.py new file mode 100644 index 0000000..c1975a7 --- /dev/null +++ b/tests/integration/test_int_facets.py @@ -0,0 +1,28 @@ +import pytest + +from visor import FacetsFilter, VisorClient +from visor.models.facets import FacetsResponse + +pytestmark = [pytest.mark.integration, pytest.mark.release_gate] + + +def test_facets_basic(client: VisorClient) -> None: + result = client.filter_facets(FacetsFilter(facets=["make", "body_type"])) + assert isinstance(result, FacetsResponse) + assert result.data.total > 0 + assert "make" in result.data.facets + assert "body_type" in result.data.facets + assert len(result.data.facets["make"]) > 0 + + +def test_facets_with_range_facet(client: VisorClient) -> None: + result = client.filter_facets(FacetsFilter(facets=["price", "make"])) + assert "price" in result.data.range_facets + assert result.data.range_facets["price"].min >= 0 + assert result.data.range_facets["price"].max > 0 + + +def test_facets_with_filter(client: VisorClient) -> None: + national = client.filter_facets(FacetsFilter(facets=["state"])) + texas_only = client.filter_facets(FacetsFilter(facets=["make"], state=["TX"])) + assert national.data.total > texas_only.data.total diff --git a/tests/integration/test_int_listings.py b/tests/integration/test_int_listings.py new file mode 100644 index 0000000..cac25ea --- /dev/null +++ b/tests/integration/test_int_listings.py @@ -0,0 +1,97 @@ +import pytest + +from visor import ListingsFilter, ValidationError, VisorClient +from visor.models.listings import ListingDetail, ListingsPage + +pytestmark = [pytest.mark.integration, pytest.mark.release_gate] + + +def test_filter_listings_default(client: VisorClient) -> None: + page = client.filter_listings() + assert isinstance(page, ListingsPage) + assert len(page.data) > 0 + assert page.pagination.total > 0 + assert page.pagination.limit == 50 + assert page.pagination.offset == 0 + + +def test_filter_listings_by_make_and_state(client: VisorClient) -> None: + page = client.filter_listings( + ListingsFilter(make=["Toyota"], state=["TX"], limit=5) + ) + assert len(page.data) > 0 + for listing in page.data: + if listing.make: + assert listing.make == "Toyota" + if listing.state: + assert listing.state == "TX" + + +def test_filter_listings_geo_postal_code(client: VisorClient) -> None: + try: + page = client.filter_listings( + ListingsFilter( + postal_code="77001", + radius=50, + limit=5, + fields=["id", "vin", "distance_miles"], + ) + ) + except ValidationError as exc: + pytest.skip( + f"Postal code unresolved by live API ({exc.error_code}) — skipping geo test" + ) + if not page.data: + pytest.skip("No listings near Houston 77001 — cannot validate geo response") + distances = [lst.distance_miles for lst in page.data if lst.distance_miles] + if not distances: + pytest.skip( + "No distance_miles populated in geo response — cannot validate range" + ) + assert all(d <= 50 for d in distances) + + +def test_filter_listings_fields_projection(client: VisorClient) -> None: + page = client.filter_listings( + ListingsFilter(limit=3, fields=["id", "vin", "price", "make", "model"]) + ) + for listing in page.data: + assert listing.id is not None + assert listing.vin is not None + assert listing.stock_number is None + assert listing.vdp_url is None + + +def test_filter_listings_include_price_history(client: VisorClient) -> None: + page = client.filter_listings(ListingsFilter(limit=3, include=["price_history"])) + for listing in page.data: + assert isinstance(listing.price_history, list) + + +def test_filter_listings_include_options(client: VisorClient) -> None: + page = client.filter_listings(ListingsFilter(limit=3, include=["options"])) + for listing in page.data: + assert isinstance(listing.options, list) + + +def test_get_listing(client: VisorClient, sample_listing_id: str) -> None: + listing = client.get_listing(sample_listing_id) + assert isinstance(listing, ListingDetail) + assert listing.id == sample_listing_id + assert listing.vin is not None + assert listing.dealer is not None + assert listing.dealer.dealer_id is not None + assert listing.vehicle is not None + assert listing.vehicle.build.year is not None + + +def test_get_listing_with_price_history( + client: VisorClient, sample_listing_id: str +) -> None: + listing = client.get_listing(sample_listing_id, include=["price_history"]) + assert isinstance(listing.price_history, list) + + +def test_get_listing_with_options(client: VisorClient, sample_listing_id: str) -> None: + listing = client.get_listing(sample_listing_id, include=["options"]) + assert isinstance(listing.vehicle.build.options, list) diff --git a/tests/integration/test_int_pagination.py b/tests/integration/test_int_pagination.py new file mode 100644 index 0000000..8a026f3 --- /dev/null +++ b/tests/integration/test_int_pagination.py @@ -0,0 +1,48 @@ +import pytest + +from visor import ( + AsyncVisorClient, + DealerFilter, + ListingsFilter, + VisorClient, + iter_dealers, + iter_listings, + paginate_listings, +) + +pytestmark = [pytest.mark.integration, pytest.mark.release_gate] + + +def test_iter_listings_two_pages(client: VisorClient) -> None: + one_page = client.filter_listings(ListingsFilter(limit=10)) + if one_page.pagination.next_offset is None: + pytest.skip("Fewer than 10 total results — cannot test pagination") + + two_pages = list(iter_listings(client, ListingsFilter(limit=10), max_pages=2)) + assert 10 < len(two_pages) <= 20 + + +def test_iter_dealers_default(client: VisorClient) -> None: + results = list(iter_dealers(client, DealerFilter(limit=5), max_pages=2)) + assert len(results) > 0 + + +def test_pagination_filter_not_mutated(client: VisorClient) -> None: + f = ListingsFilter(make=["Toyota"], limit=5, offset=0) + original_offset = f.offset + list(iter_listings(client, f, max_pages=2)) + assert f.offset == original_offset + + +@pytest.mark.asyncio +async def test_async_listings_smoke(async_client: AsyncVisorClient) -> None: + from visor.models.listings import ListingSummary + + results = [] + async for listing in paginate_listings( + async_client, ListingsFilter(limit=5), max_pages=1 + ): + results.append(listing) + assert 0 < len(results) <= 5 + for item in results: + assert isinstance(item, ListingSummary) diff --git a/tests/integration/test_int_usage.py b/tests/integration/test_int_usage.py new file mode 100644 index 0000000..4cf1827 --- /dev/null +++ b/tests/integration/test_int_usage.py @@ -0,0 +1,26 @@ +from datetime import date, timedelta + +import pytest + +from visor import VisorClient +from visor.models.usage import UsageSummary + +pytestmark = [pytest.mark.integration, pytest.mark.release_gate] + + +def test_get_usage_default(client: VisorClient) -> None: + result = client.get_usage() + assert isinstance(result, UsageSummary) + assert result.totals.requests >= 0 + assert result.meta.currency == "USD" + assert result.meta.start_date <= result.meta.end_date + + +def test_get_usage_date_range(client: VisorClient) -> None: + end = date.today() + start = end - timedelta(days=7) + result = client.get_usage(start_date=start, end_date=end) + assert result.meta.start_date == start + assert result.meta.end_date == end + for record in result.data: + assert start <= record.date <= end diff --git a/tests/integration/test_int_vins.py b/tests/integration/test_int_vins.py new file mode 100644 index 0000000..7a13f01 --- /dev/null +++ b/tests/integration/test_int_vins.py @@ -0,0 +1,44 @@ +import pytest + +from visor import NotFoundError, VisorClient +from visor.models.vins import VinDetail + +pytestmark = [pytest.mark.integration, pytest.mark.release_gate] + + +def test_lookup_vin_from_search(client: VisorClient, sample_vin: str) -> None: + result = client.lookup_vin(sample_vin) + assert isinstance(result, VinDetail) + assert result.vin == sample_vin + assert result.status in ("active", "missing", "sold") + assert result.build is not None + assert result.build.make is not None + assert result.build.year is not None + + +def test_lookup_vin_with_price_history(client: VisorClient, sample_vin: str) -> None: + result = client.lookup_vin(sample_vin, include=["price_history"]) + assert isinstance(result, VinDetail) + if result.latest_listing: + assert isinstance(result.latest_listing.price_history, list) + + +def test_lookup_vin_with_options(client: VisorClient, sample_vin: str) -> None: + result = client.lookup_vin(sample_vin, include=["options"]) + assert isinstance(result, VinDetail) + assert isinstance(result.build.options, list) + + +def test_lookup_vin_not_found(client: VisorClient) -> None: + from visor import VisorAPIError + + try: + client.lookup_vin("ZZZZZZZZZZZZZZZZZ") + pytest.fail("Expected an exception for an unknown VIN") + except NotFoundError as e: + assert e.status_code == 404 + except VisorAPIError as e: + pytest.skip( + f"Live API returned non-404 error for improbable VIN " + f"(status={e.status_code}, code={e.error_code}) — SDK is fine" + ) diff --git a/tests/test_models.py b/tests/test_models.py index 920fcb5..8d72334 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,6 +8,7 @@ from visor.models._base import ( DealerRef, VehicleBuild, + VehicleOption, VehicleRecord, ) from visor.models.dealers import ( @@ -88,6 +89,14 @@ def test_listing_summary_extra_fields_ignored() -> None: assert ls.id == "x" +def test_vehicle_option_msrp_accepts_float() -> None: + # Live API sends floats with FP noise (e.g. 950.0000000000001); must not reject. + opt = VehicleOption.model_validate( + {"code": "X1", "name": "Sunroof", "msrp": 950.0000000000001} + ) + assert opt.msrp == pytest.approx(950.0) + + # --------------------------------------------------------------------------- # ListingDetail # --------------------------------------------------------------------------- @@ -128,7 +137,7 @@ def test_listing_detail_parses() -> None: assert ld.vehicle.build.combined_msrp == 37500 assert ld.dealer.name == "North Hollywood Toyota" assert ld.photo_urls == [] - assert ld.price_history == [] + assert ld.price_history is None def test_listing_detail_vehicle_uses_vehicle_record() -> None: @@ -145,6 +154,14 @@ def test_listing_detail_vehicle_uses_vehicle_record() -> None: assert isinstance(ld.vehicle.build, VehicleBuild) +def test_vehicle_build_options_null() -> None: + # Live API sends "options": null when include=["options"] is not requested. + build = VehicleBuild.model_validate( + {"year": 2026, "make": "Toyota", "model": "Camry", "options": None} + ) + assert build.options is None + + # --------------------------------------------------------------------------- # ListingsPage # --------------------------------------------------------------------------- @@ -190,6 +207,19 @@ def test_listing_snapshot_parses() -> None: assert isinstance(snap.dealer, DealerRef) +def test_listing_snapshot_price_history_null() -> None: + # Live API sends null for price_history when not requested via include=. + snap = ListingSnapshot.model_validate( + { + "id": "s1", + "inventory_type": "new", + "dealer": DEALER_REF_DATA, + "price_history": None, + } + ) + assert snap.price_history is None + + # --------------------------------------------------------------------------- # VinDetail — latest_listing uses ListingSnapshot # ---------------------------------------------------------------------------