From 15718ab431b8957dc0a052925042bff740e2acb2 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:38:12 -0700 Subject: [PATCH] feat: community category federation in EbayCategoryCache.refresh() Adds optional community_store param to refresh(). Credentialed instances publish leaf categories to the shared community PostgreSQL after a successful Taxonomy API fetch. Credentialless instances pull from community (requires >= 10 rows) before falling back to the hardcoded bootstrap. Adds 3 new tests (14 total, all passing). --- app/platforms/ebay/categories.py | 44 ++++++++++++++++++++++++++ tests/test_ebay_categories.py | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/app/platforms/ebay/categories.py b/app/platforms/ebay/categories.py index d94e747..94c8eab 100644 --- a/app/platforms/ebay/categories.py +++ b/app/platforms/ebay/categories.py @@ -120,17 +120,50 @@ class EbayCategoryCache: def refresh( self, token_manager: Optional["EbayTokenManager"] = None, + community_store: Optional[object] = 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. + community_store: Optional SnipeCommunityStore instance. + If provided and token_manager is set, publish leaves after a successful + Taxonomy API fetch. + If provided and token_manager is None, fetch from community before + falling back to the hardcoded bootstrap (requires >= 10 rows). Returns: Number of leaf categories stored. """ if token_manager is None: + # Try community store first + if community_store is not None: + try: + community_cats = community_store.fetch_categories() + if len(community_cats) >= 10: + 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 community_cats], + ) + self._conn.commit() + log.info( + "EbayCategoryCache: loaded %d categories from community store.", + len(community_cats), + ) + return len(community_cats) + log.info( + "EbayCategoryCache: community store has %d categories (< 10) — falling back to bootstrap.", + len(community_cats), + ) + except Exception: + log.warning( + "EbayCategoryCache: community store fetch failed — falling back to bootstrap.", + exc_info=True, + ) self._seed_bootstrap() cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories") return cur.fetchone()[0] @@ -173,6 +206,17 @@ class EbayCategoryCache: "EbayCategoryCache: refreshed %d leaf categories from eBay Taxonomy API.", len(leaves), ) + + # Publish to community store if available + if community_store is not None: + try: + community_store.publish_categories(leaves) + except Exception: + log.warning( + "EbayCategoryCache: failed to publish categories to community store.", + exc_info=True, + ) + return len(leaves) except Exception: diff --git a/tests/test_ebay_categories.py b/tests/test_ebay_categories.py index eb899cc..b04a5ee 100644 --- a/tests/test_ebay_categories.py +++ b/tests/test_ebay_categories.py @@ -162,3 +162,57 @@ def test_refresh_api_error_logs_warning(db, caplog): # Falls back to bootstrap on API error assert count >= BOOTSTRAP_MIN + + +def test_refresh_publishes_to_community_when_creds_available(db): + """After a successful Taxonomy API refresh, categories are published to community store.""" + mock_tm = MagicMock() + mock_tm.get_token.return_value = "fake-token" + + id_resp = MagicMock() + id_resp.raise_for_status = MagicMock() + id_resp.json.return_value = {"categoryTreeId": "0"} + + tree_resp = MagicMock() + tree_resp.raise_for_status = MagicMock() + tree_resp.json.return_value = _make_tree_response() + + mock_community = MagicMock() + mock_community.publish_categories.return_value = 2 + + with patch("app.platforms.ebay.categories.requests.get") as mock_get: + mock_get.side_effect = [id_resp, tree_resp] + cache = EbayCategoryCache(db) + cache.refresh(mock_tm, community_store=mock_community) + + mock_community.publish_categories.assert_called_once() + published = mock_community.publish_categories.call_args[0][0] + assert len(published) == 2 + + +def test_refresh_fetches_from_community_when_no_creds(db): + """Without creds, community categories are used when available (>= 10 rows).""" + mock_community = MagicMock() + mock_community.fetch_categories.return_value = [ + (str(i), f"Cat {i}", f"Path > Cat {i}") for i in range(15) + ] + + cache = EbayCategoryCache(db) + count = cache.refresh(token_manager=None, community_store=mock_community) + + assert count == 15 + cur = db.execute("SELECT COUNT(*) FROM ebay_categories") + assert cur.fetchone()[0] == 15 + + +def test_refresh_falls_back_to_bootstrap_when_community_sparse(db): + """Falls back to bootstrap if community returns fewer than 10 rows.""" + mock_community = MagicMock() + mock_community.fetch_categories.return_value = [ + ("1", "Only One", "Path > Only One") + ] + + cache = EbayCategoryCache(db) + count = cache.refresh(token_manager=None, community_store=mock_community) + + assert count >= BOOTSTRAP_MIN