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).
This commit is contained in:
pyr0ball 2026-04-14 11:38:12 -07:00
parent 0b8cb63968
commit 15718ab431
2 changed files with 98 additions and 0 deletions

View file

@ -120,17 +120,50 @@ class EbayCategoryCache:
def refresh( def refresh(
self, self,
token_manager: Optional["EbayTokenManager"] = None, token_manager: Optional["EbayTokenManager"] = None,
community_store: Optional[object] = None,
) -> int: ) -> int:
"""Fetch the eBay category tree and upsert leaf nodes into SQLite. """Fetch the eBay category tree and upsert leaf nodes into SQLite.
Args: Args:
token_manager: An `EbayTokenManager` instance for the Taxonomy API. token_manager: An `EbayTokenManager` instance for the Taxonomy API.
If None, falls back to seeding the hardcoded bootstrap table. 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: Returns:
Number of leaf categories stored. Number of leaf categories stored.
""" """
if token_manager is None: 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() self._seed_bootstrap()
cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories") cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories")
return cur.fetchone()[0] return cur.fetchone()[0]
@ -173,6 +206,17 @@ class EbayCategoryCache:
"EbayCategoryCache: refreshed %d leaf categories from eBay Taxonomy API.", "EbayCategoryCache: refreshed %d leaf categories from eBay Taxonomy API.",
len(leaves), 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) return len(leaves)
except Exception: except Exception:

View file

@ -162,3 +162,57 @@ def test_refresh_api_error_logs_warning(db, caplog):
# Falls back to bootstrap on API error # Falls back to bootstrap on API error
assert count >= BOOTSTRAP_MIN 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