# Templates Folder Hierarchy Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Replace the flat `/design/templates` list with a Wonderware-style folder tree: nested `TemplateFolder` entity, `Template.FolderId`, composition children as inline tree leaves, split-pane editor on the right, context menus + drag-drop reorganization. **Architecture:** New `TemplateFolder` entity (self-referencing `ParentFolderId`); nullable `FolderId` on `Template`. A new `TemplateFolderService` mirrors the existing `TemplateService` pattern (Result, audit, repository). Repository methods are added to `ITemplateEngineRepository`. Management commands follow the existing `Command` record convention auto-registered by `ManagementCommandRegistry`. The Blazor page uses the existing generic `TreeView` with a unified `TmplNode` discriminated by kind (Folder / Template / Composition) and a split-pane Bootstrap layout. Drag-drop uses native HTML5; server enforces all invariants (cycle detection, sibling-name uniqueness, non-empty-on-delete). **Tech Stack:** .NET 9, Akka.NET, EF Core (SQLite), Blazor Server, Bootstrap 5, bUnit + xUnit + Moq. **Design doc:** [`docs/plans/2026-05-11-templates-folder-hierarchy-design.md`](2026-05-11-templates-folder-hierarchy-design.md) **Conventions confirmed from codebase exploration:** - Service file: `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs` (matches AreaService/InstanceService/SiteService convention). - Audit signature: `IAuditService.LogAsync(user, operation, entityType, entityId, displayName, payload, ct)`. - Repository: extend `ITemplateEngineRepository` + `TemplateEngineRepository.cs` (single concrete impl). - DbContext: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs`, configurations in `Configurations/`. - Management commands: simple records in `src/ScadaLink.Commons/Messages/Management/*.cs`; auto-discovered by reflection. - ManagementActor dispatch + authorization both via `switch` on command type. - UI injects `TemplateService` directly (and will inject `TemplateFolderService` the same way) — no HTTP round trip. --- ## Task 0: Confirm baseline + create work branch **Files:** none modified — verification only. **Step 1: Verify clean working tree** Run: `git status` Expected: `nothing to commit, working tree clean` (the design doc was committed in commit `daa0126`). **Step 2: Verify the design doc is present** Run: `ls docs/plans/2026-05-11-templates-folder-hierarchy-design.md` Expected: file exists. **Step 3: Build baseline passes** Run: `dotnet build ScadaLink.slnx` Expected: build succeeds with 0 errors. **Step 4: Existing tests pass** Run: `dotnet test ScadaLink.slnx --filter "FullyQualifiedName~TemplateEngine.Tests|FullyQualifiedName~CentralUI.Tests" --nologo` Expected: all green. **No commit at this task** — it's verification only. --- ## Task 1: Add `TemplateFolder` entity + `Template.FolderId` **Files:** - Create: `src/ScadaLink.Commons/Entities/Templates/TemplateFolder.cs` - Modify: `src/ScadaLink.Commons/Entities/Templates/Template.cs` **Step 1: Write the failing test** Add to `tests/ScadaLink.Commons.Tests/Entities/TemplateFolderTests.cs` (create new file): ```csharp using ScadaLink.Commons.Entities.Templates; using Xunit; namespace ScadaLink.Commons.Tests.Entities; public class TemplateFolderTests { [Fact] public void Constructor_SetsName() { var folder = new TemplateFolder("Dev"); Assert.Equal("Dev", folder.Name); Assert.Null(folder.ParentFolderId); Assert.Equal(0, folder.SortOrder); } [Fact] public void Constructor_NullName_Throws() { Assert.Throws(() => new TemplateFolder(null!)); } [Fact] public void Template_FolderId_DefaultsToNull() { var template = new Template("X"); Assert.Null(template.FolderId); } } ``` **Step 2: Run test — confirm fail** Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter "FullyQualifiedName~TemplateFolderTests" --nologo` Expected: FAIL (`TemplateFolder` type not found, `Template.FolderId` not found). **Step 3: Implement the entity** Create `src/ScadaLink.Commons/Entities/Templates/TemplateFolder.cs`: ```csharp namespace ScadaLink.Commons.Entities.Templates; public class TemplateFolder { public int Id { get; set; } public string Name { get; set; } public int? ParentFolderId { get; set; } public int SortOrder { get; set; } public TemplateFolder(string name) { Name = name ?? throw new ArgumentNullException(nameof(name)); } } ``` **Step 4: Add `FolderId` to `Template`** Edit `src/ScadaLink.Commons/Entities/Templates/Template.cs` — add this property after `ParentTemplateId`: ```csharp public int? FolderId { get; set; } ``` **Step 5: Run tests — confirm pass** Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter "FullyQualifiedName~TemplateFolderTests" --nologo` Expected: PASS. **Step 6: Commit** ```bash git add src/ScadaLink.Commons/Entities/Templates/TemplateFolder.cs \ src/ScadaLink.Commons/Entities/Templates/Template.cs \ tests/ScadaLink.Commons.Tests/Entities/TemplateFolderTests.cs git commit -m "feat(templates): add TemplateFolder entity and Template.FolderId" ``` --- ## Task 2: EF configuration for `TemplateFolder` + `Template.FolderId` **Files:** - Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs` - Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` **Step 1: Add `DbSet` to DbContext** Edit `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` — under the `// Templates` region, add: ```csharp public DbSet TemplateFolders => Set(); ``` **Step 2: Add `TemplateFolderConfiguration` class** Append to `src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs`: ```csharp public class TemplateFolderConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.HasKey(f => f.Id); builder.Property(f => f.Name) .IsRequired() .HasMaxLength(200); builder.HasOne() .WithMany() .HasForeignKey(f => f.ParentFolderId) .OnDelete(DeleteBehavior.Restrict) .IsRequired(false); // Unique sibling name (case-insensitive enforced at service layer; this index is for fast lookup) builder.HasIndex(f => new { f.ParentFolderId, f.Name }).IsUnique(); } } ``` **Step 3: Add `FolderId` FK to `TemplateConfiguration`** In the same file, inside `TemplateConfiguration.Configure`, add after the parent-template FK block: ```csharp builder.HasOne() .WithMany() .HasForeignKey(t => t.FolderId) .OnDelete(DeleteBehavior.Restrict) .IsRequired(false); ``` **Step 4: Verify build** Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj` Expected: 0 errors. **Step 5: Commit** ```bash git add src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs \ src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs git commit -m "feat(db): map TemplateFolder entity and Template.FolderId" ``` --- ## Task 3: Generate EF migration `AddTemplateFolders` **Files:** - Create: `src/ScadaLink.ConfigurationDatabase/Migrations/_AddTemplateFolders.cs` (generated) - Create: `src/ScadaLink.ConfigurationDatabase/Migrations/_AddTemplateFolders.Designer.cs` (generated) - Modify: `src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs` (generated) **Step 1: Run migration generator** Run: `dotnet ef migrations add AddTemplateFolders --project src/ScadaLink.ConfigurationDatabase --startup-project src/ScadaLink.Host` Expected: three files created/updated under `Migrations/`. Dev DB (auto-apply on startup per CLAUDE.md) will apply on next host start. **Step 2: Inspect the generated `Up()` method** Open the new `*_AddTemplateFolders.cs` and confirm: - `CreateTable("TemplateFolders", ...)` with columns `Id`, `Name`, `ParentFolderId` (nullable), `SortOrder`. - Self-referencing FK on `ParentFolderId` with `onDelete: Restrict`. - Unique index on `(ParentFolderId, Name)`. - `AddColumn("FolderId", "Templates", nullable: true)`. - FK on `Templates.FolderId` → `TemplateFolders.Id` with `onDelete: Restrict`. If any are missing, the entity config in Task 2 was wrong — fix and regenerate (delete the migration files, redo Task 2 fix, rerun the migration command). **Step 3: Build to verify** Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj` Expected: 0 errors. **Step 4: Commit** ```bash git add src/ScadaLink.ConfigurationDatabase/Migrations/ git commit -m "feat(db): EF migration AddTemplateFolders" ``` --- ## Task 4: Repository methods on `ITemplateEngineRepository` **Files:** - Modify: `src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs` - Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs` **Step 1: Add interface methods** Edit `src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs`. Above the trailing `Task SaveChangesAsync(...)`, insert: ```csharp // TemplateFolder Task GetFolderByIdAsync(int id, CancellationToken cancellationToken = default); Task> GetAllFoldersAsync(CancellationToken cancellationToken = default); Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default); Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default); Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default); ``` (Move-template is just an `UpdateTemplateAsync` of the `FolderId` field — no new method needed.) **Step 2: Implement on `TemplateEngineRepository`** Edit `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs`. Append (above the closing brace of the class): ```csharp // TemplateFolder public async Task GetFolderByIdAsync(int id, CancellationToken cancellationToken = default) => await _context.TemplateFolders.FirstOrDefaultAsync(f => f.Id == id, cancellationToken); public async Task> GetAllFoldersAsync(CancellationToken cancellationToken = default) => await _context.TemplateFolders.AsNoTracking().ToListAsync(cancellationToken); public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default) => await _context.TemplateFolders.AddAsync(folder, cancellationToken); public async Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default) { _context.TemplateFolders.Update(folder); await Task.CompletedTask; } public async Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default) { var folder = await _context.TemplateFolders.FirstOrDefaultAsync(f => f.Id == id, cancellationToken); if (folder != null) _context.TemplateFolders.Remove(folder); } ``` **Step 3: Build** Run: `dotnet build ScadaLink.slnx` Expected: 0 errors. Any compile failure means a mock somewhere implements `ITemplateEngineRepository` without these methods — search and fix: Run: `grep -rln "ITemplateEngineRepository" tests/ --include="*.cs"` and confirm Moq mocks don't need stubs (Moq generates defaults). **Step 4: Commit** ```bash git add src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs \ src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs git commit -m "feat(repo): add TemplateFolder repository methods" ``` --- ## Task 5: `TemplateFolderService` — CreateFolderAsync (TDD) **Files:** - Create: `tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs` - Create: `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs` **Step 1: Write failing tests** Create `tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`: ```csharp using Moq; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.TemplateEngine.Services; namespace ScadaLink.TemplateEngine.Tests.Services; public class TemplateFolderServiceTests { private readonly Mock _repoMock = new(); private readonly Mock _auditMock = new(); private readonly TemplateFolderService _sut; public TemplateFolderServiceTests() { _sut = new TemplateFolderService(_repoMock.Object, _auditMock.Object); } [Fact] public async Task CreateFolder_ValidInput_ReturnsSuccess() { _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List()); var result = await _sut.CreateFolderAsync("Dev", null, "admin"); Assert.True(result.IsSuccess); Assert.Equal("Dev", result.Value.Name); Assert.Null(result.Value.ParentFolderId); _repoMock.Verify(r => r.AddFolderAsync(It.IsAny(), It.IsAny()), Times.Once); _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Once); } [Fact] public async Task CreateFolder_EmptyName_ReturnsFailure() { var result = await _sut.CreateFolderAsync(" ", null, "admin"); Assert.True(result.IsFailure); Assert.Contains("required", result.Error, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task CreateFolder_DuplicateSiblingName_CaseInsensitive_ReturnsFailure() { _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { new("Dev") { Id = 1, ParentFolderId = null } }); var result = await _sut.CreateFolderAsync("dev", null, "admin"); Assert.True(result.IsFailure); Assert.Contains("already exists", result.Error); } [Fact] public async Task CreateFolder_ParentNotFound_ReturnsFailure() { _repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny())) .ReturnsAsync((TemplateFolder?)null); var result = await _sut.CreateFolderAsync("Sub", 99, "admin"); Assert.True(result.IsFailure); Assert.Contains("not found", result.Error); } } ``` **Step 2: Run — confirm fail** Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo` Expected: FAIL (`TemplateFolderService` not found). **Step 3: Implement minimal service** Create `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs`: ```csharp using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types; namespace ScadaLink.TemplateEngine.Services; public class TemplateFolderService { private readonly ITemplateEngineRepository _repository; private readonly IAuditService _auditService; public TemplateFolderService(ITemplateEngineRepository repository, IAuditService auditService) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService)); } public async Task> CreateFolderAsync( string name, int? parentFolderId, string user, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) return Result.Failure("Folder name is required."); if (parentFolderId.HasValue) { var parent = await _repository.GetFolderByIdAsync(parentFolderId.Value, cancellationToken); if (parent == null) return Result.Failure($"Parent folder with ID {parentFolderId.Value} not found."); } var all = await _repository.GetAllFoldersAsync(cancellationToken); if (all.Any(f => f.ParentFolderId == parentFolderId && string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase))) return Result.Failure($"A folder named '{name}' already exists at this level."); var folder = new TemplateFolder(name) { ParentFolderId = parentFolderId }; await _repository.AddFolderAsync(folder, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Create", "TemplateFolder", folder.Id.ToString(), name, folder, cancellationToken); return Result.Success(folder); } } ``` **Step 4: Run — confirm pass** Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo` Expected: PASS (4 tests). **Step 5: Commit** ```bash git add tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \ src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs git commit -m "feat(template-folder): add TemplateFolderService.CreateFolderAsync with validation" ``` --- ## Task 6: `TemplateFolderService.RenameFolderAsync` **Files:** - Modify: `tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs` - Modify: `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs` **Step 1: Add failing tests** Append to `TemplateFolderServiceTests.cs`: ```csharp [Fact] public async Task RenameFolder_ValidInput_ReturnsSuccess() { var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null }; _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny())).ReturnsAsync(folder); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { folder }); var result = await _sut.RenameFolderAsync(1, "New", "admin"); Assert.True(result.IsSuccess); Assert.Equal("New", result.Value.Name); } [Fact] public async Task RenameFolder_NotFound_ReturnsFailure() { _repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny())) .ReturnsAsync((TemplateFolder?)null); var result = await _sut.RenameFolderAsync(99, "New", "admin"); Assert.True(result.IsFailure); } [Fact] public async Task RenameFolder_DuplicateSibling_ReturnsFailure() { var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null }; var sibling = new TemplateFolder("Other") { Id = 2, ParentFolderId = null }; _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny())).ReturnsAsync(folder); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { folder, sibling }); var result = await _sut.RenameFolderAsync(1, "Other", "admin"); Assert.True(result.IsFailure); Assert.Contains("already exists", result.Error); } ``` **Step 2: Run — confirm fail** Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests.RenameFolder" --nologo` Expected: FAIL (method missing). **Step 3: Implement `RenameFolderAsync`** Add to `TemplateFolderService.cs`: ```csharp public async Task> RenameFolderAsync( int folderId, string newName, string user, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(newName)) return Result.Failure("Folder name is required."); var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken); if (folder == null) return Result.Failure($"Folder with ID {folderId} not found."); var all = await _repository.GetAllFoldersAsync(cancellationToken); if (all.Any(f => f.Id != folderId && f.ParentFolderId == folder.ParentFolderId && string.Equals(f.Name, newName, StringComparison.OrdinalIgnoreCase))) return Result.Failure($"A folder named '{newName}' already exists at this level."); folder.Name = newName; await _repository.UpdateFolderAsync(folder, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Update", "TemplateFolder", folder.Id.ToString(), newName, folder, cancellationToken); return Result.Success(folder); } ``` **Step 4: Run — confirm pass** Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo` Expected: PASS (7 tests now). **Step 5: Commit** ```bash git add tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \ src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs git commit -m "feat(template-folder): rename folder with sibling uniqueness check" ``` --- ## Task 7: `TemplateFolderService.MoveFolderAsync` with cycle detection **Files:** - Modify: `tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs` - Modify: `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs` **Step 1: Add failing tests** Append: ```csharp [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); } ``` **Step 2: Run — confirm fail** Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~MoveFolder" --nologo` Expected: FAIL (method missing). **Step 3: Implement** Add to `TemplateFolderService.cs`: ```csharp 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); } ``` **Step 4: Run — confirm pass** Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo` Expected: PASS (11 tests). **Step 5: Commit** ```bash git add tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \ src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs git commit -m "feat(template-folder): move with cycle detection and sibling uniqueness" ``` --- ## Task 8: `TemplateFolderService.DeleteFolderAsync` (non-empty check) **Files:** - Modify: `tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs` - Modify: `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs` **Step 1: Add failing tests** Append: ```csharp [Fact] public async Task DeleteFolder_Empty_ReturnsSuccess() { var f = new TemplateFolder("Empty") { Id = 1 }; _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny())).ReturnsAsync(f); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { f }); _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny())) .ReturnsAsync(new List