using Serilog; using Serilog.Core; using Serilog.Events; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; /// /// Verifies the root script-logger fan-out: events flowing through the composed /// pipeline (rolling file + companion mirror + topic sink) reach the /// topic sink only when they meet the configured /// minimum level, carrying their ScriptId identity property. /// /// /// Builds the identical that the Host's /// ScriptRootLoggerFactory.Build composes (file + /// + ). The factory itself is a thin shell over this /// exact wiring; testing the composition here keeps the suite a pure unit test with no /// dependency on the Host's Web SDK executable. /// [Trait("Category", "Unit")] public sealed class ScriptRootLoggerFanoutTests : IDisposable { private readonly string _tempFile = Path.Combine( Path.GetTempPath(), $"scripts-fanout-{Guid.NewGuid():N}-.log"); private sealed class FakePublisher : IScriptLogPublisher { /// Gets the entries that have been published to the topic. public List Published { get; } = []; /// public void Publish(ScriptLogEntry entry) => Published.Add(entry); } /// /// Builds the same pipeline the Host's ScriptRootLoggerFactory.Build composes, /// so this test exercises the real production sink fan-out. /// private Logger BuildRootLogger(FakePublisher publisher, LogEventLevel topicMinLevel) => new LoggerConfiguration() .MinimumLevel.Verbose() .WriteTo.File(_tempFile, rollingInterval: RollingInterval.Day) .WriteTo.Sink(new ScriptLogCompanionSink(Serilog.Log.Logger)) .WriteTo.Sink(new ScriptLogTopicSink(publisher, topicMinLevel)) .CreateLogger(); /// /// An Information event flows to the topic sink as a single /// carrying the bound ScriptId. /// [Fact] public void Information_event_reaches_the_topic_sink_with_its_ScriptId() { var publisher = new FakePublisher(); using var root = BuildRootLogger(publisher, LogEventLevel.Information); root.ForContext(ScriptLoggerFactory.ScriptIdProperty, "S1") .Information("virtual tag recomputed"); publisher.Published.Count.ShouldBe(1); publisher.Published[0].ScriptId.ShouldBe("S1"); publisher.Published[0].Level.ShouldBe("Information"); publisher.Published[0].Message.ShouldBe("virtual tag recomputed"); } /// /// A Debug event is gated out by the topic sink's Information minimum level, so the /// publisher receives nothing (it still lands in the file sink, which we don't assert). /// [Fact] public void Debug_event_is_gated_out_by_the_topic_minimum_level() { var publisher = new FakePublisher(); using var root = BuildRootLogger(publisher, LogEventLevel.Information); root.ForContext(ScriptLoggerFactory.ScriptIdProperty, "S1") .Debug("verbose diagnostic noise"); publisher.Published.ShouldBeEmpty(); } /// Disposes the temp log file created by the rolling file sink. public void Dispose() { // Serilog appends a date stamp where the trailing '-' sits; clean any matching file. var dir = Path.GetDirectoryName(_tempFile)!; var prefix = Path.GetFileNameWithoutExtension(_tempFile); try { foreach (var f in Directory.EnumerateFiles(dir, prefix + "*")) File.Delete(f); } catch (IOException) { // Best-effort cleanup — a locked file on a flaky CI box must not fail the test. } } }