Complete legacy operator UI and playout migration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user