using AssetManager.Data.Repositories; using AssetManager.Infrastructure.Services; using AssetManager.Models.DTOs; using FluentAssertions; using Microsoft.Extensions.Logging; using Moq; using Xunit; namespace AssetManager.Tests.Services; /// /// MarketDataService 单元测试 /// public class MarketDataServiceTests { private readonly Mock _tencentMock; private readonly Mock _tiingoMock; private readonly Mock _yahooService; private readonly Mock _okxMock; private readonly Mock _repoMock; private readonly Mock> _loggerMock; private readonly MarketDataService _service; public MarketDataServiceTests() { _tencentMock = new Mock(); _tiingoMock = new Mock(); _yahooService = new Mock(); _okxMock = new Mock(); _repoMock = new Mock(); _loggerMock = new Mock>(); _service = new MarketDataService( _loggerMock.Object, _tencentMock.Object, _yahooService.Object, _tiingoMock.Object, _okxMock.Object, _repoMock.Object ); } #region GetPriceAsync Tests [Fact] public async Task GetPriceAsync_Crypto_ShouldCallOkxService() { // Arrange var symbol = "BTC-USDT"; var expectedPrice = 50000m; _repoMock.Setup(x => x.GetPriceCacheAsync(symbol, "Crypto")) .ReturnsAsync((AssetManager.Data.MarketPriceCache?)null); _okxMock.Setup(x => x.GetCryptoPriceAsync(symbol)) .ReturnsAsync(new MarketPriceResponse { Symbol = symbol, Price = expectedPrice, PreviousClose = 49000m, AssetType = "Crypto" }); _repoMock.Setup(x => x.SavePriceCacheAsync(It.IsAny())) .ReturnsAsync(true); // Act var result = await _service.GetPriceAsync(symbol, "Crypto"); // Assert result.Should().NotBeNull(); result.Price.Should().Be(expectedPrice); _okxMock.Verify(x => x.GetCryptoPriceAsync(symbol), Times.Once); _tencentMock.Verify(x => x.GetStockPriceAsync(It.IsAny()), Times.Never); } [Fact] public async Task GetPriceAsync_Stock_ShouldCallTencentFirst() { // Arrange var symbol = "AAPL"; var expectedPrice = 150m; _repoMock.Setup(x => x.GetPriceCacheAsync(symbol, "Stock")) .ReturnsAsync((AssetManager.Data.MarketPriceCache?)null); _tencentMock.Setup(x => x.GetStockPriceAsync(symbol)) .ReturnsAsync(new MarketPriceResponse { Symbol = symbol, Price = expectedPrice, PreviousClose = 148m, AssetType = "Stock" }); _repoMock.Setup(x => x.SavePriceCacheAsync(It.IsAny())) .ReturnsAsync(true); // Act var result = await _service.GetPriceAsync(symbol, "Stock"); // Assert result.Should().NotBeNull(); result.Price.Should().Be(expectedPrice); _tencentMock.Verify(x => x.GetStockPriceAsync(symbol), Times.Once); _tiingoMock.Verify(x => x.GetStockPriceAsync(It.IsAny()), Times.Never); } [Fact] public async Task GetPriceAsync_TencentFails_ShouldFallbackToTiingo() { // Arrange var symbol = "AAPL"; _repoMock.Setup(x => x.GetPriceCacheAsync(symbol, "Stock")) .ReturnsAsync((AssetManager.Data.MarketPriceCache?)null); _tencentMock.Setup(x => x.GetStockPriceAsync(symbol)) .ThrowsAsync(new Exception("Tencent API error")); _tiingoMock.Setup(x => x.GetStockPriceAsync(symbol)) .ReturnsAsync(new MarketPriceResponse { Symbol = symbol, Price = 150m, PreviousClose = 148m, AssetType = "Stock" }); _repoMock.Setup(x => x.SavePriceCacheAsync(It.IsAny())) .ReturnsAsync(true); // Act var result = await _service.GetPriceAsync(symbol, "Stock"); // Assert result.Should().NotBeNull(); result.Price.Should().Be(150m); _tencentMock.Verify(x => x.GetStockPriceAsync(symbol), Times.Once); _tiingoMock.Verify(x => x.GetStockPriceAsync(symbol), Times.Once); } [Fact] public async Task GetPriceAsync_CacheHit_ShouldNotCallExternalApi() { // Arrange var symbol = "AAPL"; var cachedPrice = new AssetManager.Data.MarketPriceCache { Symbol = symbol, AssetType = "Stock", Price = 150m, PreviousClose = 148m, ExpiredAt = DateTime.Now.AddMinutes(5) }; _repoMock.Setup(x => x.GetPriceCacheAsync(symbol, "Stock")) .ReturnsAsync(cachedPrice); // Act var result = await _service.GetPriceAsync(symbol, "Stock"); // Assert result.Should().NotBeNull(); result.Price.Should().Be(150m); _tencentMock.Verify(x => x.GetStockPriceAsync(It.IsAny()), Times.Never); _tiingoMock.Verify(x => x.GetStockPriceAsync(It.IsAny()), Times.Never); } #endregion #region GetHistoricalDataAsync Tests [Fact] public async Task GetHistoricalDataAsync_Crypto_ShouldCallOkxService() { // Arrange var symbol = "BTC-USDT"; var timeframe = "1d"; var limit = 30; _repoMock.Setup(x => x.GetKlineCacheAsync(symbol, "Crypto", timeframe, limit)) .ReturnsAsync(new List()); _okxMock.Setup(x => x.GetCryptoHistoricalDataAsync(symbol, timeframe, limit)) .ReturnsAsync(new List { new() { Symbol = symbol, Close = 50000m, Timestamp = DateTime.Today } }); _repoMock.Setup(x => x.SaveKlineCacheBatchAsync(It.IsAny>())) .ReturnsAsync(true); // Act var result = await _service.GetHistoricalDataAsync(symbol, "Crypto", timeframe, limit); // Assert result.Should().NotBeEmpty(); _okxMock.Verify(x => x.GetCryptoHistoricalDataAsync(symbol, timeframe, limit), Times.Once); } [Fact] public async Task GetHistoricalDataAsync_CacheSufficient_ShouldReturnCached() { // Arrange var symbol = "AAPL"; var timeframe = "1d"; var limit = 2; var cachedData = new List { new() { Symbol = symbol, Close = 150m, Timestamp = DateTime.Today.AddDays(-1) }, new() { Symbol = symbol, Close = 152m, Timestamp = DateTime.Today } }; _repoMock.Setup(x => x.GetKlineCacheAsync(symbol, "Stock", timeframe, limit)) .ReturnsAsync(cachedData); // Act var result = await _service.GetHistoricalDataAsync(symbol, "Stock", timeframe, limit); // Assert result.Should().HaveCount(2); _tencentMock.Verify(x => x.GetStockHistoricalDataAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } #endregion }