ContentService now delegates Count, CountPublished, CountChildren, CountDescendants to the new QueryOperationService. This is Task 5 of Phase 2 - the first delegation task that converts ContentService from direct repository calls to delegating to the specialized query operation service. All tests pass. Baseline tests confirm facade and direct service return identical results. Full ContentService test suite passes (excluding pre-existing benchmark regressions). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
816 lines
35 KiB
C#
816 lines
35 KiB
C#
// Copyright (c) Umbraco.
|
|
// See LICENSE for more details.
|
|
|
|
using NUnit.Framework;
|
|
using Umbraco.Cms.Core;
|
|
using Umbraco.Cms.Core.Events;
|
|
using Umbraco.Cms.Core.Models;
|
|
using Umbraco.Cms.Core.Models.Entities;
|
|
using Umbraco.Cms.Core.Models.Membership;
|
|
using Umbraco.Cms.Core.Notifications;
|
|
using Umbraco.Cms.Core.Services;
|
|
using Umbraco.Cms.Tests.Common.Builders;
|
|
using Umbraco.Cms.Tests.Common.Testing;
|
|
using Umbraco.Cms.Tests.Integration.Testing;
|
|
|
|
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
|
|
|
|
/// <summary>
|
|
/// Integration tests specifically for validating ContentService refactoring.
|
|
/// These tests establish behavioral baselines that must pass throughout the refactoring phases.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[NonParallelizable] // Required: static notification handler state is shared across tests
|
|
[Category("Refactoring")] // v1.2: Added for easier test filtering during refactoring
|
|
[UmbracoTest(
|
|
Database = UmbracoTestOptions.Database.NewSchemaPerTest,
|
|
PublishedRepositoryEvents = true,
|
|
WithApplication = true,
|
|
Logger = UmbracoTestOptions.Logger.Console)]
|
|
internal sealed class ContentServiceRefactoringTests : UmbracoIntegrationTestWithContent
|
|
{
|
|
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
|
|
private IUserService UserService => GetRequiredService<IUserService>();
|
|
private IUserGroupService UserGroupService => GetRequiredService<IUserGroupService>();
|
|
|
|
protected override void CustomTestSetup(IUmbracoBuilder builder) => builder
|
|
.AddNotificationHandler<ContentSavingNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentSavedNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentPublishingNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentPublishedNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentUnpublishingNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentUnpublishedNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentMovingNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentMovedNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentMovingToRecycleBinNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentMovedToRecycleBinNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentSortingNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentSortedNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentDeletingNotification, RefactoringTestNotificationHandler>()
|
|
.AddNotificationHandler<ContentDeletedNotification, RefactoringTestNotificationHandler>();
|
|
|
|
[SetUp]
|
|
public override void Setup()
|
|
{
|
|
base.Setup();
|
|
RefactoringTestNotificationHandler.Reset();
|
|
}
|
|
|
|
[TearDown]
|
|
public void Teardown()
|
|
{
|
|
RefactoringTestNotificationHandler.Reset();
|
|
}
|
|
|
|
#region Notification Ordering Tests
|
|
|
|
/// <summary>
|
|
/// Test 1: Verifies that MoveToRecycleBin for published content fires notifications in the correct order.
|
|
/// Expected order: MovingToRecycleBin -> MovedToRecycleBin
|
|
/// Note: As per design doc, MoveToRecycleBin does NOT unpublish first - content is "masked" not unpublished.
|
|
/// </summary>
|
|
[Test]
|
|
public void MoveToRecycleBin_PublishedContent_FiresNotificationsInCorrectOrder()
|
|
{
|
|
// Arrange - Create and publish content
|
|
// First publish parent if not already published
|
|
if (!Textpage.Published)
|
|
{
|
|
ContentService.Publish(Textpage, new[] { "*" });
|
|
}
|
|
|
|
var content = ContentBuilder.CreateSimpleContent(ContentType, "TestContent", Textpage.Id);
|
|
ContentService.Save(content);
|
|
ContentService.Publish(content, new[] { "*" });
|
|
|
|
// Verify it's published
|
|
Assert.That(content.Published, Is.True, "Content should be published before test");
|
|
|
|
// Clear notification tracking
|
|
RefactoringTestNotificationHandler.Reset();
|
|
|
|
// Act
|
|
var result = ContentService.MoveToRecycleBin(content);
|
|
|
|
// Assert
|
|
Assert.That(result.Success, Is.True, "MoveToRecycleBin should succeed");
|
|
|
|
var notifications = RefactoringTestNotificationHandler.NotificationOrder;
|
|
|
|
// Verify notification sequence
|
|
Assert.That(notifications, Does.Contain(nameof(ContentMovingToRecycleBinNotification)),
|
|
"MovingToRecycleBin notification should fire");
|
|
Assert.That(notifications, Does.Contain(nameof(ContentMovedToRecycleBinNotification)),
|
|
"MovedToRecycleBin notification should fire");
|
|
|
|
// Verify order: Moving comes before Moved
|
|
var movingIndex = notifications.IndexOf(nameof(ContentMovingToRecycleBinNotification));
|
|
var movedIndex = notifications.IndexOf(nameof(ContentMovedToRecycleBinNotification));
|
|
Assert.That(movingIndex, Is.LessThan(movedIndex),
|
|
"MovingToRecycleBin should fire before MovedToRecycleBin");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test 2: Verifies that MoveToRecycleBin for unpublished content only fires move notifications.
|
|
/// No publish/unpublish notifications should be fired.
|
|
/// </summary>
|
|
[Test]
|
|
public void MoveToRecycleBin_UnpublishedContent_OnlyFiresMoveNotifications()
|
|
{
|
|
// Arrange - Create content but don't publish
|
|
// First publish parent if not already published (required for creating child content)
|
|
if (!Textpage.Published)
|
|
{
|
|
ContentService.Publish(Textpage, new[] { "*" });
|
|
}
|
|
|
|
var content = ContentBuilder.CreateSimpleContent(ContentType, "UnpublishedContent", Textpage.Id);
|
|
ContentService.Save(content);
|
|
|
|
// Verify it's not published
|
|
Assert.That(content.Published, Is.False, "Content should not be published before test");
|
|
|
|
// Clear notification tracking
|
|
RefactoringTestNotificationHandler.Reset();
|
|
|
|
// Act
|
|
var result = ContentService.MoveToRecycleBin(content);
|
|
|
|
// Assert
|
|
Assert.That(result.Success, Is.True, "MoveToRecycleBin should succeed");
|
|
|
|
var notifications = RefactoringTestNotificationHandler.NotificationOrder;
|
|
|
|
// Verify move notifications fire
|
|
Assert.That(notifications, Does.Contain(nameof(ContentMovingToRecycleBinNotification)),
|
|
"MovingToRecycleBin notification should fire");
|
|
Assert.That(notifications, Does.Contain(nameof(ContentMovedToRecycleBinNotification)),
|
|
"MovedToRecycleBin notification should fire");
|
|
|
|
// Verify no publish/unpublish notifications
|
|
Assert.That(notifications, Does.Not.Contain(nameof(ContentPublishingNotification)),
|
|
"Publishing notification should not fire for unpublished content");
|
|
Assert.That(notifications, Does.Not.Contain(nameof(ContentPublishedNotification)),
|
|
"Published notification should not fire for unpublished content");
|
|
Assert.That(notifications, Does.Not.Contain(nameof(ContentUnpublishingNotification)),
|
|
"Unpublishing notification should not fire for unpublished content");
|
|
Assert.That(notifications, Does.Not.Contain(nameof(ContentUnpublishedNotification)),
|
|
"Unpublished notification should not fire for unpublished content");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Sort Operation Tests
|
|
|
|
/// <summary>
|
|
/// Test 3: Verifies Sort(IEnumerable<IContent>) correctly reorders children.
|
|
/// </summary>
|
|
[Test]
|
|
public void Sort_WithContentItems_ChangesSortOrder()
|
|
{
|
|
// Arrange - Use existing subpages from base class (Subpage, Subpage2, Subpage3)
|
|
// Get fresh copies to ensure we have current sort orders
|
|
var child1 = ContentService.GetById(Subpage.Id)!;
|
|
var child2 = ContentService.GetById(Subpage2.Id)!;
|
|
var child3 = ContentService.GetById(Subpage3.Id)!;
|
|
|
|
// v1.2: Verify initial sort order assumption
|
|
Assert.That(child1.SortOrder, Is.LessThan(child2.SortOrder), "Setup: child1 before child2");
|
|
Assert.That(child2.SortOrder, Is.LessThan(child3.SortOrder), "Setup: child2 before child3");
|
|
|
|
// Record original sort orders
|
|
var originalOrder1 = child1.SortOrder;
|
|
var originalOrder2 = child2.SortOrder;
|
|
var originalOrder3 = child3.SortOrder;
|
|
|
|
// Create reversed order list
|
|
var reorderedItems = new[] { child3, child2, child1 };
|
|
|
|
// Act
|
|
var result = ContentService.Sort(reorderedItems);
|
|
|
|
// Assert
|
|
Assert.That(result.Success, Is.True, "Sort should succeed");
|
|
|
|
// Re-fetch to verify persisted order
|
|
child1 = ContentService.GetById(Subpage.Id)!;
|
|
child2 = ContentService.GetById(Subpage2.Id)!;
|
|
child3 = ContentService.GetById(Subpage3.Id)!;
|
|
|
|
Assert.That(child3.SortOrder, Is.EqualTo(0), "Child3 should now be first (sort order 0)");
|
|
Assert.That(child2.SortOrder, Is.EqualTo(1), "Child2 should now be second (sort order 1)");
|
|
Assert.That(child1.SortOrder, Is.EqualTo(2), "Child1 should now be third (sort order 2)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test 4: Verifies Sort(IEnumerable<int>) correctly reorders children by ID.
|
|
/// </summary>
|
|
[Test]
|
|
public void Sort_WithIds_ChangesSortOrder()
|
|
{
|
|
// Arrange - Use existing subpages from base class
|
|
var child1 = ContentService.GetById(Subpage.Id)!;
|
|
var child2 = ContentService.GetById(Subpage2.Id)!;
|
|
var child3 = ContentService.GetById(Subpage3.Id)!;
|
|
|
|
// v1.2: Verify initial sort order assumption
|
|
Assert.That(child1.SortOrder, Is.LessThan(child2.SortOrder), "Setup: child1 before child2");
|
|
Assert.That(child2.SortOrder, Is.LessThan(child3.SortOrder), "Setup: child2 before child3");
|
|
|
|
var child1Id = Subpage.Id;
|
|
var child2Id = Subpage2.Id;
|
|
var child3Id = Subpage3.Id;
|
|
|
|
// Create reversed order list by ID
|
|
var reorderedIds = new[] { child3Id, child2Id, child1Id };
|
|
|
|
// Act
|
|
var result = ContentService.Sort(reorderedIds);
|
|
|
|
// Assert
|
|
Assert.That(result.Success, Is.True, "Sort should succeed");
|
|
|
|
// Re-fetch to verify persisted order (v1.3: removed var to avoid shadowing)
|
|
child1 = ContentService.GetById(child1Id)!;
|
|
child2 = ContentService.GetById(child2Id)!;
|
|
child3 = ContentService.GetById(child3Id)!;
|
|
|
|
Assert.That(child3.SortOrder, Is.EqualTo(0), "Child3 should now be first (sort order 0)");
|
|
Assert.That(child2.SortOrder, Is.EqualTo(1), "Child2 should now be second (sort order 1)");
|
|
Assert.That(child1.SortOrder, Is.EqualTo(2), "Child1 should now be third (sort order 2)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test 5: Verifies Sort fires Sorting and Sorted notifications in correct sequence.
|
|
/// </summary>
|
|
[Test]
|
|
public void Sort_FiresSortingAndSortedNotifications()
|
|
{
|
|
// Arrange - Use existing subpages from base class
|
|
var child1 = ContentService.GetById(Subpage.Id)!;
|
|
var child2 = ContentService.GetById(Subpage2.Id)!;
|
|
var child3 = ContentService.GetById(Subpage3.Id)!;
|
|
|
|
// v1.2: Verify initial sort order assumption
|
|
Assert.That(child1.SortOrder, Is.LessThan(child2.SortOrder), "Setup: child1 before child2");
|
|
Assert.That(child2.SortOrder, Is.LessThan(child3.SortOrder), "Setup: child2 before child3");
|
|
|
|
var reorderedItems = new[] { child3, child2, child1 };
|
|
|
|
// Clear notification tracking
|
|
RefactoringTestNotificationHandler.Reset();
|
|
|
|
// Act
|
|
var result = ContentService.Sort(reorderedItems);
|
|
|
|
// Assert
|
|
Assert.That(result.Success, Is.True, "Sort should succeed");
|
|
|
|
var notifications = RefactoringTestNotificationHandler.NotificationOrder;
|
|
|
|
// Verify both sorting notifications fire
|
|
Assert.That(notifications, Does.Contain(nameof(ContentSortingNotification)),
|
|
"Sorting notification should fire");
|
|
Assert.That(notifications, Does.Contain(nameof(ContentSortedNotification)),
|
|
"Sorted notification should fire");
|
|
|
|
// Also verify Saving/Saved fire (Sort saves content)
|
|
Assert.That(notifications, Does.Contain(nameof(ContentSavingNotification)),
|
|
"Saving notification should fire during sort");
|
|
Assert.That(notifications, Does.Contain(nameof(ContentSavedNotification)),
|
|
"Saved notification should fire during sort");
|
|
|
|
// Verify order: Sorting -> Saving -> Saved -> Sorted
|
|
var sortingIndex = notifications.IndexOf(nameof(ContentSortingNotification));
|
|
var sortedIndex = notifications.IndexOf(nameof(ContentSortedNotification));
|
|
|
|
Assert.That(sortingIndex, Is.LessThan(sortedIndex),
|
|
"Sorting should fire before Sorted");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region DeleteOfType Tests
|
|
|
|
/// <summary>
|
|
/// Test 6: Verifies DeleteOfType with hierarchical content deletes everything correctly.
|
|
/// </summary>
|
|
[Test]
|
|
public void DeleteOfType_MovesDescendantsToRecycleBinFirst()
|
|
{
|
|
// Arrange - Create a second content type for descendants
|
|
var template = FileService.GetTemplate("defaultTemplate");
|
|
Assert.That(template, Is.Not.Null, "Default template must exist for test setup");
|
|
var childContentType = ContentTypeBuilder.CreateSimpleContentType(
|
|
"childType", "Child Type", defaultTemplateId: template!.Id);
|
|
ContentTypeService.Save(childContentType);
|
|
|
|
// Create parent of target type
|
|
var parent = ContentBuilder.CreateSimpleContent(ContentType, "ParentToDelete", -1);
|
|
ContentService.Save(parent);
|
|
|
|
// Create child of different type (should be moved to bin, not deleted)
|
|
var childOfDifferentType = ContentBuilder.CreateSimpleContent(childContentType, "ChildDifferentType", parent.Id);
|
|
ContentService.Save(childOfDifferentType);
|
|
|
|
// Create child of same type (should be deleted)
|
|
var childOfSameType = ContentBuilder.CreateSimpleContent(ContentType, "ChildSameType", parent.Id);
|
|
ContentService.Save(childOfSameType);
|
|
|
|
var parentId = parent.Id;
|
|
var childDiffId = childOfDifferentType.Id;
|
|
var childSameId = childOfSameType.Id;
|
|
|
|
// Act
|
|
ContentService.DeleteOfType(ContentType.Id);
|
|
|
|
// Assert
|
|
// Parent should be deleted (it's the target type)
|
|
var deletedParent = ContentService.GetById(parentId);
|
|
Assert.That(deletedParent, Is.Null, "Parent of target type should be deleted");
|
|
|
|
// Child of same type should be deleted
|
|
var deletedChildSame = ContentService.GetById(childSameId);
|
|
Assert.That(deletedChildSame, Is.Null, "Child of same type should be deleted");
|
|
|
|
// Child of different type should be in recycle bin
|
|
var trashedChild = ContentService.GetById(childDiffId);
|
|
Assert.That(trashedChild, Is.Not.Null, "Child of different type should still exist");
|
|
Assert.That(trashedChild!.Trashed, Is.True, "Child of different type should be in recycle bin");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test 7: Verifies DeleteOfType only deletes content of the specified type.
|
|
/// </summary>
|
|
[Test]
|
|
public void DeleteOfType_WithMixedTypes_OnlyDeletesSpecifiedType()
|
|
{
|
|
// Arrange - Create a second content type
|
|
var template = FileService.GetTemplate("defaultTemplate");
|
|
Assert.That(template, Is.Not.Null, "Default template must exist for test setup");
|
|
var otherContentType = ContentTypeBuilder.CreateSimpleContentType(
|
|
"otherType", "Other Type", defaultTemplateId: template!.Id);
|
|
ContentTypeService.Save(otherContentType);
|
|
|
|
// Create content of target type
|
|
var targetContent1 = ContentBuilder.CreateSimpleContent(ContentType, "Target1", -1);
|
|
var targetContent2 = ContentBuilder.CreateSimpleContent(ContentType, "Target2", -1);
|
|
ContentService.Save(targetContent1);
|
|
ContentService.Save(targetContent2);
|
|
|
|
// Create content of other type (should survive)
|
|
var otherContent = ContentBuilder.CreateSimpleContent(otherContentType, "Other", -1);
|
|
ContentService.Save(otherContent);
|
|
|
|
var target1Id = targetContent1.Id;
|
|
var target2Id = targetContent2.Id;
|
|
var otherId = otherContent.Id;
|
|
|
|
// Act
|
|
ContentService.DeleteOfType(ContentType.Id);
|
|
|
|
// Assert
|
|
Assert.That(ContentService.GetById(target1Id), Is.Null, "Target1 should be deleted");
|
|
Assert.That(ContentService.GetById(target2Id), Is.Null, "Target2 should be deleted");
|
|
Assert.That(ContentService.GetById(otherId), Is.Not.Null, "Other type content should survive");
|
|
Assert.That(ContentService.GetById(otherId)!.Trashed, Is.False, "Other type content should not be trashed");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test 8: Verifies DeleteOfTypes deletes multiple content types in a single operation.
|
|
/// </summary>
|
|
[Test]
|
|
public void DeleteOfTypes_DeletesMultipleTypesAtOnce()
|
|
{
|
|
// Arrange - Create additional content types
|
|
var template = FileService.GetTemplate("defaultTemplate");
|
|
Assert.That(template, Is.Not.Null, "Default template must exist for test setup");
|
|
|
|
var type1 = ContentTypeBuilder.CreateSimpleContentType(
|
|
"deleteType1", "Delete Type 1", defaultTemplateId: template!.Id);
|
|
var type2 = ContentTypeBuilder.CreateSimpleContentType(
|
|
"deleteType2", "Delete Type 2", defaultTemplateId: template.Id);
|
|
var survivorType = ContentTypeBuilder.CreateSimpleContentType(
|
|
"survivorType", "Survivor Type", defaultTemplateId: template.Id);
|
|
|
|
ContentTypeService.Save(type1);
|
|
ContentTypeService.Save(type2);
|
|
ContentTypeService.Save(survivorType);
|
|
|
|
// Create content of each type
|
|
var content1 = ContentBuilder.CreateSimpleContent(type1, "Content1", -1);
|
|
var content2 = ContentBuilder.CreateSimpleContent(type2, "Content2", -1);
|
|
var survivor = ContentBuilder.CreateSimpleContent(survivorType, "Survivor", -1);
|
|
|
|
ContentService.Save(content1);
|
|
ContentService.Save(content2);
|
|
ContentService.Save(survivor);
|
|
|
|
var content1Id = content1.Id;
|
|
var content2Id = content2.Id;
|
|
var survivorId = survivor.Id;
|
|
|
|
// Act - Delete multiple types
|
|
ContentService.DeleteOfTypes(new[] { type1.Id, type2.Id });
|
|
|
|
// Assert
|
|
Assert.That(ContentService.GetById(content1Id), Is.Null, "Content of type1 should be deleted");
|
|
Assert.That(ContentService.GetById(content2Id), Is.Null, "Content of type2 should be deleted");
|
|
Assert.That(ContentService.GetById(survivorId), Is.Not.Null, "Content of survivor type should exist");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Permission Tests
|
|
|
|
/// <summary>
|
|
/// Test 9: Verifies SetPermission assigns a permission and GetPermissions retrieves it.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task SetPermission_AssignsPermissionToUserGroup()
|
|
{
|
|
// Arrange
|
|
var content = ContentBuilder.CreateSimpleContent(ContentType, "PermissionTest", -1);
|
|
ContentService.Save(content);
|
|
|
|
// Get admin user group ID (should always exist)
|
|
var adminGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias);
|
|
Assert.That(adminGroup, Is.Not.Null, "Admin group should exist");
|
|
|
|
// Act - Assign browse permission ('F' is typically the Browse Node permission)
|
|
ContentService.SetPermission(content, "F", new[] { adminGroup!.Id });
|
|
|
|
// Assert
|
|
var permissions = ContentService.GetPermissions(content);
|
|
Assert.That(permissions, Is.Not.Null, "Permissions should be returned");
|
|
|
|
var adminPermissions = permissions.FirstOrDefault(p => p.UserGroupId == adminGroup.Id);
|
|
Assert.That(adminPermissions, Is.Not.Null, "Should have permissions for admin group");
|
|
Assert.That(adminPermissions!.AssignedPermissions, Does.Contain("F"),
|
|
"Admin group should have Browse permission");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test 10: Verifies multiple SetPermission calls accumulate permissions for a user group.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// v1.2: Expected behavior documentation -
|
|
/// SetPermission assigns permissions per-permission-type, not per-entity.
|
|
/// Calling SetPermission("F", ...) then SetPermission("U", ...) results in both F and U
|
|
/// permissions being assigned. Each call only replaces permissions of the same type.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task SetPermission_MultiplePermissionsForSameGroup()
|
|
{
|
|
// Arrange
|
|
var content = ContentBuilder.CreateSimpleContent(ContentType, "MultiPermissionTest", -1);
|
|
ContentService.Save(content);
|
|
|
|
var adminGroup = (await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias))!;
|
|
|
|
// Act - Assign multiple permissions
|
|
ContentService.SetPermission(content, "F", new[] { adminGroup.Id }); // Browse
|
|
ContentService.SetPermission(content, "U", new[] { adminGroup.Id }); // Update
|
|
|
|
// Assert
|
|
var permissions = ContentService.GetPermissions(content);
|
|
var adminPermissions = permissions.FirstOrDefault(p => p.UserGroupId == adminGroup.Id);
|
|
|
|
Assert.That(adminPermissions, Is.Not.Null, "Should have permissions for admin group");
|
|
Assert.That(adminPermissions!.AssignedPermissions, Does.Contain("F"), "Should have Browse permission");
|
|
Assert.That(adminPermissions.AssignedPermissions, Does.Contain("U"), "Should have Update permission");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test 11: Verifies SetPermissions assigns a complete permission set.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task SetPermissions_AssignsPermissionSet()
|
|
{
|
|
// Arrange
|
|
var content = ContentBuilder.CreateSimpleContent(ContentType, "PermissionSetTest", -1);
|
|
ContentService.Save(content);
|
|
|
|
var adminGroup = (await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias))!;
|
|
|
|
// Create permission set
|
|
var permissionSet = new EntityPermissionSet(
|
|
content.Id,
|
|
new EntityPermissionCollection(new[]
|
|
{
|
|
new EntityPermission(adminGroup.Id, content.Id, new HashSet<string> { "F", "U", "P" }) // Browse, Update, Publish
|
|
}));
|
|
|
|
// Act
|
|
ContentService.SetPermissions(permissionSet);
|
|
|
|
// Assert
|
|
var permissions = ContentService.GetPermissions(content);
|
|
var adminPermissions = permissions.FirstOrDefault(p => p.UserGroupId == adminGroup.Id);
|
|
|
|
Assert.That(adminPermissions, Is.Not.Null, "Should have permissions for admin group");
|
|
Assert.That(adminPermissions!.AssignedPermissions, Does.Contain("F"), "Should have Browse permission");
|
|
Assert.That(adminPermissions.AssignedPermissions, Does.Contain("U"), "Should have Update permission");
|
|
Assert.That(adminPermissions.AssignedPermissions, Does.Contain("P"), "Should have Publish permission");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test 12: Verifies SetPermission can assign to multiple user groups simultaneously.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task SetPermission_AssignsToMultipleUserGroups()
|
|
{
|
|
// Arrange
|
|
var content = ContentBuilder.CreateSimpleContent(ContentType, "MultiGroupTest", -1);
|
|
ContentService.Save(content);
|
|
|
|
var adminGroup = (await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias))!;
|
|
var editorGroup = (await UserGroupService.GetAsync(Constants.Security.EditorGroupKey))!;
|
|
|
|
// Act - Assign permission to multiple groups at once
|
|
ContentService.SetPermission(content, "F", new[] { adminGroup.Id, editorGroup.Id });
|
|
|
|
// Assert
|
|
var permissions = ContentService.GetPermissions(content);
|
|
|
|
var adminPermissions = permissions.FirstOrDefault(p => p.UserGroupId == adminGroup.Id);
|
|
var editorPermissions = permissions.FirstOrDefault(p => p.UserGroupId == editorGroup.Id);
|
|
|
|
Assert.That(adminPermissions, Is.Not.Null, "Should have permissions for admin group");
|
|
Assert.That(adminPermissions!.AssignedPermissions, Does.Contain("F"), "Admin should have Browse permission");
|
|
|
|
Assert.That(editorPermissions, Is.Not.Null, "Should have permissions for editor group");
|
|
Assert.That(editorPermissions!.AssignedPermissions, Does.Contain("F"), "Editor should have Browse permission");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Transaction Boundary Tests
|
|
|
|
/// <summary>
|
|
/// Test 13: Verifies that multiple operations within an uncompleted scope all roll back together.
|
|
/// </summary>
|
|
[Test]
|
|
public void AmbientScope_NestedOperationsShareTransaction()
|
|
{
|
|
// Arrange
|
|
var content1 = ContentBuilder.CreateSimpleContent(ContentType, "RollbackTest1", -1);
|
|
var content2 = ContentBuilder.CreateSimpleContent(ContentType, "RollbackTest2", -1);
|
|
|
|
// Act - Create scope, save content, but don't complete the scope
|
|
using (var scope = ScopeProvider.CreateScope())
|
|
{
|
|
ContentService.Save(content1);
|
|
ContentService.Save(content2);
|
|
|
|
// Verify content has IDs (was saved within transaction)
|
|
Assert.That(content1.Id, Is.GreaterThan(0), "Content1 should have an ID");
|
|
Assert.That(content2.Id, Is.GreaterThan(0), "Content2 should have an ID");
|
|
|
|
// v1.2: Note - IDs are captured for debugging but cannot be used after rollback
|
|
// since they were assigned within the rolled-back transaction
|
|
var id1 = content1.Id;
|
|
var id2 = content2.Id;
|
|
|
|
// DON'T call scope.Complete() - should roll back
|
|
}
|
|
|
|
// Assert - Content should not exist after rollback
|
|
// We can't use the IDs because they were assigned in the rolled-back transaction
|
|
// Instead, search by name
|
|
var foundContent = ContentService.GetRootContent()
|
|
.Where(c => c.Name == "RollbackTest1" || c.Name == "RollbackTest2")
|
|
.ToList();
|
|
|
|
Assert.That(foundContent, Is.Empty, "Content should not exist after transaction rollback");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test 14: Verifies that multiple operations within a completed scope all commit together.
|
|
/// </summary>
|
|
[Test]
|
|
public void AmbientScope_CompletedScopeCommitsAllOperations()
|
|
{
|
|
// Arrange
|
|
var content1 = ContentBuilder.CreateSimpleContent(ContentType, "CommitTest1", -1);
|
|
var content2 = ContentBuilder.CreateSimpleContent(ContentType, "CommitTest2", -1);
|
|
int id1, id2;
|
|
|
|
// Act - Create scope, save content, and complete the scope
|
|
using (var scope = ScopeProvider.CreateScope())
|
|
{
|
|
ContentService.Save(content1);
|
|
ContentService.Save(content2);
|
|
|
|
id1 = content1.Id;
|
|
id2 = content2.Id;
|
|
|
|
scope.Complete(); // Commit transaction
|
|
}
|
|
|
|
// Assert - Content should exist after commit
|
|
var retrieved1 = ContentService.GetById(id1);
|
|
var retrieved2 = ContentService.GetById(id2);
|
|
|
|
Assert.That(retrieved1, Is.Not.Null, "Content1 should exist after commit");
|
|
Assert.That(retrieved2, Is.Not.Null, "Content2 should exist after commit");
|
|
Assert.That(retrieved1!.Name, Is.EqualTo("CommitTest1"));
|
|
Assert.That(retrieved2!.Name, Is.EqualTo("CommitTest2"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test 15: Verifies MoveToRecycleBin within an uncompleted scope rolls back completely.
|
|
/// </summary>
|
|
[Test]
|
|
public void AmbientScope_MoveToRecycleBinRollsBackCompletely()
|
|
{
|
|
// Arrange - Create and save content OUTSIDE the test scope so it persists
|
|
var content = ContentBuilder.CreateSimpleContent(ContentType, "MoveRollbackTest", -1);
|
|
ContentService.Save(content);
|
|
var contentId = content.Id;
|
|
|
|
// Verify content exists and is not trashed
|
|
var beforeMove = ContentService.GetById(contentId);
|
|
Assert.That(beforeMove, Is.Not.Null, "Content should exist before test");
|
|
Assert.That(beforeMove!.Trashed, Is.False, "Content should not be trashed before test");
|
|
|
|
// Act - Move to recycle bin within an uncompleted scope
|
|
using (var scope = ScopeProvider.CreateScope())
|
|
{
|
|
ContentService.MoveToRecycleBin(content);
|
|
|
|
// Verify it's trashed within the transaction
|
|
var duringMove = ContentService.GetById(contentId);
|
|
Assert.That(duringMove!.Trashed, Is.True, "Content should be trashed within transaction");
|
|
|
|
// DON'T call scope.Complete() - should roll back
|
|
}
|
|
|
|
// Assert - Content should be back to original state after rollback
|
|
var afterRollback = ContentService.GetById(contentId);
|
|
Assert.That(afterRollback, Is.Not.Null, "Content should still exist after rollback");
|
|
Assert.That(afterRollback!.Trashed, Is.False, "Content should not be trashed after rollback");
|
|
Assert.That(afterRollback.ParentId, Is.EqualTo(-1), "Content should be at root level after rollback");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Phase 1 Tests
|
|
|
|
/// <summary>
|
|
/// Phase 1 Test: Verifies IContentCrudService is registered and resolvable from DI.
|
|
/// </summary>
|
|
[Test]
|
|
public void IContentCrudService_CanBeResolvedFromDI()
|
|
{
|
|
// Act
|
|
var crudService = GetRequiredService<IContentCrudService>();
|
|
|
|
// Assert
|
|
Assert.That(crudService, Is.Not.Null);
|
|
Assert.That(crudService, Is.InstanceOf<ContentCrudService>());
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Phase 2 - Count Method Delegation Tests
|
|
|
|
/// <summary>
|
|
/// Phase 2 Test: Verifies Count() via facade returns same result as direct service call.
|
|
/// </summary>
|
|
[Test]
|
|
public void Count_ViaFacade_ReturnsEquivalentResultToDirectService()
|
|
{
|
|
// Arrange
|
|
var queryService = GetRequiredService<IContentQueryOperationService>();
|
|
|
|
// Act
|
|
var facadeCount = ContentService.Count();
|
|
var directCount = queryService.Count();
|
|
|
|
// Assert
|
|
Assert.That(facadeCount, Is.EqualTo(directCount));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Phase 2 Test: Verifies CountPublished() via facade returns same result as direct service call.
|
|
/// </summary>
|
|
[Test]
|
|
public void CountPublished_ViaFacade_ReturnsEquivalentResultToDirectService()
|
|
{
|
|
// Arrange
|
|
var queryService = GetRequiredService<IContentQueryOperationService>();
|
|
ContentService.Publish(Textpage, new[] { "*" });
|
|
|
|
// Act
|
|
var facadeCount = ContentService.CountPublished();
|
|
var directCount = queryService.CountPublished();
|
|
|
|
// Assert
|
|
Assert.That(facadeCount, Is.EqualTo(directCount));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Phase 2 Test: Verifies CountChildren() via facade returns same result as direct service call.
|
|
/// </summary>
|
|
[Test]
|
|
public void CountChildren_ViaFacade_ReturnsEquivalentResultToDirectService()
|
|
{
|
|
// Arrange
|
|
var queryService = GetRequiredService<IContentQueryOperationService>();
|
|
var parentId = Textpage.Id;
|
|
|
|
// Act
|
|
var facadeCount = ContentService.CountChildren(parentId);
|
|
var directCount = queryService.CountChildren(parentId);
|
|
|
|
// Assert
|
|
Assert.That(facadeCount, Is.EqualTo(directCount));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Phase 2 Test: Verifies CountDescendants() via facade returns same result as direct service call.
|
|
/// </summary>
|
|
[Test]
|
|
public void CountDescendants_ViaFacade_ReturnsEquivalentResultToDirectService()
|
|
{
|
|
// Arrange
|
|
var queryService = GetRequiredService<IContentQueryOperationService>();
|
|
var parentId = Textpage.Id;
|
|
|
|
// Act
|
|
var facadeCount = ContentService.CountDescendants(parentId);
|
|
var directCount = queryService.CountDescendants(parentId);
|
|
|
|
// Assert
|
|
Assert.That(facadeCount, Is.EqualTo(directCount));
|
|
}
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Notification handler that tracks the order of notifications for test verification.
|
|
/// </summary>
|
|
internal sealed class RefactoringTestNotificationHandler :
|
|
INotificationHandler<ContentSavingNotification>,
|
|
INotificationHandler<ContentSavedNotification>,
|
|
INotificationHandler<ContentPublishingNotification>,
|
|
INotificationHandler<ContentPublishedNotification>,
|
|
INotificationHandler<ContentUnpublishingNotification>,
|
|
INotificationHandler<ContentUnpublishedNotification>,
|
|
INotificationHandler<ContentMovingNotification>,
|
|
INotificationHandler<ContentMovedNotification>,
|
|
INotificationHandler<ContentMovingToRecycleBinNotification>,
|
|
INotificationHandler<ContentMovedToRecycleBinNotification>,
|
|
INotificationHandler<ContentSortingNotification>,
|
|
INotificationHandler<ContentSortedNotification>,
|
|
INotificationHandler<ContentDeletingNotification>,
|
|
INotificationHandler<ContentDeletedNotification>
|
|
{
|
|
private static readonly List<string> _notificationOrder = new();
|
|
private static readonly object _lock = new();
|
|
|
|
public static IReadOnlyList<string> NotificationOrder
|
|
{
|
|
get
|
|
{
|
|
lock (_lock)
|
|
{
|
|
return _notificationOrder.ToList();
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void Reset()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_notificationOrder.Clear();
|
|
}
|
|
}
|
|
|
|
private static void Record(string notificationType)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_notificationOrder.Add(notificationType);
|
|
}
|
|
}
|
|
|
|
public void Handle(ContentSavingNotification notification) => Record(nameof(ContentSavingNotification));
|
|
public void Handle(ContentSavedNotification notification) => Record(nameof(ContentSavedNotification));
|
|
public void Handle(ContentPublishingNotification notification) => Record(nameof(ContentPublishingNotification));
|
|
public void Handle(ContentPublishedNotification notification) => Record(nameof(ContentPublishedNotification));
|
|
public void Handle(ContentUnpublishingNotification notification) => Record(nameof(ContentUnpublishingNotification));
|
|
public void Handle(ContentUnpublishedNotification notification) => Record(nameof(ContentUnpublishedNotification));
|
|
public void Handle(ContentMovingNotification notification) => Record(nameof(ContentMovingNotification));
|
|
public void Handle(ContentMovedNotification notification) => Record(nameof(ContentMovedNotification));
|
|
public void Handle(ContentMovingToRecycleBinNotification notification) => Record(nameof(ContentMovingToRecycleBinNotification));
|
|
public void Handle(ContentMovedToRecycleBinNotification notification) => Record(nameof(ContentMovedToRecycleBinNotification));
|
|
public void Handle(ContentSortingNotification notification) => Record(nameof(ContentSortingNotification));
|
|
public void Handle(ContentSortedNotification notification) => Record(nameof(ContentSortedNotification));
|
|
public void Handle(ContentDeletingNotification notification) => Record(nameof(ContentDeletingNotification));
|
|
public void Handle(ContentDeletedNotification notification) => Record(nameof(ContentDeletedNotification));
|
|
}
|
|
}
|