feat(ui/deployment): consolidate sites/areas/instances into Topology page
Single /deployment/topology page replaces /deployment/instances (legacy URL preserved as a secondary @page directive) and the /admin/areas* CRUD pages. TreeView with Site → Area → Instance, V1–V7 visual guide (bi-building / bi-diagram-3 / bi-box), always-visible empty containers, search dim, F2 inline area rename, and right-click context menus per node kind (Add Area, Move to Area…, lifecycle actions, etc.). Adds AreaService.MoveAreaAsync with cycle prevention, same-site enforcement, and name-collision check at the new parent. Instance rename intentionally out of scope — UniqueName is the site-side actor identity, requires its own design pass.
This commit is contained in:
@@ -116,6 +116,157 @@ public class AreaServiceTests
|
||||
_repoMock.Verify(r => r.DeleteAreaAsync(1, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveArea_ToOtherArea_Succeeds()
|
||||
{
|
||||
// Move 'Leaf' from under 'A' to under 'B'.
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("B") { Id = 2, SiteId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>
|
||||
{
|
||||
new("A") { Id = 1, SiteId = 1 },
|
||||
new("B") { Id = 2, SiteId = 1 },
|
||||
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
|
||||
});
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.MoveAreaAsync(3, 2, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value.ParentAreaId);
|
||||
_repoMock.Verify(r => r.UpdateAreaAsync(It.Is<Area>(a => a.Id == 3 && a.ParentAreaId == 2), It.IsAny<CancellationToken>()), Times.Once);
|
||||
_auditMock.Verify(a => a.LogAsync("admin", "Move", "Area", "3", "Leaf", It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveArea_ToSiteRoot_Succeeds()
|
||||
{
|
||||
// Move 'Leaf' from under 'A' to site root (null parent).
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>
|
||||
{
|
||||
new("A") { Id = 1, SiteId = 1 },
|
||||
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
|
||||
});
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.MoveAreaAsync(3, null, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null(result.Value.ParentAreaId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveArea_ToSelf_Fails()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
|
||||
|
||||
var result = await _sut.MoveAreaAsync(1, 1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("its own parent", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveArea_ToDescendant_FailsWithCycleError()
|
||||
{
|
||||
// Tree: 1 -> 2 -> 3. Try to move 1 under 3.
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Root") { Id = 1, SiteId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 });
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>
|
||||
{
|
||||
new("Root") { Id = 1, SiteId = 1 },
|
||||
new("Mid") { Id = 2, SiteId = 1, ParentAreaId = 1 },
|
||||
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 }
|
||||
});
|
||||
|
||||
var result = await _sut.MoveAreaAsync(1, 3, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("descendants", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveArea_DifferentSite_Fails()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(99, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Foreign") { Id = 99, SiteId = 2 });
|
||||
|
||||
var result = await _sut.MoveAreaAsync(1, 99, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("same site", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveArea_NameCollidesAtNewParent_Fails()
|
||||
{
|
||||
// 'Leaf' under parent 1; a sibling 'Leaf' already exists under parent 2.
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("B") { Id = 2, SiteId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>
|
||||
{
|
||||
new("A") { Id = 1, SiteId = 1 },
|
||||
new("B") { Id = 2, SiteId = 1 },
|
||||
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 },
|
||||
new("Leaf") { Id = 4, SiteId = 1, ParentAreaId = 2 }
|
||||
});
|
||||
|
||||
var result = await _sut.MoveAreaAsync(3, 2, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already exists", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveArea_SameParent_NoOpSuccess()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>
|
||||
{
|
||||
new("A") { Id = 1, SiteId = 1 },
|
||||
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
|
||||
});
|
||||
|
||||
var result = await _sut.MoveAreaAsync(3, 1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
_repoMock.Verify(r => r.UpdateAreaAsync(It.IsAny<Area>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
_auditMock.Verify(a => a.LogAsync(It.IsAny<string>(), "Move", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveArea_TargetParentMissing_Fails()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(999, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Area?)null);
|
||||
|
||||
var result = await _sut.MoveAreaAsync(3, 999, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteArea_InstancesInDescendants_Blocked()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user