Files
QuantEngineByItz/src/dotnet/QuantEngine.Core.Tests/KisDataCollectionOrchestratorTests.cs
T
kjh2064 e0d278e6eb
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 17s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
refactor(dotnet): split collection read and write contracts
2026-07-13 01:00:36 +09:00

423 lines
15 KiB
C#

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<IKisApiClient> _kisApiClientMock;
private readonly Mock<ICollectionWriteRepository> _writeRepositoryMock;
private readonly Mock<ICollectionReadRepository> _readRepositoryMock;
private readonly Mock<ILogger<KisDataCollectionOrchestrator>> _loggerMock;
private readonly Mock<IRuntimeAuditTrailService> _auditTrailMock;
private readonly PriceDataNormalizer _normalizer;
private readonly SourcePriorityResolver _priorityResolver;
private readonly KisDataCollectionOrchestrator _orchestrator;
public KisDataCollectionOrchestratorTests()
{
_kisApiClientMock = new Mock<IKisApiClient>();
var repositoryMock = new Mock<ICollectionRepository>();
_writeRepositoryMock = repositoryMock.As<ICollectionWriteRepository>();
_readRepositoryMock = repositoryMock.As<ICollectionReadRepository>();
_loggerMock = new Mock<ILogger<KisDataCollectionOrchestrator>>();
_auditTrailMock = new Mock<IRuntimeAuditTrailService>();
_priorityResolver = new SourcePriorityResolver();
_normalizer = new PriceDataNormalizer(_priorityResolver);
_kisApiClientMock
.Setup(k => k.GetDailyItemChartPriceAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), "D", It.IsAny<string>()))
.ReturnsAsync(new Dictionary<string, object>());
_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<int>()))
.ReturnsAsync(new List<CollectionSnapshotRecord> { cachedSnapshot });
_writeRepositoryMock
.Setup(r => r.SaveSnapshotAsync(It.IsAny<CollectionSnapshotRecord>()))
.Returns(Task.CompletedTask);
_writeRepositoryMock
.Setup(r => r.SavePriceHistoryDailyAsync(It.IsAny<PriceHistoryDailyRecord>()))
.Returns(Task.CompletedTask);
_writeRepositoryMock
.Setup(r => r.SaveRunAsync(It.IsAny<CollectionRunRecord>()))
.Returns(Task.CompletedTask);
var result = await _orchestrator.RunCollectionAsync(runId, account, new List<string> { ticker });
Assert.NotNull(result);
Assert.Equal("COMPLETED", result.Status);
Assert.Equal(1, result.SuccessCount);
_kisApiClientMock.Verify(
k => k.GetCurrentPriceAsync(It.IsAny<string>(), It.IsAny<string>()),
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<CollectionSnapshotRecord>(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<int>()))
.ReturnsAsync(new List<CollectionSnapshotRecord>());
_kisApiClientMock
.Setup(k => k.GetCurrentPriceAsync(ticker, account))
.ReturnsAsync(new Dictionary<string, object>
{
{ "Ticker", ticker },
{ "current_price", 50000 },
{ "open", 49900 }
});
_kisApiClientMock
.Setup(k => k.GetDailyItemChartPriceAsync(ticker, It.IsAny<string>(), It.IsAny<string>(), "D", account))
.ReturnsAsync(new Dictionary<string, object>());
_writeRepositoryMock
.Setup(r => r.SaveSnapshotAsync(It.IsAny<CollectionSnapshotRecord>()))
.Returns(Task.CompletedTask);
_writeRepositoryMock
.Setup(r => r.SavePriceHistoryDailyAsync(It.IsAny<PriceHistoryDailyRecord>()))
.Returns(Task.CompletedTask);
_writeRepositoryMock
.Setup(r => r.SaveRunAsync(It.IsAny<CollectionRunRecord>()))
.Returns(Task.CompletedTask);
var result = await _orchestrator.RunCollectionAsync(runId, account, new List<string> { ticker });
Assert.NotNull(result);
Assert.Equal("COMPLETED", result.Status);
Assert.Equal(1, result.SuccessCount);
_auditTrailMock.Verify(a => a.Append("collection_audit", runId, It.IsAny<CollectionExecutionAudit>()), 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<int>()))
.ReturnsAsync(new List<CollectionSnapshotRecord> { priorDaySnapshot });
_kisApiClientMock
.Setup(k => k.GetCurrentPriceAsync(ticker, account))
.ReturnsAsync(new Dictionary<string, object>
{
{ "Ticker", ticker },
{ "current_price", 50100 }
});
_kisApiClientMock
.Setup(k => k.GetDailyItemChartPriceAsync(ticker, It.IsAny<string>(), It.IsAny<string>(), "D", account))
.ReturnsAsync(new Dictionary<string, object>());
_writeRepositoryMock
.Setup(r => r.SaveSnapshotAsync(It.IsAny<CollectionSnapshotRecord>()))
.Returns(Task.CompletedTask);
_writeRepositoryMock
.Setup(r => r.SavePriceHistoryDailyAsync(It.IsAny<PriceHistoryDailyRecord>()))
.Returns(Task.CompletedTask);
_writeRepositoryMock
.Setup(r => r.SaveRunAsync(It.IsAny<CollectionRunRecord>()))
.Returns(Task.CompletedTask);
var result = await _orchestrator.RunCollectionAsync(runId, account, new List<string> { 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<int>()))
.ReturnsAsync(new List<CollectionSnapshotRecord>());
_kisApiClientMock
.Setup(k => k.GetCurrentPriceAsync(ticker, account))
.ReturnsAsync(new Dictionary<string, object>
{
{ "Ticker", ticker },
{ "current_price", 50000 }
});
_kisApiClientMock
.Setup(k => k.GetDailyItemChartPriceAsync(ticker, It.IsAny<string>(), It.IsAny<string>(), "D", account))
.ReturnsAsync(new Dictionary<string, object>());
_writeRepositoryMock
.Setup(r => r.SaveSnapshotAsync(It.IsAny<CollectionSnapshotRecord>()))
.Returns(Task.CompletedTask);
_writeRepositoryMock
.Setup(r => r.SavePriceHistoryDailyAsync(It.IsAny<PriceHistoryDailyRecord>()))
.Returns(Task.CompletedTask);
_writeRepositoryMock
.Setup(r => r.SaveRunAsync(It.IsAny<CollectionRunRecord>()))
.Returns(Task.CompletedTask);
var result = await _orchestrator.RunCollectionAsync(runId, account, new List<string> { ticker });
Assert.NotNull(result);
Assert.Equal("COMPLETED", result.Status);
Assert.Equal(1, result.SuccessCount);
_loggerMock.Verify(
l => l.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) =>
v.ToString()!.Contains("Failed to write lineage event") ||
v.ToString()!.Contains("lineage")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
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<string> { "005930", "000660" };
_readRepositoryMock
.Setup(r => r.GetLatestSnapshotsForTickerAsync(It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(new List<CollectionSnapshotRecord>());
_kisApiClientMock
.Setup(k => k.GetCurrentPriceAsync("005930", account))
.ReturnsAsync(new Dictionary<string, object>
{
{ "Ticker", "005930" },
{ "current_price", 50000 }
});
_kisApiClientMock
.Setup(k => k.GetCurrentPriceAsync("000660", account))
.ReturnsAsync(new Dictionary<string, object>
{
{ "Ticker", "000660" },
{ "current_price", 100000 }
});
_kisApiClientMock
.Setup(k => k.GetDailyItemChartPriceAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), "D", account))
.ReturnsAsync(new Dictionary<string, object>());
var callCount = 0;
_writeRepositoryMock
.Setup(r => r.SaveSnapshotAsync(It.IsAny<CollectionSnapshotRecord>()))
.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<PriceHistoryDailyRecord>()))
.Returns(Task.CompletedTask);
_writeRepositoryMock
.Setup(r => r.SaveErrorAsync(It.IsAny<CollectionErrorRecord>()))
.Returns(Task.CompletedTask);
_writeRepositoryMock
.Setup(r => r.SaveRunAsync(It.IsAny<CollectionRunRecord>()))
.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<CollectionErrorRecord>(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<PriceHistoryDailyRecord>(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.");
}
}