using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Resilience; using ZB.MOM.WW.OtOpcUa.Server.Hosting; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; [Trait("Category", "Unit")] public sealed class ResilienceStatusPublisherHostedServiceTests : IDisposable { private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc); private sealed class FakeClock : TimeProvider { public DateTime Utc { get; set; } = T0; public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero); } private sealed class InMemoryDbContextFactory : IDbContextFactory { private readonly DbContextOptions _options; public InMemoryDbContextFactory(string dbName) { _options = new DbContextOptionsBuilder() .UseInMemoryDatabase(dbName) .Options; } public OtOpcUaConfigDbContext CreateDbContext() => new(_options); } private readonly string _dbName = $"resilience-pub-{Guid.NewGuid():N}"; private readonly InMemoryDbContextFactory _factory; private readonly OtOpcUaConfigDbContext _readCtx; public ResilienceStatusPublisherHostedServiceTests() { _factory = new InMemoryDbContextFactory(_dbName); _readCtx = _factory.CreateDbContext(); } public void Dispose() => _readCtx.Dispose(); [Fact] public async Task EmptyTracker_Tick_NoOp_NoRowsWritten() { var tracker = new DriverResilienceStatusTracker(); var host = new ResilienceStatusPublisherHostedService( tracker, _factory, NullLogger.Instance); await host.PersistOnceAsync(CancellationToken.None); host.TickCount.ShouldBe(1); (await _readCtx.DriverInstanceResilienceStatuses.CountAsync()).ShouldBe(0); } [Fact] public async Task SingleHost_OnePairWithCounters_UpsertsNewRow() { var clock = new FakeClock(); var tracker = new DriverResilienceStatusTracker(); tracker.RecordFailure("drv-1", "plc-a", T0); tracker.RecordFailure("drv-1", "plc-a", T0); tracker.RecordBreakerOpen("drv-1", "plc-a", T0.AddSeconds(1)); var host = new ResilienceStatusPublisherHostedService( tracker, _factory, NullLogger.Instance, timeProvider: clock); clock.Utc = T0.AddSeconds(2); await host.PersistOnceAsync(CancellationToken.None); var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync(); row.DriverInstanceId.ShouldBe("drv-1"); row.HostName.ShouldBe("plc-a"); row.ConsecutiveFailures.ShouldBe(2); row.LastCircuitBreakerOpenUtc.ShouldBe(T0.AddSeconds(1)); row.LastSampledUtc.ShouldBe(T0.AddSeconds(2)); } [Fact] public async Task SecondTick_UpdatesExistingRow_InPlace() { var clock = new FakeClock(); var tracker = new DriverResilienceStatusTracker(); tracker.RecordFailure("drv-1", "plc-a", T0); var host = new ResilienceStatusPublisherHostedService( tracker, _factory, NullLogger.Instance, timeProvider: clock); clock.Utc = T0.AddSeconds(5); await host.PersistOnceAsync(CancellationToken.None); // Second tick: success resets the counter. tracker.RecordSuccess("drv-1", "plc-a", T0.AddSeconds(6)); clock.Utc = T0.AddSeconds(10); await host.PersistOnceAsync(CancellationToken.None); (await _readCtx.DriverInstanceResilienceStatuses.CountAsync()).ShouldBe(1, "one row, updated in place"); var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync(); row.ConsecutiveFailures.ShouldBe(0); row.LastSampledUtc.ShouldBe(T0.AddSeconds(10)); } [Fact] public async Task MultipleHosts_BothPersist_Independently() { var tracker = new DriverResilienceStatusTracker(); tracker.RecordFailure("drv-1", "plc-a", T0); tracker.RecordFailure("drv-1", "plc-a", T0); tracker.RecordFailure("drv-1", "plc-b", T0); var host = new ResilienceStatusPublisherHostedService( tracker, _factory, NullLogger.Instance); await host.PersistOnceAsync(CancellationToken.None); var rows = await _readCtx.DriverInstanceResilienceStatuses .OrderBy(r => r.HostName) .ToListAsync(); rows.Count.ShouldBe(2); rows[0].HostName.ShouldBe("plc-a"); rows[0].ConsecutiveFailures.ShouldBe(2); rows[1].HostName.ShouldBe("plc-b"); rows[1].ConsecutiveFailures.ShouldBe(1); } [Fact] public async Task FootprintCounters_Persist() { var tracker = new DriverResilienceStatusTracker(); tracker.RecordFootprint("drv-1", "plc-a", baselineBytes: 100_000_000, currentBytes: 150_000_000, T0); var host = new ResilienceStatusPublisherHostedService( tracker, _factory, NullLogger.Instance); await host.PersistOnceAsync(CancellationToken.None); var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync(); row.BaselineFootprintBytes.ShouldBe(100_000_000); row.CurrentFootprintBytes.ShouldBe(150_000_000); } [Fact] public async Task TickCount_Advances_OnEveryCall() { var tracker = new DriverResilienceStatusTracker(); var host = new ResilienceStatusPublisherHostedService( tracker, _factory, NullLogger.Instance); await host.PersistOnceAsync(CancellationToken.None); await host.PersistOnceAsync(CancellationToken.None); await host.PersistOnceAsync(CancellationToken.None); host.TickCount.ShouldBe(3); } }