diff --git a/docs/plans/2026-05-11-templates-folder-hierarchy.md b/docs/plans/2026-05-11-templates-folder-hierarchy.md new file mode 100644 index 0000000..a9696db --- /dev/null +++ b/docs/plans/2026-05-11-templates-folder-hierarchy.md @@ -0,0 +1,2218 @@ +# 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.sln` +Expected: build succeeds with 0 errors. + +**Step 4: Existing tests pass** + +Run: `dotnet test ScadaLink.sln --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.sln` +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