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"); } // Bug #12 fix — verifies the previously-missing wiring: applies and heartbeats both // emit sp_RegisterNodeGenerationApplied so Admin UI Fleet status + Redundancy LastSeenAt // surface live state. [Fact] public async Task First_apply_reports_Applied_status_to_central_db() { var coordinator = await SeedCoordinatorAsync(); var leases = new ApplyLeaseRegistry(); var calls = new List<(long Gen, NodeApplyStatus Status, string? Error)>(); var service = NewService(coordinator, leases, currentGeneration: () => 42, registerCalls: calls); await service.TickAsync(CancellationToken.None); calls.Count.ShouldBe(1, "exactly one register call per apply window"); calls[0].Gen.ShouldBe(42); calls[0].Status.ShouldBe(NodeApplyStatus.Applied); calls[0].Error.ShouldBeNull(); } [Fact] public async Task No_change_tick_heartbeats_with_Applied_status() { var coordinator = await SeedCoordinatorAsync(); var leases = new ApplyLeaseRegistry(); var calls = new List<(long Gen, NodeApplyStatus Status, string? Error)>(); var service = NewService(coordinator, leases, currentGeneration: () => 42, registerCalls: calls); await service.TickAsync(CancellationToken.None); // initial apply await service.TickAsync(CancellationToken.None); // no-change heartbeat await service.TickAsync(CancellationToken.None); // no-change heartbeat calls.Count.ShouldBe(3, "one apply call + two heartbeat calls"); calls.ShouldAllBe(c => c.Gen == 42 && c.Status == NodeApplyStatus.Applied && c.Error == null); } [Fact] public async Task Register_call_failure_does_not_break_apply_or_block_subsequent_ticks() { var coordinator = await SeedCoordinatorAsync(); var leases = new ApplyLeaseRegistry(); var registerCallCount = 0; var service = new GenerationRefreshHostedService( new NodeOptions { NodeId = "A", ClusterId = "c1", ConfigDbConnectionString = "unused" }, leases, coordinator, NullLogger.Instance, tickInterval: TimeSpan.FromSeconds(1), currentGenerationQuery: _ => Task.FromResult(42), registerAppliedAsync: (gen, status, err, ct) => { registerCallCount++; throw new InvalidOperationException("simulated DB outage during register"); }); await service.TickAsync(CancellationToken.None); // apply succeeds, register throws await service.TickAsync(CancellationToken.None); // heartbeat throws registerCallCount.ShouldBe(2, "both register attempts must run"); service.LastAppliedGenerationId.ShouldBe(42, "register failure must not roll back the cursor"); } // ---- 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, List<(long Gen, NodeApplyStatus Status, string? Error)>? registerCalls = null) => new(new NodeOptions { NodeId = "A", ClusterId = "c1", ConfigDbConnectionString = "unused" }, leases, coordinator, NullLogger.Instance, tickInterval: TimeSpan.FromSeconds(1), currentGenerationQuery: _ => Task.FromResult(currentGeneration()), registerAppliedAsync: registerCalls is null ? (_, _, _, _) => Task.CompletedTask : (gen, status, err, _) => { registerCalls.Add((gen, status, err)); return Task.CompletedTask; }); private sealed class DbContextFactory(DbContextOptions options) : IDbContextFactory { public OtOpcUaConfigDbContext CreateDbContext() => new(options); } }