Complete legacy operator UI and playout migration

This commit is contained in:
2026-07-12 02:05:49 +09:00
parent d2e16bf0c2
commit ffb8f43c19
131 changed files with 48473 additions and 228 deletions

View File

@@ -0,0 +1,494 @@
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;
public sealed class OperatorCatalogMutationExecutorTests
{
[Fact]
public async Task ExecuteTransactionAsync_UsesOneSerializableTransactionAndCommitsOnce()
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var factory = new CatalogMutationConnectionFactory(plan);
var executor = CreateExecutor(factory);
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var receipt = await service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0042", "전문가"),
[
new ExpertCatalogRecommendation(0, "005930", "삼성전자", 70000m),
new ExpertCatalogRecommendation(1, "035720", "카카오", 45000m)
]));
Assert.Equal(OperatorCatalogMutationKind.CreateExpert, receipt.Kind);
Assert.Equal(1, factory.CreateCount);
Assert.Equal(DataSourceKind.Oracle, factory.LastSource);
Assert.Equal(IsolationLevel.Serializable, plan.IsolationLevel);
Assert.Equal(1, plan.BeginCount);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(3, plan.Commands.Count);
Assert.Empty(plan.NonQuerySteps);
Assert.All(plan.Commands, command =>
{
Assert.Equal(7, command.CommandTimeoutSeconds);
Assert.True(command.HadTransaction);
Assert.All(command.Parameters, parameter =>
Assert.Equal(ParameterDirection.Input, parameter.Direction));
});
Assert.Contains("INSERT INTO EXPERT_LIST", plan.Commands[0].Sql, StringComparison.Ordinal);
Assert.Equal("0042", Parameter(plan.Commands[0], "expert_code").Value);
Assert.Equal("전문가", Parameter(plan.Commands[0], "expert_name").Value);
Assert.Contains("INSERT INTO RECOMMEND_LIST", plan.Commands[1].Sql, StringComparison.Ordinal);
Assert.Equal(70000m, Parameter(plan.Commands[1], "buy_amount").Value);
Assert.Equal(1, plan.ConnectionDisposeCount);
Assert.Equal(1, plan.TransactionDisposeCount);
}
[Fact]
public async Task ExecuteTransactionAsync_SupportsMariaThemeWithSameAtomicContract()
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var factory = new CatalogMutationConnectionFactory(plan);
var executor = CreateExecutor(factory);
var service = new LegacyThemeCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
await service.CreateAsync(new ThemeCatalogDefinition(
ThemeMarket.Nxt,
"00000007",
"반도체",
[new ThemeCatalogItem(0, "X005930", "삼성전자")]));
Assert.Equal(DataSourceKind.MariaDb, factory.LastSource);
Assert.Equal(IsolationLevel.Serializable, plan.IsolationLevel);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(2, plan.Commands.Count);
Assert.All(plan.Commands, command => Assert.Contains('@', command.Sql));
}
[Fact]
public async Task ExecuteTransactionAsync_AffectedRowConflictRollsBackBeforeNextCommand()
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(0));
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var factory = new CatalogMutationConnectionFactory(plan);
var executor = CreateExecutor(factory);
var service = new LegacyThemeCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var exception = await Assert.ThrowsAsync<OperatorCatalogConflictException>(() =>
service.CreateAsync(new ThemeCatalogDefinition(
ThemeMarket.Krx,
"00000001",
"중복 테마",
[new ThemeCatalogItem(0, "P005930", "삼성전자")])));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(OperatorCatalogDbCommandKind.InsertTheme, exception.CommandKind);
Assert.Single(plan.Commands);
Assert.Single(plan.NonQuerySteps);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task ExecuteTransactionAsync_EmptyChildDeleteThenIdentityParentDeleteCommits()
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(0));
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan));
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
await service.DeleteAsync(new ExpertSelectionIdentity("0001", "전문가"));
Assert.Equal(2, plan.Commands.Count);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ExecuteTransactionAsync_TimeoutOrCancellationAfterBeginIsOutcomeUnknown(
bool cancellation)
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ => cancellation
? Task.FromException<int>(new OperationCanceledException("cancelled in provider"))
: Task.FromException<int>(new TimeoutException("provider timeout")));
var factory = new CatalogMutationConnectionFactory(plan);
var executor = CreateExecutor(factory);
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[])));
Assert.True(exception.OutcomeUnknown);
Assert.Contains("do not retry automatically", exception.Message, StringComparison.Ordinal);
Assert.Single(plan.Commands);
Assert.Empty(plan.NonQuerySteps);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task ExecuteTransactionAsync_OrdinaryFailureWithKnownRollbackIsNotUnknown()
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ =>
Task.FromException<int>(new TestDatabaseException("constraint")));
var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan));
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[])));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task ExecuteTransactionAsync_RollbackFailureMakesOutcomeUnknown()
{
var plan = new CatalogMutationConnectionPlan
{
RollbackAsync = _ => Task.FromException(new IOException("rollback disconnected"))
};
plan.NonQuerySteps.Enqueue(_ =>
Task.FromException<int>(new TestDatabaseException("constraint")));
var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan));
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[])));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task ExecuteTransactionAsync_CommitFailureIsUnknownAndDoesNotRollback()
{
var plan = new CatalogMutationConnectionPlan
{
CommitAsync = _ => Task.FromException(new TimeoutException("commit response lost"))
};
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan));
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[])));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
}
[Fact]
public async Task ExecuteTransactionAsync_PreCancelledTokenDoesNotOpenConnection()
{
var plan = new CatalogMutationConnectionPlan();
var factory = new CatalogMutationConnectionFactory(plan);
var executor = CreateExecutor(factory);
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.CreateAsync(
new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[]),
cancellation.Token));
Assert.Equal(0, factory.CreateCount);
Assert.Equal(0, plan.BeginCount);
}
private static OperatorCatalogMutationExecutor CreateExecutor(
IDatabaseConnectionFactory factory) =>
new(
factory,
new DatabaseResilienceOptions
{
OperationTimeoutSeconds = 30,
MaximumRetryCount = 5,
InitialRetryDelayMilliseconds = 1
},
new StubErrorDetector(transient: true),
static (_, _) => { });
private static ObservedCatalogMutationParameter Parameter(
ObservedCatalogMutationCommand command,
string name) =>
Assert.Single(command.Parameters, parameter =>
string.Equals(parameter.Name, name, StringComparison.Ordinal));
}
internal sealed class CatalogNoQueryExecutor : IDataQueryExecutor
{
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new InvalidOperationException("No query expected.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("No query expected.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("No query expected.");
}
internal sealed class CatalogMutationConnectionPlan
{
internal Queue<Func<CancellationToken, Task<int>>> NonQuerySteps { get; } = new();
internal Func<CancellationToken, Task> OpenAsync { get; init; } =
_ => Task.CompletedTask;
internal Func<CancellationToken, Task> CommitAsync { get; init; } =
_ => Task.CompletedTask;
internal Func<CancellationToken, Task> RollbackAsync { get; init; } =
_ => Task.CompletedTask;
internal List<ObservedCatalogMutationCommand> Commands { get; } = [];
internal IsolationLevel? IsolationLevel { get; set; }
internal int BeginCount { get; set; }
internal int CommitCount { get; set; }
internal int RollbackCount { get; set; }
internal int ConnectionDisposeCount { get; set; }
internal int TransactionDisposeCount { get; set; }
}
internal sealed record ObservedCatalogMutationCommand(
string Sql,
int CommandTimeoutSeconds,
bool HadTransaction,
IReadOnlyList<ObservedCatalogMutationParameter> Parameters);
internal sealed record ObservedCatalogMutationParameter(
string Name,
object? Value,
DbType DbType,
ParameterDirection Direction);
internal sealed class CatalogMutationConnectionFactory(CatalogMutationConnectionPlan plan)
: IDatabaseConnectionFactory
{
internal int CreateCount { get; private set; }
internal DataSourceKind? LastSource { get; private set; }
public DbConnection Create(DataSourceKind source)
{
CreateCount++;
LastSource = source;
return new CatalogMutationDbConnection(plan);
}
public int GetCommandTimeoutSeconds(DataSourceKind source) => 7;
}
internal sealed class CatalogMutationDbConnection(CatalogMutationConnectionPlan plan)
: DbConnection
{
private ConnectionState _state = ConnectionState.Closed;
[AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => "CatalogFake";
public override string DataSource => "CatalogFake";
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 forbidden.");
public override async Task OpenAsync(CancellationToken cancellationToken)
{
await plan.OpenAsync(cancellationToken);
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
{
plan.BeginCount++;
plan.IsolationLevel = isolationLevel;
return new CatalogMutationDbTransaction(this, plan, isolationLevel);
}
protected override DbCommand CreateDbCommand() =>
new CatalogMutationDbCommand(this, plan);
protected override void Dispose(bool disposing)
{
if (disposing)
{
plan.ConnectionDisposeCount++;
_state = ConnectionState.Closed;
}
base.Dispose(disposing);
}
}
internal sealed class CatalogMutationDbTransaction(
DbConnection connection,
CatalogMutationConnectionPlan plan,
IsolationLevel isolationLevel) : DbTransaction
{
public override IsolationLevel IsolationLevel => isolationLevel;
protected override DbConnection DbConnection => connection;
public override void Commit() => throw new InvalidOperationException("Sync commit forbidden.");
public override void Rollback() => throw new InvalidOperationException("Sync rollback forbidden.");
public override async Task CommitAsync(CancellationToken cancellationToken = default)
{
plan.CommitCount++;
await plan.CommitAsync(cancellationToken);
}
public override async Task RollbackAsync(CancellationToken cancellationToken = default)
{
plan.RollbackCount++;
await plan.RollbackAsync(cancellationToken);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
plan.TransactionDisposeCount++;
}
base.Dispose(disposing);
}
}
internal sealed class CatalogMutationDbCommand(
DbConnection connection,
CatalogMutationConnectionPlan 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 InvalidOperationException("Synchronous execution forbidden.");
public override object? ExecuteScalar() => throw new NotSupportedException();
public override void Prepare()
{
}
protected override DbParameter CreateDbParameter() => new FakeDbParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) =>
throw new NotSupportedException();
public override Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
{
plan.Commands.Add(new ObservedCatalogMutationCommand(
CommandText,
CommandTimeout,
DbTransaction is not null,
_parameters.Cast<DbParameter>()
.Select(parameter => new ObservedCatalogMutationParameter(
parameter.ParameterName,
parameter.Value,
parameter.DbType,
parameter.Direction))
.ToArray()));
if (!plan.NonQuerySteps.TryDequeue(out var step))
{
throw new InvalidOperationException("No non-query plan remains.");
}
return step(cancellationToken);
}
}

View File

@@ -0,0 +1,501 @@
#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 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) =>
new(
factory,
new DatabaseResilienceOptions
{
OperationTimeoutSeconds = operationTimeoutSeconds,
MaximumRetryCount = 5,
InitialRetryDelayMilliseconds = 30_000
},
new StubErrorDetector(false),
command => Assert.IsType<FakeCommand>(command).BindByName = true);
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.");
}
}

View File

@@ -0,0 +1,814 @@
using System.Collections;
using System.Data;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class OracleNamedPlaylistMutationExecutorTests
{
[Fact]
public void ConfigureOracleCommand_SetsBindByNameAndRejectsAnotherProviderCommand()
{
using var oracleCommand = new OracleCommand();
OracleNamedPlaylistMutationExecutor.ConfigureOracleCommand(oracleCommand);
Assert.True(oracleCommand.BindByName);
using var fakeCommand = new MutationFakeCommand(
new MutationFakeConnection(new MutationPlan()),
new MutationPlan(),
0);
Assert.Throws<DatabaseConfigurationException>(
() => OracleNamedPlaylistMutationExecutor.ConfigureOracleCommand(fakeCommand));
}
[Fact]
public async Task ReplaceItemsAsync_UsesOneConnectionTransactionAndCommitWithBoundCommands()
{
var plan = new MutationPlan();
var factory = new MutationConnectionFactory(plan, commandTimeoutSeconds: 17);
var executor = CreateExecutor(factory);
var service = CreateService(executor);
await service.ReplaceItemsAsync(
"00000011",
[
Item(0, "Alpha", "000001"),
Item(1, "Beta", "000002")
]);
Assert.Equal(1, factory.CreateCount);
Assert.Equal([DataSourceKind.Oracle], factory.Sources);
Assert.Equal(1, plan.OpenCount);
Assert.Equal(1, plan.BeginCount);
Assert.Equal(IsolationLevel.ReadCommitted, plan.ObservedIsolationLevel);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(0, plan.SynchronousBeginCount);
Assert.Equal(0, plan.SynchronousCommitCount);
Assert.Equal(0, plan.SynchronousRollbackCount);
Assert.True(plan.ConnectionDisposed);
Assert.True(plan.TransactionDisposed);
Assert.Collection(
plan.Commands,
command =>
{
Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, CommandKind(command));
Assert.StartsWith(
"DELETE FROM PLAY_LIST",
command.CommandText.TrimStart(),
StringComparison.Ordinal);
AssertParameter(
Assert.Single(command.ParametersSnapshot),
"program_code",
"00000011",
DbType.String);
},
command => AssertInsert(command, 0, "Alpha", "000001"),
command => AssertInsert(command, 1, "Beta", "000002"));
var transaction = Assert.IsType<MutationFakeTransaction>(plan.Transaction);
foreach (var command in plan.Commands)
{
Assert.True(command.BindByName);
Assert.Equal(CommandType.Text, command.CommandType);
Assert.Equal(17, command.CommandTimeout);
Assert.Same(transaction, command.ObservedTransaction);
Assert.Equal(1, command.ExecuteCount);
Assert.Equal(0, command.SynchronousExecuteCount);
Assert.True(command.WasDisposed);
}
}
[Fact]
public async Task CommandFailure_RollsBackAndReportsKnownNonCommitWithoutRetry()
{
var plan = new MutationPlan
{
ExecuteAsync = (index, _) => index == 1
? Task.FromException<int>(new TestDatabaseException("command failure"))
: Task.FromResult(1)
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(() =>
service.ReplaceItemsAsync(
"00000011",
[Item(0, "Alpha", "000001"), Item(1, "Beta", "000002")]));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(1, factory.CreateCount);
Assert.Equal(2, plan.Commands.Count);
Assert.Equal([1, 1], plan.Commands.Select(static command => command.ExecuteCount));
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task CommandTimeout_AttemptsRollbackButStillQuarantinesOutcome()
{
var plan = new MutationPlan
{
ExecuteAsync = (_, _) => Task.FromException<int>(new TimeoutException("timeout"))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.DeleteAsync("00000011"));
Assert.True(exception.OutcomeUnknown);
Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Equal(1, factory.CreateCount);
Assert.Single(plan.Commands);
Assert.Equal(1, plan.Commands[0].ExecuteCount);
}
[Fact]
public async Task CommandCancellation_AttemptsRollbackButStillQuarantinesOutcome()
{
var plan = new MutationPlan
{
ExecuteAsync = (_, token) =>
Task.FromException<int>(new OperationCanceledException(token))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.DeleteAsync("00000011"));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Equal(1, factory.CreateCount);
}
[Fact]
public async Task OverallOperationTimeout_CancelsCommandOnceAndDoesNotRetry()
{
var plan = new MutationPlan
{
ExecuteAsync = (_, token) => WaitForCancellationAsync(token)
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory, operationTimeoutSeconds: 1));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.DeleteAsync("00000011"));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, factory.CreateCount);
Assert.Single(plan.Commands);
Assert.Equal(1, plan.Commands[0].ExecuteCount);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task RollbackFailure_ChangesOrdinaryCommandFailureToOutcomeUnknown()
{
var plan = new MutationPlan
{
ExecuteAsync = (_, _) =>
Task.FromException<int>(new TestDatabaseException("command failure")),
RollbackAsync = _ =>
Task.FromException(new TestDatabaseException("rollback failure"))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.DeleteAsync("00000011"));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Equal(1, factory.CreateCount);
}
[Fact]
public async Task CommitFailure_IsOutcomeUnknownAndNeverIssuesRollbackOrRetry()
{
var plan = new MutationPlan
{
CommitAsync = _ =>
Task.FromException(new TestDatabaseException("commit response lost"))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000011", "Morning"));
Assert.True(exception.OutcomeUnknown);
Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(1, factory.CreateCount);
Assert.Single(plan.Commands);
Assert.Equal(1, plan.Commands[0].ExecuteCount);
}
[Fact]
public async Task CommitCancellation_IsOutcomeUnknownAndNeverIssuesRollback()
{
var plan = new MutationPlan
{
CommitAsync = token =>
Task.FromException(new OperationCanceledException(token))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000011", "Morning"));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(1, factory.CreateCount);
}
[Fact]
public async Task OpenFailure_HasKnownNonCommitAndCreatesNoTransaction()
{
var plan = new MutationPlan
{
OpenAsync = _ => Task.FromException(new TestDatabaseException("open failure"))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000011", "Morning"));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(1, factory.CreateCount);
Assert.Equal(1, plan.OpenCount);
Assert.Equal(0, plan.BeginCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Empty(plan.Commands);
Assert.True(plan.ConnectionDisposed);
}
[Fact]
public async Task BeginFailure_HasKnownNonCommitAndExecutesNoCommand()
{
var plan = new MutationPlan
{
BeginAsync = (_, _) =>
Task.FromException<DbTransaction>(new TestDatabaseException("begin failure"))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000011", "Morning"));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(1, factory.CreateCount);
Assert.Equal(1, plan.BeginCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Empty(plan.Commands);
}
[Fact]
public void Constructor_RejectsInvalidCommandTimeout()
{
var factory = new MutationConnectionFactory(
new MutationPlan(),
commandTimeoutSeconds: 0);
Assert.Throws<DatabaseConfigurationException>(() => CreateExecutor(factory));
Assert.Equal(0, factory.CreateCount);
}
private static OracleNamedPlaylistMutationExecutor CreateExecutor(
MutationConnectionFactory factory,
int operationTimeoutSeconds = 30) =>
new(
factory,
new DatabaseResilienceOptions
{
OperationTimeoutSeconds = operationTimeoutSeconds,
MaximumRetryCount = 5,
InitialRetryDelayMilliseconds = 30_000
},
new TransientDatabaseErrorDetector(),
command => Assert.IsType<MutationFakeCommand>(command).BindByName = true);
private static LegacyNamedPlaylistPersistenceService CreateService(
INamedPlaylistMutationExecutor mutations) =>
new(new ForbiddenQueryExecutor(), mutations);
private static NamedPlaylistStoredItem Item(
int index,
string subject,
string dataCode) =>
new(
index,
true,
new LegacySceneSelection("KOSPI", subject, "1열판기본", "현재가", dataCode),
new NamedPlaylistPageState(1, 1));
private static void AssertInsert(
MutationFakeCommand command,
int expectedIndex,
string expectedSubject,
string expectedDataCode)
{
Assert.Equal(NamedPlaylistDbCommandKind.InsertItem, CommandKind(command));
Assert.StartsWith(
"INSERT INTO PLAY_LIST(",
command.CommandText.TrimStart(),
StringComparison.Ordinal);
Assert.Equal(11, command.ParametersSnapshot.Count);
AssertParameter(
command.ParametersSnapshot[0],
"program_code",
"00000011",
DbType.String);
AssertParameter(
command.ParametersSnapshot[1],
"list_text",
$"1^KOSPI^{expectedSubject}^1열판기본^현재가^1/1^{expectedDataCode}",
DbType.String);
foreach (var parameter in command.ParametersSnapshot.Skip(2).Take(8))
{
Assert.Same(DBNull.Value, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
Assert.Equal(ParameterDirection.Input, parameter.Direction);
}
AssertParameter(
command.ParametersSnapshot[10],
"item_index",
expectedIndex,
DbType.Int32);
}
private static NamedPlaylistDbCommandKind CommandKind(MutationFakeCommand command)
{
var sql = command.CommandText.TrimStart();
if (sql.StartsWith("INSERT INTO DC_LIST(", StringComparison.Ordinal))
{
return NamedPlaylistDbCommandKind.InsertDefinition;
}
if (sql.StartsWith("DELETE FROM PLAY_LIST", StringComparison.Ordinal))
{
return NamedPlaylistDbCommandKind.DeleteItems;
}
if (sql.StartsWith("INSERT INTO PLAY_LIST(", StringComparison.Ordinal))
{
return NamedPlaylistDbCommandKind.InsertItem;
}
if (sql.StartsWith("DELETE FROM DC_LIST", StringComparison.Ordinal))
{
return NamedPlaylistDbCommandKind.DeleteDefinition;
}
throw new InvalidOperationException("Unexpected command SQL.");
}
private static void AssertParameter(
DbParameter parameter,
string expectedName,
object expectedValue,
DbType expectedType)
{
Assert.Equal(expectedName, parameter.ParameterName);
Assert.Equal(expectedValue, parameter.Value);
Assert.Equal(expectedType, parameter.DbType);
Assert.Equal(ParameterDirection.Input, parameter.Direction);
}
private static Task<int> WaitForCancellationAsync(CancellationToken token)
{
var completion = new TaskCompletionSource<int>(
TaskCreationOptions.RunContinuationsAsynchronously);
token.Register(() => completion.TrySetCanceled(token));
return completion.Task;
}
private sealed class ForbiddenQueryExecutor : IDataQueryExecutor
{
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new InvalidOperationException("A mutation test must not run a read query.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("A mutation test must not run a read query.");
}
private sealed class MutationConnectionFactory(
MutationPlan plan,
int commandTimeoutSeconds = 15) : IDatabaseConnectionFactory
{
public int CreateCount { get; private set; }
public List<DataSourceKind> Sources { get; } = [];
public DbConnection Create(DataSourceKind source)
{
CreateCount++;
Sources.Add(source);
return new MutationFakeConnection(plan);
}
public int GetCommandTimeoutSeconds(DataSourceKind source)
{
Assert.Equal(DataSourceKind.Oracle, source);
return commandTimeoutSeconds;
}
}
private sealed class MutationPlan
{
public Func<CancellationToken, Task> OpenAsync { get; init; } =
_ => Task.CompletedTask;
public Func<IsolationLevel, CancellationToken, Task<DbTransaction>>? BeginAsync
{
get;
init;
}
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 int SynchronousBeginCount { get; set; }
public int SynchronousCommitCount { get; set; }
public int SynchronousRollbackCount { get; set; }
public IsolationLevel? ObservedIsolationLevel { get; set; }
public bool ConnectionDisposed { get; set; }
public bool TransactionDisposed { get; set; }
public MutationFakeTransaction? Transaction { get; set; }
public List<MutationFakeCommand> Commands { get; } = [];
}
private sealed class MutationFakeConnection(MutationPlan plan) : DbConnection
{
private ConnectionState _state = ConnectionState.Closed;
[AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => "MutationFake";
public override string DataSource => "MutationFake";
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() =>
throw new InvalidOperationException("Synchronous open is forbidden.");
public override async Task OpenAsync(CancellationToken cancellationToken)
{
plan.OpenCount++;
await plan.OpenAsync(cancellationToken);
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
{
plan.SynchronousBeginCount++;
throw new InvalidOperationException("Synchronous begin is forbidden.");
}
protected override async ValueTask<DbTransaction> BeginDbTransactionAsync(
IsolationLevel isolationLevel,
CancellationToken cancellationToken)
{
plan.BeginCount++;
plan.ObservedIsolationLevel = isolationLevel;
if (plan.BeginAsync is not null)
{
return await plan.BeginAsync(isolationLevel, cancellationToken);
}
var transaction = new MutationFakeTransaction(this, plan, isolationLevel);
plan.Transaction = transaction;
return transaction;
}
protected override DbCommand CreateDbCommand()
{
var command = new MutationFakeCommand(this, plan, plan.Commands.Count);
plan.Commands.Add(command);
return command;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
plan.ConnectionDisposed = true;
_state = ConnectionState.Closed;
}
base.Dispose(disposing);
}
public override ValueTask DisposeAsync()
{
plan.ConnectionDisposed = true;
_state = ConnectionState.Closed;
return ValueTask.CompletedTask;
}
}
private sealed class MutationFakeTransaction(
DbConnection connection,
MutationPlan plan,
IsolationLevel isolationLevel) : DbTransaction
{
public override IsolationLevel IsolationLevel => isolationLevel;
protected override DbConnection DbConnection => connection;
public override void Commit()
{
plan.SynchronousCommitCount++;
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()
{
plan.SynchronousRollbackCount++;
throw new InvalidOperationException("Synchronous rollback is forbidden.");
}
public override async Task RollbackAsync(CancellationToken cancellationToken = default)
{
plan.RollbackCount++;
await plan.RollbackAsync(cancellationToken);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
plan.TransactionDisposed = true;
}
base.Dispose(disposing);
}
public override ValueTask DisposeAsync()
{
plan.TransactionDisposed = true;
return ValueTask.CompletedTask;
}
}
private sealed class MutationFakeCommand(
DbConnection connection,
MutationPlan plan,
int commandIndex) : DbCommand
{
private readonly MutationFakeParameterCollection _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 DbTransaction? ObservedTransaction => DbTransaction;
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 MutationFakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) =>
throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
{
WasDisposed = true;
}
base.Dispose(disposing);
}
public override ValueTask DisposeAsync()
{
WasDisposed = true;
return ValueTask.CompletedTask;
}
}
private sealed class MutationFakeParameter : 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 MutationFakeParameterCollection : 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

@@ -115,7 +115,7 @@ public sealed class S5025TrustedManualFileDataSourceTests
using var directory = new TemporaryDirectory();
await File.WriteAllBytesAsync(
Path.Combine(directory.Path, "개인.dat"),
Cp949.GetBytes(content));
Cp949.GetBytes(content + "\r\n\r\n"));
var source = new S5025TrustedManualFileDataSource(directory.Path);
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
@@ -123,15 +123,16 @@ public sealed class S5025TrustedManualFileDataSourceTests
}
[Fact]
public async Task ReadAsync_accepts_one_trailing_line_terminator()
public async Task ReadAsync_rejects_missing_empty_terminal_record()
{
using var directory = new TemporaryDirectory();
WriteValidFile(Path.Combine(directory.Path, "개인.dat"), trailingNewLine: true);
await File.WriteAllBytesAsync(
Path.Combine(directory.Path, "개인.dat"),
Cp949.GetBytes(ValidContent() + "\r\n"));
var source = new S5025TrustedManualFileDataSource(directory.Path);
var rows = await source.ReadAsync(S5025ManualAudience.Individual);
Assert.Equal(5, rows.Count);
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
source.ReadAsync(S5025ManualAudience.Individual));
}
[Fact]
@@ -244,9 +245,9 @@ public sealed class S5025TrustedManualFileDataSourceTests
source.ReadAsync(S5025ManualAudience.Individual, cancellation.Token));
}
private static void WriteValidFile(string path, bool trailingNewLine = false)
private static void WriteValidFile(string path)
{
var content = ValidContent() + (trailingNewLine ? "\r\n" : string.Empty);
var content = ValidContent() + "\r\n\r\n";
File.WriteAllBytes(path, Cp949.GetBytes(content));
}

View File

@@ -0,0 +1,184 @@
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class S5025TrustedManualFileStoreTests
{
[Theory]
[InlineData(S5025ManualAudience.Individual, "개인.dat")]
[InlineData(S5025ManualAudience.Foreign, "외국인.dat")]
[InlineData(S5025ManualAudience.Institution, "기관.dat")]
public async Task WriteAsync_round_trips_the_closed_audience_file(
S5025ManualAudience audience,
string expectedFileName)
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
await store.WriteAsync(audience, Rows());
Assert.True(File.Exists(Path.Combine(directory.Path, expectedFileName)));
var actual = await store.ReadAsync(audience);
Assert.Equal(Rows(), actual);
Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp"));
}
[Fact]
public async Task WriteAsync_preserves_the_legacy_cp949_terminal_record()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
await store.WriteAsync(S5025ManualAudience.Individual, Rows());
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp949 = Encoding.GetEncoding(949);
var bytes = await File.ReadAllBytesAsync(Path.Combine(directory.Path, "개인.dat"));
var text = cp949.GetString(bytes);
Assert.EndsWith("\r\n\r\n", text, StringComparison.Ordinal);
Assert.Equal(5, text.Split("\r\n", StringSplitOptions.None).Length - 2);
}
[Theory]
[InlineData(0)]
[InlineData(4)]
[InlineData(6)]
public async Task WriteAsync_requires_exactly_five_rows(int rowCount)
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
store.WriteAsync(
S5025ManualAudience.Individual,
Rows().Take(rowCount).Concat(Enumerable.Repeat(Rows()[0], Math.Max(0, rowCount - 5)))
.Take(rowCount)
.ToArray()));
}
[Theory]
[InlineData("bad^name")]
[InlineData("bad\nname")]
public async Task WriteAsync_rejects_delimiter_and_control_characters(string value)
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var rows = Rows().ToArray();
rows[0] = rows[0] with { LeftName = value };
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
store.WriteAsync(S5025ManualAudience.Individual, rows));
}
[Fact]
public async Task WriteAsync_rejects_text_that_cp949_cannot_encode()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var rows = Rows().ToArray();
rows[0] = rows[0] with { LeftName = "😀" };
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
store.WriteAsync(S5025ManualAudience.Individual, rows));
}
[Fact]
public async Task WriteAsync_honors_pre_cancelled_token_without_creating_a_file()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
store.WriteAsync(S5025ManualAudience.Individual, Rows(), cancellation.Token));
Assert.Empty(Directory.EnumerateFiles(directory.Path));
}
[Fact]
public async Task WriteAsync_rejects_unknown_audience_before_creating_a_file()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
store.WriteAsync((S5025ManualAudience)99, Rows()));
Assert.Empty(Directory.EnumerateFiles(directory.Path));
}
[Fact]
public async Task WriteAsync_rejects_an_existing_reparse_destination()
{
using var directory = new TemporaryDirectory();
using var outside = new TemporaryDirectory();
var target = Path.Combine(outside.Path, "outside.dat");
await File.WriteAllTextAsync(target, "outside");
var link = Path.Combine(directory.Path, "개인.dat");
if (!TryCreateFileSymbolicLink(link, target))
{
return;
}
using var store = CreateStore(directory.Path);
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
store.WriteAsync(S5025ManualAudience.Individual, Rows()));
Assert.Equal("outside", await File.ReadAllTextAsync(target));
}
private static IReadOnlyList<S5025TrustedManualRow> Rows() =>
[
new("삼성전자", "1,234", "SK하이닉스", "-987"),
new("현대차", "2", "기아", "-2"),
new("NAVER", "3", "카카오", "-3"),
new("LG화학", "4", "포스코", "-4"),
new("한화", "5", "셀트리온", "-5")
];
private static S5025TrustedManualFileStore CreateStore(string directory)
{
TrustedManualDirectory.PreparePrivateWritableDirectory(directory);
return new S5025TrustedManualFileStore(directory);
}
private static bool TryCreateFileSymbolicLink(string link, string target)
{
try
{
File.CreateSymbolicLink(link, target);
return true;
}
catch (Exception exception) when (exception is
UnauthorizedAccessException or IOException or PlatformNotSupportedException)
{
return false;
}
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch (Exception exception) when (exception is
IOException or UnauthorizedAccessException)
{
// Cleanup must not hide the test result.
}
}
}
}

View File

@@ -0,0 +1,127 @@
using System.Security.AccessControl;
using System.Security.Principal;
using System.Runtime.Versioning;
using MBN_STOCK_WEBVIEW.Infrastructure;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class TrustedManualDirectoryTests
{
[Fact]
public void Prepare_creates_nested_private_directory_with_protected_acl()
{
using var parent = new TemporaryDirectory();
var target = Path.Combine(parent.Path, "operator", "private");
var prepared = TrustedManualDirectory.PreparePrivateWritableDirectory(target);
Assert.Equal(Path.GetFullPath(target), prepared);
Assert.True(Directory.Exists(prepared));
if (OperatingSystem.IsWindows())
{
AssertPrivateAcl(prepared);
}
}
[SupportedOSPlatform("windows")]
private static void AssertPrivateAcl(string directory)
{
var security = new DirectoryInfo(directory).GetAccessControl(
AccessControlSections.Access);
Assert.True(security.AreAccessRulesProtected);
Assert.DoesNotContain(
security.GetAccessRules(true, true, typeof(SecurityIdentifier))
.Cast<FileSystemAccessRule>(),
rule => rule.IsInherited);
}
[Fact]
public void Writable_store_rejects_acl_expanded_to_world_readable()
{
if (!OperatingSystem.IsWindows())
{
return;
}
using var directory = new TemporaryDirectory();
TrustedManualDirectory.PreparePrivateWritableDirectory(directory.Path);
var info = new DirectoryInfo(directory.Path);
var security = info.GetAccessControl();
security.AddAccessRule(new FileSystemAccessRule(
new SecurityIdentifier(WellKnownSidType.WorldSid, null),
FileSystemRights.Read,
AccessControlType.Allow));
info.SetAccessControl(security);
Assert.Throws<S5025TrustedManualDataException>(() =>
new S5025TrustedManualFileStore(directory.Path));
}
[Fact]
public void Writable_lease_pins_directory_against_rename_race()
{
if (!OperatingSystem.IsWindows())
{
return;
}
using var parent = new TemporaryDirectory();
var directory = Path.Combine(parent.Path, "operator");
TrustedManualDirectory.PreparePrivateWritableDirectory(directory);
using (var store = new S5025TrustedManualFileStore(directory))
{
Assert.ThrowsAny<IOException>(() =>
Directory.Move(directory, Path.Combine(parent.Path, "redirected")));
}
Directory.Move(directory, Path.Combine(parent.Path, "released"));
}
[Fact]
public void Prepare_rejects_reparse_ancestor_before_creating_target()
{
using var parent = new TemporaryDirectory();
using var outside = new TemporaryDirectory();
var link = Path.Combine(parent.Path, "linked");
try
{
Directory.CreateSymbolicLink(link, outside.Path);
}
catch (Exception exception) when (exception is
UnauthorizedAccessException or IOException or PlatformNotSupportedException)
{
return;
}
Assert.Throws<S5025TrustedManualDataException>(() =>
TrustedManualDirectory.PreparePrivateWritableDirectory(
Path.Combine(link, "must-not-be-created")));
Assert.False(Directory.Exists(Path.Combine(outside.Path, "must-not-be-created")));
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch (Exception exception) when (exception is
IOException or UnauthorizedAccessException)
{
// Cleanup must not hide the test result.
}
}
}
}

View File

@@ -0,0 +1,193 @@
using System.Text;
using MBN_STOCK_WEBVIEW.Infrastructure;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class ViTrustedManualFileStoreTests
{
[Fact]
public async Task ReadAsync_imports_the_original_nine_column_cp949_shape()
{
using var directory = new TemporaryDirectory();
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp949 = Encoding.GetEncoding(949);
await File.WriteAllBytesAsync(
Path.Combine(directory.Path, "VI발동.dat"),
cp949.GetBytes(
"PKR7005930003^삼성전자^^^^^^^\r\n" +
"PKR7016360000^삼성증권^^^^^^^\r\n"));
using var store = CreateStore(directory.Path);
var actual = await store.ReadAsync();
Assert.Equal(
[
new ViTrustedManualItem("PKR7005930003", "삼성전자"),
new ViTrustedManualItem("PKR7016360000", "삼성증권")
],
actual);
}
[Fact]
public async Task WriteAsync_round_trips_order_duplicates_and_version()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
ViTrustedManualItem[] expected =
[
new("005930", "삼성전자"),
new("016360", "삼성증권"),
new("005930", "삼성전자")
];
var write = await store.WriteAsync(expected);
Assert.True(write.Persisted);
Assert.Matches("^[a-f0-9]{64}$", write.Snapshot.RowVersion);
Assert.Equal(expected, await store.ReadAsync());
Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp"));
}
[Fact]
public async Task Empty_list_is_an_original_compatible_no_op()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var firstNoOp = await store.WriteAsync(Array.Empty<ViTrustedManualItem>());
Assert.False(firstNoOp.Persisted);
Assert.False(File.Exists(Path.Combine(directory.Path, "VI발동.dat")));
Assert.Empty(await store.ReadAsync());
var expected = new[] { new ViTrustedManualItem("005930", "삼성전자") };
var written = await store.WriteAsync(expected);
var secondNoOp = await store.WriteAsync(Array.Empty<ViTrustedManualItem>());
Assert.False(secondNoOp.Persisted);
Assert.Equal(written.Snapshot.RowVersion, secondNoOp.Snapshot.RowVersion);
Assert.Equal(expected, await store.ReadAsync());
}
[Fact]
public async Task Missing_file_is_an_empty_versioned_first_run_list()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var snapshot = await store.ReadSnapshotAsync();
Assert.Empty(snapshot.Items);
Assert.Equal(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
snapshot.RowVersion);
}
[Fact]
public async Task ReadAsync_rejects_nonempty_legacy_extra_columns()
{
using var directory = new TemporaryDirectory();
await File.WriteAllTextAsync(
Path.Combine(directory.Path, "VI발동.dat"),
"005930^삼성전자^unexpected^^^^^^\r\n");
using var store = CreateStore(directory.Path);
await Assert.ThrowsAsync<ViTrustedManualDataException>(() => store.ReadAsync());
}
[Theory]
[InlineData("", "삼성전자")]
[InlineData("005930", "")]
[InlineData("005^930", "삼성전자")]
[InlineData("005930", "삼성\n전자")]
[InlineData(" 005930", "삼성전자")]
[InlineData("005930", "삼성전자 ")]
[InlineData("005930", "삼성,전자")]
[InlineData("005930", "😀")]
public async Task WriteAsync_rejects_invalid_or_non_cp949_values(string code, string name)
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
await Assert.ThrowsAsync<ViTrustedManualDataException>(() =>
store.WriteAsync([new ViTrustedManualItem(code, name)]));
}
[Fact]
public async Task WriteAsync_caps_the_original_five_row_scene_at_twenty_pages()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var items = Enumerable.Range(1, ViTrustedManualFileStore.MaximumItemCount + 1)
.Select(index => new ViTrustedManualItem(index.ToString("D6"), $"종목{index}"))
.ToArray();
await Assert.ThrowsAsync<ViTrustedManualDataException>(() => store.WriteAsync(items));
}
[Fact]
public async Task WriteAsync_honors_pre_cancelled_token()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
store.WriteAsync(
[new ViTrustedManualItem("005930", "삼성전자")],
cancellation.Token));
Assert.Empty(Directory.EnumerateFiles(directory.Path));
}
[Fact]
public async Task Canonical_snapshot_version_covers_all_twenty_pages()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var items = Enumerable.Range(1, ViTrustedManualFileStore.MaximumItemCount)
.Select(index => new ViTrustedManualItem(
$"P{index:D6}",
$"현실적인긴종목명테스트{index}"))
.ToArray();
var written = await store.WriteAsync(items);
var snapshot = await store.ReadSnapshotAsync();
Assert.True(written.Persisted);
Assert.Equal(100, snapshot.Items.Count);
Assert.Equal(written.Snapshot.RowVersion, snapshot.RowVersion);
Assert.Matches("^[a-f0-9]{64}$", snapshot.RowVersion);
}
private static ViTrustedManualFileStore CreateStore(string directory)
{
TrustedManualDirectory.PreparePrivateWritableDirectory(directory);
return new ViTrustedManualFileStore(directory);
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch (Exception exception) when (exception is
IOException or UnauthorizedAccessException)
{
// Cleanup must not hide the test result.
}
}
}
}