from __future__ import annotations import httpx import pytest import pytest_httpx from cerbero_mcp.exchanges.sentiment.fetchers import ( fetch_crypto_news, fetch_funding_rates, fetch_social_sentiment, fetch_world_news, ) # --- CER-017 multi-source news aggregator --- _COINDESK_RSS = ( '' "ETH rallyhttps://coindesk.com/eth" "2026-04-19" "Common headlinehttps://coindesk.com/x" "2026-04-18" "" ) def _mock_three_providers(httpx_mock: pytest_httpx.HTTPXMock, *, cc_items=None, messari_items=None): httpx_mock.add_response(url="https://www.coindesk.com/arc/outboundfeeds/rss/", text=_COINDESK_RSS) httpx_mock.add_response( url="https://min-api.cryptocompare.com/data/v2/news/?lang=EN", json={"Data": cc_items if cc_items is not None else [ {"title": "BTC ATH", "source": "CryptoCompare", "published_on": 1761868800, "url": "https://x/1"}, {"title": "Common headline", "source": "Reuters", "published_on": 1761782400, "url": "https://x/2"}, ]}, ) httpx_mock.add_response( url="https://data.messari.io/api/v1/news", json={"data": messari_items if messari_items is not None else [ {"title": "SOL rally", "author": {"name": "Messari"}, "published_at": "2026-04-19T10:00:00Z", "url": "https://x/3"}, ]}, ) @pytest.mark.asyncio async def test_crypto_news_aggregates_three_sources(httpx_mock: pytest_httpx.HTTPXMock): """CER-017: CoinDesk + CryptoCompare + Messari in parallelo.""" _mock_three_providers(httpx_mock) result = await fetch_crypto_news(limit=20) titles = {h["title"] for h in result["headlines"]} assert "ETH rally" in titles assert "BTC ATH" in titles assert "SOL rally" in titles assert set(result["sources"]) == {"coindesk", "cryptocompare", "messari"} assert result["sources_failed"] == [] @pytest.mark.asyncio async def test_crypto_news_dedup_by_title(httpx_mock: pytest_httpx.HTTPXMock): """CER-017: stesso titolo su 2 provider → 1 sola entry.""" _mock_three_providers(httpx_mock) result = await fetch_crypto_news(limit=20) common_count = sum(1 for h in result["headlines"] if h["title"].lower() == "common headline") assert common_count == 1 assert result["total_before_dedup"] > result["total_after_dedup"] @pytest.mark.asyncio async def test_crypto_news_partial_failure(httpx_mock: pytest_httpx.HTTPXMock): """CER-017: 1 provider 500 → altri proseguono, sources_failed riporta.""" httpx_mock.add_response(url="https://www.coindesk.com/arc/outboundfeeds/rss/", text=_COINDESK_RSS) httpx_mock.add_response( url="https://min-api.cryptocompare.com/data/v2/news/?lang=EN", status_code=500, ) httpx_mock.add_response( url="https://data.messari.io/api/v1/news", json={"data": [{"title": "OK Messari", "author": {"name": "M"}, "published_at": "2026-04-19T10:00:00Z", "url": "https://x"}]}, ) result = await fetch_crypto_news(limit=20) assert "cryptocompare" in result["sources_failed"] assert "coindesk" in result["sources"] assert "messari" in result["sources"] @pytest.mark.asyncio async def test_crypto_news_sorted_desc_by_date(httpx_mock: pytest_httpx.HTTPXMock): """CER-017: ordine published_at DESC.""" _mock_three_providers(httpx_mock) result = await fetch_crypto_news(limit=20) dates = [h.get("published_at") or "" for h in result["headlines"] if h.get("published_at")] assert dates == sorted(dates, reverse=True) @pytest.mark.asyncio async def test_crypto_news_with_cryptopanic_key(httpx_mock: pytest_httpx.HTTPXMock): """CER-017: se api_key presente, include Cryptopanic come 4° source.""" _mock_three_providers(httpx_mock) httpx_mock.add_response( url=httpx.URL("https://cryptopanic.com/api/v1/posts/", params={"auth_token": "k", "public": "true"}), json={"results": [{ "title": "Cryptopanic exclusive", "source": {"title": "CP"}, "published_at": "2026-04-20T00:00:00Z", "url": "https://x/cp", }]}, ) result = await fetch_crypto_news(api_key="k", limit=20) titles = {h["title"] for h in result["headlines"]} assert "Cryptopanic exclusive" in titles assert "cryptopanic" in result["sources"] @pytest.mark.asyncio async def test_crypto_news_placeholder_key_skips_cryptopanic(httpx_mock: pytest_httpx.HTTPXMock): """CER-017: api_key placeholder → no Cryptopanic call.""" _mock_three_providers(httpx_mock) result = await fetch_crypto_news(api_key="placeholder", limit=20) assert "cryptopanic" not in result["sources"] assert "cryptopanic" not in result["sources_failed"] @pytest.mark.asyncio async def test_crypto_news_provider_tracing(httpx_mock: pytest_httpx.HTTPXMock): """CER-017: ogni headline ha campo provider.""" _mock_three_providers(httpx_mock) result = await fetch_crypto_news(limit=20) for h in result["headlines"]: assert h.get("provider") in {"coindesk", "cryptocompare", "messari"} # --- fetch_social_sentiment --- @pytest.mark.asyncio async def test_social_sentiment_happy(httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( url=httpx.URL( "https://api.alternative.me/fng/", params={"limit": "1"}, ), json={"data": [{"value": "72", "value_classification": "Greed"}]}, ) result = await fetch_social_sentiment() assert result["fear_greed_index"] == 72 assert result["fear_greed_label"] == "Greed" assert "social_volume" in result @pytest.mark.asyncio async def test_social_sentiment_empty_data(httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( url=httpx.URL( "https://api.alternative.me/fng/", params={"limit": "1"}, ), json={"data": []}, ) result = await fetch_social_sentiment() assert result["fear_greed_index"] == 0 assert result["fear_greed_label"] == "" @pytest.mark.asyncio async def test_social_sentiment_derives_proxy_from_fng( httpx_mock: pytest_httpx.HTTPXMock, monkeypatch ): """CER-P2-005: senza LUNARCRUSH_API_KEY, twitter/reddit derivano da F&G.""" monkeypatch.delenv("LUNARCRUSH_API_KEY", raising=False) httpx_mock.add_response( url=httpx.URL("https://api.alternative.me/fng/", params={"limit": "1"}), json={"data": [{"value": "25", "value_classification": "Extreme Fear"}]}, ) result = await fetch_social_sentiment() assert result["twitter_sentiment"] == pytest.approx(-0.5) assert result["reddit_sentiment"] == pytest.approx(-0.5) assert result["derived"] is True assert result["source"] == "fear_greed_only" @pytest.mark.asyncio async def test_social_sentiment_uses_lunarcrush_when_key_present( httpx_mock: pytest_httpx.HTTPXMock, monkeypatch ): """CER-P2-005: con LUNARCRUSH_API_KEY, valori reali.""" monkeypatch.setenv("LUNARCRUSH_API_KEY", "test-key") httpx_mock.add_response( url=httpx.URL("https://api.alternative.me/fng/", params={"limit": "1"}), json={"data": [{"value": "50", "value_classification": "Neutral"}]}, ) httpx_mock.add_response( url="https://lunarcrush.com/api4/public/coins/BTC/v1", json={"data": { "sentiment": 80, "galaxy_score": 75, "alt_rank": 3, "social_volume_24h": 12345, "social_dominance": 25.5, }}, ) result = await fetch_social_sentiment("BTC") assert result["twitter_sentiment"] == pytest.approx(0.6) assert result["reddit_sentiment"] == pytest.approx(0.6) assert result["social_volume"] == 12345 assert result["galaxy_score"] == 75 assert result["derived"] is False assert "lunarcrush" in result["source"] @pytest.mark.asyncio async def test_social_sentiment_lunarcrush_failure_fallback_to_proxy( httpx_mock: pytest_httpx.HTTPXMock, monkeypatch ): """CER-P2-005: se LC fallisce, fallback a proxy F&G — no crash.""" monkeypatch.setenv("LUNARCRUSH_API_KEY", "broken-key") httpx_mock.add_response( url=httpx.URL("https://api.alternative.me/fng/", params={"limit": "1"}), json={"data": [{"value": "75", "value_classification": "Greed"}]}, ) httpx_mock.add_response( url="https://lunarcrush.com/api4/public/coins/BTC/v1", status_code=401, json={"error": "unauthorized"}, ) result = await fetch_social_sentiment("BTC") assert result["twitter_sentiment"] == pytest.approx(0.5) assert result["derived"] is True assert result["source"] == "fear_greed_only" # --- fetch_funding_rates --- @pytest.mark.asyncio async def test_funding_rates_all_exchanges(httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( url=httpx.URL( "https://fapi.binance.com/fapi/v1/premiumIndex", params={"symbol": "BTCUSDT"}, ), json={"lastFundingRate": "0.0001", "nextFundingTime": 1700000000000}, ) httpx_mock.add_response( url=httpx.URL( "https://api.bybit.com/v5/market/tickers", params={"category": "linear", "symbol": "BTCUSDT"}, ), json={"result": {"list": [{"fundingRate": "0.0002", "nextFundingTime": "1700000000000"}]}}, ) httpx_mock.add_response( url=httpx.URL( "https://www.okx.com/api/v5/public/funding-rate", params={"instId": "BTC-USDT-SWAP"}, ), json={"data": [{"fundingRate": "0.00015", "nextFundingTime": "1700000000000"}]}, ) result = await fetch_funding_rates() assert "rates" in result exchanges = {r["exchange"] for r in result["rates"]} assert "binance" in exchanges assert "bybit" in exchanges assert "okx" in exchanges @pytest.mark.asyncio async def test_funding_rates_partial_failure(httpx_mock: pytest_httpx.HTTPXMock): """If some exchanges fail, we still get results from others.""" httpx_mock.add_response( url=httpx.URL( "https://fapi.binance.com/fapi/v1/premiumIndex", params={"symbol": "BTCUSDT"}, ), json={"lastFundingRate": "0.0001", "nextFundingTime": 1700000000000}, ) httpx_mock.add_response( url=httpx.URL( "https://api.bybit.com/v5/market/tickers", params={"category": "linear", "symbol": "BTCUSDT"}, ), status_code=500, ) httpx_mock.add_response( url=httpx.URL( "https://www.okx.com/api/v5/public/funding-rate", params={"instId": "BTC-USDT-SWAP"}, ), status_code=500, ) result = await fetch_funding_rates() assert len(result["rates"]) == 1 assert result["rates"][0]["exchange"] == "binance" # --- fetch_world_news --- @pytest.mark.asyncio async def test_world_news_happy(httpx_mock: pytest_httpx.HTTPXMock): rss_xml = """ Markets rallyhttp://example.com/1Mon, 15 Jan 2024 10:00:00 +0000Stocks up """ for _, url in [ ("Reuters Business", "https://feeds.reuters.com/reuters/businessNews"), ("CNBC Top News", "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114"), ("Bloomberg Markets", "https://feeds.bloomberg.com/markets/news.rss"), ("CoinDesk", "https://www.coindesk.com/arc/outboundfeeds/rss/"), ]: httpx_mock.add_response(url=url, text=rss_xml) result = await fetch_world_news() assert result["count"] == 4 assert result["articles"][0]["title"] == "Markets rally" @pytest.mark.asyncio async def test_world_news_all_fail(httpx_mock: pytest_httpx.HTTPXMock): for _, url in [ ("Reuters Business", "https://feeds.reuters.com/reuters/businessNews"), ("CNBC Top News", "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114"), ("Bloomberg Markets", "https://feeds.bloomberg.com/markets/news.rss"), ("CoinDesk", "https://www.coindesk.com/arc/outboundfeeds/rss/"), ]: httpx_mock.add_response(url=url, status_code=503) result = await fetch_world_news() assert result["articles"] == [] assert result["count"] == 0