using System.Security.Claims; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; using InstanceConfigurePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.InstanceConfigure; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// /// Behavioural (bUnit) coverage of the grouped Connection Bindings panel (design /// 2026-08-20), complementing the structural pins: the rows come from the flattened /// configuration, a flatten FAILURE falls back to the direct attributes with a banner, /// a group Apply whose header connection is left at "— mixed —" keeps every member's /// own connection (review finding: it used to unbind the group, which an upsert Save /// could never persist), and Save posts the composed rows. /// public class InstanceConfigureBindingBehaviourTests : BunitContext { private readonly ITemplateEngineRepository _templateRepo = Substitute.For(); private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly IFlatteningPipeline _pipeline = Substitute.For(); public InstanceConfigureBindingBehaviourTests() { JSInterop.Mode = JSRuntimeMode.Loose; Services.AddSingleton(_templateRepo); Services.AddSingleton(_siteRepo); Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For())); Services.AddSingleton(_pipeline); Services.AddSingleton(Substitute.For()); Services.AddSingleton(Substitute.For()); var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "deployer"), new Claim(JwtTokenService.RoleClaimType, "Deployer"), }; var authProvider = new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))); Services.AddSingleton(authProvider); Services.AddSingleton(new SiteScopeService(authProvider)); Services.AddAuthorizationCore(); Services.AddTestConfigurationMutations(); AuthorizationPolicies.AddScadaBridgeAuthorization(Services); } private void SeedInstance(IReadOnlyList bindings) { var instance = new Instance("Z28061") { Id = 42, TemplateId = 1, SiteId = 1, State = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.InstanceState.NotDeployed }; _templateRepo.GetInstanceByIdAsync(42, Arg.Any()).Returns(instance); _templateRepo.GetTemplateByIdAsync(1, Arg.Any()).Returns(new Template("Reactor") { Id = 1 }); _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(new List { new("Plant A", "plant-a") { Id = 1 } }); _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any()).Returns(new List()); // Direct attributes only — the composed members are NOT here; they only exist in the flattened config. _templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any()) .Returns(new List { new("ChlorineFlow") { Id = 1, DataSourceReference = "ChlorineFlow" } }); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List { new("GW-1", "MxGateway", 1) { Id = 1 }, new("GW-2", "MxGateway", 1) { Id = 2 } }); _templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any()).Returns(bindings); _templateRepo.GetOverridesByInstanceIdAsync(42, Arg.Any()).Returns(new List()); _templateRepo.GetAlarmsByTemplateIdAsync(1, Arg.Any()).Returns(new List()); _templateRepo.GetAlarmOverridesByInstanceIdAsync(42, Arg.Any()).Returns(new List()); } private static Result Flattened(params ResolvedAttribute[] attrs) => Result.Success(new FlatteningPipelineResult( new FlattenedConfiguration { InstanceUniqueName = "Z28061", Attributes = attrs }, "hash", new ValidationResult())); private static ResolvedAttribute DataSourced(string canonical, string dflt, int? conn = null, string? effective = null) => new() { CanonicalName = canonical, DataType = "Boolean", DataSourceReference = effective ?? dflt, DefaultDataSourceReference = dflt, BoundDataConnectionId = conn }; [Fact] public void Rows_ComeFromFlattenedConfiguration_ComposedMembersGrouped() { SeedInstance(new List()); _pipeline.FlattenAndValidateAsync(42, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Flattened( DataSourced("ChlorineFlow", "ChlorineFlow"), DataSourced("LeftMESReceiver.MoveInFlag", "MoveInFlag"), DataSourced("LeftMESReceiver.ScanStateCmd", "ScanStateCmd"), DataSourced("RightMESReceiver.MoveInFlag", "MoveInFlag"))); var cut = Render(p => p.Add(c => c.Id, 42)); cut.WaitForAssertion(() => { var headers = cut.FindAll("[data-test=\"binding-group-header\"]"); Assert.Equal(3, headers.Count); Assert.Contains("(instance)", headers[0].TextContent); Assert.Contains("LeftMESReceiver", headers[1].TextContent); Assert.Contains("RightMESReceiver", headers[2].TextContent); Assert.Equal("4 unbound", cut.Find("[data-test=\"binding-total-unbound\"]").TextContent.Trim()); Assert.Empty(cut.FindAll("[data-test=\"binding-source-warning\"]")); }); } [Fact] public void FlattenFailure_FallsBackToDirectAttributes_WithBanner() { SeedInstance(new List()); _pipeline.FlattenAndValidateAsync(42, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Failure("Template chain is empty.")); var cut = Render(p => p.Add(c => c.Id, 42)); cut.WaitForAssertion(() => { Assert.Contains("Template chain is empty.", cut.Find("[data-test=\"binding-source-warning\"]").TextContent); var headers = cut.FindAll("[data-test=\"binding-group-header\"]"); Assert.Single(headers); Assert.Contains("(instance)", headers[0].TextContent); }); } [Fact] public async Task ApplyOnMixedConnectionGroup_KeepsEachMembersConnection_AndSavePostsComposedRows() { SeedInstance(new List { new("LeftMESReceiver.MoveInFlag") { Id = 1, InstanceId = 42, DataConnectionId = 1, DataSourceReferenceOverride = "Old.MoveInFlag" }, new("LeftMESReceiver.ScanStateCmd") { Id = 2, InstanceId = 42, DataConnectionId = 2, DataSourceReferenceOverride = "Old.ScanStateCmd" }, }); _pipeline.FlattenAndValidateAsync(42, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Flattened( DataSourced("LeftMESReceiver.MoveInFlag", "MoveInFlag", 1, "Old.MoveInFlag"), DataSourced("LeftMESReceiver.ScanStateCmd", "ScanStateCmd", 2, "Old.ScanStateCmd"))); var cut = Render(p => p.Add(c => c.Id, 42)); cut.WaitForAssertion(() => Assert.Single(cut.FindAll("[data-test=\"binding-group-header\"]"))); // Connections differ (1 vs 2) → header select reads "— mixed —" (value 0); inferred object is "Old". var header = cut.Find("[data-test=\"binding-group-header\"]"); Assert.Contains("— mixed —", header.QuerySelector("[data-test=\"binding-group-connection\"]")!.TextContent); Assert.Equal("Old", header.QuerySelector("[data-test=\"binding-group-object\"]")!.GetAttribute("value")); await cut.InvokeAsync(() => cut.Find("[data-test=\"binding-group-object\"]").Change("MESReceiver_022")); await cut.InvokeAsync(() => cut.Find("[data-test=\"binding-group-apply\"]").Click()); cut.WaitForAssertion(() => { // Still bound — Apply with the header at "— mixed —" must not unbind anyone. Assert.Empty(cut.FindAll("[data-test=\"binding-group-unbound\"]")); Assert.Contains("2 changed", cut.Find("[data-test=\"binding-group-header\"]").TextContent); // Object unified → header pre-fills the new prefix; the group still reads // "mixed" because the CONNECTIONS were deliberately left different (1 vs 2). Assert.Equal("MESReceiver_022", cut.Find("[data-test=\"binding-group-object\"]").GetAttribute("value")); Assert.Single(cut.FindAll("[data-test=\"binding-group-mixed\"]")); Assert.Contains("— mixed —", cut.Find("[data-test=\"binding-group-connection\"]").TextContent); }); // Save posts both composed rows with their ORIGINAL connections and the rewritten overrides. var existing = await _templateRepo.GetBindingsByInstanceIdAsync(42); _templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any()).Returns(existing); await cut.InvokeAsync(() => cut.FindAll("button").Single(b => b.TextContent.Trim() == "Save Bindings").Click()); await _templateRepo.Received(1).UpdateInstanceConnectionBindingAsync( Arg.Is(b => b.AttributeName == "LeftMESReceiver.MoveInFlag" && b.DataConnectionId == 1 && b.DataSourceReferenceOverride == "MESReceiver_022.MoveInFlag"), Arg.Any()); await _templateRepo.Received(1).UpdateInstanceConnectionBindingAsync( Arg.Is(b => b.AttributeName == "LeftMESReceiver.ScanStateCmd" && b.DataConnectionId == 2 && b.DataSourceReferenceOverride == "MESReceiver_022.ScanStateCmd"), Arg.Any()); } }