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
}