From 8ba64b1d99ce981b9d08366b07be6193bc88a01c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 8 Jun 2026 14:12:46 -0400 Subject: [PATCH] fix(uns): enforce #122 on line reparent across clusters (final review) --- .../Uns/UnsTreeService.cs | 35 ++++++++ .../Uns/UnsTreeServiceAreaLineTests.cs | 87 +++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 0a2a4864..c50209ad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -408,6 +408,41 @@ public sealed class UnsTreeService(IDbContextFactory dbF } db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + + // Decision #122: a reparent to a different area must not orphan driver-bound equipment + // from its driver's cluster. Resolve the new area's cluster and check every bound + // equipment item under this line against it. + if (newUnsAreaId != entity.UnsAreaId) + { + var newAreaCluster = await db.UnsAreas + .Where(a => a.UnsAreaId == newUnsAreaId) + .Select(a => (string?)a.ClusterId) + .FirstOrDefaultAsync(ct); + + if (newAreaCluster is not null) + { + var boundEquipment = await db.Equipment + .Where(eq => eq.UnsLineId == unsLineId && eq.DriverInstanceId != null) + .Select(eq => new { eq.EquipmentId, eq.DriverInstanceId }) + .ToListAsync(ct); + + foreach (var eq in boundEquipment) + { + var driverCluster = await db.DriverInstances + .Where(d => d.DriverInstanceId == eq.DriverInstanceId) + .Select(d => d.ClusterId) + .FirstOrDefaultAsync(ct); + + if (driverCluster is not null && driverCluster != newAreaCluster) + { + return new UnsMutationResult( + false, + $"Cannot move line to area '{newUnsAreaId}': equipment '{eq.EquipmentId}' is bound to a driver in cluster '{driverCluster}' (decision #122). Re-home or unbind it first."); + } + } + } + } + entity.UnsAreaId = newUnsAreaId; entity.Name = name; entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs index 1c5f7ab9..20f05463 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs @@ -337,6 +337,93 @@ public sealed class UnsTreeServiceAreaLineTests result.Error.ShouldBe("Row no longer exists."); } + /// The #122 guard blocks reparenting a line to an area in a different cluster when + /// the line's equipment is driver-bound (the driver lives in the original cluster). + [Fact] + public async Task UpdateLine_reparent_to_other_cluster_blocked_when_driver_bound() + { + var (service, dbName) = Fresh(); + + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + // Source area A1 in cluster MAIN. + db.UnsAreas.Add(new UnsArea { UnsAreaId = "A1-MAIN", ClusterId = "MAIN", Name = "area-main" }); + // Target area A2 in cluster SITE-A. + db.UnsAreas.Add(new UnsArea { UnsAreaId = "A2-SITE-A", ClusterId = "SITE-A", Name = "area-site-a" }); + db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-BOUND", UnsAreaId = "A1-MAIN", Name = "line" }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "DRV-MAIN-122", + ClusterId = "MAIN", + NamespaceId = "NS-1", + Name = "drv", + DriverType = "ModbusTcp", + DriverConfig = "{}", + }); + db.Equipment.Add(new Equipment + { + EquipmentId = "EQ-LINE-BOUND", + EquipmentUuid = Guid.NewGuid(), + UnsLineId = "LINE-BOUND", + Name = "eq", + MachineCode = "mc_line_bound", + DriverInstanceId = "DRV-MAIN-122", + }); + db.SaveChanges(); + rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").RowVersion; + } + + var result = await service.UpdateLineAsync("LINE-BOUND", "line", null, "A2-SITE-A", rv); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("decision #122"); + result.Error.ShouldContain("EQ-LINE-BOUND"); + result.Error.ShouldContain("A2-SITE-A"); + result.Error.ShouldContain("MAIN"); + + // The line must not have moved. + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").UnsAreaId.ShouldBe("A1-MAIN"); + } + + /// The #122 guard allows reparenting a line to an area in a different cluster when + /// the line's equipment is driver-less (DriverInstanceId == null). + [Fact] + public async Task UpdateLine_reparent_to_other_cluster_allowed_when_equipment_driverless() + { + var (service, dbName) = Fresh(); + + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.UnsAreas.Add(new UnsArea { UnsAreaId = "A1-FREE", ClusterId = "MAIN", Name = "area-main" }); + db.UnsAreas.Add(new UnsArea { UnsAreaId = "A2-FREE", ClusterId = "SITE-A", Name = "area-site-a" }); + db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-FREE", UnsAreaId = "A1-FREE", Name = "line" }); + db.Equipment.Add(new Equipment + { + EquipmentId = "EQ-LINE-FREE", + EquipmentUuid = Guid.NewGuid(), + UnsLineId = "LINE-FREE", + Name = "eq", + MachineCode = "mc_line_free", + DriverInstanceId = null, + }); + db.SaveChanges(); + rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-FREE").RowVersion; + } + + var result = await service.UpdateLineAsync("LINE-FREE", "line", null, "A2-FREE", rv); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + // The line's UnsAreaId must have changed to A2-FREE. + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.UnsLines.Single(l => l.UnsLineId == "LINE-FREE").UnsAreaId.ShouldBe("A2-FREE"); + } + // ----- DeleteLine ----- /// Deleting a line removes the row.