Migrate remaining legacy operator workflows
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user