P0 - 安全修复: - 移除硬编码 API Key,启动时校验必填环境变量 P1 - 高优先级: - Entity 拆分:Position.cs, Transaction.cs 独立文件 - Controller Facade 封装:IPortfolioFacade 减少依赖注入 P2 - 中优先级: - Repository 抽象:IPortfolioRepository, IMarketDataRepository - MarketDataService 拆分:组合模式整合 Tencent/Tiingo/OKX P3 - 低优先级: - DTO 命名规范:统一 PascalCase - 单元测试框架:xUnit + Moq + FluentAssertions
225 lines
7.3 KiB
C#
225 lines
7.3 KiB
C#
using AssetManager.Data.Repositories;
|
|
using AssetManager.Infrastructure.Services;
|
|
using AssetManager.Models.DTOs;
|
|
using FluentAssertions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace AssetManager.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// MarketDataService 单元测试
|
|
/// </summary>
|
|
public class MarketDataServiceTests
|
|
{
|
|
private readonly Mock<ITencentMarketService> _tencentMock;
|
|
private readonly Mock<ITiingoMarketService> _tiingoMock;
|
|
private readonly Mock<IOkxMarketService> _okxMock;
|
|
private readonly Mock<IMarketDataRepository> _repoMock;
|
|
private readonly Mock<ILogger<MarketDataService>> _loggerMock;
|
|
private readonly MarketDataService _service;
|
|
|
|
public MarketDataServiceTests()
|
|
{
|
|
_tencentMock = new Mock<ITencentMarketService>();
|
|
_tiingoMock = new Mock<ITiingoMarketService>();
|
|
_okxMock = new Mock<IOkxMarketService>();
|
|
_repoMock = new Mock<IMarketDataRepository>();
|
|
_loggerMock = new Mock<ILogger<MarketDataService>>();
|
|
|
|
_service = new MarketDataService(
|
|
_loggerMock.Object,
|
|
_tencentMock.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<AssetManager.Data.MarketPriceCache>()))
|
|
.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<string>()), 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<AssetManager.Data.MarketPriceCache>()))
|
|
.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<string>()), 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<AssetManager.Data.MarketPriceCache>()))
|
|
.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<string>()), Times.Never);
|
|
_tiingoMock.Verify(x => x.GetStockPriceAsync(It.IsAny<string>()), 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<AssetManager.Data.MarketKlineCache>());
|
|
|
|
_okxMock.Setup(x => x.GetCryptoHistoricalDataAsync(symbol, timeframe, limit))
|
|
.ReturnsAsync(new List<MarketDataResponse>
|
|
{
|
|
new() { Symbol = symbol, Close = 50000m, Timestamp = DateTime.Today }
|
|
});
|
|
|
|
_repoMock.Setup(x => x.SaveKlineCacheBatchAsync(It.IsAny<List<AssetManager.Data.MarketKlineCache>>()))
|
|
.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<AssetManager.Data.MarketKlineCache>
|
|
{
|
|
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<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Never);
|
|
}
|
|
|
|
#endregion
|
|
}
|