From a8eb11dc46160ade16d11d92098221e568bca475 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 25 Mar 2026 12:53:03 -0700 Subject: [PATCH] feat: add PlatformAdapter base and eBay token manager --- app/platforms/__init__.py | 27 ++++++++++++++++++ app/platforms/ebay/__init__.py | 0 app/platforms/ebay/auth.py | 46 +++++++++++++++++++++++++++++++ tests/platforms/test_ebay_auth.py | 46 +++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 app/platforms/__init__.py create mode 100644 app/platforms/ebay/__init__.py create mode 100644 app/platforms/ebay/auth.py create mode 100644 tests/platforms/test_ebay_auth.py diff --git a/app/platforms/__init__.py b/app/platforms/__init__.py new file mode 100644 index 0000000..da6c94c --- /dev/null +++ b/app/platforms/__init__.py @@ -0,0 +1,27 @@ +"""PlatformAdapter abstract base and shared types.""" +from __future__ import annotations +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Optional +from app.db.models import Listing, Seller + + +@dataclass +class SearchFilters: + max_price: Optional[float] = None + min_price: Optional[float] = None + condition: Optional[list[str]] = field(default_factory=list) + location_radius_km: Optional[int] = None + + +class PlatformAdapter(ABC): + @abstractmethod + def search(self, query: str, filters: SearchFilters) -> list[Listing]: ... + + @abstractmethod + def get_seller(self, seller_platform_id: str) -> Optional[Seller]: ... + + @abstractmethod + def get_completed_sales(self, query: str) -> list[Listing]: + """Fetch recently completed/sold listings for price comp data.""" + ... diff --git a/app/platforms/ebay/__init__.py b/app/platforms/ebay/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/platforms/ebay/auth.py b/app/platforms/ebay/auth.py new file mode 100644 index 0000000..1a4df4b --- /dev/null +++ b/app/platforms/ebay/auth.py @@ -0,0 +1,46 @@ +"""eBay OAuth2 client credentials token manager.""" +from __future__ import annotations +import base64 +import time +from typing import Optional +import requests + +EBAY_OAUTH_URLS = { + "production": "https://api.ebay.com/identity/v1/oauth2/token", + "sandbox": "https://api.sandbox.ebay.com/identity/v1/oauth2/token", +} + + +class EbayTokenManager: + """Fetches and caches eBay app-level OAuth tokens. Thread-safe for single process.""" + + def __init__(self, client_id: str, client_secret: str, env: str = "production"): + self._client_id = client_id + self._client_secret = client_secret + self._token_url = EBAY_OAUTH_URLS[env] + self._token: Optional[str] = None + self._expires_at: float = 0.0 + + def get_token(self) -> str: + """Return a valid access token, fetching or refreshing as needed.""" + if self._token and time.time() < self._expires_at - 60: + return self._token + self._fetch_token() + return self._token # type: ignore[return-value] + + def _fetch_token(self) -> None: + credentials = base64.b64encode( + f"{self._client_id}:{self._client_secret}".encode() + ).decode() + resp = requests.post( + self._token_url, + headers={ + "Authorization": f"Basic {credentials}", + "Content-Type": "application/x-www-form-urlencoded", + }, + data={"grant_type": "client_credentials", "scope": "https://api.ebay.com/oauth/api_scope"}, + ) + resp.raise_for_status() + data = resp.json() + self._token = data["access_token"] + self._expires_at = time.time() + data["expires_in"] diff --git a/tests/platforms/test_ebay_auth.py b/tests/platforms/test_ebay_auth.py new file mode 100644 index 0000000..e16b517 --- /dev/null +++ b/tests/platforms/test_ebay_auth.py @@ -0,0 +1,46 @@ +import time +import requests +from unittest.mock import patch, MagicMock +import pytest +from app.platforms.ebay.auth import EbayTokenManager + + +def test_fetches_token_on_first_call(): + manager = EbayTokenManager(client_id="id", client_secret="secret", env="sandbox") + mock_resp = MagicMock() + mock_resp.json.return_value = {"access_token": "tok123", "expires_in": 7200} + mock_resp.raise_for_status = MagicMock() + with patch("app.platforms.ebay.auth.requests.post", return_value=mock_resp) as mock_post: + token = manager.get_token() + assert token == "tok123" + assert mock_post.called + + +def test_returns_cached_token_before_expiry(): + manager = EbayTokenManager(client_id="id", client_secret="secret", env="sandbox") + manager._token = "cached" + manager._expires_at = time.time() + 3600 + with patch("app.platforms.ebay.auth.requests.post") as mock_post: + token = manager.get_token() + assert token == "cached" + assert not mock_post.called + + +def test_refreshes_token_after_expiry(): + manager = EbayTokenManager(client_id="id", client_secret="secret", env="sandbox") + manager._token = "old" + manager._expires_at = time.time() - 1 # expired + mock_resp = MagicMock() + mock_resp.json.return_value = {"access_token": "new_tok", "expires_in": 7200} + mock_resp.raise_for_status = MagicMock() + with patch("app.platforms.ebay.auth.requests.post", return_value=mock_resp): + token = manager.get_token() + assert token == "new_tok" + + +def test_token_fetch_failure_raises(): + """Spec requires: on token fetch failure, raise immediately — no silent fallback.""" + manager = EbayTokenManager(client_id="id", client_secret="secret", env="sandbox") + with patch("app.platforms.ebay.auth.requests.post", side_effect=requests.RequestException("network error")): + with pytest.raises(requests.RequestException): + manager.get_token()