feat(adminui): UNS-tree delete for Cluster + Enterprise (refuse-if-children, no rowversion)

This commit is contained in:
Joseph Doherty
2026-06-16 16:35:07 -04:00
parent 6a8020e7e7
commit 526eebb3bb
5 changed files with 349 additions and 3 deletions
@@ -0,0 +1,190 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies <see cref="UnsTreeService.DeleteClusterAsync"/> and
/// <see cref="UnsTreeService.DeleteEnterpriseAsync"/>: refuse-if-children deletes that
/// mirror the Area/Line/Equipment convention but carry no <c>RowVersion</c> concurrency
/// token (the <see cref="ServerCluster"/> entity has none — Option B, no migration).
/// </summary>
/// <remarks>
/// The EF InMemory provider does not enforce FK <c>Restrict</c>, so the refuse-if-children
/// behaviour is driven by an explicit child pre-check in the service (not the DB throwing),
/// which is what these tests exercise.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceDeleteClusterTests
{
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-delcluster-{Guid.NewGuid():N}";
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
private static ServerCluster NewCluster(string clusterId, string enterprise) => new()
{
ClusterId = clusterId,
Name = clusterId,
Enterprise = enterprise,
Site = "site",
RedundancyMode = RedundancyMode.None,
CreatedBy = "test",
};
// ----- DeleteCluster -----
/// <summary>Deleting a child-free cluster removes the row.</summary>
[Fact]
public async Task DeleteCluster_empty_removes_row()
{
var (service, dbName) = Fresh();
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.ServerClusters.Add(NewCluster("CL-EMPTY", "zb"));
db.SaveChanges();
}
var result = await service.DeleteClusterAsync("CL-EMPTY");
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.ServerClusters.Any(c => c.ClusterId == "CL-EMPTY").ShouldBeFalse();
}
/// <summary>Deleting a cluster that is already gone is a no-op success (idempotent).</summary>
[Fact]
public async Task DeleteCluster_already_gone_returns_ok()
{
var (service, _) = Fresh();
var result = await service.DeleteClusterAsync("GHOST-CLUSTER");
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
/// <summary>A cluster with a UNS area refuses deletion with a friendly message and stays put.</summary>
[Fact]
public async Task DeleteCluster_with_area_refuses_and_keeps_row()
{
var (service, dbName) = Fresh();
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.ServerClusters.Add(NewCluster("CL-AREA", "zb"));
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-CL", ClusterId = "CL-AREA", Name = "a" });
db.SaveChanges();
}
var result = await service.DeleteClusterAsync("CL-AREA");
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("CL-AREA");
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.ServerClusters.Any(c => c.ClusterId == "CL-AREA").ShouldBeTrue();
}
/// <summary>A cluster with a driver instance refuses deletion and stays put.</summary>
[Fact]
public async Task DeleteCluster_with_driver_refuses_and_keeps_row()
{
var (service, dbName) = Fresh();
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.ServerClusters.Add(NewCluster("CL-DRV", "zb"));
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-CL",
ClusterId = "CL-DRV",
NamespaceId = "NS-1",
Name = "drv",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
db.SaveChanges();
}
var result = await service.DeleteClusterAsync("CL-DRV");
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.ServerClusters.Any(c => c.ClusterId == "CL-DRV").ShouldBeTrue();
}
// ----- DeleteEnterprise -----
/// <summary>Deleting an enterprise whose every cluster is child-free removes them all.</summary>
[Fact]
public async Task DeleteEnterprise_all_empty_removes_all_clusters()
{
var (service, dbName) = Fresh();
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.ServerClusters.Add(NewCluster("ENT1-A", "ent-1"));
db.ServerClusters.Add(NewCluster("ENT1-B", "ent-1"));
// A cluster under a different enterprise must survive.
db.ServerClusters.Add(NewCluster("ENT2-A", "ent-2"));
db.SaveChanges();
}
var result = await service.DeleteEnterpriseAsync("ent-1");
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.ServerClusters.Any(c => c.Enterprise == "ent-1").ShouldBeFalse();
verify.ServerClusters.Any(c => c.ClusterId == "ENT2-A").ShouldBeTrue();
}
/// <summary>Deleting an enterprise with no matching clusters is a no-op success.</summary>
[Fact]
public async Task DeleteEnterprise_no_matches_returns_ok()
{
var (service, _) = Fresh();
var result = await service.DeleteEnterpriseAsync("nonexistent-enterprise");
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
/// <summary>
/// When one cluster under the enterprise has children, the whole delete refuses (naming the
/// blocking cluster) and nothing is deleted — no partial enterprise removal.
/// </summary>
[Fact]
public async Task DeleteEnterprise_one_cluster_with_children_refuses_and_deletes_nothing()
{
var (service, dbName) = Fresh();
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.ServerClusters.Add(NewCluster("ENT3-EMPTY", "ent-3"));
db.ServerClusters.Add(NewCluster("ENT3-FULL", "ent-3"));
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-ENT3", ClusterId = "ENT3-FULL", Name = "a" });
db.SaveChanges();
}
var result = await service.DeleteEnterpriseAsync("ent-3");
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("ENT3-FULL");
// Nothing partial: BOTH clusters must still be present.
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.ServerClusters.Any(c => c.ClusterId == "ENT3-EMPTY").ShouldBeTrue();
verify.ServerClusters.Any(c => c.ClusterId == "ENT3-FULL").ShouldBeTrue();
}
}