from unittest.mock import AsyncMock import httpx import pytest from pytest_mock.plugin import MockerFixture from lnbits.settings import ExchangeRateProvider, Settings from lnbits.utils.exchange_rates import ( allowed_currencies, apply_trimmed_mean_filter, btc_price, btc_rates, fiat_amount_as_satoshis, get_fiat_rate_and_price_satoshis, get_fiat_rate_satoshis, satoshis_amount_as_fiat, ) class MockResponse: def __init__( self, *, text: str = "", json_data=None, error: Exception | None = None ): self.text = text self._json_data = json_data or {} self._error = error def raise_for_status(self): if self._error: raise self._error def json(self): return self._json_data class MockAsyncClient: def __init__(self, response: MockResponse): self.response = response self.calls: list[tuple[str, int]] = [] async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return False async def get(self, url: str, timeout: int = 3): self.calls.append((url, timeout)) return self.response class TestApplyTrimmedMeanFilter: """Test the trimmed mean filtering function""" def test_trimmed_mean_filter_with_outliers(self): """Test filtering removes outliers that deviate more than threshold""" # Mock rates with one outlier (20% deviation) rates = [ ("Binance", 50000.0), ("Coinbase", 51000.0), ("Kraken", 52000.0), ("Outlier", 60000.0), # 20% higher than others ] result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01) # Should remove the outliers (binance and outlier) assert len(result) == 2 assert ("Outlier", 60000.0) not in result assert ("Binance", 50000.0) not in result assert ("Coinbase", 51000.0) in result assert ("Kraken", 52000.0) in result def test_trimmed_mean_filter_no_outliers(self): """Test filtering keeps all rates when none are outliers""" rates = [ ("Binance", 50000.0), ("Coinbase", 50100.0), ("Kraken", 50200.0), ] result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01) # Should keep all rates assert len(result) == 3 assert result == rates def test_trimmed_mean_filter_insufficient_data(self): """Test filtering returns original data when less than 3 rates""" rates = [ ("Binance", 50000.0), ("Coinbase", 51000.0), ] result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01) # Should return original rates unchanged assert result == rates def test_trimmed_mean_filter_single_rate(self): """Test filtering with single rate""" rates = [("Binance", 50000.0)] result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01) # Should return original rate unchanged assert result == rates def test_trimmed_mean_filter_empty_list(self): """Test filtering with empty list""" rates = [] result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01) # Should return empty list assert result == [] def test_trimmed_mean_filter_too_many_outliers(self): """Test fallback to median when filtering removes too many values""" rates = [ ("Provider1", 50000.0), ("Provider2", 60000.0), # 20% higher ("Provider3", 40000.0), # 20% lower ] result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01) # Should fall back to rate closest to median (Provider1) assert len(result) == 1 assert result[0] == ("Provider1", 50000.0) def test_trimmed_mean_filter_different_thresholds(self): """Test filtering with different threshold percentages""" rates = [ ("Binance", 50000.0), ("Coinbase", 51000.0), ("Kraken", 53000.0), ("Outlier", 55000.0), ] # For the values, the average is 52250 # 1% either side of the average is 51727.50 and 52772.50 # This would result in three rates being removed (Binance, Kraken and Outlier) result_1pct = apply_trimmed_mean_filter(rates, threshold_percentage=0.01) assert len(result_1pct) == 1 assert ("Binance", 50000.0) not in result_1pct assert ("Coinbase", 51000.0) in result_1pct assert ("Kraken", 53000.0) not in result_1pct assert ("Outlier", 55000.0) not in result_1pct # With 5% threshold, should keep just three result_5pct = apply_trimmed_mean_filter(rates, threshold_percentage=0.05) assert len(result_5pct) == 3 assert ("Binance", 50000.0) in result_5pct assert ("Coinbase", 51000.0) in result_5pct assert ("Kraken", 53000.0) in result_5pct assert ("Outlier", 55000.0) not in result_5pct def test_trimmed_mean_filter_edge_case_exact_threshold(self): """Test filtering with rates exactly at the threshold""" rates = [ ("Binance", 50000.0), ("Coinbase", 50500.0), # Exactly 1% higher ] result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01) # Should keep the rate at exactly 1% deviation assert len(result) == 2 assert result == rates def test_allowed_currencies_returns_full_list_by_default(settings: Settings): original_allowed_currencies = settings.lnbits_allowed_currencies try: settings.lnbits_allowed_currencies = [] currencies = allowed_currencies() assert "USD" in currencies assert "EUR" in currencies finally: settings.lnbits_allowed_currencies = original_allowed_currencies def test_allowed_currencies_respects_allow_list(settings: Settings): original_allowed_currencies = settings.lnbits_allowed_currencies try: settings.lnbits_allowed_currencies = ["USD", "EUR"] assert allowed_currencies() == ["EUR", "USD"] finally: settings.lnbits_allowed_currencies = original_allowed_currencies @pytest.mark.anyio async def test_btc_rates_rejects_disallowed_currency(settings: Settings): original_allowed_currencies = settings.lnbits_allowed_currencies try: settings.lnbits_allowed_currencies = ["EUR"] with pytest.raises(ValueError, match="Currency 'usd' not allowed."): await btc_rates("usd") finally: settings.lnbits_allowed_currencies = original_allowed_currencies @pytest.mark.anyio async def test_btc_rates_parses_plain_text_response( settings: Settings, mocker: MockerFixture ): provider = ExchangeRateProvider( name="PlainText", api_url="https://plain.test/{TO}", path="", ) client = MockAsyncClient(MockResponse(text="12,345.67")) mocker.patch.object(settings, "lnbits_allowed_currencies", []) mocker.patch.object(settings, "lnbits_exchange_rate_providers", [provider]) mocker.patch("lnbits.utils.exchange_rates.httpx.AsyncClient", return_value=client) rates = await btc_rates("usd") assert rates == [("PlainText", 12345.67)] assert client.calls == [("https://plain.test/USD", 3)] @pytest.mark.anyio async def test_btc_rates_parses_json_path_response( settings: Settings, mocker: MockerFixture ): provider = ExchangeRateProvider( name="JsonProvider", api_url="https://json.test/{TO}", path="$.data.rates.{TO}", ) client = MockAsyncClient( MockResponse(json_data={"data": {"rates": {"USD": "54321.0"}}}) ) mocker.patch.object(settings, "lnbits_allowed_currencies", []) mocker.patch.object(settings, "lnbits_exchange_rate_providers", [provider]) mocker.patch("lnbits.utils.exchange_rates.httpx.AsyncClient", return_value=client) rates = await btc_rates("usd") assert rates == [("JsonProvider", 54321.0)] assert client.calls == [("https://json.test/USD", 3)] @pytest.mark.anyio async def test_btc_rates_skips_unsupported_and_failing_providers( settings: Settings, mocker: MockerFixture ): unsupported = ExchangeRateProvider( name="Unsupported", api_url="https://unsupported.test/{TO}", path="$.price", exclude_to=["usd"], ) failing = ExchangeRateProvider( name="Failing", api_url="https://failing.test/{TO}", path="$.price", ) client = MockAsyncClient(MockResponse(error=httpx.HTTPError("boom"))) mocker.patch.object(settings, "lnbits_allowed_currencies", []) mocker.patch.object( settings, "lnbits_exchange_rate_providers", [unsupported, failing] ) mocker.patch("lnbits.utils.exchange_rates.httpx.AsyncClient", return_value=client) assert await btc_rates("usd") == [] @pytest.mark.anyio async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture): mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[])) assert await btc_price("usd") == 0.0 mocker.patch( "lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[("Only", 50000.0)]), ) assert await btc_price("usd") == 50000.0 mocker.patch( "lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[("A", 40000.0), ("B", 50000.0)]), ) assert await btc_price("usd") == 45000.0 @pytest.mark.anyio async def test_rate_and_amount_conversion_helpers(mocker: MockerFixture): cache_result = AsyncMock(return_value=50000.0) mocker.patch("lnbits.utils.exchange_rates.cache.save_result", cache_result) rate, price = await get_fiat_rate_and_price_satoshis("usd") assert price == 50000.0 assert rate == 2000.0 cache_result.assert_awaited_once() mocker.patch( "lnbits.utils.exchange_rates.get_fiat_rate_and_price_satoshis", AsyncMock(return_value=(1250.0, 80000.0)), ) assert await get_fiat_rate_satoshis("usd") == 1250.0 mocker.patch( "lnbits.utils.exchange_rates.get_fiat_rate_satoshis", AsyncMock(return_value=100.0), ) assert await fiat_amount_as_satoshis(2.5, "usd") == 250 assert await satoshis_amount_as_fiat(500, "usd") == 5.0 @pytest.mark.anyio async def test_amount_conversion_helpers_raise_when_rate_missing( mocker: MockerFixture, ): mocker.patch( "lnbits.utils.exchange_rates.get_fiat_rate_satoshis", AsyncMock(return_value=0.0), ) with pytest.raises(ValueError, match="Could not get exchange rate for usd."): await fiat_amount_as_satoshis(1, "usd") with pytest.raises(ValueError, match="Could not get exchange rate for usd."): await satoshis_amount_as_fiat(100, "usd")