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>

View File

@@ -0,0 +1,279 @@
using System.Collections;
using System.Data;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
internal sealed class ConnectionPlan
{
public Func<CancellationToken, Task> OpenAsync { get; init; } = _ => Task.CompletedTask;
public Func<CancellationToken, Task<DbDataReader>> ExecuteReaderAsync { get; init; } =
_ => Task.FromResult<DbDataReader>(new DataTable().CreateDataReader());
public bool Disposed { get; set; }
public int? ObservedCommandTimeoutSeconds { get; set; }
public static ConnectionPlan Success(DataTable data) => new()
{
ExecuteReaderAsync = _ => Task.FromResult<DbDataReader>(data.CreateDataReader())
};
}
internal sealed class FakeConnectionFactory : IDatabaseConnectionFactory
{
private readonly Queue<ConnectionPlan> _plans;
public FakeConnectionFactory(params ConnectionPlan[] plans)
{
_plans = new Queue<ConnectionPlan>(plans);
}
public int CreateCount { get; private set; }
public List<DbConnection> CreatedConnections { get; } = [];
public DbConnection Create(DataSourceKind source)
{
CreateCount++;
if (!_plans.TryDequeue(out var plan))
{
throw new InvalidOperationException("No fake connection plan remains.");
}
var connection = new FakeDbConnection(plan);
CreatedConnections.Add(connection);
return connection;
}
public int GetCommandTimeoutSeconds(DataSourceKind source) => 5;
}
internal sealed class StubErrorDetector(bool transient) : ITransientDatabaseErrorDetector
{
public bool IsTransient(DataSourceKind source, Exception exception) => transient;
public bool IsTimeout(DataSourceKind source, Exception exception) => exception is TimeoutException;
public int? GetProviderErrorCode(DataSourceKind source, Exception exception) => null;
}
internal sealed class TestDatabaseException(string message) : Exception(message);
internal sealed class FakeDbConnection(ConnectionPlan plan) : DbConnection
{
private ConnectionState _state = ConnectionState.Closed;
[AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => "FakeDatabase";
public override string DataSource => "FakeDataSource";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName)
{
}
public override void Close() => _state = ConnectionState.Closed;
public override void Open() => OpenAsync(CancellationToken.None).GetAwaiter().GetResult();
public override async Task OpenAsync(CancellationToken cancellationToken)
{
await plan.OpenAsync(cancellationToken);
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeDbCommand(this, plan);
protected override void Dispose(bool disposing)
{
if (disposing)
{
plan.Disposed = true;
_state = ConnectionState.Closed;
}
base.Dispose(disposing);
}
}
internal sealed class FakeDbCommand(DbConnection connection, ConnectionPlan plan) : DbCommand
{
private readonly FakeDbParameterCollection _parameters = new();
[AllowNull]
public override string CommandText { get; set; } = string.Empty;
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; } = connection;
protected override DbParameterCollection DbParameterCollection => _parameters;
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel()
{
}
public override int ExecuteNonQuery() => throw new NotSupportedException();
public override object? ExecuteScalar() => throw new NotSupportedException();
public override void Prepare()
{
}
protected override DbParameter CreateDbParameter() => new FakeDbParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) =>
ExecuteDbDataReaderAsync(behavior, CancellationToken.None).GetAwaiter().GetResult();
protected override Task<DbDataReader> ExecuteDbDataReaderAsync(
CommandBehavior behavior,
CancellationToken cancellationToken)
{
plan.ObservedCommandTimeoutSeconds = CommandTimeout;
return plan.ExecuteReaderAsync(cancellationToken);
}
}
internal sealed class FakeDbParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
[AllowNull]
public override string ParameterName { get; set; } = string.Empty;
public override int Size { get; set; }
[AllowNull]
public override string SourceColumn { get; set; } = string.Empty;
public override bool SourceColumnNullMapping { get; set; }
public override object? Value { get; set; }
public override void ResetDbType()
{
}
}
internal sealed class FakeDbParameterCollection : DbParameterCollection
{
private readonly List<DbParameter> _parameters = [];
public override int Count => _parameters.Count;
public override object SyncRoot => ((ICollection)_parameters).SyncRoot;
public override int Add(object value)
{
_parameters.Add(RequireParameter(value));
return _parameters.Count - 1;
}
public override void AddRange(Array values)
{
foreach (var value in values)
{
Add(value ?? throw new ArgumentNullException(nameof(values)));
}
}
public override void Clear() => _parameters.Clear();
public override bool Contains(object value) =>
value is DbParameter parameter && _parameters.Contains(parameter);
public override bool Contains(string value) => IndexOf(value) >= 0;
public override void CopyTo(Array array, int index) =>
((ICollection)_parameters).CopyTo(array, index);
public override IEnumerator GetEnumerator() => _parameters.GetEnumerator();
public override int IndexOf(object value) =>
value is DbParameter parameter ? _parameters.IndexOf(parameter) : -1;
public override int IndexOf(string parameterName) =>
_parameters.FindIndex(
parameter => string.Equals(
parameter.ParameterName,
parameterName,
StringComparison.OrdinalIgnoreCase));
public override void Insert(int index, object value) =>
_parameters.Insert(index, RequireParameter(value));
public override void Remove(object value)
{
if (value is DbParameter parameter)
{
_parameters.Remove(parameter);
}
}
public override void RemoveAt(int index) => _parameters.RemoveAt(index);
public override void RemoveAt(string parameterName)
{
var index = IndexOf(parameterName);
if (index >= 0)
{
RemoveAt(index);
}
}
protected override DbParameter GetParameter(int index) => _parameters[index];
protected override DbParameter GetParameter(string parameterName)
{
var index = IndexOf(parameterName);
return index >= 0
? _parameters[index]
: throw new IndexOutOfRangeException();
}
protected override void SetParameter(int index, DbParameter value) => _parameters[index] = value;
protected override void SetParameter(string parameterName, DbParameter value)
{
var index = IndexOf(parameterName);
if (index >= 0)
{
_parameters[index] = value;
}
else
{
_parameters.Add(value);
}
}
private static DbParameter RequireParameter(object value) =>
value as DbParameter
?? throw new ArgumentException("A DbParameter is required.", nameof(value));
}

View File

@@ -0,0 +1,105 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class DatabaseHealthServiceTests
{
[Fact]
public async Task CheckAsync_Unconfigured_ReturnsNotConfiguredWithoutQuery()
{
var executor = new StubDataQueryExecutor();
var service = new DatabaseHealthService(executor, new DatabaseOptions());
var status = await service.CheckAsync(DataSourceKind.Oracle);
Assert.Equal(DatabaseHealthState.NotConfigured, status.State);
Assert.Equal(DataSourceKind.Oracle, status.Source);
Assert.Equal(TimeSpan.Zero, status.Elapsed);
Assert.Equal(0, executor.CallCount);
}
[Theory]
[InlineData(DataSourceKind.Oracle, "SELECT 1 FROM DUAL")]
[InlineData(DataSourceKind.MariaDb, "SELECT 1")]
public async Task CheckAsync_ConfiguredAndQuerySucceeds_ReturnsHealthy(
DataSourceKind source,
string expectedQuery)
{
var executor = new StubDataQueryExecutor
{
ExecuteAsyncHandler = (_, _, _, _) => Task.FromResult(new DataTable())
};
var service = new DatabaseHealthService(executor, TestOptions.CreateConfigured());
var status = await service.CheckAsync(source);
Assert.Equal(DatabaseHealthState.Healthy, status.State);
Assert.Equal(source, status.Source);
Assert.Equal(1, executor.CallCount);
Assert.Equal(expectedQuery, executor.LastQuery);
}
[Fact]
public async Task CheckAsync_ConfiguredAndQueryFails_ReturnsUnhealthyWithoutThrowing()
{
const string host = "health-host-sensitive-sentinel";
var executor = new StubDataQueryExecutor
{
ExecuteAsyncHandler = (_, _, _, _) => Task.FromException<DataTable>(
new DatabaseQueryException(DataSourceKind.MariaDb, isTransient: true))
};
var service = new DatabaseHealthService(executor, TestOptions.CreateConfigured());
var status = await service.CheckAsync(DataSourceKind.MariaDb);
Assert.Equal(DatabaseHealthState.Unhealthy, status.State);
Assert.Equal(DataSourceKind.MariaDb, status.Source);
Assert.DoesNotContain(host, status.UserMessage, StringComparison.Ordinal);
}
[Fact]
public async Task CheckAsync_ConfiguredAndQueryTimesOut_ReturnsTimedOut()
{
var executor = new StubDataQueryExecutor
{
ExecuteAsyncHandler = (_, _, _, _) => Task.FromException<DataTable>(
new DatabaseQueryTimeoutException(DataSourceKind.Oracle))
};
var service = new DatabaseHealthService(executor, TestOptions.CreateConfigured());
var status = await service.CheckAsync(DataSourceKind.Oracle);
Assert.Equal(DatabaseHealthState.TimedOut, status.State);
Assert.Equal(DataSourceKind.Oracle, status.Source);
}
}
internal sealed class StubDataQueryExecutor : IDataQueryExecutor
{
public Func<DataSourceKind, string, string, CancellationToken, Task<DataTable>>? ExecuteAsyncHandler
{
get;
init;
}
public int CallCount { get; private set; }
public string? LastQuery { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
ExecuteAsync(source, tableName, query).GetAwaiter().GetResult();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
CallCount++;
LastQuery = query;
return ExecuteAsyncHandler?.Invoke(source, tableName, query, cancellationToken)
?? Task.FromResult(new DataTable());
}
}

View File

@@ -0,0 +1,135 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class DatabaseOptionsLoaderTests
{
[Fact]
public void Load_MissingFile_ReturnsSafeUnconfiguredDefaults()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.json");
var loader = new DatabaseOptionsLoader(_ => null);
var options = loader.Load(path);
Assert.False(options.IsConfigured(DataSourceKind.Oracle));
Assert.False(options.IsConfigured(DataSourceKind.MariaDb));
Assert.Equal(1521, options.Oracle.Port);
Assert.Equal(3306, options.MariaDb.Port);
Assert.Null(options.Oracle.Password);
Assert.Null(options.MariaDb.Password);
}
[Fact]
public async Task LoadAsync_EnvironmentValuesOverrideJsonValues()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "database.local.json");
try
{
var jsonOptions = new DatabaseOptions
{
Oracle = new OracleDatabaseOptions
{
Host = "json-oracle-host.invalid",
Port = 1521,
Sid = "JSONSID",
UserName = "json-oracle-user",
Password = "json-oracle-password"
},
MariaDb = new MariaDbDatabaseOptions
{
Host = "json-maria-host.invalid",
Port = 3306,
Database = "json_database",
UserName = "json-maria-user",
Password = "json-maria-password",
TlsMode = MariaDbTlsMode.Preferred
},
Resilience = new DatabaseResilienceOptions
{
OperationTimeoutSeconds = 45,
MaximumRetryCount = 2,
InitialRetryDelayMilliseconds = 250
}
};
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(jsonOptions));
var environment = new Dictionary<string, string>(StringComparer.Ordinal)
{
["MBN_STOCK_ORACLE_HOST"] = "env-oracle-host.invalid",
["MBN_STOCK_ORACLE_PORT"] = "1522",
["MBN_STOCK_ORACLE_USERNAME"] = "env-oracle-user",
["MBN_STOCK_ORACLE_PASSWORD"] = "env-oracle-password",
["MBN_STOCK_MARIADB_HOST"] = "env-maria-host.invalid",
["MBN_STOCK_MARIADB_PORT"] = "3307",
["MBN_STOCK_MARIADB_DATABASE"] = "env_database",
["MBN_STOCK_MARIADB_USERNAME"] = "env-maria-user",
["MBN_STOCK_MARIADB_PASSWORD"] = "env-maria-password",
["MBN_STOCK_MARIADB_TLS_MODE"] = "Required",
["MBN_STOCK_DB_OPERATION_TIMEOUT_SECONDS"] = "12",
["MBN_STOCK_DB_MAX_RETRY_COUNT"] = "4",
["MBN_STOCK_DB_INITIAL_RETRY_DELAY_MS"] = "75"
};
var loader = new DatabaseOptionsLoader(
name => environment.TryGetValue(name, out var value) ? value : null);
var options = await loader.LoadAsync(path);
Assert.Equal("env-oracle-host.invalid", options.Oracle.Host);
Assert.Equal(1522, options.Oracle.Port);
Assert.Equal("JSONSID", options.Oracle.Sid);
Assert.Equal("env-oracle-user", options.Oracle.UserName);
Assert.Equal("env-oracle-password", options.Oracle.Password);
Assert.Equal("env-maria-host.invalid", options.MariaDb.Host);
Assert.Equal(3307, options.MariaDb.Port);
Assert.Equal("env_database", options.MariaDb.Database);
Assert.Equal("env-maria-user", options.MariaDb.UserName);
Assert.Equal("env-maria-password", options.MariaDb.Password);
Assert.Equal(MariaDbTlsMode.Required, options.MariaDb.TlsMode);
Assert.Equal(12, options.Resilience.OperationTimeoutSeconds);
Assert.Equal(4, options.Resilience.MaximumRetryCount);
Assert.Equal(75, options.Resilience.InitialRetryDelayMilliseconds);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ToString_DoesNotExposeHostUserOrPassword()
{
const string oracleHost = "oracle-host-sensitive-sentinel";
const string oracleUser = "oracle-user-sensitive-sentinel";
const string oraclePassword = "oracle-password-sensitive-sentinel";
const string mariaHost = "maria-host-sensitive-sentinel";
const string mariaUser = "maria-user-sensitive-sentinel";
const string mariaPassword = "maria-password-sensitive-sentinel";
var options = TestOptions.CreateConfigured();
options.Oracle.Host = oracleHost;
options.Oracle.UserName = oracleUser;
options.Oracle.Password = oraclePassword;
options.MariaDb.Host = mariaHost;
options.MariaDb.UserName = mariaUser;
options.MariaDb.Password = mariaPassword;
var representations = string.Join(
Environment.NewLine,
options.ToString(),
options.Oracle.ToString(),
options.MariaDb.ToString(),
options.Resilience.ToString());
Assert.DoesNotContain(oracleHost, representations, StringComparison.Ordinal);
Assert.DoesNotContain(oracleUser, representations, StringComparison.Ordinal);
Assert.DoesNotContain(oraclePassword, representations, StringComparison.Ordinal);
Assert.DoesNotContain(mariaHost, representations, StringComparison.Ordinal);
Assert.DoesNotContain(mariaUser, representations, StringComparison.Ordinal);
Assert.DoesNotContain(mariaPassword, representations, StringComparison.Ordinal);
}
}

View File

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

View File

@@ -0,0 +1,26 @@
<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" />
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,86 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
using MySqlConnector;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class ProviderDatabaseConnectionFactoryTests
{
[Fact]
public void CreateOracleConnection_UsesSidDescriptor()
{
var options = TestOptions.CreateConfigured();
options.Oracle.Sid = "TESTSID";
options.Oracle.ServiceName = null;
var factory = new ProviderDatabaseConnectionFactory(options);
using var connection = factory.Create(DataSourceKind.Oracle);
var parsed = new OracleConnectionStringBuilder(connection.ConnectionString);
Assert.True(parsed.DataSource.Contains("(SID=TESTSID)", StringComparison.Ordinal));
Assert.False(parsed.DataSource.Contains("SERVICE_NAME", StringComparison.Ordinal));
Assert.False(parsed.PersistSecurityInfo);
}
[Fact]
public void CreateOracleConnection_UsesServiceNameDescriptor()
{
var options = TestOptions.CreateConfigured();
options.Oracle.Sid = null;
options.Oracle.ServiceName = "TESTSERVICE";
var factory = new ProviderDatabaseConnectionFactory(options);
using var connection = factory.Create(DataSourceKind.Oracle);
var parsed = new OracleConnectionStringBuilder(connection.ConnectionString);
Assert.True(parsed.DataSource.Contains("(SERVICE_NAME=TESTSERVICE)", StringComparison.Ordinal));
Assert.False(parsed.DataSource.Contains("(SID=", StringComparison.Ordinal));
Assert.False(parsed.PersistSecurityInfo);
}
[Theory]
[InlineData(MariaDbTlsMode.Disabled, MySqlSslMode.None)]
[InlineData(MariaDbTlsMode.Preferred, MySqlSslMode.Preferred)]
[InlineData(MariaDbTlsMode.Required, MySqlSslMode.Required)]
[InlineData(MariaDbTlsMode.VerifyCertificate, MySqlSslMode.VerifyCA)]
[InlineData(MariaDbTlsMode.VerifyIdentity, MySqlSslMode.VerifyFull)]
public void CreateMariaDbConnection_UsesEndpointPortAndTls(
MariaDbTlsMode configuredTls,
MySqlSslMode expectedTls)
{
var options = TestOptions.CreateConfigured();
options.MariaDb.Host = "maria-builder-test.invalid";
options.MariaDb.Port = 43306;
options.MariaDb.TlsMode = configuredTls;
var factory = new ProviderDatabaseConnectionFactory(options);
using var connection = factory.Create(DataSourceKind.MariaDb);
var parsed = new MySqlConnectionStringBuilder(connection.ConnectionString);
Assert.Equal("maria-builder-test.invalid", parsed.Server);
Assert.Equal(43306u, parsed.Port);
Assert.Equal(expectedTls, parsed.SslMode);
Assert.False(parsed.PersistSecurityInfo);
}
[Fact]
public void PublicConfigurationException_DoesNotExposeRejectedHostUserOrPassword()
{
const string rejectedHost = "rejected(host)-sensitive-sentinel";
const string user = "oracle-user-sensitive-sentinel";
const string password = "oracle-password-sensitive-sentinel";
var options = TestOptions.CreateConfigured();
options.Oracle.Host = rejectedHost;
options.Oracle.UserName = user;
options.Oracle.Password = password;
var factory = new ProviderDatabaseConnectionFactory(options);
var exception = Assert.Throws<DatabaseConfigurationException>(
() => factory.Create(DataSourceKind.Oracle));
Assert.DoesNotContain(rejectedHost, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(user, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(password, exception.Message, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,216 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class ResilientDataQueryExecutorTests
{
[Fact]
public async Task ExecuteAsync_Success_PreservesSchemaRowsAndDbNull()
{
var source = new DataTable();
source.Columns.Add("CODE", typeof(int));
source.Columns.Add("NAME", typeof(string));
source.Rows.Add(7, DBNull.Value);
source.Rows.Add(8, "sample");
var plan = ConnectionPlan.Success(source);
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory);
var result = await executor.ExecuteAsync(
DataSourceKind.Oracle,
"Stocks",
"SELECT CODE, NAME FROM SAMPLE");
Assert.Equal("Stocks", result.TableName);
Assert.Equal(2, result.Columns.Count);
Assert.Equal("CODE", result.Columns[0].ColumnName);
Assert.Equal(typeof(int), result.Columns[0].DataType);
Assert.Equal("NAME", result.Columns[1].ColumnName);
Assert.Equal(typeof(string), result.Columns[1].DataType);
Assert.Equal(2, result.Rows.Count);
Assert.Equal(7, result.Rows[0]["CODE"]);
Assert.Equal(DBNull.Value, result.Rows[0]["NAME"]);
Assert.Equal("sample", result.Rows[1]["NAME"]);
Assert.Equal(1, factory.CreateCount);
Assert.True(plan.Disposed);
Assert.Equal(5, plan.ObservedCommandTimeoutSeconds);
}
[Fact]
public async Task ExecuteAsync_CanceledBeforeOpen_DoesNotRetry()
{
var plan = new ConnectionPlan
{
OpenAsync = token =>
{
token.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
};
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => executor.ExecuteAsync(
DataSourceKind.Oracle,
"Canceled",
"SELECT 1 FROM DUAL",
cancellation.Token));
Assert.Equal(1, factory.CreateCount);
Assert.True(plan.Disposed);
}
[Fact]
public async Task ExecuteAsync_CanceledDuringCommand_DoesNotRetry()
{
var commandStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var plan = new ConnectionPlan
{
ExecuteReaderAsync = async token =>
{
commandStarted.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, token);
throw new InvalidOperationException("Unreachable.");
}
};
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
using var cancellation = new CancellationTokenSource();
var operation = executor.ExecuteAsync(
DataSourceKind.MariaDb,
"Canceled",
"SELECT 1",
cancellation.Token);
await commandStarted.Task;
await cancellation.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
Assert.Equal(1, factory.CreateCount);
Assert.True(plan.Disposed);
}
[Fact]
public async Task ExecuteAsync_OverallTimeout_ThrowsSafeTimeoutAndDoesNotRetryCancellation()
{
var plan = new ConnectionPlan
{
ExecuteReaderAsync = async token =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, token);
throw new InvalidOperationException("Unreachable.");
}
};
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory, transient: true, retryCount: 2, timeoutSeconds: 1);
var exception = await Assert.ThrowsAsync<DatabaseQueryTimeoutException>(
() => executor.ExecuteAsync(
DataSourceKind.Oracle,
"Timeout",
"SELECT 1 FROM DUAL"));
Assert.Equal(DataSourceKind.Oracle, exception.DataSource);
Assert.True(exception.IsTransient);
Assert.Equal(1, factory.CreateCount);
Assert.True(plan.Disposed);
}
[Fact]
public async Task ExecuteAsync_TransientReadFailure_RetriesWithFreshConnectionAndSucceeds()
{
var first = new ConnectionPlan
{
OpenAsync = _ => Task.FromException(new TestDatabaseException("temporary"))
};
var data = new DataTable();
data.Columns.Add("VALUE", typeof(int));
data.Rows.Add(1);
var second = ConnectionPlan.Success(data);
var factory = new FakeConnectionFactory(first, second);
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
var result = await executor.ExecuteAsync(
DataSourceKind.MariaDb,
"Retry",
"SELECT VALUE FROM SAMPLE");
Assert.Single(result.Rows.Cast<DataRow>());
Assert.Equal(2, factory.CreateCount);
Assert.Equal(2, factory.CreatedConnections.Count);
Assert.NotSame(factory.CreatedConnections[0], factory.CreatedConnections[1]);
Assert.True(first.Disposed);
Assert.True(second.Disposed);
}
[Fact]
public async Task ExecuteAsync_PermanentFailure_AttemptsOnceAndUsesSafePublicMessage()
{
const string host = "host-sensitive-sentinel";
const string user = "user-sensitive-sentinel";
const string password = "password-sensitive-sentinel";
var plan = new ConnectionPlan
{
OpenAsync = _ => Task.FromException(
new TestDatabaseException($"{host}|{user}|{password}"))
};
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory, transient: false, retryCount: 2);
var exception = await Assert.ThrowsAsync<DatabaseQueryException>(
() => executor.ExecuteAsync(
DataSourceKind.Oracle,
"Permanent",
"SELECT 1 FROM DUAL"));
Assert.False(exception.IsTransient);
Assert.Equal(1, factory.CreateCount);
Assert.Null(exception.InnerException);
Assert.DoesNotContain(host, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(user, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(password, exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task ExecuteAsync_NonReadQuery_DoesNotRetryTransientFailure()
{
var plan = new ConnectionPlan
{
ExecuteReaderAsync = _ => Task.FromException<System.Data.Common.DbDataReader>(
new TestDatabaseException("temporary"))
};
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
var exception = await Assert.ThrowsAsync<DatabaseQueryException>(
() => executor.ExecuteAsync(
DataSourceKind.MariaDb,
"Mutation",
"UPDATE SAMPLE SET VALUE = 1"));
Assert.True(exception.IsTransient);
Assert.Equal(1, factory.CreateCount);
Assert.True(plan.Disposed);
}
private static ResilientDataQueryExecutor CreateExecutor(
FakeConnectionFactory factory,
bool transient = false,
int retryCount = 0,
int timeoutSeconds = 5) =>
new(
factory,
new DatabaseResilienceOptions
{
OperationTimeoutSeconds = timeoutSeconds,
MaximumRetryCount = retryCount,
InitialRetryDelayMilliseconds = 0
},
new StubErrorDetector(transient));
}

View File

@@ -0,0 +1,39 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
internal static class TestOptions
{
public static DatabaseOptions CreateConfigured() => new()
{
Oracle = new OracleDatabaseOptions
{
Host = "oracle-unit-test.invalid",
Port = 1521,
Sid = "TESTSID",
UserName = "oracle-unit-user",
Password = "oracle-unit-password",
ConnectTimeoutSeconds = 5,
CommandTimeoutSeconds = 5,
Pooling = false
},
MariaDb = new MariaDbDatabaseOptions
{
Host = "maria-unit-test.invalid",
Port = 3306,
Database = "test_database",
UserName = "maria-unit-user",
Password = "maria-unit-password",
TlsMode = MariaDbTlsMode.Preferred,
ConnectTimeoutSeconds = 5,
CommandTimeoutSeconds = 5,
Pooling = false
},
Resilience = new DatabaseResilienceOptions
{
OperationTimeoutSeconds = 5,
MaximumRetryCount = 2,
InitialRetryDelayMilliseconds = 0
}
};
}