Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

671 lines
33 KiB
C#

using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services;
public class TemplateFolderServiceTests
{
private readonly Mock<ITemplateEngineRepository> _repoMock = new();
private readonly Mock<IAuditService> _auditMock = new();
private readonly TemplateFolderService _sut;
/// <summary>Initializes the test class with a mocked repository and the service under test.</summary>
public TemplateFolderServiceTests()
{
_sut = new TemplateFolderService(_repoMock.Object, _auditMock.Object);
}
/// <summary>Verifies that creating a folder with valid input succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateFolder_ValidInput_ReturnsSuccess()
{
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder>());
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<TemplateFolder>(), It.IsAny<CancellationToken>()), Times.Once);
// Two saves: the folder entity, then the staged audit row.
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
}
/// <summary>Verifies that creating a folder with an empty name fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that creating a folder whose name duplicates a sibling's, ignoring case, fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateFolder_DuplicateSiblingName_CaseInsensitive_ReturnsFailure()
{
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder>
{
new("Dev") { Id = 1, ParentFolderId = null }
});
var result = await _sut.CreateFolderAsync("dev", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
/// <summary>Verifies that creating a folder under a missing parent fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateFolder_ParentNotFound_ReturnsFailure()
{
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((TemplateFolder?)null);
var result = await _sut.CreateFolderAsync("Sub", 99, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
/// <summary>Verifies that renaming a folder with valid input succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RenameFolder_ValidInput_ReturnsSuccess()
{
var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { folder });
var result = await _sut.RenameFolderAsync(1, "New", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("New", result.Value.Name);
}
/// <summary>Verifies that renaming a missing folder fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RenameFolder_NotFound_ReturnsFailure()
{
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((TemplateFolder?)null);
var result = await _sut.RenameFolderAsync(99, "New", "admin");
Assert.True(result.IsFailure);
}
/// <summary>Verifies that renaming a folder to a sibling's name fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { folder, sibling });
var result = await _sut.RenameFolderAsync(1, "Other", "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
/// <summary>Verifies that moving a folder under a valid parent succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that moving a folder onto itself fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that moving a folder onto one of its descendants fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that moving a folder to the root succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that moving a folder in a graph with a pre-existing cycle fails without looping forever.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MoveFolder_PreExistingCycleInGraph_ReturnsFailure_DoesNotInfiniteLoop()
{
// Manufactured malformed graph: X.parent=Y, Y.parent=X. We move Z under X.
// The ancestor walk would loop forever without a guard.
var x = new TemplateFolder("X") { Id = 1, ParentFolderId = 2 };
var y = new TemplateFolder("Y") { Id = 2, ParentFolderId = 1 };
var z = new TemplateFolder("Z") { Id = 3, ParentFolderId = null };
_repoMock.Setup(r => r.GetFolderByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(z);
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(x);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { x, y, z });
var result = await _sut.MoveFolderAsync(3, 1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that deleting an empty folder succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteFolder_Empty_ReturnsSuccess()
{
var f = new TemplateFolder("Empty") { Id = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>());
var result = await _sut.DeleteFolderAsync(1, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.DeleteFolderAsync(1, It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>Verifies that deleting a folder with child folders fails and reports the counts.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteFolder_HasChildFolders_ReturnsFailure_WithCounts()
{
var parent = new TemplateFolder("P") { Id = 1 };
var child = new TemplateFolder("C") { Id = 2, ParentFolderId = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { parent, child });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>());
var result = await _sut.DeleteFolderAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("1 subfolder", result.Error);
}
/// <summary>Verifies that deleting a folder that contains templates fails and reports the counts.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteFolder_HasTemplates_ReturnsFailure_WithCounts()
{
var f = new TemplateFolder("P") { Id = 1 };
var t = new Template("X") { Id = 5, FolderId = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _sut.DeleteFolderAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("1 template", result.Error);
}
// ========================================================================
// ReorderFolderAsync — sibling reorder by SortOrder (Up = lower, Down = higher)
// ========================================================================
/// <summary>Verifies that reordering a folder up swaps its sort order with the previous sibling.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReorderFolder_Up_SwapsSortOrderWithPreviousSibling()
{
// Siblings under root: A(0), B(1), C(2). Move B up -> swaps with A.
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
var c = new TemplateFolder("C") { Id = 3, ParentFolderId = null, SortOrder = 2 };
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(b);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b, c });
var result = await _sut.ReorderFolderAsync(2, ReorderDirection.Up, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, b.SortOrder);
Assert.Equal(1, a.SortOrder);
Assert.Equal(2, c.SortOrder);
}
/// <summary>Verifies that reordering a folder down swaps its sort order with the next sibling.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReorderFolder_Down_SwapsSortOrderWithNextSibling()
{
// Siblings under root: A(0), B(1), C(2). Move B down -> swaps with C.
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
var c = new TemplateFolder("C") { Id = 3, ParentFolderId = null, SortOrder = 2 };
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(b);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b, c });
var result = await _sut.ReorderFolderAsync(2, ReorderDirection.Down, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, a.SortOrder);
Assert.Equal(2, b.SortOrder);
Assert.Equal(1, c.SortOrder);
}
/// <summary>Verifies that reordering the top folder up is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReorderFolder_UpAtTop_IsNoOp()
{
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(a);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b });
var result = await _sut.ReorderFolderAsync(1, ReorderDirection.Up, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, a.SortOrder);
Assert.Equal(1, b.SortOrder);
// No-op: nothing persisted, nothing audited.
_repoMock.Verify(r => r.UpdateFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()), Times.Never);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
_auditMock.Verify(a => a.LogAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>Verifies that reordering the bottom folder down is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReorderFolder_DownAtBottom_IsNoOp()
{
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(b);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b });
var result = await _sut.ReorderFolderAsync(2, ReorderDirection.Down, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, a.SortOrder);
Assert.Equal(1, b.SortOrder);
_repoMock.Verify(r => r.UpdateFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()), Times.Never);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>Verifies that reordering only affects folders within the same parent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReorderFolder_OnlyReordersWithinSameParent()
{
// root: A(0), B(1); under parent 99: X(0), Y(1).
// Moving X down must swap with Y, NOT with any root sibling.
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
var x = new TemplateFolder("X") { Id = 10, ParentFolderId = 99, SortOrder = 0 };
var y = new TemplateFolder("Y") { Id = 11, ParentFolderId = 99, SortOrder = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(10, It.IsAny<CancellationToken>())).ReturnsAsync(x);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b, x, y });
var result = await _sut.ReorderFolderAsync(10, ReorderDirection.Down, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, x.SortOrder);
Assert.Equal(0, y.SortOrder);
// Root siblings untouched.
Assert.Equal(0, a.SortOrder);
Assert.Equal(1, b.SortOrder);
}
/// <summary>Verifies that moving one of three siblings down and then up yields the expected order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReorderFolder_ThreeSiblings_DownThenUp_YieldsExpectedOrder()
{
// A(0), B(1), C(2). Move A down -> B,A,C ; then move A (now order 1) down -> B,C,A.
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
var c = new TemplateFolder("C") { Id = 3, ParentFolderId = null, SortOrder = 2 };
var all = new List<TemplateFolder> { a, b, c };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(a);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>())).ReturnsAsync(all);
var first = await _sut.ReorderFolderAsync(1, ReorderDirection.Down, "admin");
Assert.True(first.IsSuccess);
// Order by SortOrder: B(0), A(1), C(2)
var ordered1 = all.OrderBy(f => f.SortOrder).Select(f => f.Name).ToList();
Assert.Equal(new[] { "B", "A", "C" }, ordered1);
var second = await _sut.ReorderFolderAsync(1, ReorderDirection.Down, "admin");
Assert.True(second.IsSuccess);
// Order by SortOrder: B(0), C(1), A(2)
var ordered2 = all.OrderBy(f => f.SortOrder).Select(f => f.Name).ToList();
Assert.Equal(new[] { "B", "C", "A" }, ordered2);
}
/// <summary>Verifies that reordering a missing folder fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReorderFolder_NotFound_ReturnsFailure()
{
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((TemplateFolder?)null);
var result = await _sut.ReorderFolderAsync(99, ReorderDirection.Up, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
// ========================================================================
// CreateFolderAsync — distinct SortOrder assignment (max+1 among siblings)
// ========================================================================
/// <summary>Verifies that the first sibling folder gets sort order zero.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateFolder_FirstSibling_GetsSortOrderZero()
{
// No existing siblings at root → first folder gets SortOrder 0.
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder>());
TemplateFolder? captured = null;
_repoMock.Setup(r => r.AddFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()))
.Callback<TemplateFolder, CancellationToken>((f, _) => captured = f);
var result = await _sut.CreateFolderAsync("Alpha", null, "admin");
Assert.True(result.IsSuccess);
Assert.NotNull(captured);
Assert.Equal(0, captured!.SortOrder);
}
/// <summary>Verifies that a second sibling folder under the same parent gets sort order one.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateFolder_SecondSiblingUnderSameParent_GetsSortOrderOne()
{
// One existing root sibling with SortOrder=0 → new folder gets SortOrder 1.
var existing = new TemplateFolder("Alpha") { Id = 1, ParentFolderId = null, SortOrder = 0 };
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { existing });
TemplateFolder? captured = null;
_repoMock.Setup(r => r.AddFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()))
.Callback<TemplateFolder, CancellationToken>((f, _) => captured = f);
var result = await _sut.CreateFolderAsync("Beta", null, "admin");
Assert.True(result.IsSuccess);
Assert.NotNull(captured);
Assert.Equal(1, captured!.SortOrder);
}
/// <summary>Verifies that folder sort orders do not interfere across different parents.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateFolder_SortOrdersDoNotInterferAcrossParents()
{
// A root folder (SortOrder=5) should NOT influence a subfolder's SortOrder
// when the subfolder has no siblings of its own.
var root = new TemplateFolder("Root") { Id = 1, ParentFolderId = null, SortOrder = 5 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(root);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { root });
TemplateFolder? captured = null;
_repoMock.Setup(r => r.AddFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()))
.Callback<TemplateFolder, CancellationToken>((f, _) => captured = f);
// Create a child under root — it has no siblings, so SortOrder should be 0.
var result = await _sut.CreateFolderAsync("Child", 1, "admin");
Assert.True(result.IsSuccess);
Assert.NotNull(captured);
Assert.Equal(0, captured!.SortOrder);
}
/// <summary>Verifies that reordering two sibling folders visibly changes their order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateFolder_TwoSiblingsReorderIsVisiblyEffective()
{
// Regression: when two newly-created siblings have distinct SortOrders (0, 1),
// a Move-up on the second-created folder must visibly change the order.
//
// Simulate creating two folders sequentially. The first gets SortOrder=0,
// the second gets SortOrder=1 (as guaranteed by the max+1 logic under fix).
// Then reorder the second folder Up — it should swap to SortOrder=0,
// moving it before the first.
var first = new TemplateFolder("First") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var second = new TemplateFolder("Second") { Id = 2, ParentFolderId = null, SortOrder = 1 };
var all = new List<TemplateFolder> { first, second };
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(second);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>())).ReturnsAsync(all);
var result = await _sut.ReorderFolderAsync(2, ReorderDirection.Up, "admin");
Assert.True(result.IsSuccess);
// After a Move-up the second folder should be at SortOrder 0 (before the first).
Assert.Equal(0, second.SortOrder);
Assert.Equal(1, first.SortOrder);
// Verify the swap was actually persisted.
_repoMock.Verify(r => r.UpdateFolderAsync(first, It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.UpdateFolderAsync(second, It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>Verifies that reordering a folder persists the change and writes an audit entry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReorderFolder_Persists_And_WritesAudit()
{
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(b);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b });
var result = await _sut.ReorderFolderAsync(2, ReorderDirection.Up, "admin");
Assert.True(result.IsSuccess);
// Both swapped siblings persisted.
_repoMock.Verify(r => r.UpdateFolderAsync(a, It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.UpdateFolderAsync(b, It.IsAny<CancellationToken>()), Times.Once);
// Two saves: the swapped siblings, then the staged audit row.
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
// Audit entry written (mirrors Move/Rename audit assertions).
_auditMock.Verify(au => au.LogAsync("admin", "Reorder", "TemplateFolder", "2", "B",
It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once);
}
// ========================================================================
// Each folder mutator must SaveChanges *after* LogAsync
// so the staged audit row is persisted (and not discarded when the scope is
// disposed). Verified by tracking call order across the mutators.
// ========================================================================
/// <summary>Verifies that creating a folder persists the audit row by saving after logging.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateFolder_PersistsAuditRow_SaveFollowsLog()
{
AssertAuditRowPersisted(await BuildOrderTracker(async () =>
await _sut.CreateFolderAsync("Dev", null, "admin"),
seed: () => _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder>())));
}
/// <summary>Verifies that renaming a folder persists the audit row by saving after logging.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RenameFolder_PersistsAuditRow_SaveFollowsLog()
{
var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null };
AssertAuditRowPersisted(await BuildOrderTracker(async () =>
await _sut.RenameFolderAsync(1, "New", "admin"),
seed: () =>
{
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { folder });
}));
}
/// <summary>Verifies that moving a folder persists the audit row by saving after logging.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MoveFolder_PersistsAuditRow_SaveFollowsLog()
{
var f1 = new TemplateFolder("A") { Id = 1, ParentFolderId = null };
var f2 = new TemplateFolder("B") { Id = 2, ParentFolderId = null };
AssertAuditRowPersisted(await BuildOrderTracker(async () =>
await _sut.MoveFolderAsync(1, 2, "admin"),
seed: () =>
{
_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 });
}));
}
/// <summary>Verifies that reordering a folder persists the audit row by saving after logging.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReorderFolder_PersistsAuditRow_SaveFollowsLog()
{
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
AssertAuditRowPersisted(await BuildOrderTracker(async () =>
await _sut.ReorderFolderAsync(2, ReorderDirection.Up, "admin"),
seed: () =>
{
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(b);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b });
}));
}
/// <summary>Verifies that deleting a folder persists the audit row by saving after logging.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteFolder_PersistsAuditRow_SaveFollowsLog()
{
var f = new TemplateFolder("Empty") { Id = 1 };
AssertAuditRowPersisted(await BuildOrderTracker(async () =>
await _sut.DeleteFolderAsync(1, "admin"),
seed: () =>
{
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>());
}));
}
/// <summary>
/// Records the interleaving of <c>LogAsync</c> and <c>SaveChangesAsync</c> calls
/// while invoking a mutator, returning the ordered list of call markers
/// ("save" / "log") observed during the operation.
/// </summary>
private async Task<List<string>> BuildOrderTracker(Func<Task> act, Action seed)
{
seed();
var calls = new List<string>();
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback(() => calls.Add("save"))
.ReturnsAsync(1);
_auditMock.Setup(a => a.LogAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
.Callback(() => calls.Add("log"))
.Returns(Task.CompletedTask);
await act();
return calls;
}
/// <summary>
/// Asserts the mutator logged an audit entry and then issued a
/// <c>SaveChangesAsync</c> after it — proving the staged audit row is
/// persisted rather than discarded.
/// </summary>
private static void AssertAuditRowPersisted(List<string> calls)
{
var logIndex = calls.IndexOf("log");
Assert.True(logIndex >= 0, "Expected an audit LogAsync call.");
// There must be at least one SaveChangesAsync recorded *after* the log call.
Assert.Contains("save", calls.Skip(logIndex + 1));
}
}