feat: complete Oracle and MariaDB WebView data layer
This commit is contained in:
3
tests/MBN_STOCK_WEBVIEW.Core.Tests/AssemblyInfo.cs
Normal file
3
tests/MBN_STOCK_WEBVIEW.Core.Tests/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using Xunit;
|
||||
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
65
tests/MBN_STOCK_WEBVIEW.Core.Tests/DataQueryExecutorTests.cs
Normal file
65
tests/MBN_STOCK_WEBVIEW.Core.Tests/DataQueryExecutorTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
1
tests/MBN_STOCK_WEBVIEW.Core.Tests/GlobalUsings.cs
Normal file
1
tests/MBN_STOCK_WEBVIEW.Core.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user