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()); 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()) { Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForOracleStateChanged(state))); Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForMariaDbStateChanged(state))); } foreach (var state in Enum.GetValues()) { Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForTornadoStateChanged(state))); } foreach (var command in Enum.GetValues()) { Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForPlayoutCommandRequested(command))); Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForOutcomeUnknown(command))); foreach (var completion in Enum.GetValues()) { 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. } } } }