feat: import 6 MCP services + common workspace

This commit is contained in:
AdrianoDev
2026-04-27 17:34:14 +02:00
parent 9676f22a8e
commit 6fc3d1d94f
67 changed files with 10693 additions and 0 deletions
@@ -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