308 lines
12 KiB
C#
308 lines
12 KiB
C#
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.
|
|
}
|
|
}
|
|
}
|
|
}
|