using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Server.Hosting; using ZB.MOM.WW.OtOpcUa.Server.Redundancy; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// /// Unit tests for . Exercises the /// lease-around-refresh semantics via a stub generation-query delegate — the real /// DB path is exercised end-to-end by the Phase 6.3 compliance script. /// [Trait("Category", "Unit")] public sealed class GenerationRefreshHostedServiceTests : IDisposable { private readonly OtOpcUaConfigDbContext _db; private readonly IDbContextFactory _dbFactory; public GenerationRefreshHostedServiceTests() { var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase($"gen-refresh-{Guid.NewGuid():N}") .Options; _db = new OtOpcUaConfigDbContext(opts); _dbFactory = new DbContextFactory(opts); } public void Dispose() => _db.Dispose(); [Fact] public async Task First_tick_applies_current_generation_and_closes_the_lease() { var coordinator = await SeedCoordinatorAsync(); var leases = new ApplyLeaseRegistry(); var service = NewService(coordinator, leases, currentGeneration: () => 42); leases.IsApplyInProgress.ShouldBeFalse("no lease before first tick"); await service.TickAsync(CancellationToken.None); service.LastAppliedGenerationId.ShouldBe(42); service.TickCount.ShouldBe(1); service.RefreshCount.ShouldBe(1); leases.IsApplyInProgress.ShouldBeFalse("lease must be disposed after the apply window"); } [Fact] public async Task Subsequent_tick_with_same_generation_is_a_no_op() { var coordinator = await SeedCoordinatorAsync(); var leases = new ApplyLeaseRegistry(); var service = NewService(coordinator, leases, currentGeneration: () => 42); await service.TickAsync(CancellationToken.None); await service.TickAsync(CancellationToken.None); service.TickCount.ShouldBe(2); service.RefreshCount.ShouldBe(1, "second identical tick must skip the refresh"); leases.IsApplyInProgress.ShouldBeFalse(); } [Fact] public async Task Generation_change_triggers_new_refresh() { var coordinator = await SeedCoordinatorAsync(); var leases = new ApplyLeaseRegistry(); var current = 42L; var service = NewService(coordinator, leases, currentGeneration: () => current); await service.TickAsync(CancellationToken.None); current = 43L; await service.TickAsync(CancellationToken.None); service.LastAppliedGenerationId.ShouldBe(43); service.RefreshCount.ShouldBe(2); } [Fact] public async Task Null_generation_means_no_published_config_yet_and_does_not_apply() { var coordinator = await SeedCoordinatorAsync(); var leases = new ApplyLeaseRegistry(); var service = NewService(coordinator, leases, currentGeneration: () => null); await service.TickAsync(CancellationToken.None); service.LastAppliedGenerationId.ShouldBeNull(); service.RefreshCount.ShouldBe(0); service.TickCount.ShouldBe(1); } [Fact] public async Task Lease_is_opened_during_the_refresh_window() { // Drive a query delegate that *also* observes lease state mid-call: the delegate // fires before BeginApplyLease, so it sees IsApplyInProgress=false here, not // during the lease window. We observe the lease from the outside by checking // OpenLeaseCount on completion — if the `await using` mis-disposed we'd see 1 // dangling. Cleanest assertion in a stub-only world. var coordinator = await SeedCoordinatorAsync(); var leases = new ApplyLeaseRegistry(); var service = NewService(coordinator, leases, currentGeneration: () => 42); await service.TickAsync(CancellationToken.None); leases.OpenLeaseCount.ShouldBe(0, "IAsyncDisposable dispose must fire regardless of outcome"); } // ---- fixture helpers --------------------------------------------------- private async Task SeedCoordinatorAsync() { _db.ServerClusters.Add(new ServerCluster { ClusterId = "c1", Name = "W", Enterprise = "zb", Site = "w", RedundancyMode = RedundancyMode.None, CreatedBy = "test", }); _db.ClusterNodes.Add(new ClusterNode { NodeId = "A", ClusterId = "c1", RedundancyRole = RedundancyRole.Primary, Host = "a", ApplicationUri = "urn:A", CreatedBy = "test", }); await _db.SaveChangesAsync(); var coordinator = new RedundancyCoordinator( _dbFactory, NullLogger.Instance, "A", "c1"); await coordinator.InitializeAsync(CancellationToken.None); return coordinator; } private static GenerationRefreshHostedService NewService( RedundancyCoordinator coordinator, ApplyLeaseRegistry leases, Func currentGeneration) => new(new NodeOptions { NodeId = "A", ClusterId = "c1", ConfigDbConnectionString = "unused" }, leases, coordinator, NullLogger.Instance, tickInterval: TimeSpan.FromSeconds(1), currentGenerationQuery: _ => Task.FromResult(currentGeneration())); private sealed class DbContextFactory(DbContextOptions options) : IDbContextFactory { public OtOpcUaConfigDbContext CreateDbContext() => new(options); } }