feat: add PlatformAdapter base and eBay token manager
This commit is contained in:
parent
675146ff1a
commit
a8eb11dc46
4 changed files with 119 additions and 0 deletions
27
app/platforms/__init__.py
Normal file
27
app/platforms/__init__.py
Normal file
|
|
@ -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."""
|
||||
...
|
||||
0
app/platforms/ebay/__init__.py
Normal file
0
app/platforms/ebay/__init__.py
Normal file
46
app/platforms/ebay/auth.py
Normal file
46
app/platforms/ebay/auth.py
Normal file
|
|
@ -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"]
|
||||
46
tests/platforms/test_ebay_auth.py
Normal file
46
tests/platforms/test_ebay_auth.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in a new issue