using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; /// /// Covers — the low-frequency trigger that makes key expiry reach /// a live subscription without waiting for a deployment (arch-review 03/S19). /// public sealed class AclRevalidationTimerTests { /// A positive interval arms one timer whose due time and period are both that interval. [Fact] public void A_positive_interval_creates_a_timer_with_that_period() { var clock = new RecordingTimeProvider(); using var _ = new AclRevalidationTimer( () => { }, TimeSpan.FromSeconds(30), clock, NullLogger.Instance); clock.Created.Count.ShouldBe(1); clock.Created[0].DueTime.ShouldBe(TimeSpan.FromSeconds(30)); clock.Created[0].Period.ShouldBe(TimeSpan.FromSeconds(30)); } /// /// A non-positive interval arms nothing at all, and says so once — a disabled guard should be /// visible in the log rather than inferred from an absence of activity. /// [Fact] public void A_zero_interval_creates_no_timer() { var clock = new RecordingTimeProvider(); var log = new CapturingLogger(); using var _ = new AclRevalidationTimer(() => { }, TimeSpan.Zero, clock, log); clock.Created.ShouldBeEmpty(); log.Lines.ShouldContain(l => l.Contains("RevalidationIntervalSeconds", StringComparison.Ordinal)); } /// /// A pass that throws must not escape the tick. /// /// /// An unhandled exception on a timer callback thread takes the process down. The next tick is the /// recovery, so the fault is logged and the timer keeps running. /// [Fact] public void A_throwing_pass_does_not_escape_the_tick() { var log = new CapturingLogger(); using var timer = new AclRevalidationTimer( () => throw new InvalidOperationException("pass blew up"), TimeSpan.FromSeconds(30), new RecordingTimeProvider(), log); Should.NotThrow(timer.Tick); log.Lines.ShouldContain(l => l.Contains("revalidation", StringComparison.OrdinalIgnoreCase)); } /// A tick runs the pass — the control for the two "nothing happened" cases above. [Fact] public void A_tick_runs_the_pass() { var runs = 0; using var timer = new AclRevalidationTimer( () => runs++, TimeSpan.FromSeconds(30), new RecordingTimeProvider(), NullLogger.Instance); timer.Tick(); timer.Tick(); runs.ShouldBe(2); } /// Disposing disposes the underlying timer and stops further ticks. [Fact] public void Dispose_stops_further_ticks() { var clock = new RecordingTimeProvider(); var runs = 0; var timer = new AclRevalidationTimer( () => runs++, TimeSpan.FromSeconds(30), clock, NullLogger.Instance); timer.Dispose(); clock.Created[0].Disposed.ShouldBeTrue(); timer.Tick(); runs.ShouldBe(0, "a disposed timer must not run the pass again"); } // ---------- helpers ---------- /// /// A that records the timers asked of it instead of arming real ones. /// private sealed class RecordingTimeProvider : TimeProvider { /// Every timer this provider was asked to create, in order. public List Created { get; } = []; /// public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { var timer = new FakeTimer(dueTime, period); Created.Add(timer); return timer; } /// A timer that does nothing but remember how it was asked for, and whether it was disposed. internal sealed class FakeTimer(TimeSpan dueTime, TimeSpan period) : ITimer { public TimeSpan DueTime { get; } = dueTime; public TimeSpan Period { get; } = period; public bool Disposed { get; private set; } public bool Change(TimeSpan due, TimeSpan p) => true; public void Dispose() => Disposed = true; public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; } } } /// Collects rendered log messages so a "said so once" claim can be asserted. private sealed class CapturingLogger : ILogger { public List Lines { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Lines.Add(formatter(state, exception)); } }