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( () => 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(() => 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( () => 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()); 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( () => 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( new TestDatabaseException("temporary")) }; var factory = new FakeConnectionFactory(plan); var executor = CreateExecutor(factory, transient: true, retryCount: 2); var exception = await Assert.ThrowsAsync( () => 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)); }