using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; /// /// Gitea #535 — every UNS authoring path applies the segment rule the deploy gate applies. /// /// /// /// The bug was not that a bad name crashed anything: it saved perfectly, and the next /// deployment failed with UnsSegmentInvalid. Because a deployment snapshots the whole /// database, one badly-named area blocked every cluster's deploy, discovered by someone other /// than whoever typed it. /// /// /// So these are rejection tests, and each is paired with a name that must still be accepted /// — a guard that refused everything would satisfy the rejection half alone. They live at the /// service, not the form, because a form attribute cannot cover the CSV import and this repo has /// no bUnit to reach a form with. /// /// [Trait("Category", "Unit")] public sealed class UnsSegmentGuardTests { private static (UnsTreeService Service, string DbName) Fresh() { var dbName = $"uns-segment-{Guid.NewGuid():N}"; return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); } private static void SeedAreaAndLine(string dbName) { using var db = UnsTreeTestDb.CreateNamed(dbName); db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" }); db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" }); db.SaveChanges(); } private static EquipmentInput Input(string name, string machineCode) => new(name, machineCode, "LINE-1", ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null, HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null, AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true); /// The two names that actually caused the outage, plus the neighbouring shapes. public static TheoryData BadNames() => new() { "Packaging", // the reported case: a capital "Line 1", // the reported case: a space "line_1", // underscore is not in the segment alphabet "line/1", // a slash would split into two NodeId levels "", // empty " ", // whitespace "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 33 chars }; /// Names that must keep working — the control for every rejection below. public static TheoryData GoodNames() => new() { "packaging", "line-1", "a", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // exactly 32 UnsSegment.DefaultSegment, // the gate accepts it, so the UI must too }; // --- Area --------------------------------------------------------------------------------- [Theory] [MemberData(nameof(BadNames))] public async Task CreateArea_rejects_a_name_the_deploy_gate_would_reject(string name) { var (service, _) = Fresh(); var result = await service.CreateAreaAsync("MAIN", "AREA-NEW", name, null); result.Ok.ShouldBeFalse(); result.Error.ShouldNotBeNull(); } [Theory] [MemberData(nameof(GoodNames))] public async Task CreateArea_accepts_a_valid_segment(string name) { var (service, _) = Fresh(); (await service.CreateAreaAsync("MAIN", "AREA-NEW", name, null)).Ok.ShouldBeTrue(); } /// /// Renaming is the other way in, and the one an operator reaches after the name is already /// deployed — so it is guarded too, and the guard runs before the row is touched. /// [Fact] public async Task UpdateArea_rejects_a_rename_to_an_invalid_segment_and_leaves_the_row_alone() { var (service, dbName) = Fresh(); await service.CreateAreaAsync("MAIN", "AREA-1", "packaging", null); var rowVersion = Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").RowVersion; (await service.UpdateAreaAsync("AREA-1", "Packaging", null, "MAIN", rowVersion)).Ok.ShouldBeFalse(); Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").Name.ShouldBe("packaging"); (await service.UpdateAreaAsync("AREA-1", "assembly", null, "MAIN", rowVersion)).Ok.ShouldBeTrue(); Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").Name.ShouldBe("assembly"); } // --- Line --------------------------------------------------------------------------------- [Theory] [MemberData(nameof(BadNames))] public async Task CreateLine_rejects_a_name_the_deploy_gate_would_reject(string name) { var (service, dbName) = Fresh(); SeedAreaAndLine(dbName); var result = await service.CreateLineAsync("AREA-1", "LINE-NEW", name, null); result.Ok.ShouldBeFalse(); result.Error.ShouldNotBeNull(); } [Fact] public async Task CreateLine_accepts_a_valid_segment() { var (service, dbName) = Fresh(); SeedAreaAndLine(dbName); (await service.CreateLineAsync("AREA-1", "LINE-NEW", "line-2", null)).Ok.ShouldBeTrue(); } [Fact] public async Task UpdateLine_rejects_a_rename_to_an_invalid_segment() { var (service, dbName) = Fresh(); SeedAreaAndLine(dbName); var rowVersion = Load(dbName).UnsLines.Single(l => l.UnsLineId == "LINE-1").RowVersion; (await service.UpdateLineAsync("LINE-1", "Line 1", null, "AREA-1", rowVersion)).Ok.ShouldBeFalse(); (await service.UpdateLineAsync("LINE-1", "line-1", null, "AREA-1", rowVersion)).Ok.ShouldBeTrue(); } // --- Equipment ---------------------------------------------------------------------------- [Theory] [MemberData(nameof(BadNames))] public async Task CreateEquipment_rejects_a_name_the_deploy_gate_would_reject(string name) { var (service, dbName) = Fresh(); SeedAreaAndLine(dbName); (await service.CreateEquipmentAsync(Input(name, "mc_1"))).Ok.ShouldBeFalse(); } [Fact] public async Task CreateEquipment_accepts_a_valid_segment() { var (service, dbName) = Fresh(); SeedAreaAndLine(dbName); (await service.CreateEquipmentAsync(Input("packer-1", "mc_1"))).Ok.ShouldBeTrue(); } [Fact] public async Task UpdateEquipment_rejects_a_rename_to_an_invalid_segment() { var (service, dbName) = Fresh(); SeedAreaAndLine(dbName); var created = await service.CreateEquipmentAsync(Input("packer-1", "mc_1")); created.Ok.ShouldBeTrue(); var row = Load(dbName).Equipment.Single(); (await service.UpdateEquipmentAsync(row.EquipmentId, Input("Packer 1", "mc_1"), row.RowVersion)) .Ok.ShouldBeFalse(); (await service.UpdateEquipmentAsync(row.EquipmentId, Input("packer-2", "mc_1"), row.RowVersion)) .Ok.ShouldBeTrue(); } // --- CSV import --------------------------------------------------------------------------- /// /// The bulk path is the one a form attribute could never have covered, and the one where a bad /// row does the most damage — an import of hundreds lands, and the fleet stops deploying. /// /// /// A bad name is an error, not a skip: a skip means "you already have this", which would /// tell the operator the row is present when it is not. /// [Fact] public async Task Import_reports_an_invalid_name_as_an_error_and_still_inserts_the_good_rows() { var (service, dbName) = Fresh(); SeedAreaAndLine(dbName); var result = await service.ImportEquipmentAsync( [ Input("packer-1", "mc_1"), Input("Packer 2", "mc_2"), Input("packer-3", "mc_3"), ]); result.Inserted.ShouldBe(2); result.Skipped.ShouldBe(0, "a bad name is not a row the operator already has"); result.Errors.ShouldHaveSingleItem().ShouldContain("mc_2"); Load(dbName).Equipment.Select(e => e.Name).OrderBy(n => n) .ShouldBe(["packer-1", "packer-3"]); } private static Configuration.OtOpcUaConfigDbContext Load(string dbName) => UnsTreeTestDb.CreateNamed(dbName); } /// The shared rule itself — one authority, so the gate and the AdminUI cannot drift (#535). [Trait("Category", "Unit")] public sealed class UnsSegmentTests { /// /// The attribute pattern the forms use must accept exactly what /// accepts. They are two expressions of one rule, and a form stricter than the service is a field /// an operator cannot satisfy; a form looser than it is the #535 bug again. /// [Theory] [InlineData("packaging")] [InlineData("line-1")] [InlineData("_default")] [InlineData("Packaging")] [InlineData("Line 1")] [InlineData("line_1")] [InlineData("")] [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] public void The_attribute_pattern_agrees_with_IsValid(string candidate) { var byAttribute = System.Text.RegularExpressions.Regex.IsMatch( candidate, UnsSegment.AttributePattern); byAttribute.ShouldBe(UnsSegment.IsValid(candidate)); } /// /// alone does NOT admit _default. Pinned because using it /// on a form — the obvious mistake, and what the equipment form did — makes the UI stricter than /// the deploy gate, so a _default row becomes uneditable through the AdminUI. /// [Fact] public void The_bare_pattern_excludes_the_default_segment_but_IsValid_admits_it() { System.Text.RegularExpressions.Regex .IsMatch(UnsSegment.DefaultSegment, UnsSegment.Pattern).ShouldBeFalse(); UnsSegment.IsValid(UnsSegment.DefaultSegment).ShouldBeTrue(); } }