529 lines
18 KiB
C#
529 lines
18 KiB
C#
#nullable enable
|
|
|
|
using System.Collections;
|
|
using System.Data;
|
|
using System.Data.Common;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using MMoneyCoderSharp.Data;
|
|
using Oracle.ManagedDataAccess.Client;
|
|
|
|
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
|
|
|
|
public sealed class OracleManualFinancialMutationExecutorTests
|
|
{
|
|
private const string Version = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
|
|
[Fact]
|
|
public void ConfigureOracleCommand_sets_bind_by_name_and_rejects_another_provider()
|
|
{
|
|
using var oracleCommand = new OracleCommand();
|
|
OracleManualFinancialMutationExecutor.ConfigureOracleCommand(oracleCommand);
|
|
Assert.True(oracleCommand.BindByName);
|
|
|
|
var plan = new Plan();
|
|
using var fake = new FakeCommand(new FakeConnection(plan), plan, 0);
|
|
Assert.Throws<DatabaseConfigurationException>(() =>
|
|
OracleManualFinancialMutationExecutor.ConfigureOracleCommand(fake));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_locks_then_executes_bound_command_once_and_commits_one_transaction()
|
|
{
|
|
var plan = new Plan
|
|
{
|
|
ExecuteAsync = (index, _) => Task.FromResult(index == 0 ? -1 : 1)
|
|
};
|
|
var factory = new Factory(plan, 17);
|
|
var service = Service(Executor(factory));
|
|
|
|
var receipt = await service.CreateAsync(Sales("Operator ' quoted"));
|
|
|
|
Assert.Equal(1, factory.CreateCount);
|
|
Assert.Equal(ManualFinancialMutationKind.Create, receipt.Kind);
|
|
Assert.Equal(ManualFinancialScreenKind.Sales, receipt.Screen);
|
|
Assert.Equal("Operator ' quoted", receipt.StockName);
|
|
Assert.Equal(1, receipt.AffectedRows);
|
|
Assert.NotEqual(Guid.Empty, receipt.OperationId);
|
|
Assert.Equal(1, plan.OpenCount);
|
|
Assert.Equal(1, plan.BeginCount);
|
|
Assert.Equal(IsolationLevel.ReadCommitted, plan.IsolationLevel);
|
|
Assert.Equal(1, plan.CommitCount);
|
|
Assert.Equal(0, plan.RollbackCount);
|
|
Assert.Equal(2, plan.Commands.Count);
|
|
Assert.Equal("LOCK TABLE INPUT_SELL IN EXCLUSIVE MODE", plan.Commands[0].CommandText);
|
|
Assert.Empty(plan.Commands[0].ParametersSnapshot);
|
|
Assert.StartsWith("INSERT INTO INPUT_SELL", plan.Commands[1].CommandText.TrimStart(), StringComparison.Ordinal);
|
|
Assert.DoesNotContain("Operator ' quoted", plan.Commands[1].CommandText, StringComparison.Ordinal);
|
|
Assert.Equal("Operator ' quoted", plan.Commands[1].ParametersSnapshot
|
|
.Single(parameter => parameter.ParameterName == "stock_name").Value);
|
|
Assert.All(plan.Commands, command =>
|
|
{
|
|
Assert.True(command.BindByName);
|
|
Assert.Equal(17, command.CommandTimeout);
|
|
Assert.Equal(1, command.ExecuteCount);
|
|
Assert.Equal(0, command.SynchronousExecuteCount);
|
|
Assert.True(command.WasDisposed);
|
|
});
|
|
Assert.True(plan.ConnectionDisposed);
|
|
Assert.True(plan.TransactionDisposed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeniedAuthorizationCreatesNoConnectionOrTransaction()
|
|
{
|
|
var plan = new Plan();
|
|
var factory = new Factory(plan);
|
|
var authorization = new DenyAllDatabaseMutationAuthorization();
|
|
var service = Service(Executor(factory, mutationAuthorization: authorization));
|
|
|
|
var exception = await Assert.ThrowsAsync<ManualFinancialMutationException>(() =>
|
|
service.CreateAsync(Sales("Alpha")));
|
|
|
|
Assert.False(exception.OutcomeUnknown);
|
|
var denied = Assert.IsType<DatabaseMutationAuthorizationException>(
|
|
exception.InnerException);
|
|
Assert.Equal(DatabaseMutationTarget.ManualFinancial, denied.Request.Target);
|
|
Assert.Equal(DataSourceKind.Oracle, denied.Request.Source);
|
|
Assert.Equal(nameof(ManualFinancialMutationKind.Create), denied.Request.MutationKind);
|
|
Assert.NotEqual(Guid.Empty, denied.Request.OperationId);
|
|
Assert.Single(authorization.Requests);
|
|
Assert.Equal(0, factory.CreateCount);
|
|
Assert.Equal(0, plan.OpenCount);
|
|
Assert.Equal(0, plan.BeginCount);
|
|
Assert.Empty(plan.Commands);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Zero_affected_create_rolls_back_as_known_duplicate_before_commit()
|
|
{
|
|
var plan = new Plan
|
|
{
|
|
ExecuteAsync = (index, _) => Task.FromResult(index == 0 ? -1 : 0)
|
|
};
|
|
var factory = new Factory(plan);
|
|
var service = Service(Executor(factory));
|
|
|
|
var exception = await Assert.ThrowsAsync<ManualFinancialMutationRejectedException>(() =>
|
|
service.CreateAsync(Sales("Alpha")));
|
|
|
|
Assert.False(exception.OutcomeUnknown);
|
|
Assert.Equal(ManualFinancialMutationRejectionReason.DuplicateIdentity, exception.Reason);
|
|
Assert.Equal(1, plan.RollbackCount);
|
|
Assert.Equal(0, plan.CommitCount);
|
|
Assert.Equal(2, plan.Commands.Count);
|
|
Assert.Equal([1, 1], plan.Commands.Select(command => command.ExecuteCount));
|
|
Assert.Equal(1, factory.CreateCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Command_timeout_is_OutcomeUnknown_and_is_never_retried()
|
|
{
|
|
var plan = new Plan
|
|
{
|
|
ExecuteAsync = (index, _) => index == 0
|
|
? Task.FromResult(-1)
|
|
: Task.FromException<int>(new TimeoutException("late"))
|
|
};
|
|
var factory = new Factory(plan);
|
|
var service = Service(Executor(factory));
|
|
|
|
var exception = await Assert.ThrowsAsync<ManualFinancialMutationException>(() =>
|
|
service.CreateAsync(Sales("Alpha")));
|
|
|
|
Assert.True(exception.OutcomeUnknown);
|
|
Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Equal(1, factory.CreateCount);
|
|
Assert.Equal(1, plan.RollbackCount);
|
|
Assert.Equal(0, plan.CommitCount);
|
|
Assert.Equal(2, plan.Commands.Count);
|
|
Assert.Equal([1, 1], plan.Commands.Select(command => command.ExecuteCount));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Commit_response_loss_is_OutcomeUnknown_without_rollback_or_retry()
|
|
{
|
|
var plan = new Plan
|
|
{
|
|
ExecuteAsync = (index, _) => Task.FromResult(index == 0 ? -1 : 1),
|
|
CommitAsync = _ => Task.FromException(new TestDatabaseException("lost"))
|
|
};
|
|
var factory = new Factory(plan);
|
|
var service = Service(Executor(factory));
|
|
|
|
var exception = await Assert.ThrowsAsync<ManualFinancialMutationException>(() =>
|
|
service.DeleteAsync(new ManualFinancialSnapshot(Sales("Alpha"), Version)));
|
|
|
|
Assert.True(exception.OutcomeUnknown);
|
|
Assert.Equal(1, plan.CommitCount);
|
|
Assert.Equal(0, plan.RollbackCount);
|
|
Assert.Equal(1, factory.CreateCount);
|
|
Assert.Equal(2, plan.Commands.Count);
|
|
}
|
|
|
|
private static LegacyManualFinancialScreenService Service(
|
|
IManualFinancialMutationExecutor executor) =>
|
|
new(new ForbiddenQueryExecutor(), executor);
|
|
|
|
private static OracleManualFinancialMutationExecutor Executor(
|
|
Factory factory,
|
|
int operationTimeoutSeconds = 30,
|
|
IDatabaseMutationAuthorization? mutationAuthorization = null) =>
|
|
new(
|
|
factory,
|
|
new DatabaseResilienceOptions
|
|
{
|
|
OperationTimeoutSeconds = operationTimeoutSeconds,
|
|
MaximumRetryCount = 5,
|
|
InitialRetryDelayMilliseconds = 30_000
|
|
},
|
|
new StubErrorDetector(false),
|
|
command => Assert.IsType<FakeCommand>(command).BindByName = true,
|
|
mutationAuthorization ?? DatabaseMutationAuthorization.AllowAll);
|
|
|
|
private static ManualSalesRecord Sales(string stockName) =>
|
|
new(
|
|
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName),
|
|
[
|
|
new("1Q", 10),
|
|
new("2Q", 20),
|
|
new("3Q", 30),
|
|
new("4Q", 40),
|
|
new("5Q", 50),
|
|
new("6Q", 60)
|
|
]);
|
|
|
|
private sealed class ForbiddenQueryExecutor : IDataQueryExecutor
|
|
{
|
|
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
|
|
throw new InvalidOperationException("A mutation test must not read.");
|
|
|
|
public Task<DataTable> ExecuteAsync(
|
|
DataSourceKind source,
|
|
string tableName,
|
|
string query,
|
|
CancellationToken cancellationToken = default) =>
|
|
throw new InvalidOperationException("A mutation test must not read.");
|
|
}
|
|
|
|
private sealed class Factory(Plan plan, int commandTimeoutSeconds = 15)
|
|
: IDatabaseConnectionFactory
|
|
{
|
|
public int CreateCount { get; private set; }
|
|
|
|
public DbConnection Create(DataSourceKind source)
|
|
{
|
|
Assert.Equal(DataSourceKind.Oracle, source);
|
|
CreateCount++;
|
|
return new FakeConnection(plan);
|
|
}
|
|
|
|
public int GetCommandTimeoutSeconds(DataSourceKind source)
|
|
{
|
|
Assert.Equal(DataSourceKind.Oracle, source);
|
|
return commandTimeoutSeconds;
|
|
}
|
|
}
|
|
|
|
private sealed class Plan
|
|
{
|
|
public Func<int, CancellationToken, Task<int>> ExecuteAsync { get; init; } =
|
|
(_, _) => Task.FromResult(1);
|
|
|
|
public Func<CancellationToken, Task> CommitAsync { get; init; } =
|
|
_ => Task.CompletedTask;
|
|
|
|
public Func<CancellationToken, Task> RollbackAsync { get; init; } =
|
|
_ => Task.CompletedTask;
|
|
|
|
public int OpenCount { get; set; }
|
|
|
|
public int BeginCount { get; set; }
|
|
|
|
public int CommitCount { get; set; }
|
|
|
|
public int RollbackCount { get; set; }
|
|
|
|
public IsolationLevel? IsolationLevel { get; set; }
|
|
|
|
public bool ConnectionDisposed { get; set; }
|
|
|
|
public bool TransactionDisposed { get; set; }
|
|
|
|
public List<FakeCommand> Commands { get; } = [];
|
|
}
|
|
|
|
private sealed class FakeConnection(Plan plan) : DbConnection
|
|
{
|
|
private ConnectionState _state = ConnectionState.Closed;
|
|
|
|
[AllowNull]
|
|
public override string ConnectionString { get; set; } = string.Empty;
|
|
|
|
public override string Database => "Fake";
|
|
|
|
public override string DataSource => "Fake";
|
|
|
|
public override string ServerVersion => "1";
|
|
|
|
public override ConnectionState State => _state;
|
|
|
|
public override void ChangeDatabase(string databaseName)
|
|
{
|
|
}
|
|
|
|
public override void Close() => _state = ConnectionState.Closed;
|
|
|
|
public override void Open() => throw new InvalidOperationException("Synchronous open is forbidden.");
|
|
|
|
public override Task OpenAsync(CancellationToken cancellationToken)
|
|
{
|
|
plan.OpenCount++;
|
|
_state = ConnectionState.Open;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
|
|
throw new InvalidOperationException("Synchronous begin is forbidden.");
|
|
|
|
protected override ValueTask<DbTransaction> BeginDbTransactionAsync(
|
|
IsolationLevel isolationLevel,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
plan.BeginCount++;
|
|
plan.IsolationLevel = isolationLevel;
|
|
return ValueTask.FromResult<DbTransaction>(new FakeTransaction(this, plan, isolationLevel));
|
|
}
|
|
|
|
protected override DbCommand CreateDbCommand()
|
|
{
|
|
var command = new FakeCommand(this, plan, plan.Commands.Count);
|
|
plan.Commands.Add(command);
|
|
return command;
|
|
}
|
|
|
|
public override ValueTask DisposeAsync()
|
|
{
|
|
plan.ConnectionDisposed = true;
|
|
_state = ConnectionState.Closed;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeTransaction(
|
|
DbConnection connection,
|
|
Plan plan,
|
|
IsolationLevel isolationLevel) : DbTransaction
|
|
{
|
|
public override IsolationLevel IsolationLevel => isolationLevel;
|
|
|
|
protected override DbConnection DbConnection => connection;
|
|
|
|
public override void Commit() => throw new InvalidOperationException("Synchronous commit is forbidden.");
|
|
|
|
public override async Task CommitAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
plan.CommitCount++;
|
|
await plan.CommitAsync(cancellationToken);
|
|
}
|
|
|
|
public override void Rollback() => throw new InvalidOperationException("Synchronous rollback is forbidden.");
|
|
|
|
public override async Task RollbackAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
plan.RollbackCount++;
|
|
await plan.RollbackAsync(cancellationToken);
|
|
}
|
|
|
|
public override ValueTask DisposeAsync()
|
|
{
|
|
plan.TransactionDisposed = true;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeCommand(
|
|
DbConnection connection,
|
|
Plan plan,
|
|
int commandIndex) : DbCommand
|
|
{
|
|
private readonly FakeParameterCollection _parameters = new();
|
|
|
|
public bool BindByName { get; set; }
|
|
|
|
public int ExecuteCount { get; private set; }
|
|
|
|
public int SynchronousExecuteCount { get; private set; }
|
|
|
|
public bool WasDisposed { get; private set; }
|
|
|
|
public IReadOnlyList<DbParameter> ParametersSnapshot =>
|
|
_parameters.Cast<DbParameter>().ToArray();
|
|
|
|
[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()
|
|
{
|
|
SynchronousExecuteCount++;
|
|
throw new InvalidOperationException("Synchronous execution is forbidden.");
|
|
}
|
|
|
|
public override async Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
|
|
{
|
|
ExecuteCount++;
|
|
return await plan.ExecuteAsync(commandIndex, cancellationToken);
|
|
}
|
|
|
|
public override object? ExecuteScalar() => throw new NotSupportedException();
|
|
|
|
public override void Prepare()
|
|
{
|
|
}
|
|
|
|
protected override DbParameter CreateDbParameter() => new FakeParameter();
|
|
|
|
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) =>
|
|
throw new NotSupportedException();
|
|
|
|
public override ValueTask DisposeAsync()
|
|
{
|
|
WasDisposed = true;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeParameter : 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() => DbType = default;
|
|
}
|
|
|
|
private sealed class FakeParameterCollection : 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(Require(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, Require(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 Require(object value) =>
|
|
value as DbParameter ?? throw new ArgumentException("A parameter is required.");
|
|
}
|
|
}
|