feat: migrate legacy playout workflow and scenes
This commit is contained in:
@@ -18,12 +18,28 @@ internal sealed class ConnectionPlan
|
||||
|
||||
public int? ObservedCommandTimeoutSeconds { get; set; }
|
||||
|
||||
public string? ObservedCommandText { get; set; }
|
||||
|
||||
public IReadOnlyList<ObservedDbParameter> ObservedParameters { get; set; } = [];
|
||||
|
||||
public CancellationToken ObservedExecuteCancellationToken { get; set; }
|
||||
|
||||
public int SynchronousOpenCount { get; set; }
|
||||
|
||||
public int SynchronousExecuteCount { get; set; }
|
||||
|
||||
public static ConnectionPlan Success(DataTable data) => new()
|
||||
{
|
||||
ExecuteReaderAsync = _ => Task.FromResult<DbDataReader>(data.CreateDataReader())
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed record ObservedDbParameter(
|
||||
string Name,
|
||||
object? Value,
|
||||
DbType? ExplicitDbType,
|
||||
ParameterDirection Direction);
|
||||
|
||||
internal sealed class FakeConnectionFactory : IDatabaseConnectionFactory
|
||||
{
|
||||
private readonly Queue<ConnectionPlan> _plans;
|
||||
@@ -85,7 +101,11 @@ internal sealed class FakeDbConnection(ConnectionPlan plan) : DbConnection
|
||||
|
||||
public override void Close() => _state = ConnectionState.Closed;
|
||||
|
||||
public override void Open() => OpenAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
public override void Open()
|
||||
{
|
||||
plan.SynchronousOpenCount++;
|
||||
throw new InvalidOperationException("The synchronous open path must not be used.");
|
||||
}
|
||||
|
||||
public override async Task OpenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -146,20 +166,50 @@ internal sealed class FakeDbCommand(DbConnection connection, ConnectionPlan plan
|
||||
protected override DbParameter CreateDbParameter() => new FakeDbParameter();
|
||||
|
||||
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) =>
|
||||
ExecuteDbDataReaderAsync(behavior, CancellationToken.None).GetAwaiter().GetResult();
|
||||
throw RecordSynchronousExecution();
|
||||
|
||||
protected override Task<DbDataReader> ExecuteDbDataReaderAsync(
|
||||
CommandBehavior behavior,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
plan.ObservedCommandTimeoutSeconds = CommandTimeout;
|
||||
plan.ObservedCommandText = CommandText;
|
||||
plan.ObservedParameters = _parameters
|
||||
.Cast<DbParameter>()
|
||||
.Select(parameter => new ObservedDbParameter(
|
||||
parameter.ParameterName,
|
||||
parameter.Value,
|
||||
parameter is FakeDbParameter fakeParameter && fakeParameter.WasDbTypeSet
|
||||
? parameter.DbType
|
||||
: null,
|
||||
parameter.Direction))
|
||||
.ToArray();
|
||||
plan.ObservedExecuteCancellationToken = cancellationToken;
|
||||
return plan.ExecuteReaderAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private Exception RecordSynchronousExecution()
|
||||
{
|
||||
plan.SynchronousExecuteCount++;
|
||||
return new InvalidOperationException("The synchronous command path must not be used.");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeDbParameter : DbParameter
|
||||
{
|
||||
public override DbType DbType { get; set; }
|
||||
private DbType _dbType;
|
||||
|
||||
public bool WasDbTypeSet { get; private set; }
|
||||
|
||||
public override DbType DbType
|
||||
{
|
||||
get => _dbType;
|
||||
set
|
||||
{
|
||||
_dbType = value;
|
||||
WasDbTypeSet = true;
|
||||
}
|
||||
}
|
||||
|
||||
public override ParameterDirection Direction { get; set; }
|
||||
|
||||
@@ -179,6 +229,8 @@ internal sealed class FakeDbParameter : DbParameter
|
||||
|
||||
public override void ResetDbType()
|
||||
{
|
||||
_dbType = default;
|
||||
WasDbTypeSet = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,33 @@ namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
|
||||
|
||||
public sealed class ProviderDatabaseConnectionFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void OracleCommand_AcceptsLogicalNameForColonPlaceholder()
|
||||
{
|
||||
using var command = new OracleCommand("SELECT :code FROM DUAL");
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = "code";
|
||||
parameter.Value = 7;
|
||||
|
||||
command.Parameters.Add(parameter);
|
||||
|
||||
Assert.Equal(0, command.Parameters.IndexOf("code"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MariaDbCommand_NormalizesLogicalNameForAtPlaceholder()
|
||||
{
|
||||
using var command = new MySqlCommand("SELECT @code");
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = "code";
|
||||
parameter.Value = 7;
|
||||
|
||||
command.Parameters.Add(parameter);
|
||||
|
||||
Assert.Equal(0, command.Parameters.IndexOf("code"));
|
||||
Assert.Equal(0, command.Parameters.IndexOf("@code"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateOracleConnection_UsesSidDescriptor()
|
||||
{
|
||||
|
||||
@@ -6,6 +6,228 @@ namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
|
||||
|
||||
public sealed class ResilientDataQueryExecutorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DataSourceKind.Oracle, ':')]
|
||||
[InlineData(DataSourceKind.MariaDb, '@')]
|
||||
public async Task ExecuteAsync_ParameterizedSpec_BindsValuesTypesAndDbNullWithoutSyncCalls(
|
||||
DataSourceKind source,
|
||||
char marker)
|
||||
{
|
||||
var data = new DataTable();
|
||||
data.Columns.Add("VALUE", typeof(int));
|
||||
data.Rows.Add(1);
|
||||
var plan = ConnectionPlan.Success(data);
|
||||
var factory = new FakeConnectionFactory(plan);
|
||||
var executor = CreateExecutor(factory);
|
||||
var query = new DataQuerySpec(
|
||||
$"SELECT value FROM sample WHERE code = {marker}code AND optional_value = {marker}optional_value",
|
||||
[
|
||||
new DataQueryParameter("optional_value", null),
|
||||
new DataQueryParameter("code", "005930", DbType.String)
|
||||
]);
|
||||
|
||||
var result = await executor.ExecuteAsync(source, "Parameterized", query);
|
||||
|
||||
Assert.Single(result.Rows.Cast<DataRow>());
|
||||
Assert.Equal(query.Sql, plan.ObservedCommandText);
|
||||
Assert.Collection(
|
||||
plan.ObservedParameters,
|
||||
parameter =>
|
||||
{
|
||||
Assert.Equal("code", parameter.Name);
|
||||
Assert.Equal("005930", parameter.Value);
|
||||
Assert.Equal(DbType.String, parameter.ExplicitDbType);
|
||||
Assert.Equal(ParameterDirection.Input, parameter.Direction);
|
||||
},
|
||||
parameter =>
|
||||
{
|
||||
Assert.Equal("optional_value", parameter.Name);
|
||||
Assert.Equal(DBNull.Value, parameter.Value);
|
||||
Assert.Null(parameter.ExplicitDbType);
|
||||
Assert.Equal(ParameterDirection.Input, parameter.Direction);
|
||||
});
|
||||
Assert.True(plan.ObservedExecuteCancellationToken.CanBeCanceled);
|
||||
Assert.Equal(0, plan.SynchronousOpenCount);
|
||||
Assert.Equal(0, plan.SynchronousExecuteCount);
|
||||
Assert.True(plan.Disposed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DataSourceKind.Oracle, ':')]
|
||||
[InlineData(DataSourceKind.MariaDb, '@')]
|
||||
public async Task ExecuteAsync_ParameterizedSpec_PropagatesCancellationWithoutRetryOrSyncFallback(
|
||||
DataSourceKind source,
|
||||
char marker)
|
||||
{
|
||||
var commandStarted = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var plan = new ConnectionPlan
|
||||
{
|
||||
ExecuteReaderAsync = async token =>
|
||||
{
|
||||
commandStarted.SetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, token);
|
||||
throw new InvalidOperationException("Unreachable.");
|
||||
}
|
||||
};
|
||||
var factory = new FakeConnectionFactory(plan);
|
||||
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
|
||||
var query = new DataQuerySpec(
|
||||
$"SELECT value FROM sample WHERE code = {marker}code",
|
||||
[new DataQueryParameter("code", 7, DbType.Int32)]);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
var operation = executor.ExecuteAsync(source, "Canceled", query, cancellation.Token);
|
||||
await commandStarted.Task;
|
||||
await cancellation.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
|
||||
Assert.Equal(1, factory.CreateCount);
|
||||
Assert.Equal(0, plan.SynchronousOpenCount);
|
||||
Assert.Equal(0, plan.SynchronousExecuteCount);
|
||||
Assert.True(plan.Disposed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DataSourceKind.Oracle, ':')]
|
||||
[InlineData(DataSourceKind.MariaDb, '@')]
|
||||
public async Task ExecuteAsync_ParameterizedCancellation_DoesNotExposeProviderMessage(
|
||||
DataSourceKind source,
|
||||
char marker)
|
||||
{
|
||||
const string sensitiveName = "cancel_secret_name";
|
||||
const string sensitiveValue = "cancel-value-sensitive-sentinel";
|
||||
var commandStarted = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var plan = new ConnectionPlan
|
||||
{
|
||||
ExecuteReaderAsync = async token =>
|
||||
{
|
||||
commandStarted.SetResult();
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw new OperationCanceledException(
|
||||
$"{sensitiveName}|{sensitiveValue}",
|
||||
token);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unreachable.");
|
||||
}
|
||||
};
|
||||
var factory = new FakeConnectionFactory(plan);
|
||||
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
|
||||
var query = new DataQuerySpec(
|
||||
$"SELECT value FROM sample WHERE code = {marker}{sensitiveName}",
|
||||
[new DataQueryParameter(sensitiveName, sensitiveValue)]);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
var operation = executor.ExecuteAsync(source, "Canceled", query, cancellation.Token);
|
||||
await commandStarted.Task;
|
||||
await cancellation.CancelAsync();
|
||||
|
||||
var exception = await Assert.ThrowsAsync<OperationCanceledException>(() => operation);
|
||||
Assert.DoesNotContain(sensitiveName, exception.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(sensitiveValue, exception.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(cancellation.Token, exception.CancellationToken);
|
||||
Assert.Null(exception.InnerException);
|
||||
Assert.Equal(1, factory.CreateCount);
|
||||
Assert.Equal(0, plan.SynchronousOpenCount);
|
||||
Assert.Equal(0, plan.SynchronousExecuteCount);
|
||||
Assert.True(plan.Disposed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DataSourceKind.Oracle, ':')]
|
||||
[InlineData(DataSourceKind.MariaDb, '@')]
|
||||
public async Task ExecuteAsync_ParameterizedProviderFailure_DoesNotExposeNameOrValue(
|
||||
DataSourceKind source,
|
||||
char marker)
|
||||
{
|
||||
const string sensitiveName = "account_secret_name";
|
||||
const string sensitiveValue = "value-sensitive-sentinel";
|
||||
var plan = new ConnectionPlan
|
||||
{
|
||||
ExecuteReaderAsync = _ => Task.FromException<System.Data.Common.DbDataReader>(
|
||||
new TestDatabaseException($"{sensitiveName}|{sensitiveValue}"))
|
||||
};
|
||||
var factory = new FakeConnectionFactory(plan);
|
||||
var executor = CreateExecutor(factory);
|
||||
var query = new DataQuerySpec(
|
||||
$"SELECT value FROM sample WHERE account = {marker}{sensitiveName}",
|
||||
[new DataQueryParameter(sensitiveName, sensitiveValue, DbType.String)]);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<DatabaseQueryException>(
|
||||
() => executor.ExecuteAsync(source, "Sensitive", query));
|
||||
|
||||
Assert.Null(exception.InnerException);
|
||||
Assert.DoesNotContain(sensitiveName, exception.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(sensitiveValue, exception.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(1, factory.CreateCount);
|
||||
Assert.True(plan.Disposed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DataSourceKind.Oracle, ':')]
|
||||
[InlineData(DataSourceKind.MariaDb, '@')]
|
||||
public async Task ExecuteAsync_ParameterizedTransientReadFailure_RecreatesBoundCommandOnRetry(
|
||||
DataSourceKind source,
|
||||
char marker)
|
||||
{
|
||||
var first = new ConnectionPlan
|
||||
{
|
||||
ExecuteReaderAsync = _ => Task.FromException<System.Data.Common.DbDataReader>(
|
||||
new TestDatabaseException("temporary"))
|
||||
};
|
||||
var data = new DataTable();
|
||||
data.Columns.Add("VALUE", typeof(int));
|
||||
data.Rows.Add(42);
|
||||
var second = ConnectionPlan.Success(data);
|
||||
var factory = new FakeConnectionFactory(first, second);
|
||||
var executor = CreateExecutor(factory, transient: true, retryCount: 1);
|
||||
var query = new DataQuerySpec(
|
||||
$"SELECT value FROM sample WHERE code = {marker}code",
|
||||
[new DataQueryParameter("code", 42, DbType.Int32)]);
|
||||
|
||||
var result = await executor.ExecuteAsync(source, "Retry", query);
|
||||
|
||||
Assert.Equal(42, Assert.Single(result.Rows.Cast<DataRow>())["VALUE"]);
|
||||
Assert.Equal(2, factory.CreateCount);
|
||||
Assert.All(
|
||||
new[] { first, second },
|
||||
plan =>
|
||||
{
|
||||
var parameter = Assert.Single(plan.ObservedParameters);
|
||||
Assert.Equal("code", parameter.Name);
|
||||
Assert.Equal(42, parameter.Value);
|
||||
Assert.Equal(DbType.Int32, parameter.ExplicitDbType);
|
||||
Assert.Equal(0, plan.SynchronousOpenCount);
|
||||
Assert.Equal(0, plan.SynchronousExecuteCount);
|
||||
Assert.True(plan.Disposed);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ParameterizedSpec_RejectsProviderMarkerBeforeOpeningConnection()
|
||||
{
|
||||
var plan = ConnectionPlan.Success(new DataTable());
|
||||
var factory = new FakeConnectionFactory(plan);
|
||||
var executor = CreateExecutor(factory);
|
||||
var query = new DataQuerySpec(
|
||||
"SELECT value FROM sample WHERE code = :code",
|
||||
[new DataQueryParameter("code", 7)]);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => executor.ExecuteAsync(DataSourceKind.MariaDb, "Rejected", query));
|
||||
|
||||
Assert.Equal(0, factory.CreateCount);
|
||||
Assert.DoesNotContain("code", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("7", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Success_PreservesSchemaRowsAndDbNull()
|
||||
{
|
||||
@@ -199,6 +421,30 @@ public sealed class ResilientDataQueryExecutorTests
|
||||
Assert.True(plan.Disposed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT value INTO OUTFILE '/tmp/sample' FROM sample")]
|
||||
[InlineData("SELECT 1; REPLACE INTO sample VALUES (1)")]
|
||||
[InlineData("SELECT 1; SET @value = 1")]
|
||||
[InlineData("WITH value AS (SELECT 1) DELETE FROM sample")]
|
||||
public async Task ExecuteAsync_AmbiguousOrStateChangingLegacyRead_DoesNotRetry(
|
||||
string query)
|
||||
{
|
||||
var plan = new ConnectionPlan
|
||||
{
|
||||
ExecuteReaderAsync = _ => Task.FromException<System.Data.Common.DbDataReader>(
|
||||
new TestDatabaseException("temporary"))
|
||||
};
|
||||
var factory = new FakeConnectionFactory(plan);
|
||||
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<DatabaseQueryException>(
|
||||
() => executor.ExecuteAsync(DataSourceKind.MariaDb, "Mutation", query));
|
||||
|
||||
Assert.True(exception.IsTransient);
|
||||
Assert.Equal(1, factory.CreateCount);
|
||||
Assert.True(plan.Disposed);
|
||||
}
|
||||
|
||||
private static ResilientDataQueryExecutor CreateExecutor(
|
||||
FakeConnectionFactory factory,
|
||||
bool transient = false,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
using System.Text;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
|
||||
|
||||
public sealed class S5025TrustedManualFileDataSourceTests
|
||||
{
|
||||
private static readonly Encoding Cp949 = CreateCp949Encoding();
|
||||
|
||||
[Theory]
|
||||
[InlineData(S5025ManualAudience.Individual, "개인.dat")]
|
||||
[InlineData(S5025ManualAudience.Foreign, "외국인.dat")]
|
||||
[InlineData(S5025ManualAudience.Institution, "기관.dat")]
|
||||
public async Task ReadAsync_maps_closed_audience_to_fixed_cp949_file(
|
||||
S5025ManualAudience audience,
|
||||
string expectedFileName)
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
WriteValidFile(Path.Combine(directory.Path, expectedFileName));
|
||||
var source = new S5025TrustedManualFileDataSource(directory.Path);
|
||||
|
||||
var rows = await source.ReadAsync(audience);
|
||||
|
||||
Assert.Equal(5, rows.Count);
|
||||
Assert.Equal("삼성전자", rows[0].LeftName);
|
||||
Assert.Equal("1,234", rows[0].LeftAmount);
|
||||
Assert.Equal("SK하이닉스", rows[0].RightName);
|
||||
Assert.Equal("-987", rows[0].RightAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFromEnvironment_uses_only_documented_directory_setting()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
WriteValidFile(Path.Combine(directory.Path, "개인.dat"));
|
||||
var requestedNames = new List<string>();
|
||||
var source = S5025TrustedManualFileDataSource.CreateFromEnvironment(name =>
|
||||
{
|
||||
requestedNames.Add(name);
|
||||
return directory.Path;
|
||||
});
|
||||
|
||||
var rows = await source.ReadAsync(S5025ManualAudience.Individual);
|
||||
|
||||
Assert.Equal(
|
||||
S5025TrustedManualFileDataSource.DirectoryEnvironmentVariable,
|
||||
Assert.Single(requestedNames));
|
||||
Assert.Equal(5, rows.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("relative-data")]
|
||||
public void Constructor_rejects_missing_or_non_absolute_directory(string? path)
|
||||
{
|
||||
Assert.Throws<S5025TrustedManualDataException>(() =>
|
||||
new S5025TrustedManualFileDataSource(path!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_rejects_nonexistent_absolute_directory()
|
||||
{
|
||||
var missing = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"missing");
|
||||
|
||||
Assert.Throws<S5025TrustedManualDataException>(() =>
|
||||
new S5025TrustedManualFileDataSource(missing));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(S5025ManualAudience.Individual)]
|
||||
[InlineData(S5025ManualAudience.Foreign)]
|
||||
[InlineData(S5025ManualAudience.Institution)]
|
||||
public async Task ReadAsync_does_not_fall_back_when_fixed_file_is_missing(
|
||||
S5025ManualAudience audience)
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
var source = new S5025TrustedManualFileDataSource(directory.Path);
|
||||
|
||||
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
|
||||
source.ReadAsync(audience));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_rejects_unknown_audience_before_any_file_lookup()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
var source = new S5025TrustedManualFileDataSource(directory.Path);
|
||||
|
||||
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
|
||||
source.ReadAsync((S5025ManualAudience)99));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(
|
||||
"L1^1^R1^2\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2",
|
||||
"four rows")]
|
||||
[InlineData(
|
||||
"L1^1^R1^2\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2\r\nL5^1^R5^2\r\nL6^1^R6^2",
|
||||
"six rows")]
|
||||
[InlineData(
|
||||
"L1^1^R1\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2\r\nL5^1^R5^2",
|
||||
"three columns")]
|
||||
[InlineData(
|
||||
"L1^1^R1^2^EXTRA\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2\r\nL5^1^R5^2",
|
||||
"five columns")]
|
||||
public async Task ReadAsync_requires_exactly_five_rows_and_four_columns(
|
||||
string content,
|
||||
string _)
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
await File.WriteAllBytesAsync(
|
||||
Path.Combine(directory.Path, "개인.dat"),
|
||||
Cp949.GetBytes(content));
|
||||
var source = new S5025TrustedManualFileDataSource(directory.Path);
|
||||
|
||||
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
|
||||
source.ReadAsync(S5025ManualAudience.Individual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_accepts_one_trailing_line_terminator()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
WriteValidFile(Path.Combine(directory.Path, "개인.dat"), trailingNewLine: true);
|
||||
var source = new S5025TrustedManualFileDataSource(directory.Path);
|
||||
|
||||
var rows = await source.ReadAsync(S5025ManualAudience.Individual);
|
||||
|
||||
Assert.Equal(5, rows.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_accepts_the_original_single_empty_terminal_record()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
var content = ValidContent() + "\r\n\r\n";
|
||||
await File.WriteAllBytesAsync(
|
||||
Path.Combine(directory.Path, "개인.dat"),
|
||||
Cp949.GetBytes(content));
|
||||
var source = new S5025TrustedManualFileDataSource(directory.Path);
|
||||
|
||||
var rows = await source.ReadAsync(S5025ManualAudience.Individual);
|
||||
|
||||
Assert.Equal(5, rows.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_rejects_multiple_empty_terminal_records()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
var content = ValidContent() + "\r\n\r\n\r\n";
|
||||
await File.WriteAllBytesAsync(
|
||||
Path.Combine(directory.Path, "개인.dat"),
|
||||
Cp949.GetBytes(content));
|
||||
var source = new S5025TrustedManualFileDataSource(directory.Path);
|
||||
|
||||
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
|
||||
source.ReadAsync(S5025ManualAudience.Individual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_rejects_invalid_cp949_and_unicode_bom_files()
|
||||
{
|
||||
using var invalidDirectory = new TemporaryDirectory();
|
||||
await File.WriteAllBytesAsync(
|
||||
Path.Combine(invalidDirectory.Path, "개인.dat"),
|
||||
[0x81]);
|
||||
var invalidSource = new S5025TrustedManualFileDataSource(invalidDirectory.Path);
|
||||
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
|
||||
invalidSource.ReadAsync(S5025ManualAudience.Individual));
|
||||
|
||||
using var bomDirectory = new TemporaryDirectory();
|
||||
var content = ValidContent();
|
||||
await File.WriteAllBytesAsync(
|
||||
Path.Combine(bomDirectory.Path, "개인.dat"),
|
||||
Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(content)).ToArray());
|
||||
var bomSource = new S5025TrustedManualFileDataSource(bomDirectory.Path);
|
||||
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
|
||||
bomSource.ReadAsync(S5025ManualAudience.Individual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_rejects_oversized_file_before_decoding()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
await File.WriteAllBytesAsync(
|
||||
Path.Combine(directory.Path, "개인.dat"),
|
||||
new byte[S5025TrustedManualFileDataSource.MaximumFileSizeBytes + 1]);
|
||||
var source = new S5025TrustedManualFileDataSource(directory.Path);
|
||||
|
||||
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
|
||||
source.ReadAsync(S5025ManualAudience.Individual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_rejects_reparse_file_in_trusted_directory()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
using var outside = new TemporaryDirectory();
|
||||
var target = Path.Combine(outside.Path, "outside.dat");
|
||||
WriteValidFile(target);
|
||||
var link = Path.Combine(directory.Path, "개인.dat");
|
||||
if (!TryCreateFileSymbolicLink(link, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var source = new S5025TrustedManualFileDataSource(directory.Path);
|
||||
|
||||
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
|
||||
source.ReadAsync(S5025ManualAudience.Individual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_rejects_reparse_directory_or_ancestor()
|
||||
{
|
||||
using var parent = new TemporaryDirectory();
|
||||
using var outside = new TemporaryDirectory();
|
||||
var link = Path.Combine(parent.Path, "linked-data");
|
||||
if (!TryCreateDirectorySymbolicLink(link, outside.Path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.Throws<S5025TrustedManualDataException>(() =>
|
||||
new S5025TrustedManualFileDataSource(link));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_honors_pre_cancelled_token()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
WriteValidFile(Path.Combine(directory.Path, "개인.dat"));
|
||||
var source = new S5025TrustedManualFileDataSource(directory.Path);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
source.ReadAsync(S5025ManualAudience.Individual, cancellation.Token));
|
||||
}
|
||||
|
||||
private static void WriteValidFile(string path, bool trailingNewLine = false)
|
||||
{
|
||||
var content = ValidContent() + (trailingNewLine ? "\r\n" : string.Empty);
|
||||
File.WriteAllBytes(path, Cp949.GetBytes(content));
|
||||
}
|
||||
|
||||
private static string ValidContent() => string.Join(
|
||||
"\r\n",
|
||||
"삼성전자^1,234^SK하이닉스^-987",
|
||||
"현대차^2^기아^-2",
|
||||
"NAVER^3^카카오^-3",
|
||||
"LG화학^4^셀트리온^-4",
|
||||
"POSCO홀딩스^5^한화에어로스페이스^-5");
|
||||
|
||||
private static Encoding CreateCp949Encoding()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
return Encoding.GetEncoding(
|
||||
949,
|
||||
EncoderFallback.ExceptionFallback,
|
||||
DecoderFallback.ExceptionFallback);
|
||||
}
|
||||
|
||||
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 static bool TryCreateDirectorySymbolicLink(string link, string target)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.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 (IOException)
|
||||
{
|
||||
// A failed cleanup must not hide the behavior under test.
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// A failed cleanup must not hide the behavior under test.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user