diff --git a/src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs b/src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs index eeceef6..3eec81b 100644 --- a/src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs +++ b/src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs @@ -67,4 +67,55 @@ public class TemplateFolderService return Result.Success(folder); } + + public async Task> MoveFolderAsync( + int folderId, int? newParentId, string user, + CancellationToken cancellationToken = default) + { + var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken); + if (folder == null) + return Result.Failure($"Folder with ID {folderId} not found."); + + if (newParentId.HasValue) + { + if (newParentId.Value == folderId) + return Result.Failure("Cannot move a folder into itself (cycle)."); + + var newParent = await _repository.GetFolderByIdAsync(newParentId.Value, cancellationToken); + if (newParent == null) + return Result.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.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.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.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.Success(folder); + } } diff --git a/tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs b/tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs index f4efe25..72abb67 100644 --- a/tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs +++ b/tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs @@ -107,4 +107,64 @@ public class TemplateFolderServiceTests Assert.True(result.IsFailure); 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())).ReturnsAsync(f1); + _repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny())).ReturnsAsync(f2); + _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) + .ReturnsAsync(new List { 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())).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())).ReturnsAsync(fa); + _repoMock.Setup(r => r.GetFolderByIdAsync(3, It.IsAny())).ReturnsAsync(fc); + _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) + .ReturnsAsync(new List { 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())).ReturnsAsync(f); + _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) + .ReturnsAsync(new List { f }); + + var result = await _sut.MoveFolderAsync(1, null, "admin"); + + Assert.True(result.IsSuccess); + Assert.Null(result.Value.ParentFolderId); + } }