feat: complete Oracle and MariaDB WebView data layer

This commit is contained in:
2026-07-10 05:33:19 +09:00
parent 5aa90e4aaa
commit 39c4504b87
44 changed files with 3956 additions and 46 deletions

View File

@@ -0,0 +1,3 @@
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View File

@@ -0,0 +1,65 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class DataQueryExecutorTests : IDisposable
{
public DataQueryExecutorTests() => DataQueryExecutor.Reset();
public void Dispose() => DataQueryExecutor.Reset();
[Fact]
public void Configure_PublishesExecutorAndResetRemovesIt()
{
var executor = new StubExecutor();
DataQueryExecutor.Configure(executor);
Assert.True(DataQueryExecutor.IsConfigured);
Assert.Same(executor, DataQueryExecutor.Current);
DataQueryExecutor.Reset();
Assert.False(DataQueryExecutor.IsConfigured);
Assert.Throws<InvalidOperationException>(() => DataQueryExecutor.Current);
}
[Fact]
public void Configure_RejectsNullWithoutReplacingCurrentExecutor()
{
var executor = new StubExecutor();
DataQueryExecutor.Configure(executor);
Assert.Throws<ArgumentNullException>(() => DataQueryExecutor.Configure(null!));
Assert.Same(executor, DataQueryExecutor.Current);
}
[Fact]
public void ConfigureAndReset_AreSafeUnderConcurrentPublication()
{
var executors = Enumerable.Range(0, 64).Select(_ => new StubExecutor()).ToArray();
Parallel.ForEach(executors, executor =>
{
DataQueryExecutor.Configure(executor);
_ = DataQueryExecutor.IsConfigured;
DataQueryExecutor.Reset();
});
DataQueryExecutor.Configure(executors[^1]);
Assert.Same(executors[^1], DataQueryExecutor.Current);
}
private sealed class StubExecutor : IDataQueryExecutor
{
public DataTable Execute(DataSourceKind source, string tableName, string query) => new();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) => Task.FromResult(new DataTable());
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,232 @@
using System.Collections.Concurrent;
using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyMarketDataServiceTests
{
[Fact]
public async Task GetAsync_Kospi_ForwardsBothLegacyQueriesToTheirProviders()
{
var executor = new RecordingQueryExecutor(call => CreateSingleRowTable(call.Name));
var service = new LegacyMarketDataService(executor);
using var cancellation = new CancellationTokenSource();
var snapshot = await service.GetAsync(MarketDataView.Kospi, cancellationToken: cancellation.Token);
Assert.Equal(MarketDataView.Kospi, snapshot.View);
Assert.Collection(
snapshot.Tables,
table =>
{
Assert.Equal("코스피", table.Name);
Assert.Equal(DataSourceKind.Oracle, table.Source);
},
table =>
{
Assert.Equal("NXT코스피", table.Name);
Assert.Equal(DataSourceKind.MariaDb, table.Source);
});
var calls = executor.Calls.OrderBy(call => call.Name, StringComparer.Ordinal).ToArray();
Assert.Equal(2, calls.Length);
Assert.Contains(calls, call =>
call.Source == DataSourceKind.Oracle &&
call.Name == "코스피" &&
call.Query == "SELECT F_STOCK_WANNAME, F_STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N'");
Assert.Contains(calls, call =>
call.Source == DataSourceKind.MariaDb &&
call.Name == "NXT코스피" &&
call.Query == "SELECT CONCAT(F_STOCK_NAME, '(NXT)') F_STOCK_NAME, F_STOCK_CODE FROM N_STOCK WHERE F_STOP_GUBUN = 'N'");
Assert.All(calls, call => Assert.Equal(cancellation.Token, call.CancellationToken));
Assert.Equal(0, executor.SynchronousCallCount);
}
[Fact]
public async Task GetAsync_Index_ForwardsFiveOracleQueriesAndPreservesLegacyKospiSql()
{
const string expectedKospiSql = @"SELECT '코스피' name, round(f_part_idx/100,2) part_idx, f_chg_type chg_type, round(f_part_chg/100,2) part_chg,
round((f_part_chg / decode(f_chg_type, '+', f_part_idx - f_part_chg, '-', f_part_idx + f_part_chg, 1)) * 100, 2) rate
FROM t_index
WHERE f_part_code = '001'";
var executor = new RecordingQueryExecutor(call => CreateSingleRowTable(call.Name));
var service = new LegacyMarketDataService(executor);
var snapshot = await service.GetAsync(MarketDataView.Index);
Assert.Equal(5, snapshot.Tables.Count);
Assert.Equal(
["코스피", "코스닥", "코스피200", "KRX100", "선물"],
snapshot.Tables.Select(table => table.Name));
var calls = executor.Calls.ToArray();
Assert.Equal(5, calls.Length);
Assert.All(calls, call => Assert.Equal(DataSourceKind.Oracle, call.Source));
Assert.Equal(
expectedKospiSql,
Assert.Single(calls, call => call.Name == "코스피").Query);
}
[Fact]
public async Task GetAsync_CapsReturnedRowsButReportsUncappedTotal()
{
var executor = new RecordingQueryExecutor(_ => CreateNumberedTable(5));
var service = new LegacyMarketDataService(executor);
var snapshot = await service.GetAsync(MarketDataView.Overseas, maximumRowsPerTable: 2);
var table = Assert.Single(snapshot.Tables);
Assert.Equal(["Number", "Label"], table.Columns);
Assert.Equal(2, table.Rows.Count);
Assert.Equal(["1", "row-1"], table.Rows[0]);
Assert.Equal(["2", "row-2"], table.Rows[1]);
Assert.Equal(5, table.TotalRowCount);
}
[Fact]
public async Task GetAsync_FormatsNullNumbersAndDatesUsingInvariantCulture()
{
var previousCulture = CultureInfo.CurrentCulture;
var previousUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr-FR");
var executor = new RecordingQueryExecutor(_ => CreateFormattingTable());
var service = new LegacyMarketDataService(executor);
var snapshot = await service.GetAsync(MarketDataView.Overseas);
var row = Assert.Single(Assert.Single(snapshot.Tables).Rows);
Assert.Equal(
[null, "1234.56", "2026-07-10 14:15:16", "2026-07-10 14:15:16 +09:00", "plain"],
row);
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
CultureInfo.CurrentUICulture = previousUiCulture;
}
}
[Fact]
public async Task GetAsync_PropagatesCancellationToExecutorAndCaller()
{
var executor = new RecordingQueryExecutor((call, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(CreateSingleRowTable(call.Name));
});
var service = new LegacyMarketDataService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.GetAsync(MarketDataView.Kospi, cancellationToken: cancellation.Token));
Assert.NotEmpty(executor.Calls);
Assert.All(executor.Calls, call => Assert.Equal(cancellation.Token, call.CancellationToken));
}
[Theory]
[InlineData(-1)]
[InlineData(0)]
[InlineData(2001)]
[InlineData(int.MaxValue)]
public async Task GetAsync_RejectsInvalidRowLimitBeforeExecutingQueries(int maximumRows)
{
var executor = new RecordingQueryExecutor(_ => CreateSingleRowTable("unused"));
var service = new LegacyMarketDataService(executor);
var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => service.GetAsync(MarketDataView.Kospi, maximumRows));
Assert.Equal("maximumRowsPerTable", exception.ParamName);
Assert.Empty(executor.Calls);
}
private static DataTable CreateSingleRowTable(string value)
{
var table = new DataTable();
table.Columns.Add("Value", typeof(string));
table.Rows.Add(value);
return table;
}
private static DataTable CreateNumberedTable(int rowCount)
{
var table = new DataTable();
table.Columns.Add("Number", typeof(int));
table.Columns.Add("Label", typeof(string));
for (var index = 1; index <= rowCount; index++)
{
table.Rows.Add(index, $"row-{index}");
}
return table;
}
private static DataTable CreateFormattingTable()
{
var table = new DataTable();
table.Columns.Add("Null", typeof(object));
table.Columns.Add("Decimal", typeof(decimal));
table.Columns.Add("Date", typeof(DateTime));
table.Columns.Add("DateOffset", typeof(DateTimeOffset));
table.Columns.Add("Text", typeof(string));
table.Rows.Add(
DBNull.Value,
1234.56m,
new DateTime(2026, 7, 10, 14, 15, 16, DateTimeKind.Unspecified),
new DateTimeOffset(2026, 7, 10, 14, 15, 16, TimeSpan.FromHours(9)),
"plain");
return table;
}
private sealed record QueryCall(
DataSourceKind Source,
string Name,
string Query,
CancellationToken CancellationToken);
private sealed class RecordingQueryExecutor : IDataQueryExecutor
{
private readonly Func<QueryCall, CancellationToken, Task<DataTable>> _handler;
private int _synchronousCallCount;
public RecordingQueryExecutor(Func<QueryCall, DataTable> handler)
: this((call, _) => Task.FromResult(handler(call)))
{
}
public RecordingQueryExecutor(Func<QueryCall, CancellationToken, Task<DataTable>> handler)
{
_handler = handler;
}
public ConcurrentQueue<QueryCall> Calls { get; } = new();
public int SynchronousCallCount => Volatile.Read(ref _synchronousCallCount);
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
Interlocked.Increment(ref _synchronousCallCount);
throw new InvalidOperationException("The live market-data service must use the asynchronous executor API.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
var call = new QueryCall(source, tableName, query, cancellationToken);
Calls.Enqueue(call);
return _handler(call, cancellationToken);
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
</ItemGroup>
</Project>