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>
|
||||
279
tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseFakes.cs
Normal file
279
tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseFakes.cs
Normal 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));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
39
tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/TestOptions.cs
Normal file
39
tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/TestOptions.cs
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user