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.Commons.Types.Templates; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services; public class TemplateFolderServiceTests { private readonly Mock _repoMock = new(); private readonly Mock _auditMock = new(); private readonly TemplateFolderService _sut; /// Initializes the test class with a mocked repository and the service under test. public TemplateFolderServiceTests() { _sut = new TemplateFolderService(_repoMock.Object, _auditMock.Object); } /// Verifies that creating a folder with valid input succeeds. /// A task that represents the asynchronous operation. [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); // Two saves: the folder entity, then the staged audit row. _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Exactly(2)); } /// Verifies that creating a folder with an empty name fails. /// A task that represents the asynchronous operation. [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); } /// Verifies that creating a folder whose name duplicates a sibling's, ignoring case, fails. /// A task that represents the asynchronous operation. [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); } /// Verifies that creating a folder under a missing parent fails. /// A task that represents the asynchronous operation. [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); } /// Verifies that renaming a folder with valid input succeeds. /// A task that represents the asynchronous operation. [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); } /// Verifies that renaming a missing folder fails. /// A task that represents the asynchronous operation. [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); } /// Verifies that renaming a folder to a sibling's name fails. /// A task that represents the asynchronous operation. [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); } /// Verifies that moving a folder under a valid parent succeeds. /// A task that represents the asynchronous operation. [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); } /// Verifies that moving a folder onto itself fails. /// A task that represents the asynchronous operation. [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); } /// Verifies that moving a folder onto one of its descendants fails. /// A task that represents the asynchronous operation. [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); } /// Verifies that moving a folder to the root succeeds. /// A task that represents the asynchronous operation. [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); } /// Verifies that moving a folder in a graph with a pre-existing cycle fails without looping forever. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(z); _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny())).ReturnsAsync(x); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { x, y, z }); var result = await _sut.MoveFolderAsync(3, 1, "admin"); Assert.True(result.IsFailure); Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase); } /// Verifies that deleting an empty folder succeeds. /// A task that represents the asynchronous operation. [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.GetTemplateStubsAsync(It.IsAny())) .ReturnsAsync(new List()); var result = await _sut.DeleteFolderAsync(1, "admin"); Assert.True(result.IsSuccess); _repoMock.Verify(r => r.DeleteFolderAsync(1, It.IsAny()), Times.Once); } /// Verifies that deleting a folder with child folders fails and reports the counts. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(parent); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { parent, child }); _repoMock.Setup(r => r.GetTemplateStubsAsync(It.IsAny())) .ReturnsAsync(new List()); var result = await _sut.DeleteFolderAsync(1, "admin"); Assert.True(result.IsFailure); Assert.Contains("1 subfolder", result.Error); } /// Verifies that deleting a folder that contains templates fails and reports the counts. /// A task that represents the asynchronous operation. [Fact] public async Task DeleteFolder_HasTemplates_ReturnsFailure_WithCounts() { var f = new TemplateFolder("P") { Id = 1 }; var t = new TemplateStub(5, "X", null, 1, false, null); _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.GetTemplateStubsAsync(It.IsAny())) .ReturnsAsync(new List { t }); var result = await _sut.DeleteFolderAsync(1, "admin"); Assert.True(result.IsFailure); Assert.Contains("1 template", result.Error); // Review 2026-10-21 04/P6 + 08/P1: a folder-content count needs only FolderId — // never the tracked, script-bodies-included full template graph. _repoMock.Verify(r => r.GetAllTemplatesAsync(It.IsAny()), Times.Never); } // ======================================================================== // ReorderFolderAsync — sibling reorder by SortOrder (Up = lower, Down = higher) // ======================================================================== /// Verifies that reordering a folder up swaps its sort order with the previous sibling. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(b); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { 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); } /// Verifies that reordering a folder down swaps its sort order with the next sibling. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(b); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { 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); } /// Verifies that reordering the top folder up is a no-op. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(a); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { 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(), It.IsAny()), Times.Never); _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Never); _auditMock.Verify(a => a.LogAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } /// Verifies that reordering the bottom folder down is a no-op. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(b); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { 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(), It.IsAny()), Times.Never); _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Never); } /// Verifies that reordering only affects folders within the same parent. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(x); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { 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); } /// Verifies that moving one of three siblings down and then up yields the expected order. /// A task that represents the asynchronous operation. [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 { a, b, c }; _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny())).ReturnsAsync(a); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())).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); } /// Verifies that reordering a missing folder fails. /// A task that represents the asynchronous operation. [Fact] public async Task ReorderFolder_NotFound_ReturnsFailure() { _repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny())) .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) // ======================================================================== /// Verifies that the first sibling folder gets sort order zero. /// A task that represents the asynchronous operation. [Fact] public async Task CreateFolder_FirstSibling_GetsSortOrderZero() { // No existing siblings at root → first folder gets SortOrder 0. _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List()); TemplateFolder? captured = null; _repoMock.Setup(r => r.AddFolderAsync(It.IsAny(), It.IsAny())) .Callback((f, _) => captured = f); var result = await _sut.CreateFolderAsync("Alpha", null, "admin"); Assert.True(result.IsSuccess); Assert.NotNull(captured); Assert.Equal(0, captured!.SortOrder); } /// Verifies that a second sibling folder under the same parent gets sort order one. /// A task that represents the asynchronous operation. [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())) .ReturnsAsync(new List { existing }); TemplateFolder? captured = null; _repoMock.Setup(r => r.AddFolderAsync(It.IsAny(), It.IsAny())) .Callback((f, _) => captured = f); var result = await _sut.CreateFolderAsync("Beta", null, "admin"); Assert.True(result.IsSuccess); Assert.NotNull(captured); Assert.Equal(1, captured!.SortOrder); } /// Verifies that folder sort orders do not interfere across different parents. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(root); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { root }); TemplateFolder? captured = null; _repoMock.Setup(r => r.AddFolderAsync(It.IsAny(), It.IsAny())) .Callback((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); } /// Verifies that reordering two sibling folders visibly changes their order. /// A task that represents the asynchronous operation. [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 { first, second }; _repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny())).ReturnsAsync(second); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())).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()), Times.Once); _repoMock.Verify(r => r.UpdateFolderAsync(second, It.IsAny()), Times.Once); } /// Verifies that reordering a folder persists the change and writes an audit entry. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(b); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { 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()), Times.Once); _repoMock.Verify(r => r.UpdateFolderAsync(b, It.IsAny()), Times.Once); // Two saves: the swapped siblings, then the staged audit row. _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Exactly(2)); // Audit entry written (mirrors Move/Rename audit assertions). _auditMock.Verify(au => au.LogAsync("admin", "Reorder", "TemplateFolder", "2", "B", It.IsAny(), It.IsAny()), 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. // ======================================================================== /// Verifies that creating a folder persists the audit row by saving after logging. /// A task that represents the asynchronous operation. [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())) .ReturnsAsync(new List()))); } /// Verifies that renaming a folder persists the audit row by saving after logging. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(folder); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { folder }); })); } /// Verifies that moving a folder persists the audit row by saving after logging. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(f1); _repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny())).ReturnsAsync(f2); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { f1, f2 }); })); } /// Verifies that reordering a folder persists the audit row by saving after logging. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(b); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { a, b }); })); } /// Verifies that deleting a folder persists the audit row by saving after logging. /// A task that represents the asynchronous operation. [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())).ReturnsAsync(f); _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) .ReturnsAsync(new List { f }); _repoMock.Setup(r => r.GetTemplateStubsAsync(It.IsAny())) .ReturnsAsync(new List()); })); } /// /// Records the interleaving of LogAsync and SaveChangesAsync calls /// while invoking a mutator, returning the ordered list of call markers /// ("save" / "log") observed during the operation. /// private async Task> BuildOrderTracker(Func act, Action seed) { seed(); var calls = new List(); _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny())) .Callback(() => calls.Add("save")) .ReturnsAsync(1); _auditMock.Setup(a => a.LogAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback(() => calls.Add("log")) .Returns(Task.CompletedTask); await act(); return calls; } /// /// Asserts the mutator logged an audit entry and then issued a /// SaveChangesAsync after it — proving the staged audit row is /// persisted rather than discarded. /// private static void AssertAuditRowPersisted(List 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)); } }