diff --git a/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart b/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart index cddd3f5b..e23f63e5 100644 --- a/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart @@ -13,12 +13,10 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; // Declaring constants here to make this easier to copy & move around /// The base URL for the Binance API. -List get binanceApiEndpoint => - ['https://api.binance.com/api/v3', 'https://api.binance.us/api/v3']; - -BinanceRepository binanceRepository = BinanceRepository( - binanceProvider: const BinanceProvider(), -); +List get binanceApiEndpoint => [ + 'https://api.binance.com/api/v3', + 'https://api.binance.us/api/v3', +]; /// A repository class for interacting with the Binance API. /// This class provides methods to fetch legacy tickers and OHLC candle data. @@ -27,12 +25,11 @@ class BinanceRepository implements CexRepository { BinanceRepository({ required IBinanceProvider binanceProvider, BackoffStrategy? defaultBackoffStrategy, - }) : _binanceProvider = binanceProvider, - _defaultBackoffStrategy = defaultBackoffStrategy ?? - ExponentialBackoff( - maxDelay: const Duration(seconds: 5), - ), - _idResolutionStrategy = BinanceIdResolutionStrategy(); + }) : _binanceProvider = binanceProvider, + _defaultBackoffStrategy = + defaultBackoffStrategy ?? + ExponentialBackoff(maxDelay: const Duration(seconds: 5)), + _idResolutionStrategy = BinanceIdResolutionStrategy(); final IBinanceProvider _binanceProvider; final BackoffStrategy _defaultBackoffStrategy; @@ -213,11 +210,11 @@ class BinanceRepository implements CexRepository { backoffStrategy: backoffStrategy, ); - final batchResult = - ohlcData.ohlc.fold>({}, (map, ohlc) { - final date = DateTime.fromMillisecondsSinceEpoch( - ohlc.closeTime, - ); + final batchResult = ohlcData.ohlc.fold>({}, ( + map, + ohlc, + ) { + final date = DateTime.fromMillisecondsSinceEpoch(ohlc.closeTime); map[DateTime(date.year, date.month, date.day)] = ohlc.close; return map; }); diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart index dc0d8654..5b1addb0 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart @@ -4,8 +4,55 @@ import 'package:http/http.dart' as http; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/coin_historical_data.dart'; +/// Interface for fetching data from CoinGecko API. +abstract class ICoinGeckoProvider { + Future> fetchCoinList({bool includePlatforms = false}); + + Future> fetchSupportedVsCurrencies(); + + Future> fetchCoinMarketData({ + String vsCurrency = 'usd', + List? ids, + String? category, + String order = 'market_cap_asc', + int perPage = 100, + int page = 1, + bool sparkline = false, + String? priceChangePercentage, + String locale = 'en', + String? precision, + }); + + Future fetchCoinMarketChart({ + required String id, + required String vsCurrency, + required int fromUnixTimestamp, + required int toUnixTimestamp, + String? precision, + }); + + Future fetchCoinOhlc( + String id, + String vsCurrency, + int days, { + int? precision, + }); + + Future fetchCoinHistoricalMarketData({ + required String id, + required DateTime date, + String vsCurrency = 'usd', + bool localization = false, + }); + + Future> fetchCoinPrices( + List coinGeckoIds, { + List vsCurrencies = const ['usd'], + }); +} + /// A class for fetching data from CoinGecko API. -class CoinGeckoCexProvider { +class CoinGeckoCexProvider implements ICoinGeckoProvider { /// Creates a new instance of [CoinGeckoCexProvider]. CoinGeckoCexProvider({ this.baseUrl = 'api.coingecko.com', @@ -45,8 +92,10 @@ class CoinGeckoCexProvider { /// Fetches the list of supported vs currencies. Future> fetchSupportedVsCurrencies() async { - final uri = - Uri.https(baseUrl, '$apiVersion/simple/supported_vs_currencies'); + final uri = Uri.https( + baseUrl, + '$apiVersion/simple/supported_vs_currencies', + ); final response = await http.get(uri); if (response.statusCode == 200) { @@ -96,8 +145,11 @@ class CoinGeckoCexProvider { 'locale': locale, if (precision != null) 'price_change_percentage': precision, }; - final uri = - Uri.https(baseUrl, '$apiVersion/coins/markets', queryParameters); + final uri = Uri.https( + baseUrl, + '$apiVersion/coins/markets', + queryParameters, + ); return http.get(uri).then((http.Response response) { if (response.statusCode == 200) { diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart index 78b2dc5d..f587e08f 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart @@ -23,7 +23,7 @@ class CoinGeckoRepository implements CexRepository { _idResolutionStrategy = CoinGeckoIdResolutionStrategy(); /// The CoinGecko provider to use for fetching data. - final CoinGeckoCexProvider coinGeckoProvider; + final ICoinGeckoProvider coinGeckoProvider; final BackoffStrategy _defaultBackoffStrategy; final IdResolutionStrategy _idResolutionStrategy; diff --git a/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart index e48c7bdf..372f3443 100644 --- a/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart +++ b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart @@ -4,8 +4,13 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:komodo_cex_market_data/src/models/models.dart'; +/// Interface for fetching prices from Komodo API. +abstract class IKomodoPriceProvider { + Future> getKomodoPrices(); +} + /// A class for fetching prices from Komodo API. -class KomodoPriceProvider { +class KomodoPriceProvider implements IKomodoPriceProvider { /// Creates a new instance of [KomodoPriceProvider]. KomodoPriceProvider({ this.mainTickersUrl = @@ -42,8 +47,10 @@ class KomodoPriceProvider { final prices = {}; json.forEach((String priceTicker, dynamic pricesData) { - prices[priceTicker] = - CexPrice.fromJson(priceTicker, pricesData as Map); + prices[priceTicker] = CexPrice.fromJson( + priceTicker, + pricesData as Map, + ); }); return prices; } diff --git a/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart index 09f9222d..203f40a5 100644 --- a/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart @@ -4,15 +4,19 @@ import 'package:komodo_cex_market_data/src/models/models.dart'; import 'package:komodo_cex_market_data/src/repository_selection_strategy.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +/// Interface for Komodo price repository. +abstract class IKomodoPriceRepository implements CexRepository { + Future> getKomodoPrices(); +} + /// A repository for fetching the prices of coins from the Komodo Defi API. class KomodoPriceRepository extends CexRepository { /// Creates a new instance of [KomodoPriceRepository]. - KomodoPriceRepository({ - required KomodoPriceProvider cexPriceProvider, - }) : _cexPriceProvider = cexPriceProvider; + KomodoPriceRepository({required IKomodoPriceProvider cexPriceProvider}) + : _cexPriceProvider = cexPriceProvider; /// The price provider to fetch the prices from. - final KomodoPriceProvider _cexPriceProvider; + final IKomodoPriceProvider _cexPriceProvider; // Supported coins and vs currencies are not expected to change regularly, // so this in-memory cache is acceptable for now until a more complete and @@ -89,9 +93,9 @@ class KomodoPriceRepository extends CexRepository { List timestamps, { String vsCurrency = 'usd', }) async { - return (await _cexPriceProvider.getKomodoPrices()) - .values - .firstWhere((CexPrice element) { + return (await _cexPriceProvider.getKomodoPrices()).values.firstWhere(( + CexPrice element, + ) { if (element.ticker != coinId) { return false; } @@ -104,6 +108,7 @@ class KomodoPriceRepository extends CexRepository { /// Fetches the prices of the provided coin IDs. /// /// Returns a map of coin IDs to their prices. + @override Future> getKomodoPrices() async { return _cexPriceProvider.getKomodoPrices(); } @@ -114,17 +119,18 @@ class KomodoPriceRepository extends CexRepository { return _cachedCoinsList!; } final prices = await getKomodoPrices(); - _cachedCoinsList = prices.values - .map( - (e) => CexCoin( - id: e.ticker, - symbol: e.ticker, - name: e.ticker, - currencies: {'USD', 'USDT'}, - source: 'komodo', - ), - ) - .toList(); + _cachedCoinsList = + prices.values + .map( + (e) => CexCoin( + id: e.ticker, + symbol: e.ticker, + name: e.ticker, + currencies: {'USD', 'USDT'}, + source: 'komodo', + ), + ) + .toList(); _cachedFiatCurrencies = {'USD', 'USDT'}; return _cachedCoinsList!; } @@ -145,4 +151,51 @@ class KomodoPriceRepository extends CexRepository { requestType == PriceRequestType.priceChange; return supportsAsset && supportsFiat && supportsRequestType; } + + @override + Future getCoinOhlc( + CexCoinPair symbol, + GraphInterval interval, { + DateTime? startAt, + DateTime? endAt, + int? limit, + }) { + throw UnimplementedError('KomodoPriceRepository does not support OHLC'); + } + + @override + String resolveTradingSymbol(AssetId assetId) { + return assetId.symbol.configSymbol; + } + + @override + bool canHandleAsset(AssetId assetId) { + final symbol = assetId.symbol.configSymbol.toUpperCase(); + return _cachedCoinsList?.any((c) => c.id.toUpperCase() == symbol) ?? false; + } + + @override + Future getCoinFiatPrice( + AssetId assetId, { + DateTime? priceDate, + String fiatCoinId = 'usdt', + }) async { + final prices = await getKomodoPrices(); + final symbol = assetId.symbol.configSymbol; + final price = prices[symbol]?.price; + if (price == null) { + throw StateError('Price not found for ${assetId.symbol.configSymbol}'); + } + return price; + } + + @override + Future> getCoinFiatPrices( + AssetId assetId, + List dates, { + String fiatCoinId = 'usdt', + }) async { + final price = await getCoinFiatPrice(assetId, fiatCoinId: fiatCoinId); + return {for (final date in dates) date: price}; + } } diff --git a/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart b/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart index f5b85e24..2b0a046c 100644 --- a/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart +++ b/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart @@ -5,16 +5,26 @@ import 'package:komodo_cex_market_data/src/models/cex_coin.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; /// Enum for the type of price request -enum PriceRequestType { - currentPrice, - priceChange, - priceHistory, +enum PriceRequestType { currentPrice, priceChange, priceHistory } + +/// Strategy interface for selecting repositories +abstract class RepositorySelectionStrategy { + Future ensureCacheInitialized(List repositories); + + Future selectRepository({ + required AssetId assetId, + required AssetId fiatAssetId, + required PriceRequestType requestType, + required List availableRepositories, + }); } -/// Strategy for selecting the best repository for a given asset and operation -class RepositorySelectionStrategy { +/// Default strategy for selecting the best repository for a given asset +class DefaultRepositorySelectionStrategy + implements RepositorySelectionStrategy { final Map _supportCache = {}; + @override Future ensureCacheInitialized(List repositories) async { for (final repo in repositories) { if (!_supportCache.containsKey(repo)) { @@ -29,6 +39,7 @@ class RepositorySelectionStrategy { } /// Selects the best repository for a given asset, fiat, and request type + @override Future selectRepository({ required AssetId assetId, required AssetId fiatAssetId, @@ -37,15 +48,17 @@ class RepositorySelectionStrategy { }) async { await ensureCacheInitialized(availableRepositories); final fiatSymbol = fiatAssetId.symbol.configSymbol.toUpperCase(); - final candidates = availableRepositories.where((repo) { - final cache = _supportCache[repo]; - if (cache == null) return false; - final supportsAsset = cache.coins.any( - (c) => c.id.toUpperCase() == assetId.symbol.configSymbol.toUpperCase(), - ); - final supportsFiat = cache.fiatCurrencies.contains(fiatSymbol); - return supportsAsset && supportsFiat; - }).toList(); + final candidates = + availableRepositories.where((repo) { + final cache = _supportCache[repo]; + if (cache == null) return false; + final supportsAsset = cache.coins.any( + (c) => + c.id.toUpperCase() == assetId.symbol.configSymbol.toUpperCase(), + ); + final supportsFiat = cache.fiatCurrencies.contains(fiatSymbol); + return supportsAsset && supportsFiat; + }).toList(); candidates.sort( (a, b) => RepositoryPriorityManager.getPriority(a) .compareTo(RepositoryPriorityManager.getPriority(b)), diff --git a/packages/komodo_cex_market_data/pubspec.yaml b/packages/komodo_cex_market_data/pubspec.yaml index 4e503889..4dadf49a 100644 --- a/packages/komodo_cex_market_data/pubspec.yaml +++ b/packages/komodo_cex_market_data/pubspec.yaml @@ -20,4 +20,5 @@ dependencies: dev_dependencies: flutter_lints: ^6.0.0 # flutter.dev + mocktail: ^1.0.4 test: ^1.25.7 diff --git a/packages/komodo_cex_market_data/test/komodo_price_repository_test.dart b/packages/komodo_cex_market_data/test/komodo_price_repository_test.dart new file mode 100644 index 00000000..a6efaf75 --- /dev/null +++ b/packages/komodo_cex_market_data/test/komodo_price_repository_test.dart @@ -0,0 +1,55 @@ +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockKomodoPriceProvider extends Mock implements IKomodoPriceProvider {} + +void main() { + group('KomodoPriceRepository', () { + late MockKomodoPriceProvider provider; + late KomodoPriceRepository repository; + + setUp(() { + provider = MockKomodoPriceProvider(); + repository = KomodoPriceRepository(cexPriceProvider: provider); + }); + + AssetId asset(String id) => AssetId( + id: id, + name: id, + symbol: AssetSymbol(assetConfigId: id), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + test('supports returns true for supported asset and fiat', () async { + when( + () => provider.getKomodoPrices(), + ).thenAnswer((_) async => {'KMD': CexPrice(ticker: 'KMD', price: 1.0)}); + + final result = await repository.supports( + asset('KMD'), + AssetId.fromFiatTicker('usd'), + PriceRequestType.currentPrice, + ); + + expect(result, isTrue); + }); + + test('supports returns false for unsupported asset', () async { + when( + () => provider.getKomodoPrices(), + ).thenAnswer((_) async => {'BTC': CexPrice(ticker: 'BTC', price: 1.0)}); + + final result = await repository.supports( + asset('KMD'), + AssetId.fromFiatTicker('usd'), + PriceRequestType.currentPrice, + ); + + expect(result, isFalse); + }); + }); +} diff --git a/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart b/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart new file mode 100644 index 00000000..3af64c49 --- /dev/null +++ b/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart @@ -0,0 +1,66 @@ +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockBinanceRepository extends Mock implements BinanceRepository {} + +class MockCoinGeckoRepository extends Mock implements CoinGeckoRepository {} + +void main() { + group('RepositorySelectionStrategy', () { + late RepositorySelectionStrategy strategy; + late MockBinanceRepository binance; + late MockCoinGeckoRepository gecko; + + setUp(() { + strategy = DefaultRepositorySelectionStrategy(); + binance = MockBinanceRepository(); + gecko = MockCoinGeckoRepository(); + }); + + test('selects repository based on priority', () async { + final asset = AssetId( + id: 'BTC', + name: 'BTC', + symbol: AssetSymbol(assetConfigId: 'BTC'), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + final fiat = AssetId.fromFiatTicker('usd'); + + when(() => binance.getCoinList()).thenAnswer( + (_) async => [ + CexCoin( + id: 'BTC', + symbol: 'BTC', + name: 'BTC', + currencies: {'USD'}, + source: 'binance', + ), + ], + ); + when(() => gecko.getCoinList()).thenAnswer( + (_) async => [ + CexCoin( + id: 'BTC', + symbol: 'BTC', + name: 'BTC', + currencies: {'USD'}, + source: 'gecko', + ), + ], + ); + + final repo = await strategy.selectRepository( + assetId: asset, + fiatAssetId: fiat, + requestType: PriceRequestType.currentPrice, + availableRepositories: [gecko, binance], + ); + + expect(repo, equals(binance)); + }); + }); +} diff --git a/packages/komodo_defi_sdk/lib/src/bootstrap.dart b/packages/komodo_defi_sdk/lib/src/bootstrap.dart index 63944b12..b925cfad 100644 --- a/packages/komodo_defi_sdk/lib/src/bootstrap.dart +++ b/packages/komodo_defi_sdk/lib/src/bootstrap.dart @@ -160,32 +160,39 @@ Future bootstrap({ BinanceRepository(binanceProvider: const BinanceProvider()), ); + container.registerSingleton(CoinGeckoCexProvider()); + container.registerSingleton( - CoinGeckoRepository(coinGeckoProvider: CoinGeckoCexProvider()), + CoinGeckoRepository(coinGeckoProvider: container()), + ); + + container.registerSingleton(KomodoPriceProvider()); + + container.registerSingleton( + KomodoPriceRepository(cexPriceProvider: container()), ); - container.registerSingleton(KomodoPriceProvider()); + container.registerSingleton( + DefaultRepositorySelectionStrategy(), + ); container.registerSingletonAsync( () async => MessageSigningManager(await container.getAsync()), dependsOn: [ApiClient], ); - container.registerSingleton( - KomodoPriceRepository(cexPriceProvider: container()), - ); - container.registerSingletonAsync(() async { final manager = CexMarketDataManager( priceRepositories: [ container(), container(), ], - komodoPriceRepository: container(), + komodoPriceRepository: container(), + selectionStrategy: container(), ); await manager.init(); return manager; - }); + }, dependsOn: [RepositorySelectionStrategy]); container.registerSingletonAsync(() async { final client = await container.getAsync(); diff --git a/packages/komodo_defi_sdk/lib/src/market_data/market_data_manager.dart b/packages/komodo_defi_sdk/lib/src/market_data/market_data_manager.dart index 4e772f83..23327165 100644 --- a/packages/komodo_defi_sdk/lib/src/market_data/market_data_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/market_data/market_data_manager.dart @@ -72,7 +72,7 @@ class CexMarketDataManager implements MarketDataManager { /// Creates a new instance of [CexMarketDataManager] CexMarketDataManager({ required List priceRepositories, - required KomodoPriceRepository komodoPriceRepository, + required IKomodoPriceRepository komodoPriceRepository, RepositorySelectionStrategy? selectionStrategy, }) : _priceRepositories = priceRepositories, _komodoPriceRepository = komodoPriceRepository, @@ -103,7 +103,7 @@ class CexMarketDataManager implements MarketDataManager { _knownTickers = UnmodifiableSetView(allTickers); _logger.fine('Initialized known tickers: ${_knownTickers?.length ?? 0}'); // Start cache clearing timer - _cacheTimer = Timer.periodic(_cacheClearInterval, (_) => _clearCaches()); + _cacheTimer = _timerFactory(_cacheClearInterval, _clearCaches); _logger.finer( 'Started cache clearing timer with interval $_cacheClearInterval', ); @@ -112,7 +112,7 @@ class CexMarketDataManager implements MarketDataManager { Set? _knownTickers; final List _priceRepositories; - final KomodoPriceRepository _komodoPriceRepository; + final IKomodoPriceRepository _komodoPriceRepository; final RepositorySelectionStrategy _selectionStrategy; bool _isDisposed = false; diff --git a/packages/komodo_defi_sdk/test/market_data_manager_test.dart b/packages/komodo_defi_sdk/test/market_data_manager_test.dart new file mode 100644 index 00000000..a8ebc459 --- /dev/null +++ b/packages/komodo_defi_sdk/test/market_data_manager_test.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_defi_sdk/src/market_data/market_data_manager.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockKomodoPriceRepository extends Mock + implements IKomodoPriceRepository {} + +class MockCexRepository extends Mock implements CexRepository {} + +void main() { + group('CexMarketDataManager', () { + AssetId asset(String id) => AssetId( + id: id, + name: id, + symbol: AssetSymbol(assetConfigId: id), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + test('prefers KomodoPriceRepository when supported', () async { + final komodo = MockKomodoPriceRepository(); + final fallback = MockCexRepository(); + final manager = CexMarketDataManager( + priceRepositories: [fallback], + komodoPriceRepository: komodo, + selectionStrategy: DefaultRepositorySelectionStrategy(), + timerFactory: (d, cb) => Timer(d, cb), + ); + + when(() => komodo.getCoinList()).thenAnswer( + (_) async => [ + CexCoin( + id: 'BTC', + symbol: 'BTC', + name: 'BTC', + currencies: {'USDT'}, + source: 'komodo', + ), + ], + ); + when( + () => komodo.getCoinFiatPrice( + asset('BTC'), + priceDate: null, + fiatCoinId: 'usdt', + ), + ).thenAnswer((_) async => 2.0); + when(() => fallback.getCoinList()).thenAnswer( + (_) async => [ + CexCoin( + id: 'BTC', + symbol: 'BTC', + name: 'BTC', + currencies: {'USDT'}, + source: 'fallback', + ), + ], + ); + when( + () => fallback.getCoinFiatPrice( + asset('BTC'), + priceDate: null, + fiatCoinId: 'usdt', + ), + ).thenAnswer((_) async => 3.0); + + await manager.init(); + final price = await manager.fiatPrice(asset('BTC')); + expect(price, Decimal.parse('2.0')); + verify( + () => komodo.getCoinFiatPrice( + asset('BTC'), + priceDate: null, + fiatCoinId: 'usdt', + ), + ).called(1); + verifyNever( + () => fallback.getCoinFiatPrice( + asset('BTC'), + priceDate: null, + fiatCoinId: 'usdt', + ), + ); + }); + + test( + 'falls back when Komodo repository unsupported and caches results', + () async { + final komodo = MockKomodoPriceRepository(); + final fallback = MockCexRepository(); + final manager = CexMarketDataManager( + priceRepositories: [fallback], + komodoPriceRepository: komodo, + selectionStrategy: DefaultRepositorySelectionStrategy(), + timerFactory: (d, cb) => Timer(d, cb), + ); + + when(() => komodo.getCoinList()).thenAnswer((_) async => []); + when(() => fallback.getCoinList()).thenAnswer( + (_) async => [ + CexCoin( + id: 'BTC', + symbol: 'BTC', + name: 'BTC', + currencies: {'USDT'}, + source: 'fallback', + ), + ], + ); + when( + () => fallback.getCoinFiatPrice( + asset('BTC'), + priceDate: null, + fiatCoinId: 'usdt', + ), + ).thenAnswer((_) async => 3.0); + + await manager.init(); + final first = await manager.fiatPrice(asset('BTC')); + final second = await manager.fiatPrice(asset('BTC')); + + expect(first, Decimal.parse('3.0')); + expect(second, Decimal.parse('3.0')); + verify( + () => fallback.getCoinFiatPrice( + asset('BTC'), + priceDate: null, + fiatCoinId: 'usdt', + ), + ).called(1); + + await manager.dispose(); + expect(() => manager.priceIfKnown(asset('BTC')), throwsStateError); + }, + ); + }); +}