using Akka.Actor;
using Akka.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
///
/// Pins the ACL revalidation timer's lifecycle on the hosted service (arch-review 03/S19) — that it is
/// armed at the configured interval, that 0 arms nothing, and that stopping disposes it.
///
///
/// The timer is armed through rather
/// than by running StartAsync, because StartAsync boots the real OPC UA SDK (certificate
/// store, listening socket) and none of that is what this is about. Everything the timer's lifecycle
/// touches — the options, the clock, the disposal — is exercised exactly as production reaches it.
///
public sealed class OtOpcUaServerHostedServiceRevalidationTests
{
/// The configured interval is what the timer is armed at.
[Fact]
public void A_configured_interval_arms_one_timer_at_that_period()
{
var clock = new RecordingTimeProvider();
var sut = Service(clock, intervalSeconds: 30);
sut.StartRevalidationTimer();
clock.Periods.ShouldBe([TimeSpan.FromSeconds(30)]);
}
///
/// RevalidationIntervalSeconds = 0 arms nothing — the documented rollback to
/// deploy-triggered-only revalidation.
///
[Fact]
public void A_zero_interval_arms_nothing()
{
var clock = new RecordingTimeProvider();
var sut = Service(clock, intervalSeconds: 0);
sut.StartRevalidationTimer();
clock.Periods.ShouldBeEmpty();
}
///
/// StopAsync disposes the timer, so no tick can land on a node manager that is being torn
/// down.
///
///
/// The disposal is the FIRST thing StopAsync does, ahead of
/// SetDataPlaneAuthority(null) / SetDataPlaneAuthorizer(null) — a stopping node sheds
/// the trigger before it sheds the thing the trigger drives.
///
[Fact]
public async Task StopAsync_disposes_the_timer()
{
var clock = new RecordingTimeProvider();
var sut = Service(clock, intervalSeconds: 30);
sut.StartRevalidationTimer();
clock.Timers[0].Disposed.ShouldBeFalse("non-vacuity: the timer is live before the stop");
await sut.StopAsync(CancellationToken.None);
clock.Timers[0].Disposed.ShouldBeTrue();
}
/// Disposing the service disposes the timer too, for a host that never called StopAsync.
[Fact]
public async Task DisposeAsync_disposes_the_timer()
{
var clock = new RecordingTimeProvider();
var sut = Service(clock, intervalSeconds: 30);
sut.StartRevalidationTimer();
await sut.DisposeAsync();
clock.Timers[0].Disposed.ShouldBeTrue();
}
// ---------- helpers ----------
private static OtOpcUaServerHostedService Service(TimeProvider clock, int intervalSeconds) => new(
Options.Create(new OpcUaApplicationHostOptions()),
new DeferredAddressSpaceSink(),
new DeferredServiceLevelPublisher(),
NullOpcUaUserAuthenticator.Instance,
NullHistorianDataSource.Instance,
EmptyDataPlaneAuthoritySource.Instance,
new ConfigurationBuilder().Build(),
() => throw new NotSupportedException("no ActorSystem is needed for the timer lifecycle"),
new ActorRegistry(),
dataPlaneAuthorizer: null,
Options.Create(new AclOptions { RevalidationIntervalSeconds = intervalSeconds }),
clock,
NullLoggerFactory.Instance);
/// 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 Timers { get; } = [];
/// The period each of those timers was armed at.
public IReadOnlyList Periods => [.. Timers.Select(t => t.Period)];
///
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new FakeTimer(period);
Timers.Add(timer);
return timer;
}
/// A timer that remembers its period and whether it was disposed.
internal sealed class FakeTimer(TimeSpan period) : ITimer
{
public TimeSpan Period { get; } = period;
public bool Disposed { get; private set; }
public bool Change(TimeSpan dueTime, TimeSpan p) => true;
public void Dispose() => Disposed = true;
public ValueTask DisposeAsync()
{
Disposed = true;
return ValueTask.CompletedTask;
}
}
}
}