152 lines
5.2 KiB
C#
152 lines
5.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Covers <see cref="AclRevalidationTimer"/> — the low-frequency trigger that makes key expiry reach
|
|
/// a live subscription without waiting for a deployment (arch-review 03/S19).
|
|
/// </summary>
|
|
public sealed class AclRevalidationTimerTests
|
|
{
|
|
/// <summary>A positive interval arms one timer whose due time and period are both that interval.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A pass that throws must not escape the tick.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>A tick runs the pass — the control for the two "nothing happened" cases above.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Disposing disposes the underlying timer and stops further ticks.</summary>
|
|
[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 ----------
|
|
|
|
/// <summary>
|
|
/// A <see cref="TimeProvider"/> that records the timers asked of it instead of arming real ones.
|
|
/// </summary>
|
|
private sealed class RecordingTimeProvider : TimeProvider
|
|
{
|
|
/// <summary>Every timer this provider was asked to create, in order.</summary>
|
|
public List<FakeTimer> Created { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
|
{
|
|
var timer = new FakeTimer(dueTime, period);
|
|
Created.Add(timer);
|
|
return timer;
|
|
}
|
|
|
|
/// <summary>A timer that does nothing but remember how it was asked for, and whether it was disposed.</summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Collects rendered log messages so a "said so once" claim can be asserted.</summary>
|
|
private sealed class CapturingLogger : ILogger
|
|
{
|
|
public List<string> Lines { get; } = [];
|
|
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception?, string> formatter) => Lines.Add(formatter(state, exception));
|
|
}
|
|
}
|