fix(uns): enforce #122 on line reparent across clusters (final review)
v2-ci / build (push) Failing after 4m38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
v2-ci / build (push) Failing after 4m38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
This commit is contained in:
@@ -408,6 +408,41 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
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.UnsAreaId = newUnsAreaId;
|
||||||
entity.Name = name;
|
entity.Name = name;
|
||||||
entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;
|
entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;
|
||||||
|
|||||||
@@ -337,6 +337,93 @@ public sealed class UnsTreeServiceAreaLineTests
|
|||||||
result.Error.ShouldBe("Row no longer exists.");
|
result.Error.ShouldBe("Row no longer exists.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The #122 guard allows reparenting a line to an area in a different cluster when
|
||||||
|
/// the line's equipment is driver-less (DriverInstanceId == null).</summary>
|
||||||
|
[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 -----
|
// ----- DeleteLine -----
|
||||||
|
|
||||||
/// <summary>Deleting a line removes the row.</summary>
|
/// <summary>Deleting a line removes the row.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user