diff --git a/app/platforms/ebay/categories.py b/app/platforms/ebay/categories.py index 82e8d97..d94e747 100644 --- a/app/platforms/ebay/categories.py +++ b/app/platforms/ebay/categories.py @@ -13,6 +13,8 @@ import sqlite3 from datetime import datetime, timedelta, timezone from typing import Optional +import requests + log = logging.getLogger(__name__) # Bootstrap table — common categories for self-hosters without eBay API credentials. @@ -114,3 +116,95 @@ class EbayCategoryCache: (limit,), ) return [(row[0], row[1]) for row in cur.fetchall()] + + def refresh( + self, + token_manager: Optional["EbayTokenManager"] = None, + ) -> int: + """Fetch the eBay category tree and upsert leaf nodes into SQLite. + + Args: + token_manager: An `EbayTokenManager` instance for the Taxonomy API. + If None, falls back to seeding the hardcoded bootstrap table. + + Returns: + Number of leaf categories stored. + """ + if token_manager is None: + self._seed_bootstrap() + cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories") + return cur.fetchone()[0] + + try: + token = token_manager.get_token() + headers = {"Authorization": f"Bearer {token}"} + + # Step 1: get default tree ID for EBAY_US + id_resp = requests.get( + "https://api.ebay.com/commerce/taxonomy/v1/get_default_category_tree_id", + params={"marketplace_id": "EBAY_US"}, + headers=headers, + timeout=30, + ) + id_resp.raise_for_status() + tree_id = id_resp.json()["categoryTreeId"] + + # Step 2: fetch full tree (large response — may take several seconds) + tree_resp = requests.get( + f"https://api.ebay.com/commerce/taxonomy/v1/category_tree/{tree_id}", + headers=headers, + timeout=120, + ) + tree_resp.raise_for_status() + tree = tree_resp.json() + + leaves: list[tuple[str, str, str]] = [] + _extract_leaves(tree["rootCategoryNode"], path="", leaves=leaves) + + now = datetime.now(timezone.utc).isoformat() + self._conn.executemany( + "INSERT OR REPLACE INTO ebay_categories" + " (category_id, name, full_path, is_leaf, refreshed_at)" + " VALUES (?, ?, ?, 1, ?)", + [(cid, name, path, now) for cid, name, path in leaves], + ) + self._conn.commit() + log.info( + "EbayCategoryCache: refreshed %d leaf categories from eBay Taxonomy API.", + len(leaves), + ) + return len(leaves) + + except Exception: + log.warning( + "EbayCategoryCache: Taxonomy API refresh failed — falling back to bootstrap.", + exc_info=True, + ) + self._seed_bootstrap() + cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories") + return cur.fetchone()[0] + + +def _extract_leaves( + node: dict, + path: str, + leaves: list[tuple[str, str, str]], +) -> None: + """Recursively walk the eBay category tree, collecting leaf node tuples. + + Args: + node: A categoryTreeNode dict from the eBay Taxonomy API response. + path: The ancestor breadcrumb, e.g. "Consumer Electronics > Computers". + leaves: Accumulator list of (category_id, name, full_path) tuples. + """ + cat = node["category"] + cat_id: str = cat["categoryId"] + cat_name: str = cat["categoryName"] + full_path = f"{path} > {cat_name}" if path else cat_name + + if node.get("leafCategoryTreeNode", False): + leaves.append((cat_id, cat_name, full_path)) + return # leaf — no children to recurse into + + for child in node.get("childCategoryTreeNodes", []): + _extract_leaves(child, full_path, leaves) diff --git a/tests/test_ebay_categories.py b/tests/test_ebay_categories.py index 531e70a..eb899cc 100644 --- a/tests/test_ebay_categories.py +++ b/tests/test_ebay_categories.py @@ -4,6 +4,7 @@ from __future__ import annotations import sqlite3 from datetime import datetime, timedelta, timezone from pathlib import Path +from unittest.mock import MagicMock, patch import pytest @@ -87,3 +88,77 @@ def test_get_all_for_prompt_returns_rows(db): assert len(results) > 0 # Each entry is (category_id, full_path) assert all(len(r) == 2 for r in results) + + +def _make_tree_response() -> dict: + """Minimal eBay Taxonomy API tree response with two leaf nodes.""" + return { + "categoryTreeId": "0", + "rootCategoryNode": { + "category": {"categoryId": "6000", "categoryName": "Root"}, + "leafCategoryTreeNode": False, + "childCategoryTreeNodes": [ + { + "category": {"categoryId": "6001", "categoryName": "Electronics"}, + "leafCategoryTreeNode": False, + "childCategoryTreeNodes": [ + { + "category": {"categoryId": "6002", "categoryName": "GPUs"}, + "leafCategoryTreeNode": True, + "childCategoryTreeNodes": [], + }, + { + "category": {"categoryId": "6003", "categoryName": "CPUs"}, + "leafCategoryTreeNode": True, + "childCategoryTreeNodes": [], + }, + ], + } + ], + }, + } + + +def test_refresh_inserts_leaf_nodes(db): + mock_tm = MagicMock() + mock_tm.get_token.return_value = "fake-token" + + tree_resp = MagicMock() + tree_resp.raise_for_status = MagicMock() + tree_resp.json.return_value = _make_tree_response() + + id_resp = MagicMock() + id_resp.raise_for_status = MagicMock() + id_resp.json.return_value = {"categoryTreeId": "0"} + + with patch("app.platforms.ebay.categories.requests.get") as mock_get: + mock_get.side_effect = [id_resp, tree_resp] + cache = EbayCategoryCache(db) + count = cache.refresh(mock_tm) + + assert count == 2 # two leaf nodes in our fake tree + cur = db.execute("SELECT category_id FROM ebay_categories ORDER BY category_id") + ids = {row[0] for row in cur.fetchall()} + assert "6002" in ids + assert "6003" in ids + + +def test_refresh_no_token_manager_seeds_bootstrap(db): + cache = EbayCategoryCache(db) + count = cache.refresh(token_manager=None) + assert count >= BOOTSTRAP_MIN + + +def test_refresh_api_error_logs_warning(db, caplog): + import logging + mock_tm = MagicMock() + mock_tm.get_token.return_value = "fake-token" + + with patch("app.platforms.ebay.categories.requests.get") as mock_get: + mock_get.side_effect = Exception("network error") + cache = EbayCategoryCache(db) + with caplog.at_level(logging.WARNING, logger="app.platforms.ebay.categories"): + count = cache.refresh(mock_tm) + + # Falls back to bootstrap on API error + assert count >= BOOTSTRAP_MIN