feat: import 6 MCP services + common workspace
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_httpx
|
||||
from mcp_sentiment.fetchers import (
|
||||
fetch_crypto_news,
|
||||
fetch_funding_rates,
|
||||
fetch_social_sentiment,
|
||||
fetch_world_news,
|
||||
)
|
||||
|
||||
# --- CER-017 multi-source news aggregator ---
|
||||
|
||||
_COINDESK_RSS = (
|
||||
'<?xml version="1.0"?><rss><channel>'
|
||||
"<item><title>ETH rally</title><link>https://coindesk.com/eth</link>"
|
||||
"<pubDate>2026-04-19</pubDate></item>"
|
||||
"<item><title>Common headline</title><link>https://coindesk.com/x</link>"
|
||||
"<pubDate>2026-04-18</pubDate></item>"
|
||||
"</channel></rss>"
|
||||
)
|
||||
|
||||
|
||||
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 = """<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Markets rally</title><link>http://example.com/1</link><pubDate>Mon, 15 Jan 2024 10:00:00 +0000</pubDate><description>Stocks up</description></item>
|
||||
</channel></rss>"""
|
||||
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
|
||||
Reference in New Issue
Block a user