using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; /// /// Covers the B2 Wave-B service prelude added to — the tag CRUD/import path /// (WP4 manual entry + WP5 CSV import) and the driver/device config + merged-probe projections (WP3 /// modals). Reuses the shared InMemory fixture. /// /// As with the WP1 suite, the EF InMemory provider does not enforce RowVersion tokens, so the /// optimistic-concurrency failure is exercised via the "row vanished under a stale handle" path. /// /// [Trait("Category", "Unit")] public sealed class RawTreeServiceWaveBTests { private static (RawTreeService Service, string DbName) Seeded() { var name = RawTreeTestDb.SeedFresh(); return (new RawTreeService(RawTreeTestDb.Factory(name)), name); } private static byte[] RowVersionOf(string dbName, Func selector) { using var db = RawTreeTestDb.CreateNamed(dbName); return selector(db); } private static RawTagInput Tag(string name, string dataType = "Float", string tagConfig = "{}") => new(name, dataType, TagAccessLevel.ReadWrite, WriteIdempotent: false, PollGroupId: null, tagConfig); // -------------------------------------------------------------------------------- CreateTag (WP4) [Fact] public async Task CreateTag_under_group_succeeds_and_returns_new_id() { var (service, dbName) = Seeded(); var result = await service.CreateTagAsync( RawTreeTestDb.FolderedDeviceId, RawTreeTestDb.RootGroupId, Tag("torque")); result.Ok.ShouldBeTrue(); result.CreatedId.ShouldNotBeNull(); using var db = RawTreeTestDb.CreateNamed(dbName); var tag = db.Tags.Single(t => t.TagId == result.CreatedId); tag.Name.ShouldBe("torque"); tag.DeviceId.ShouldBe(RawTreeTestDb.FolderedDeviceId); tag.TagGroupId.ShouldBe(RawTreeTestDb.RootGroupId); } [Fact] public async Task CreateTag_collapses_whitespace_pollgroup_to_null() { var (service, dbName) = Seeded(); var result = await service.CreateTagAsync( RawTreeTestDb.FolderedDeviceId, null, new RawTagInput("flow", "Float", TagAccessLevel.Read, false, " ", "{}")); result.Ok.ShouldBeTrue(); using var db = RawTreeTestDb.CreateNamed(dbName); db.Tags.Single(t => t.TagId == result.CreatedId).PollGroupId.ShouldBeNull(); } [Fact] public async Task CreateTag_rejects_sibling_name_collision() { var (service, _) = Seeded(); // "speed" already exists under RootGroup. var dup = await service.CreateTagAsync(RawTreeTestDb.FolderedDeviceId, RawTreeTestDb.RootGroupId, Tag("speed")); dup.Ok.ShouldBeFalse(); dup.Error!.ShouldContain("already exists"); } [Fact] public async Task CreateTag_rejects_malformed_tagconfig_json() { var (service, _) = Seeded(); var bad = await service.CreateTagAsync( RawTreeTestDb.FolderedDeviceId, null, Tag("pressure", tagConfig: "{not json")); bad.Ok.ShouldBeFalse(); bad.Error!.ShouldContain("JSON"); } [Fact] public async Task CreateTag_rejects_invalid_name_segment() { var (service, _) = Seeded(); var bad = await service.CreateTagAsync(RawTreeTestDb.FolderedDeviceId, null, Tag("a/b")); bad.Ok.ShouldBeFalse(); bad.Error!.ShouldContain("/"); } // -------------------------------------------------------------------------------- UpdateTag (WP4) [Fact] public async Task UpdateTag_edits_fields_last_write_wins() { var (service, dbName) = Seeded(); var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion); var result = await service.UpdateTagAsync( RawTreeTestDb.PlainTagId, new RawTagInput("running", "Boolean", TagAccessLevel.ReadWrite, true, "pg1", "{\"scale\":2}"), rv); result.Ok.ShouldBeTrue(); using var db = RawTreeTestDb.CreateNamed(dbName); var tag = db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId); tag.Name.ShouldBe("running"); tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite); tag.WriteIdempotent.ShouldBeTrue(); tag.PollGroupId.ShouldBe("pg1"); tag.TagConfig.ShouldContain("scale"); // Identity location preserved. tag.DeviceId.ShouldBe(RawTreeTestDb.FolderedDeviceId); tag.TagGroupId.ShouldBe(RawTreeTestDb.RootGroupId); } [Fact] public async Task UpdateTag_rejects_rename_collision_excluding_self() { var (service, dbName) = Seeded(); // Rename "state" (PlainTag) to "speed" — a sibling under the same group ⇒ collision. var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion); var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("speed"), rv); result.Ok.ShouldBeFalse(); result.Error!.ShouldContain("already exists"); } [Fact] public async Task UpdateTag_allows_renaming_to_same_name_excluding_self() { var (service, dbName) = Seeded(); var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion); // Keeping its own name "state" must not trip the excl-self uniqueness check. var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("state", "Boolean"), rv); result.Ok.ShouldBeTrue(); } [Fact] public async Task UpdateTag_fails_when_row_vanished_concurrently() { var (service, dbName) = Seeded(); var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion); using (var db = RawTreeTestDb.CreateNamed(dbName)) { db.Tags.Remove(db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId)); db.SaveChanges(); } var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("whatever", "Boolean"), rv); result.Ok.ShouldBeFalse(); result.Error.ShouldNotBeNull(); } // ------------------------------------------------------------------------------ ImportTags (WP5) [Fact] public async Task ImportTags_autocreates_nested_groups_and_reuses_them() { var (service, dbName) = Seeded(); var rows = new[] { new RawTagImportRow("Line/A", Tag("t1")), new RawTagImportRow("Line/A", Tag("t2")), // reuses Line + Line/A new RawTagImportRow("Line/B", Tag("t3")), // reuses Line, new B }; var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows); result.Errors.ShouldBeEmpty(); result.Inserted.ShouldBe(3); using var db = RawTreeTestDb.CreateNamed(dbName); var line = db.TagGroups.Single(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.ParentTagGroupId == null && g.Name == "Line"); var groupA = db.TagGroups.Single(g => g.ParentTagGroupId == line.TagGroupId && g.Name == "A"); var groupB = db.TagGroups.Single(g => g.ParentTagGroupId == line.TagGroupId && g.Name == "B"); db.Tags.Count(t => t.TagGroupId == groupA.TagGroupId).ShouldBe(2); // Line/A reused, not duplicated db.Tags.Count(t => t.TagGroupId == groupB.TagGroupId).ShouldBe(1); // Exactly one "Line" group created despite three references. db.TagGroups.Count(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.Name == "Line").ShouldBe(1); } [Fact] public async Task ImportTags_places_tag_at_device_root_when_path_blank() { var (service, dbName) = Seeded(); var result = await service.ImportTagsAsync( RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("rootTag")) }); result.Inserted.ShouldBe(1); using var db = RawTreeTestDb.CreateNamed(dbName); db.Tags.Single(t => t.Name == "rootTag").TagGroupId.ShouldBeNull(); } [Fact] public async Task ImportTags_all_or_nothing_on_a_bad_row_inserts_nothing() { var (service, dbName) = Seeded(); var rows = new[] { new RawTagImportRow("Grp", Tag("good1")), new RawTagImportRow("Grp", Tag("bad", tagConfig: "{oops")), // malformed JSON ⇒ error new RawTagImportRow("Grp", Tag("good2")), }; var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows); result.Inserted.ShouldBe(0); result.Errors.ShouldNotBeEmpty(); result.Errors[0].ShouldContain("Row 2"); // Nothing committed — not the two good tags, not the auto-created group. using var db = RawTreeTestDb.CreateNamed(dbName); db.Tags.Any(t => t.Name == "good1" || t.Name == "good2").ShouldBeFalse(); db.TagGroups.Any(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.Name == "Grp").ShouldBeFalse(); } [Fact] public async Task ImportTags_detects_intra_batch_duplicate() { var (service, _) = Seeded(); var rows = new[] { new RawTagImportRow("Grp", Tag("dup")), new RawTagImportRow("Grp", Tag("dup")), // same path + name within the batch }; var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows); result.Inserted.ShouldBe(0); result.Errors.ShouldContain(e => e.Contains("Row 2") && e.Contains("already exists")); } [Fact] public async Task ImportTags_detects_collision_with_existing_tag() { var (service, _) = Seeded(); // "temperature" already sits at the device root of RootDevice. var result = await service.ImportTagsAsync( RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("temperature")) }); result.Inserted.ShouldBe(0); result.Errors.ShouldContain(e => e.Contains("already exists")); } // ----------------------------------------------------------------------- Driver config (WP3) [Fact] public async Task LoadDriverForEdit_projects_fields() { var (service, _) = Seeded(); var dto = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId); dto.ShouldNotBeNull(); dto!.Name.ShouldBe("modbus-1"); dto.DriverType.ShouldBe("Modbus"); dto.DriverConfig.ShouldBe("{}"); } [Fact] public async Task LoadDriverForEdit_returns_null_when_missing() { var (service, _) = Seeded(); (await service.LoadDriverForEditAsync("DRV-nope")).ShouldBeNull(); } [Fact] public async Task UpdateDriver_edits_name_and_config_preserving_type() { var (service, dbName) = Seeded(); var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion); var result = await service.UpdateDriverAsync( RawTreeTestDb.FolderedDriverId, "modbus-main", "{\"Host\":\"10.0.0.1\"}", "{\"x\":1}", rv); result.Ok.ShouldBeTrue(); using var db = RawTreeTestDb.CreateNamed(dbName); var drv = db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId); drv.Name.ShouldBe("modbus-main"); drv.DriverConfig.ShouldContain("Host"); drv.ResilienceConfig.ShouldBe("{\"x\":1}"); drv.DriverType.ShouldBe("Modbus"); // immutable } [Fact] public async Task UpdateDriver_rejects_sibling_name_collision() { var (service, dbName) = Seeded(); // Two cluster-root vs foldered drivers differ in scope; create a sibling in the same folder to collide. await service.CreateDriverAsync(RawTreeTestDb.ClusterId, RawTreeTestDb.RootFolderId, "modbus-2", "Modbus", "{}"); var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion); var result = await service.UpdateDriverAsync(RawTreeTestDb.FolderedDriverId, "modbus-2", "{}", null, rv); result.Ok.ShouldBeFalse(); result.Error!.ShouldContain("already exists"); } [Fact] public async Task UpdateDriver_fails_when_row_vanished_concurrently() { var (service, dbName) = Seeded(); var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion); using (var db = RawTreeTestDb.CreateNamed(dbName)) { db.Devices.RemoveRange(db.Devices.Where(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId)); db.Tags.RemoveRange(db.Tags.Where(t => t.DeviceId == RawTreeTestDb.FolderedDeviceId)); db.TagGroups.RemoveRange(db.TagGroups.Where(g => g.DeviceId == RawTreeTestDb.FolderedDeviceId)); db.DriverInstances.Remove(db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId)); db.SaveChanges(); } var result = await service.UpdateDriverAsync(RawTreeTestDb.FolderedDriverId, "x", "{}", null, rv); result.Ok.ShouldBeFalse(); result.Error.ShouldNotBeNull(); } // ----------------------------------------------------------------------- Device config (WP3) [Fact] public async Task LoadDeviceForEdit_projects_fields_with_driver_type_join() { var (service, _) = Seeded(); var dto = await service.LoadDeviceForEditAsync(RawTreeTestDb.FolderedDeviceId); dto.ShouldNotBeNull(); dto!.Name.ShouldBe("Device1"); dto.DriverInstanceId.ShouldBe(RawTreeTestDb.FolderedDriverId); dto.Enabled.ShouldBeTrue(); dto.DriverType.ShouldBe("Modbus"); // joined from the parent driver } [Fact] public async Task LoadDeviceForEdit_returns_null_when_missing() { var (service, _) = Seeded(); (await service.LoadDeviceForEditAsync("DEV-nope")).ShouldBeNull(); } [Fact] public async Task UpdateDevice_edits_name_config_and_enabled() { var (service, dbName) = Seeded(); var rv = RowVersionOf(dbName, db => db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId).RowVersion); var result = await service.UpdateDeviceAsync( RawTreeTestDb.FolderedDeviceId, "PLC-A", "{\"Host\":\"10.0.0.9\",\"Port\":502}", enabled: false, rv); result.Ok.ShouldBeTrue(); using var db = RawTreeTestDb.CreateNamed(dbName); var dev = db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId); dev.Name.ShouldBe("PLC-A"); dev.DeviceConfig.ShouldContain("Host"); dev.Enabled.ShouldBeFalse(); } [Fact] public async Task UpdateDevice_rejects_sibling_name_collision() { var (service, dbName) = Seeded(); await service.CreateDeviceAsync(RawTreeTestDb.FolderedDriverId, "Device2", "{}"); var rv = RowVersionOf(dbName, db => db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId).RowVersion); var result = await service.UpdateDeviceAsync(RawTreeTestDb.FolderedDeviceId, "Device2", "{}", true, rv); result.Ok.ShouldBeFalse(); result.Error!.ShouldContain("already exists"); } // ------------------------------------------------------------- Merged probe config (Test connect) [Fact] public async Task LoadMergedProbeConfig_merges_deviceconfig_endpoint_up() { var (service, dbName) = Seeded(); // Put an endpoint on the device config (endpoint lives in DeviceConfig in v3). using (var db = RawTreeTestDb.CreateNamed(dbName)) { var dev = db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId); dev.DeviceConfig = "{\"Host\":\"10.1.2.3\",\"Port\":5020}"; db.SaveChanges(); } var probe = await service.LoadMergedProbeConfigAsync(RawTreeTestDb.FolderedDeviceId); probe.ShouldNotBeNull(); probe!.Value.DriverType.ShouldBe("Modbus"); // Endpoint key from DeviceConfig appears merged up at the top level of the probe config. probe.Value.MergedConfigJson.ShouldContain("Host"); probe.Value.MergedConfigJson.ShouldContain("10.1.2.3"); } [Fact] public async Task LoadMergedProbeConfig_returns_null_for_unknown_device() { var (service, _) = Seeded(); (await service.LoadMergedProbeConfigAsync("DEV-nope")).ShouldBeNull(); } }