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
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
6 changes: 3 additions & 3 deletions src/visor/models/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions src/visor/models/listings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
160 changes: 160 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions tests/integration/test_int_dealers.py
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions tests/integration/test_int_errors.py
Original file line number Diff line number Diff line change
@@ -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()
28 changes: 28 additions & 0 deletions tests/integration/test_int_facets.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading