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(), 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( () => 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( () => 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( () => 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( () => 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( () => 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( () => 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( () => 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( () => 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( () => 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( () => 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 Rows() => [ new("삼성전자", "1,000", "SK하이닉스", "-2,000"), new("현대차", "2", "기아", "-2"), new("NAVER", "3", "카카오", "-3"), new("LG화학", "4", "포스코", "-4"), new("한화", "5", "셀트리온", "-5") ]; private static IReadOnlyList 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 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. } } } }