feat(template-folder): move with cycle detection and sibling uniqueness
This commit is contained in:
@@ -67,4 +67,55 @@ public class TemplateFolderService
|
|||||||
|
|
||||||
return Result<TemplateFolder>.Success(folder);
|
return Result<TemplateFolder>.Success(folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateFolder>> MoveFolderAsync(
|
||||||
|
int folderId, int? newParentId, string user,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
|
||||||
|
if (folder == null)
|
||||||
|
return Result<TemplateFolder>.Failure($"Folder with ID {folderId} not found.");
|
||||||
|
|
||||||
|
if (newParentId.HasValue)
|
||||||
|
{
|
||||||
|
if (newParentId.Value == folderId)
|
||||||
|
return Result<TemplateFolder>.Failure("Cannot move a folder into itself (cycle).");
|
||||||
|
|
||||||
|
var newParent = await _repository.GetFolderByIdAsync(newParentId.Value, cancellationToken);
|
||||||
|
if (newParent == null)
|
||||||
|
return Result<TemplateFolder>.Failure($"Target folder with ID {newParentId.Value} not found.");
|
||||||
|
|
||||||
|
var all = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||||
|
// Walk up from newParentId — if we encounter folderId, the move would create a cycle.
|
||||||
|
var byId = all.ToDictionary(f => f.Id);
|
||||||
|
var cursor = newParentId;
|
||||||
|
while (cursor.HasValue)
|
||||||
|
{
|
||||||
|
if (cursor.Value == folderId)
|
||||||
|
return Result<TemplateFolder>.Failure("Cannot move a folder under one of its descendants (cycle).");
|
||||||
|
cursor = byId.TryGetValue(cursor.Value, out var node) ? node.ParentFolderId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sibling-name uniqueness in destination.
|
||||||
|
if (all.Any(f => f.Id != folderId
|
||||||
|
&& f.ParentFolderId == newParentId
|
||||||
|
&& string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return Result<TemplateFolder>.Failure($"A folder named '{folder.Name}' already exists in the target folder.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var all = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||||
|
if (all.Any(f => f.Id != folderId
|
||||||
|
&& f.ParentFolderId == null
|
||||||
|
&& string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return Result<TemplateFolder>.Failure($"A folder named '{folder.Name}' already exists at the root.");
|
||||||
|
}
|
||||||
|
|
||||||
|
folder.ParentFolderId = newParentId;
|
||||||
|
await _repository.UpdateFolderAsync(folder, cancellationToken);
|
||||||
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
await _auditService.LogAsync(user, "Move", "TemplateFolder", folder.Id.ToString(), folder.Name, folder, cancellationToken);
|
||||||
|
|
||||||
|
return Result<TemplateFolder>.Success(folder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,4 +107,64 @@ public class TemplateFolderServiceTests
|
|||||||
Assert.True(result.IsFailure);
|
Assert.True(result.IsFailure);
|
||||||
Assert.Contains("already exists", result.Error);
|
Assert.Contains("already exists", result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveFolder_ValidParent_ReturnsSuccess()
|
||||||
|
{
|
||||||
|
var f1 = new TemplateFolder("A") { Id = 1, ParentFolderId = null };
|
||||||
|
var f2 = new TemplateFolder("B") { Id = 2, ParentFolderId = null };
|
||||||
|
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f1);
|
||||||
|
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(f2);
|
||||||
|
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<TemplateFolder> { f1, f2 });
|
||||||
|
|
||||||
|
var result = await _sut.MoveFolderAsync(1, 2, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value.ParentFolderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveFolder_OntoSelf_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var f1 = new TemplateFolder("A") { Id = 1 };
|
||||||
|
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f1);
|
||||||
|
|
||||||
|
var result = await _sut.MoveFolderAsync(1, 1, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveFolder_OntoDescendant_ReturnsFailure()
|
||||||
|
{
|
||||||
|
// A -> B -> C; attempting to move A under C must fail.
|
||||||
|
var fa = new TemplateFolder("A") { Id = 1, ParentFolderId = null };
|
||||||
|
var fb = new TemplateFolder("B") { Id = 2, ParentFolderId = 1 };
|
||||||
|
var fc = new TemplateFolder("C") { Id = 3, ParentFolderId = 2 };
|
||||||
|
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(fa);
|
||||||
|
_repoMock.Setup(r => r.GetFolderByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(fc);
|
||||||
|
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<TemplateFolder> { fa, fb, fc });
|
||||||
|
|
||||||
|
var result = await _sut.MoveFolderAsync(1, 3, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveFolder_ToRoot_ReturnsSuccess()
|
||||||
|
{
|
||||||
|
var f = new TemplateFolder("Sub") { Id = 1, ParentFolderId = 99 };
|
||||||
|
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
|
||||||
|
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<TemplateFolder> { f });
|
||||||
|
|
||||||
|
var result = await _sut.MoveFolderAsync(1, null, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Null(result.Value.ParentFolderId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user