Migrate remaining legacy operator workflows
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
|
||||
|
||||
public sealed class LegacyManualOperatorDataImporterTests
|
||||
{
|
||||
private static readonly string[] DestinationFiles =
|
||||
[
|
||||
"개인.dat",
|
||||
"외국인.dat",
|
||||
"기관.dat",
|
||||
"VI발동.dat"
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task Fixed_source_reads_the_four_original_files_without_modifying_them()
|
||||
{
|
||||
using var sourceDirectory = new TemporaryDirectory();
|
||||
WriteSourceFiles(sourceDirectory.Path);
|
||||
var before = DestinationFiles.ToDictionary(
|
||||
name => name,
|
||||
name => File.ReadAllBytes(Path.Combine(sourceDirectory.Path, name)));
|
||||
var source = new FixedLegacyManualOperatorDataSource(
|
||||
sourceDirectory.Path,
|
||||
TimeSpan.Zero);
|
||||
|
||||
var snapshot = await source.ReadAsync();
|
||||
|
||||
Assert.Equal(5, snapshot.IndividualRows.Count);
|
||||
Assert.Equal(5, snapshot.ForeignRows.Count);
|
||||
Assert.Equal(5, snapshot.InstitutionRows.Count);
|
||||
Assert.Equal(3, snapshot.ViItems.Count);
|
||||
Assert.Matches("^[a-f0-9]{64}$", snapshot.SourceSha256);
|
||||
Assert.All(DestinationFiles, name =>
|
||||
Assert.Equal(before[name], File.ReadAllBytes(Path.Combine(sourceDirectory.Path, name))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_commits_all_four_files_and_the_versioned_marker_last()
|
||||
{
|
||||
using var sourceDirectory = new TemporaryDirectory();
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
WriteSourceFiles(sourceDirectory.Path);
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var now = new DateTimeOffset(2026, 7, 12, 1, 2, 3, TimeSpan.Zero);
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new FixedLegacyManualOperatorDataSource(sourceDirectory.Path, TimeSpan.Zero),
|
||||
() => now);
|
||||
|
||||
var result = await importer.ImportAsync();
|
||||
|
||||
Assert.Equal(1, result.MarkerVersion);
|
||||
Assert.Equal(15, result.NetSellRowCount);
|
||||
Assert.Equal(3, result.ViItemCount);
|
||||
Assert.Equal(now, result.ImportedAt);
|
||||
Assert.All(DestinationFiles, name =>
|
||||
Assert.True(File.Exists(Path.Combine(destinationDirectory.Path, name))));
|
||||
var markerPath = Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.MarkerFileName);
|
||||
Assert.True(File.Exists(markerPath));
|
||||
using var marker = JsonDocument.Parse(await File.ReadAllBytesAsync(markerPath));
|
||||
Assert.Equal(1, marker.RootElement.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal(result.SourceSha256, marker.RootElement.GetProperty("sourceSha256").GetString());
|
||||
Assert.Matches(
|
||||
"^[a-f0-9]{64}$",
|
||||
marker.RootElement.GetProperty("productionSha256").GetString()!);
|
||||
Assert.Equal(15, marker.RootElement.GetProperty("netSellRowCount").GetInt32());
|
||||
Assert.Equal(3, marker.RootElement.GetProperty("viItemCount").GetInt32());
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName)));
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.ReadyImportedData,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
|
||||
|
||||
using var netStore = new S5025TrustedManualFileStore(destinationDirectory.Path);
|
||||
using var viStore = new ViTrustedManualFileStore(destinationDirectory.Path);
|
||||
Assert.Equal(Rows(), await netStore.ReadAsync(S5025ManualAudience.Individual));
|
||||
Assert.Equal(3, (await viStore.ReadAsync()).Count);
|
||||
|
||||
var serializedResult = JsonSerializer.Serialize(result);
|
||||
var serializedMarker = await File.ReadAllTextAsync(markerPath);
|
||||
Assert.DoesNotContain(sourceDirectory.Path, serializedResult, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(sourceDirectory.Path, serializedMarker, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("source\\repos", serializedMarker, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_vi_file_keeps_its_distinct_zero_length_contract_after_import_and_restart()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var snapshot = new LegacyManualOperatorSourceSnapshot(
|
||||
Rows(),
|
||||
Rows(),
|
||||
Rows(),
|
||||
Array.Empty<LegacyManualViItem>(),
|
||||
new string('c', 64));
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new CountingSource(snapshot));
|
||||
|
||||
var result = await importer.ImportAsync();
|
||||
|
||||
Assert.Equal(0, result.ViItemCount);
|
||||
Assert.Equal(
|
||||
0,
|
||||
new FileInfo(Path.Combine(destinationDirectory.Path, "VI발동.dat")).Length);
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.ReadyImportedData,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
|
||||
using var viStore = new ViTrustedManualFileStore(destinationDirectory.Path);
|
||||
Assert.Empty(await viStore.ReadAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("개인.dat")]
|
||||
[InlineData("외국인.dat")]
|
||||
[InlineData("기관.dat")]
|
||||
[InlineData("VI발동.dat")]
|
||||
public async Task Any_existing_private_data_file_blocks_the_whole_import_without_overwrite(
|
||||
string existingFile)
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var path = Path.Combine(destinationDirectory.Path, existingFile);
|
||||
var original = new byte[] { 1, 2, 3, 4 };
|
||||
await File.WriteAllBytesAsync(path, original);
|
||||
var source = new CountingSource(Snapshot());
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
source);
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.DestinationNotEmpty, error.Failure);
|
||||
Assert.False(error.OutcomeUnknown);
|
||||
Assert.Equal(0, source.ReadCount);
|
||||
Assert.Equal(original, await File.ReadAllBytesAsync(path));
|
||||
Assert.Single(Directory.EnumerateFileSystemEntries(destinationDirectory.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Marker_blocks_a_second_import_before_the_source_is_read_again()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var source = new CountingSource(Snapshot());
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
source);
|
||||
|
||||
_ = await importer.ImportAsync();
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.AlreadyImported, error.Failure);
|
||||
Assert.False(error.OutcomeUnknown);
|
||||
Assert.Equal(1, source.ReadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preexisting_intent_and_partial_data_fail_closed_before_source_or_store_use()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var intentPath = Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName);
|
||||
await File.WriteAllTextAsync(intentPath, "{}");
|
||||
var partialPath = Path.Combine(destinationDirectory.Path, DestinationFiles[0]);
|
||||
var partialBytes = LegacyManualOperatorDataCodec.SerializeNetSell(Rows());
|
||||
await File.WriteAllBytesAsync(partialPath, partialBytes);
|
||||
|
||||
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(
|
||||
destinationDirectory.Path);
|
||||
var source = new CountingSource(Snapshot());
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
source);
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorDataRuntimeState.InterruptedImport, inspection.State);
|
||||
Assert.False(inspection.IsReady);
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.InterruptedImport, error.Failure);
|
||||
Assert.True(error.OutcomeUnknown);
|
||||
Assert.Equal(0, source.ReadCount);
|
||||
Assert.Equal(partialBytes, await File.ReadAllBytesAsync(partialPath));
|
||||
Assert.Equal("{}", await File.ReadAllTextAsync(intentPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_legacy_files_without_marker_or_intent_remain_runtime_eligible()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
await File.WriteAllBytesAsync(
|
||||
Path.Combine(destinationDirectory.Path, DestinationFiles[0]),
|
||||
LegacyManualOperatorDataCodec.SerializeNetSell(Rows()));
|
||||
|
||||
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(
|
||||
destinationDirectory.Path);
|
||||
|
||||
Assert.True(inspection.IsReady);
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.ReadyLegacyData,
|
||||
inspection.State);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public async Task Failure_after_each_data_move_rolls_back_data_and_intent(
|
||||
int failAfterMove)
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new CountingSource(Snapshot()),
|
||||
utcNow: null,
|
||||
afterDataFileMoved: movedCount =>
|
||||
{
|
||||
if (movedCount == failAfterMove)
|
||||
{
|
||||
throw new IOException("injected move failure");
|
||||
}
|
||||
});
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.ImportFailed, error.Failure);
|
||||
Assert.False(error.OutcomeUnknown);
|
||||
Assert.All(DestinationFiles, name =>
|
||||
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, name))));
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.MarkerFileName)));
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public async Task Abrupt_termination_after_each_move_leaves_intent_and_blocks_all_reuse(
|
||||
int crashAfterMove)
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new CountingSource(Snapshot()),
|
||||
utcNow: null,
|
||||
afterDataFileMoved: movedCount =>
|
||||
{
|
||||
if (movedCount == crashAfterMove)
|
||||
{
|
||||
throw new LegacyManualOperatorImportCrashSimulationException();
|
||||
}
|
||||
}))
|
||||
{
|
||||
await Assert.ThrowsAsync<LegacyManualOperatorImportCrashSimulationException>(
|
||||
() => importer.ImportAsync());
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName)));
|
||||
Assert.Equal(
|
||||
crashAfterMove,
|
||||
DestinationFiles.Count(name => File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
name))));
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.InterruptedImport,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
|
||||
|
||||
var secondSource = new CountingSource(Snapshot());
|
||||
using var secondImporter = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
secondSource);
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => secondImporter.ImportAsync());
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.InterruptedImport, error.Failure);
|
||||
Assert.True(error.OutcomeUnknown);
|
||||
Assert.Equal(0, secondSource.ReadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abrupt_termination_after_completed_marker_keeps_stale_intent_fail_closed()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new CountingSource(Snapshot()),
|
||||
utcNow: null,
|
||||
afterDataFileMoved: null,
|
||||
afterMarkerCommitted: () =>
|
||||
throw new LegacyManualOperatorImportCrashSimulationException()))
|
||||
{
|
||||
await Assert.ThrowsAsync<LegacyManualOperatorImportCrashSimulationException>(
|
||||
() => importer.ImportAsync());
|
||||
}
|
||||
|
||||
Assert.All(DestinationFiles, name =>
|
||||
Assert.True(File.Exists(Path.Combine(destinationDirectory.Path, name))));
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.MarkerFileName)));
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName)));
|
||||
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(
|
||||
destinationDirectory.Path);
|
||||
Assert.False(inspection.IsReady);
|
||||
Assert.Equal(LegacyManualOperatorDataRuntimeState.InterruptedImport, inspection.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_marker_with_missing_or_modified_data_is_rejected()
|
||||
{
|
||||
using var missingDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(missingDirectory.Path);
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
missingDirectory.Path,
|
||||
new CountingSource(Snapshot())))
|
||||
{
|
||||
_ = await importer.ImportAsync();
|
||||
}
|
||||
File.Delete(Path.Combine(missingDirectory.Path, DestinationFiles[0]));
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.InvalidImportedState,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(missingDirectory.Path).State);
|
||||
|
||||
using var modifiedDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(modifiedDirectory.Path);
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
modifiedDirectory.Path,
|
||||
new CountingSource(Snapshot())))
|
||||
{
|
||||
_ = await importer.ImportAsync();
|
||||
}
|
||||
await File.AppendAllTextAsync(
|
||||
Path.Combine(modifiedDirectory.Path, DestinationFiles[0]),
|
||||
"tamper");
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.InvalidImportedState,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(modifiedDirectory.Path).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_marker_allows_later_verified_operator_edits()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new CountingSource(Snapshot())))
|
||||
{
|
||||
_ = await importer.ImportAsync();
|
||||
}
|
||||
|
||||
var editedRows = Rows().ToArray();
|
||||
editedRows[0] = editedRows[0] with { LeftAmount = "9,999" };
|
||||
using (var store = new S5025TrustedManualFileStore(destinationDirectory.Path))
|
||||
{
|
||||
await store.WriteAsync(S5025ManualAudience.Individual, editedRows);
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.ReadyImportedData,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Commit_failure_rolls_back_every_file_created_by_the_operation_without_retry()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var source = new CountingSource(Snapshot());
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
source,
|
||||
utcNow: null,
|
||||
afterDataFileMoved: movedCount =>
|
||||
{
|
||||
if (movedCount == 1)
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(destinationDirectory.Path, "외국인.dat"));
|
||||
}
|
||||
});
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.ImportFailed, error.Failure);
|
||||
Assert.False(error.OutcomeUnknown);
|
||||
Assert.Equal(1, source.ReadCount);
|
||||
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, "개인.dat")));
|
||||
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, "기관.dat")));
|
||||
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, "VI발동.dat")));
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.MarkerFileName)));
|
||||
Assert.Empty(Directory.EnumerateFiles(destinationDirectory.Path, "*.tmp"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsafe_rollback_is_reported_as_outcome_unknown_and_is_never_retried()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var source = new CountingSource(Snapshot());
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
source,
|
||||
utcNow: null,
|
||||
afterDataFileMoved: movedCount =>
|
||||
{
|
||||
if (movedCount != 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(destinationDirectory.Path, "개인.dat"), "tampered");
|
||||
Directory.CreateDirectory(Path.Combine(destinationDirectory.Path, "외국인.dat"));
|
||||
});
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.RollbackFailed, error.Failure);
|
||||
Assert.True(error.OutcomeUnknown);
|
||||
Assert.Equal(1, source.ReadCount);
|
||||
Assert.True(File.Exists(Path.Combine(destinationDirectory.Path, "개인.dat")));
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.MarkerFileName)));
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName)));
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.InterruptedImport,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_source_format_creates_no_private_file_and_does_not_disclose_the_path()
|
||||
{
|
||||
using var sourceDirectory = new TemporaryDirectory();
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
WriteSourceFiles(sourceDirectory.Path);
|
||||
await File.WriteAllTextAsync(Path.Combine(sourceDirectory.Path, "기관.dat"), "invalid");
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new FixedLegacyManualOperatorDataSource(sourceDirectory.Path, TimeSpan.Zero));
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.SourceInvalid, error.Failure);
|
||||
Assert.DoesNotContain(sourceDirectory.Path, error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(Directory.EnumerateFileSystemEntries(destinationDirectory.Path));
|
||||
}
|
||||
|
||||
private static void WriteSourceFiles(string directory)
|
||||
{
|
||||
foreach (var fileName in DestinationFiles.Take(3))
|
||||
{
|
||||
File.WriteAllBytes(
|
||||
Path.Combine(directory, fileName),
|
||||
LegacyManualOperatorDataCodec.SerializeNetSell(Rows()));
|
||||
}
|
||||
|
||||
File.WriteAllBytes(
|
||||
Path.Combine(directory, "VI발동.dat"),
|
||||
LegacyManualOperatorDataCodec.SerializeVi(ViItems()));
|
||||
}
|
||||
|
||||
private static LegacyManualOperatorSourceSnapshot Snapshot() => new(
|
||||
Rows(),
|
||||
Rows(),
|
||||
Rows(),
|
||||
ViItems(),
|
||||
new string('a', 64));
|
||||
|
||||
private static IReadOnlyList<S5025TrustedManualRow> Rows() =>
|
||||
[
|
||||
new("삼성전자", "1,000", "SK하이닉스", "-2,000"),
|
||||
new("현대차", "2", "기아", "-2"),
|
||||
new("NAVER", "3", "카카오", "-3"),
|
||||
new("LG화학", "4", "포스코", "-4"),
|
||||
new("한화", "5", "셀트리온", "-5")
|
||||
];
|
||||
|
||||
private static IReadOnlyList<LegacyManualViItem> ViItems() =>
|
||||
[
|
||||
new("PKR7005930003", "삼성전자"),
|
||||
new("PKR7016360000", "삼성증권"),
|
||||
new("PKR7005930003", "삼성전자")
|
||||
];
|
||||
|
||||
private static void PreparePrivate(string path) =>
|
||||
TrustedManualDirectory.PreparePrivateWritableDirectory(path);
|
||||
|
||||
private sealed class CountingSource : ILegacyManualOperatorDataSource
|
||||
{
|
||||
private readonly LegacyManualOperatorSourceSnapshot _snapshot;
|
||||
|
||||
public CountingSource(LegacyManualOperatorSourceSnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public int ReadCount { get; private set; }
|
||||
|
||||
public Task<LegacyManualOperatorSourceSnapshot> ReadAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ReadCount++;
|
||||
return Task.FromResult(_snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
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,183 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
|
||||
|
||||
public sealed class NativeOperatorLogTransitionTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void DatabaseMapping_CoversEveryClosedHealthState()
|
||||
{
|
||||
var expected = new Dictionary<DatabaseHealthState, NativeOperatorDatabaseState>
|
||||
{
|
||||
[DatabaseHealthState.NotConfigured] = NativeOperatorDatabaseState.NotConfigured,
|
||||
[DatabaseHealthState.Healthy] = NativeOperatorDatabaseState.Healthy,
|
||||
[DatabaseHealthState.Unhealthy] = NativeOperatorDatabaseState.Unhealthy,
|
||||
[DatabaseHealthState.TimedOut] = NativeOperatorDatabaseState.TimedOut
|
||||
};
|
||||
|
||||
Assert.Equal(Enum.GetValues<DatabaseHealthState>().Length, expected.Count);
|
||||
foreach (var pair in expected)
|
||||
{
|
||||
Assert.True(NativeOperatorLogTransitionTracker.TryMapDatabaseState(
|
||||
pair.Key,
|
||||
out var actual));
|
||||
Assert.Equal(pair.Value, actual);
|
||||
}
|
||||
Assert.False(NativeOperatorLogTransitionTracker.TryMapDatabaseState(
|
||||
(DatabaseHealthState)int.MaxValue,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TornadoMapping_CoversEveryClosedConnectionState()
|
||||
{
|
||||
var expected = new Dictionary<PlayoutConnectionState, NativeOperatorTornadoState>
|
||||
{
|
||||
[PlayoutConnectionState.Disabled] = NativeOperatorTornadoState.Disabled,
|
||||
[PlayoutConnectionState.DryRunReady] = NativeOperatorTornadoState.DryRunReady,
|
||||
[PlayoutConnectionState.Disconnected] = NativeOperatorTornadoState.Disconnected,
|
||||
[PlayoutConnectionState.Connecting] = NativeOperatorTornadoState.Connecting,
|
||||
[PlayoutConnectionState.Connected] = NativeOperatorTornadoState.Connected,
|
||||
[PlayoutConnectionState.Reconnecting] = NativeOperatorTornadoState.Reconnecting,
|
||||
[PlayoutConnectionState.Disconnecting] = NativeOperatorTornadoState.Disconnecting,
|
||||
[PlayoutConnectionState.Faulted] = NativeOperatorTornadoState.Faulted,
|
||||
[PlayoutConnectionState.OutcomeUnknown] = NativeOperatorTornadoState.OutcomeUnknown,
|
||||
[PlayoutConnectionState.Disposed] = NativeOperatorTornadoState.Disposed
|
||||
};
|
||||
|
||||
Assert.Equal(Enum.GetValues<PlayoutConnectionState>().Length, expected.Count);
|
||||
foreach (var pair in expected)
|
||||
{
|
||||
Assert.True(NativeOperatorLogTransitionTracker.TryMapTornadoState(
|
||||
pair.Key,
|
||||
out var actual));
|
||||
Assert.Equal(pair.Value, actual);
|
||||
}
|
||||
Assert.False(NativeOperatorLogTransitionTracker.TryMapTornadoState(
|
||||
(PlayoutConnectionState)int.MaxValue,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatabaseTransitions_SuppressDuplicatesIndependentlyPerProvider()
|
||||
{
|
||||
var tracker = new NativeOperatorLogTransitionTracker();
|
||||
|
||||
Assert.True(tracker.TryObserveDatabaseState(
|
||||
DataSourceKind.Oracle,
|
||||
DatabaseHealthState.Healthy,
|
||||
out var oracleHealthy));
|
||||
Assert.Equal(NativeOperatorLogEvent.OracleStateChanged, oracleHealthy.Event);
|
||||
Assert.Equal(NativeOperatorDatabaseState.Healthy, oracleHealthy.DatabaseState);
|
||||
Assert.False(tracker.TryObserveDatabaseState(
|
||||
DataSourceKind.Oracle,
|
||||
DatabaseHealthState.Healthy,
|
||||
out _));
|
||||
|
||||
Assert.True(tracker.TryObserveDatabaseState(
|
||||
DataSourceKind.MariaDb,
|
||||
DatabaseHealthState.Healthy,
|
||||
out var mariaHealthy));
|
||||
Assert.Equal(NativeOperatorLogEvent.MariaDbStateChanged, mariaHealthy.Event);
|
||||
Assert.False(tracker.TryObserveDatabaseState(
|
||||
DataSourceKind.MariaDb,
|
||||
DatabaseHealthState.Healthy,
|
||||
out _));
|
||||
|
||||
Assert.True(tracker.TryObserveDatabaseState(
|
||||
DataSourceKind.Oracle,
|
||||
DatabaseHealthState.TimedOut,
|
||||
out var oracleTimedOut));
|
||||
Assert.Equal(NativeOperatorDatabaseState.TimedOut, oracleTimedOut.DatabaseState);
|
||||
Assert.False(tracker.TryObserveDatabaseState(
|
||||
(DataSourceKind)int.MaxValue,
|
||||
DatabaseHealthState.Healthy,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TornadoTransitions_SuppressOnlyIdenticalConsecutiveStates()
|
||||
{
|
||||
var tracker = new NativeOperatorLogTransitionTracker();
|
||||
|
||||
Assert.True(tracker.TryObserveTornadoState(
|
||||
PlayoutConnectionState.Disconnected,
|
||||
out var disconnected));
|
||||
Assert.Equal(NativeOperatorTornadoState.Disconnected, disconnected.TornadoState);
|
||||
Assert.False(tracker.TryObserveTornadoState(
|
||||
PlayoutConnectionState.Disconnected,
|
||||
out _));
|
||||
Assert.True(tracker.TryObserveTornadoState(
|
||||
PlayoutConnectionState.Connecting,
|
||||
out var connecting));
|
||||
Assert.Equal(NativeOperatorTornadoState.Connecting, connecting.TornadoState);
|
||||
Assert.True(tracker.TryObserveTornadoState(
|
||||
PlayoutConnectionState.Disconnected,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandTracker_EmitsOneRequestAndExactlyOneMatchingTerminalRecord()
|
||||
{
|
||||
var tracker = new NativeOperatorCommandLogTracker();
|
||||
|
||||
Assert.True(tracker.TryBegin(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
out var requested));
|
||||
Assert.Equal(NativeOperatorLogEvent.PlayoutCommandRequested, requested.Event);
|
||||
Assert.Equal(NativeOperatorPlayoutCommand.Prepare, requested.Command);
|
||||
Assert.False(tracker.TryComplete(
|
||||
NativeOperatorPlayoutCommand.Next,
|
||||
NativeOperatorPlayoutCompletion.Succeeded,
|
||||
out _));
|
||||
Assert.True(tracker.TryComplete(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
NativeOperatorPlayoutCompletion.Rejected,
|
||||
out var completed));
|
||||
Assert.Equal(NativeOperatorLogEvent.PlayoutCommandCompleted, completed.Event);
|
||||
Assert.Equal(NativeOperatorPlayoutCompletion.Rejected, completed.Completion);
|
||||
Assert.False(tracker.TryComplete(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
NativeOperatorPlayoutCompletion.Succeeded,
|
||||
out _));
|
||||
Assert.False(tracker.TryMarkCurrentOutcomeUnknown(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandTracker_TimeoutOutcomeWinsAndSuppressesLateCompletion()
|
||||
{
|
||||
var tracker = new NativeOperatorCommandLogTracker();
|
||||
Assert.True(tracker.TryBegin(NativeOperatorPlayoutCommand.TakeIn, out _));
|
||||
|
||||
Assert.True(tracker.TryMarkCurrentOutcomeUnknown(out var unknown));
|
||||
Assert.Equal(NativeOperatorLogEvent.OutcomeUnknown, unknown.Event);
|
||||
Assert.Equal(NativeOperatorPlayoutCommand.TakeIn, unknown.Command);
|
||||
Assert.False(tracker.TryComplete(
|
||||
NativeOperatorPlayoutCommand.TakeIn,
|
||||
NativeOperatorPlayoutCompletion.Succeeded,
|
||||
out _));
|
||||
|
||||
Assert.True(tracker.TryBegin(NativeOperatorPlayoutCommand.Next, out _));
|
||||
Assert.True(tracker.TryMarkOutcomeUnknown(
|
||||
NativeOperatorPlayoutCommand.Next,
|
||||
out var secondUnknown));
|
||||
Assert.Equal(NativeOperatorLogEvent.OutcomeUnknown, secondUnknown.Event);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandTracker_UndefinedScalarsFailClosed()
|
||||
{
|
||||
var tracker = new NativeOperatorCommandLogTracker();
|
||||
|
||||
Assert.False(tracker.TryBegin((NativeOperatorPlayoutCommand)int.MaxValue, out _));
|
||||
Assert.False(tracker.TryComplete(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
(NativeOperatorPlayoutCompletion)int.MaxValue,
|
||||
out _));
|
||||
Assert.False(tracker.TryMarkOutcomeUnknown(
|
||||
(NativeOperatorPlayoutCommand)int.MaxValue,
|
||||
out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Reflection;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
|
||||
|
||||
public sealed class NativeOperatorLogWriterTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedUtcNow =
|
||||
new(2026, 7, 12, 3, 4, 5, 678, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_WritesOnlyClosedEventsToTheFixedLocalApplicationDataPath()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var writer = CreateWriter(scope.Path);
|
||||
var records = new[]
|
||||
{
|
||||
NativeOperatorLogRecord.ForStartup(),
|
||||
NativeOperatorLogRecord.ForShutdown(),
|
||||
NativeOperatorLogRecord.ForOracleStateChanged(NativeOperatorDatabaseState.Healthy),
|
||||
NativeOperatorLogRecord.ForMariaDbStateChanged(NativeOperatorDatabaseState.Unhealthy),
|
||||
NativeOperatorLogRecord.ForTornadoStateChanged(NativeOperatorTornadoState.Connected),
|
||||
NativeOperatorLogRecord.ForPlayoutCommandRequested(NativeOperatorPlayoutCommand.Prepare),
|
||||
NativeOperatorLogRecord.ForPlayoutCommandCompleted(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
NativeOperatorPlayoutCompletion.Succeeded),
|
||||
NativeOperatorLogRecord.ForOutcomeUnknown(NativeOperatorPlayoutCommand.TakeIn)
|
||||
};
|
||||
|
||||
Assert.All(records, record => Assert.True(writer.TryWrite(record)));
|
||||
|
||||
var expectedPath = Path.Combine(
|
||||
scope.Path,
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"Logs",
|
||||
"Log_0712.log");
|
||||
Assert.True(File.Exists(expectedPath));
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"[2026-07-12T03:04:05.678+00:00] event=Startup",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=Shutdown",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=OracleStateChanged state=Healthy",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=MariaDbStateChanged state=Unhealthy",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=TornadoStateChanged state=Connected",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=PlayoutCommandRequested command=Prepare",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=PlayoutCommandCompleted command=Prepare result=Succeeded",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=OutcomeUnknown command=TakeIn"
|
||||
},
|
||||
File.ReadAllLines(expectedPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicApi_CannotAcceptFreeFormTextPathsSqlExceptionsOrSceneData()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"Startup",
|
||||
"Shutdown",
|
||||
"OracleStateChanged",
|
||||
"MariaDbStateChanged",
|
||||
"TornadoStateChanged",
|
||||
"PlayoutCommandRequested",
|
||||
"PlayoutCommandCompleted",
|
||||
"OutcomeUnknown"
|
||||
},
|
||||
Enum.GetNames<NativeOperatorLogEvent>());
|
||||
|
||||
var recordType = typeof(NativeOperatorLogRecord);
|
||||
Assert.All(
|
||||
recordType.GetProperties(BindingFlags.Instance | BindingFlags.Public),
|
||||
property =>
|
||||
{
|
||||
var scalarType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
|
||||
Assert.True(scalarType.IsEnum, $"{property.Name} must remain a closed enum scalar.");
|
||||
});
|
||||
Assert.DoesNotContain(
|
||||
recordType.GetConstructors(BindingFlags.Instance | BindingFlags.Public),
|
||||
constructor => constructor.GetParameters().Length > 0);
|
||||
|
||||
var factories = recordType.GetMethods(BindingFlags.Static | BindingFlags.Public)
|
||||
.Where(method => method.Name.StartsWith("For", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.Equal(8, factories.Length);
|
||||
Assert.All(
|
||||
factories.SelectMany(factory => factory.GetParameters()),
|
||||
parameter => Assert.True(parameter.ParameterType.IsEnum));
|
||||
|
||||
var publicWriterConstructors = typeof(NativeOperatorLogWriter)
|
||||
.GetConstructors(BindingFlags.Instance | BindingFlags.Public);
|
||||
var constructor = Assert.Single(publicWriterConstructors);
|
||||
Assert.Empty(constructor.GetParameters());
|
||||
var tryWrite = typeof(INativeOperatorLogWriter).GetMethod(nameof(INativeOperatorLogWriter.TryWrite));
|
||||
Assert.NotNull(tryWrite);
|
||||
Assert.Equal(
|
||||
new[] { typeof(NativeOperatorLogRecord) },
|
||||
tryWrite.GetParameters().Select(parameter => parameter.ParameterType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_InvalidDefaultAndUndefinedEnumsFailClosedWithoutCreatingALog()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var writer = CreateWriter(scope.Path);
|
||||
|
||||
Assert.False(writer.TryWrite(default));
|
||||
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForOracleStateChanged(
|
||||
(NativeOperatorDatabaseState)int.MaxValue)));
|
||||
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForPlayoutCommandCompleted(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
(NativeOperatorPlayoutCompletion)int.MaxValue)));
|
||||
|
||||
Assert.False(Directory.Exists(Path.Combine(scope.Path, "MBN_STOCK_WEBVIEW")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_AcceptsEveryDefinedClosedScalarAndNoUnmappedRuntimeState()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var writer = CreateWriter(scope.Path);
|
||||
|
||||
foreach (var state in Enum.GetValues<NativeOperatorDatabaseState>())
|
||||
{
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForOracleStateChanged(state)));
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForMariaDbStateChanged(state)));
|
||||
}
|
||||
|
||||
foreach (var state in Enum.GetValues<NativeOperatorTornadoState>())
|
||||
{
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForTornadoStateChanged(state)));
|
||||
}
|
||||
|
||||
foreach (var command in Enum.GetValues<NativeOperatorPlayoutCommand>())
|
||||
{
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForPlayoutCommandRequested(command)));
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForOutcomeUnknown(command)));
|
||||
foreach (var completion in Enum.GetValues<NativeOperatorPlayoutCompletion>())
|
||||
{
|
||||
Assert.True(writer.TryWrite(
|
||||
NativeOperatorLogRecord.ForPlayoutCommandCompleted(command, completion)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_StopsAtTheConfiguredFileSizeWithoutChangingExistingBytes()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var initialWriter = CreateWriter(scope.Path);
|
||||
Assert.True(initialWriter.TryWrite(NativeOperatorLogRecord.ForStartup()));
|
||||
var path = LogPath(scope.Path);
|
||||
var original = File.ReadAllBytes(path);
|
||||
|
||||
var boundedWriter = CreateWriter(scope.Path, maximumFileBytes: original.Length);
|
||||
Assert.False(boundedWriter.TryWrite(NativeOperatorLogRecord.ForShutdown()));
|
||||
|
||||
Assert.Equal(original, File.ReadAllBytes(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_DeletesOnlyExpiredClosedLogNamesAndKeepsRecentOrUnrelatedFiles()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var logDirectory = Path.Combine(scope.Path, "MBN_STOCK_WEBVIEW", "Logs");
|
||||
Directory.CreateDirectory(logDirectory);
|
||||
var expired = Path.Combine(logDirectory, "Log_0601.log");
|
||||
var recent = Path.Combine(logDirectory, "Log_0711.log");
|
||||
var unrelated = Path.Combine(logDirectory, "operator-notes.txt");
|
||||
File.WriteAllText(expired, "expired");
|
||||
File.WriteAllText(recent, "recent");
|
||||
File.WriteAllText(unrelated, "unrelated");
|
||||
File.SetLastWriteTimeUtc(expired, FixedUtcNow.UtcDateTime.AddDays(-12));
|
||||
File.SetLastWriteTimeUtc(recent, FixedUtcNow.UtcDateTime.AddDays(-1));
|
||||
File.SetLastWriteTimeUtc(unrelated, FixedUtcNow.UtcDateTime.AddDays(-100));
|
||||
var writer = CreateWriter(scope.Path, retentionDays: 10);
|
||||
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForStartup()));
|
||||
|
||||
Assert.False(File.Exists(expired));
|
||||
Assert.True(File.Exists(recent));
|
||||
Assert.True(File.Exists(unrelated));
|
||||
Assert.True(File.Exists(LogPath(scope.Path)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryWrite_ConcurrentWritersProduceCompleteNonInterleavedLines()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var writers = Enumerable.Range(0, 4)
|
||||
.Select(_ => CreateWriter(scope.Path))
|
||||
.ToArray();
|
||||
const int taskCount = 8;
|
||||
const int writesPerTask = 40;
|
||||
|
||||
var tasks = Enumerable.Range(0, taskCount)
|
||||
.Select(taskIndex => Task.Run(() =>
|
||||
{
|
||||
var writer = writers[taskIndex % writers.Length];
|
||||
for (var index = 0; index < writesPerTask; index++)
|
||||
{
|
||||
if (!writer.TryWrite(NativeOperatorLogRecord.ForTornadoStateChanged(
|
||||
NativeOperatorTornadoState.Connected)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}))
|
||||
.ToArray();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
Assert.All(results, Assert.True);
|
||||
var lines = File.ReadAllLines(LogPath(scope.Path));
|
||||
Assert.Equal(taskCount * writesPerTask, lines.Length);
|
||||
Assert.All(
|
||||
lines,
|
||||
line => Assert.Equal(
|
||||
"[2026-07-12T03:04:05.678+00:00] event=TornadoStateChanged state=Connected",
|
||||
line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_UnsafeDirectoryLayoutsAndReparseAttributesFailWithoutThrowing()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
File.WriteAllText(Path.Combine(scope.Path, "MBN_STOCK_WEBVIEW"), "not a directory");
|
||||
var writer = CreateWriter(scope.Path);
|
||||
|
||||
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForStartup()));
|
||||
Assert.False(NativeOperatorLogWriter.IsSafeDirectoryAttributes(
|
||||
FileAttributes.Directory | FileAttributes.ReparsePoint));
|
||||
Assert.False(NativeOperatorLogWriter.IsSafeFileAttributes(
|
||||
FileAttributes.Normal | FileAttributes.ReparsePoint));
|
||||
Assert.False(NativeOperatorLogWriter.IsSafeFileAttributes(FileAttributes.Directory));
|
||||
Assert.True(NativeOperatorLogWriter.IsSafeDirectoryAttributes(FileAttributes.Directory));
|
||||
Assert.True(NativeOperatorLogWriter.IsSafeFileAttributes(FileAttributes.Normal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_MalformedInternalRootIsAlwaysBestEffort()
|
||||
{
|
||||
var writer = new NativeOperatorLogWriter(
|
||||
"relative-root",
|
||||
new FixedTimeProvider(FixedUtcNow),
|
||||
NativeOperatorLogWriter.DefaultMaximumFileBytes,
|
||||
NativeOperatorLogWriter.DefaultRetentionDays);
|
||||
|
||||
var exception = Record.Exception(() =>
|
||||
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForStartup())));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
private static NativeOperatorLogWriter CreateWriter(
|
||||
string localApplicationDataRoot,
|
||||
int maximumFileBytes = NativeOperatorLogWriter.DefaultMaximumFileBytes,
|
||||
int retentionDays = NativeOperatorLogWriter.DefaultRetentionDays) =>
|
||||
new(
|
||||
localApplicationDataRoot,
|
||||
new FixedTimeProvider(FixedUtcNow),
|
||||
maximumFileBytes,
|
||||
retentionDays);
|
||||
|
||||
private static string LogPath(string localApplicationDataRoot) =>
|
||||
Path.Combine(localApplicationDataRoot, "MBN_STOCK_WEBVIEW", "Logs", "Log_0712.log");
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => utcNow;
|
||||
|
||||
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||
}
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
private TemporaryDirectory(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public static TemporaryDirectory Create()
|
||||
{
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"MBN_STOCK_WEBVIEW-log-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return new TemporaryDirectory(path);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Test cleanup is best effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,52 @@ public sealed class OperatorCatalogMutationExecutorTests
|
||||
Assert.All(plan.Commands, command => Assert.Contains('@', command.Sql));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("123", ThemeMarket.Krx)]
|
||||
[InlineData("0123", ThemeMarket.Krx)]
|
||||
[InlineData("001234", ThemeMarket.Krx)]
|
||||
[InlineData("00001234", ThemeMarket.Krx)]
|
||||
[InlineData("123", ThemeMarket.Nxt)]
|
||||
[InlineData("0123", ThemeMarket.Nxt)]
|
||||
[InlineData("001234", ThemeMarket.Nxt)]
|
||||
[InlineData("00001234", ThemeMarket.Nxt)]
|
||||
public async Task ExecuteTransactionAsync_ReplaceAndDeleteAcceptLegacyThemeIdentityLengths(
|
||||
string code,
|
||||
ThemeMarket market)
|
||||
{
|
||||
var source = market == ThemeMarket.Krx ? DataSourceKind.Oracle : DataSourceKind.MariaDb;
|
||||
var session = market == ThemeMarket.Krx
|
||||
? ThemeSession.NotApplicable
|
||||
: ThemeSession.PreMarket;
|
||||
var identity = new ThemeSelectionIdentity(market, session, code, "기존 테마");
|
||||
|
||||
var replacePlan = new CatalogMutationConnectionPlan();
|
||||
replacePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
|
||||
replacePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(0));
|
||||
var replaceFactory = new CatalogMutationConnectionFactory(replacePlan);
|
||||
var replaceService = new LegacyThemeCatalogPersistenceService(
|
||||
new CatalogNoQueryExecutor(),
|
||||
CreateExecutor(replaceFactory));
|
||||
|
||||
await replaceService.ReplaceAsync(identity, "변경 테마", []);
|
||||
|
||||
Assert.Equal(source, replaceFactory.LastSource);
|
||||
Assert.Equal(1, replacePlan.CommitCount);
|
||||
|
||||
var deletePlan = new CatalogMutationConnectionPlan();
|
||||
deletePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(0));
|
||||
deletePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
|
||||
var deleteFactory = new CatalogMutationConnectionFactory(deletePlan);
|
||||
var deleteService = new LegacyThemeCatalogPersistenceService(
|
||||
new CatalogNoQueryExecutor(),
|
||||
CreateExecutor(deleteFactory));
|
||||
|
||||
await deleteService.DeleteAsync(identity);
|
||||
|
||||
Assert.Equal(source, deleteFactory.LastSource);
|
||||
Assert.Equal(1, deletePlan.CommitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteTransactionAsync_AffectedRowConflictRollsBackBeforeNextCommand()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user