using Xunit; using Moq; using System.Reflection; using System.Text.Json; using Microsoft.Extensions.Logging; using QuantEngine.Application.Interfaces; using QuantEngine.Application.Models; using QuantEngine.Core.Interfaces; using QuantEngine.Application.Services; namespace QuantEngine.Core.Tests; public class KisDataCollectionOrchestratorTests { private readonly Mock _kisApiClientMock; private readonly Mock _writeRepositoryMock; private readonly Mock _readRepositoryMock; private readonly Mock> _loggerMock; private readonly Mock _auditTrailMock; private readonly PriceDataNormalizer _normalizer; private readonly SourcePriorityResolver _priorityResolver; private readonly KisDataCollectionOrchestrator _orchestrator; public KisDataCollectionOrchestratorTests() { _kisApiClientMock = new Mock(); var repositoryMock = new Mock(); _writeRepositoryMock = repositoryMock.As(); _readRepositoryMock = repositoryMock.As(); _loggerMock = new Mock>(); _auditTrailMock = new Mock(); _priorityResolver = new SourcePriorityResolver(); _normalizer = new PriceDataNormalizer(_priorityResolver); _kisApiClientMock .Setup(k => k.GetDailyItemChartPriceAsync(It.IsAny(), It.IsAny(), It.IsAny(), "D", It.IsAny())) .ReturnsAsync(new Dictionary()); _orchestrator = new KisDataCollectionOrchestrator( _kisApiClientMock.Object, _writeRepositoryMock.Object, _readRepositoryMock.Object, _normalizer, _priorityResolver, _loggerMock.Object, _auditTrailMock.Object ); } [Fact] public async Task RunCollectionAsync_WithCachedSnapshot_ShouldNotCallKisApiClient() { var runId = "test-run-001"; var ticker = "005930"; var account = "mock"; var todayPrefix = DateTime.UtcNow.AddHours(9).ToString("yyyy-MM-dd"); var cachedSnapshot = new CollectionSnapshotRecord( RunId: "prev-run", DatasetName: "data_feed", Ticker: ticker, SourceName: "kis_open_api", PayloadJson: """{"Ticker":"005930","current_price":50000}""", CapturedAt: $"{todayPrefix}T14:30:00" ); _readRepositoryMock .Setup(r => r.GetLatestSnapshotsForTickerAsync(ticker, It.IsAny())) .ReturnsAsync(new List { cachedSnapshot }); _writeRepositoryMock .Setup(r => r.SaveSnapshotAsync(It.IsAny())) .Returns(Task.CompletedTask); _writeRepositoryMock .Setup(r => r.SavePriceHistoryDailyAsync(It.IsAny())) .Returns(Task.CompletedTask); _writeRepositoryMock .Setup(r => r.SaveRunAsync(It.IsAny())) .Returns(Task.CompletedTask); var result = await _orchestrator.RunCollectionAsync(runId, account, new List { ticker }); Assert.NotNull(result); Assert.Equal("COMPLETED", result.Status); Assert.Equal(1, result.SuccessCount); _kisApiClientMock.Verify( k => k.GetCurrentPriceAsync(It.IsAny(), It.IsAny()), Times.Never, "IsMarketClosed should return true and cached snapshot should be used, so KIS API should not be called" ); _writeRepositoryMock.Verify( r => r.SaveSnapshotAsync(It.Is(s => s.SourceName.Contains("(Cached)"))), Times.Once, "Cached snapshot source name should include '(Cached)' suffix" ); } [Fact] public async Task RunCollectionAsync_WithoutCachedSnapshot_ShouldCallKisApiClient() { var runId = "test-run-002"; var ticker = "005930"; var account = "mock"; _readRepositoryMock .Setup(r => r.GetLatestSnapshotsForTickerAsync(ticker, It.IsAny())) .ReturnsAsync(new List()); _kisApiClientMock .Setup(k => k.GetCurrentPriceAsync(ticker, account)) .ReturnsAsync(new Dictionary { { "Ticker", ticker }, { "current_price", 50000 }, { "open", 49900 } }); _kisApiClientMock .Setup(k => k.GetDailyItemChartPriceAsync(ticker, It.IsAny(), It.IsAny(), "D", account)) .ReturnsAsync(new Dictionary()); _writeRepositoryMock .Setup(r => r.SaveSnapshotAsync(It.IsAny())) .Returns(Task.CompletedTask); _writeRepositoryMock .Setup(r => r.SavePriceHistoryDailyAsync(It.IsAny())) .Returns(Task.CompletedTask); _writeRepositoryMock .Setup(r => r.SaveRunAsync(It.IsAny())) .Returns(Task.CompletedTask); var result = await _orchestrator.RunCollectionAsync(runId, account, new List { ticker }); Assert.NotNull(result); Assert.Equal("COMPLETED", result.Status); Assert.Equal(1, result.SuccessCount); _auditTrailMock.Verify(a => a.Append("collection_audit", runId, It.IsAny()), Times.Exactly(2)); _kisApiClientMock.Verify( k => k.GetCurrentPriceAsync(ticker, account), Times.Once, "No cached snapshot exists, so KIS API should be called" ); } [Fact] public async Task RunCollectionAsync_WithPriorDaySnapshot_ShouldCallKisApiClient() { var runId = "test-run-003"; var ticker = "005930"; var account = "mock"; var priorDay = DateTime.UtcNow.AddHours(9).AddDays(-1).ToString("yyyy-MM-dd"); var priorDaySnapshot = new CollectionSnapshotRecord( RunId: "prev-run", DatasetName: "data_feed", Ticker: ticker, SourceName: "kis_open_api", PayloadJson: """{"Ticker":"005930","current_price":49000}""", CapturedAt: $"{priorDay}T14:30:00" ); _readRepositoryMock .Setup(r => r.GetLatestSnapshotsForTickerAsync(ticker, It.IsAny())) .ReturnsAsync(new List { priorDaySnapshot }); _kisApiClientMock .Setup(k => k.GetCurrentPriceAsync(ticker, account)) .ReturnsAsync(new Dictionary { { "Ticker", ticker }, { "current_price", 50100 } }); _kisApiClientMock .Setup(k => k.GetDailyItemChartPriceAsync(ticker, It.IsAny(), It.IsAny(), "D", account)) .ReturnsAsync(new Dictionary()); _writeRepositoryMock .Setup(r => r.SaveSnapshotAsync(It.IsAny())) .Returns(Task.CompletedTask); _writeRepositoryMock .Setup(r => r.SavePriceHistoryDailyAsync(It.IsAny())) .Returns(Task.CompletedTask); _writeRepositoryMock .Setup(r => r.SaveRunAsync(It.IsAny())) .Returns(Task.CompletedTask); var result = await _orchestrator.RunCollectionAsync(runId, account, new List { ticker }); Assert.NotNull(result); Assert.Equal("COMPLETED", result.Status); Assert.Equal(1, result.SuccessCount); _kisApiClientMock.Verify( k => k.GetCurrentPriceAsync(ticker, account), Times.Once, "Snapshot is from prior day, not today, so KIS API should be called" ); } [Fact] public async Task RunCollectionAsync_ShouldCompleteEvenIfLineageWriteFails() { var runId = "test-run-004"; var ticker = "005930"; var account = "mock"; _readRepositoryMock .Setup(r => r.GetLatestSnapshotsForTickerAsync(ticker, It.IsAny())) .ReturnsAsync(new List()); _kisApiClientMock .Setup(k => k.GetCurrentPriceAsync(ticker, account)) .ReturnsAsync(new Dictionary { { "Ticker", ticker }, { "current_price", 50000 } }); _kisApiClientMock .Setup(k => k.GetDailyItemChartPriceAsync(ticker, It.IsAny(), It.IsAny(), "D", account)) .ReturnsAsync(new Dictionary()); _writeRepositoryMock .Setup(r => r.SaveSnapshotAsync(It.IsAny())) .Returns(Task.CompletedTask); _writeRepositoryMock .Setup(r => r.SavePriceHistoryDailyAsync(It.IsAny())) .Returns(Task.CompletedTask); _writeRepositoryMock .Setup(r => r.SaveRunAsync(It.IsAny())) .Returns(Task.CompletedTask); var result = await _orchestrator.RunCollectionAsync(runId, account, new List { ticker }); Assert.NotNull(result); Assert.Equal("COMPLETED", result.Status); Assert.Equal(1, result.SuccessCount); _loggerMock.Verify( l => l.Log( LogLevel.Warning, It.IsAny(), It.Is((v, t) => v.ToString()!.Contains("Failed to write lineage event") || v.ToString()!.Contains("lineage")), It.IsAny(), It.IsAny>()), Times.Never, "Lineage write should succeed in normal case (no directory permission issues)" ); } [Fact] public async Task RunCollectionAsync_WithMultipleTickers_ShouldHandleSuccessAndErrors() { var runId = "test-run-005"; var account = "mock"; var tickers = new List { "005930", "000660" }; _readRepositoryMock .Setup(r => r.GetLatestSnapshotsForTickerAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); _kisApiClientMock .Setup(k => k.GetCurrentPriceAsync("005930", account)) .ReturnsAsync(new Dictionary { { "Ticker", "005930" }, { "current_price", 50000 } }); _kisApiClientMock .Setup(k => k.GetCurrentPriceAsync("000660", account)) .ReturnsAsync(new Dictionary { { "Ticker", "000660" }, { "current_price", 100000 } }); _kisApiClientMock .Setup(k => k.GetDailyItemChartPriceAsync(It.IsAny(), It.IsAny(), It.IsAny(), "D", account)) .ReturnsAsync(new Dictionary()); var callCount = 0; _writeRepositoryMock .Setup(r => r.SaveSnapshotAsync(It.IsAny())) .Returns((CollectionSnapshotRecord snapshot) => { callCount++; if (callCount == 2 && snapshot.Ticker == "000660") throw new Exception("Storage Error"); return Task.CompletedTask; }); _writeRepositoryMock .Setup(r => r.SavePriceHistoryDailyAsync(It.IsAny())) .Returns(Task.CompletedTask); _writeRepositoryMock .Setup(r => r.SaveErrorAsync(It.IsAny())) .Returns(Task.CompletedTask); _writeRepositoryMock .Setup(r => r.SaveRunAsync(It.IsAny())) .Returns(Task.CompletedTask); var result = await _orchestrator.RunCollectionAsync(runId, account, tickers); Assert.NotNull(result); Assert.Equal("COMPLETED_WITH_ERRORS", result.Status); Assert.Equal(1, result.SuccessCount); Assert.Equal(1, result.ErrorCount); _writeRepositoryMock.Verify( r => r.SaveErrorAsync(It.Is(e => e.Ticker == "000660" && e.ErrorMessage == "Storage Error")), Times.Once ); } [Fact] public void TryParseOhlcvBar_WithValidBar_ShouldReturnRecord() { var method = typeof(KisDataCollectionOrchestrator).GetMethod( "TryParseOhlcvBar", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); using var document = JsonDocument.Parse(""" { "stck_bsop_date": "20260711", "stck_oprc": "1000", "stck_hgpr": "1100", "stck_lwpr": "900", "stck_clpr": "1050", "acml_vol": "12345" } """); object?[] args = { document.RootElement, "005930", null, }; var result = (bool)method!.Invoke(null, args)!; Assert.True(result); var record = Assert.IsType(args[2]); Assert.Equal("005930", record.Ticker); Assert.Equal(new DateOnly(2026, 7, 11), record.TradeDate); Assert.Equal(1000m, record.Open); Assert.Equal(1100m, record.High); Assert.Equal(900m, record.Low); Assert.Equal(1050m, record.Close); Assert.Equal(12345L, record.Volume); Assert.Equal("kis_open_api", record.Source); } [Fact] public void TryParseOhlcvBar_WithInvalidBar_ShouldReturnFalse() { var method = typeof(KisDataCollectionOrchestrator).GetMethod( "TryParseOhlcvBar", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); using var document = JsonDocument.Parse(""" { "stck_bsop_date": "20260711", "stck_oprc": "1000", "stck_hgpr": "900", "stck_lwpr": "1100", "stck_clpr": "1050", "acml_vol": "12345" } """); object?[] args = { document.RootElement, "005930", null, }; var result = (bool)method!.Invoke(null, args)!; Assert.False(result); Assert.Null(args[2]); } private static string FindRepoRoot() { var current = new DirectoryInfo(AppContext.BaseDirectory); while (current != null) { if (Directory.Exists(Path.Combine(current.FullName, ".git"))) { return current.FullName; } current = current.Parent; } throw new InvalidOperationException("Repository root not found."); } }