Compare commits
11 Commits
main
...
phase-0-ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 6db0554b1e | |||
| 0ef17bb1fc | |||
| 3239a4534e | |||
| 7e989c0f8c | |||
| cf74f7850e | |||
| 86b0d3d803 | |||
| 0c22afa3cf | |||
| 0f408dd299 | |||
| 336adef2c2 | |||
| bf054e9d62 | |||
| f4a01ed50d |
1016
docs/plans/2025-12-19-contentservice-refactor-design.md
Normal file
1016
docs/plans/2025-12-19-contentservice-refactor-design.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for ContentService performance benchmarks.
|
||||
/// Extends UmbracoIntegrationTestWithContent with structured benchmark recording.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usage:
|
||||
/// <code>
|
||||
/// [Test]
|
||||
/// [LongRunning]
|
||||
/// public void MyBenchmark()
|
||||
/// {
|
||||
/// var sw = Stopwatch.StartNew();
|
||||
/// // ... operation under test ...
|
||||
/// sw.Stop();
|
||||
/// RecordBenchmark("MyBenchmark", sw.ElapsedMilliseconds, itemCount);
|
||||
/// }
|
||||
/// </code>
|
||||
///
|
||||
/// Results are output in both human-readable and JSON formats for baseline comparison.
|
||||
/// </remarks>
|
||||
public abstract class ContentServiceBenchmarkBase : UmbracoIntegrationTestWithContent
|
||||
{
|
||||
private readonly List<BenchmarkResult> _results = new();
|
||||
|
||||
/// <summary>
|
||||
/// Records a benchmark result for later output.
|
||||
/// </summary>
|
||||
/// <param name="name">Name of the benchmark (should match method name).</param>
|
||||
/// <param name="elapsedMs">Elapsed time in milliseconds.</param>
|
||||
/// <param name="itemCount">Number of items processed (for per-item metrics).</param>
|
||||
protected void RecordBenchmark(string name, long elapsedMs, int itemCount)
|
||||
{
|
||||
var result = new BenchmarkResult(name, elapsedMs, itemCount);
|
||||
_results.Add(result);
|
||||
|
||||
// Human-readable output
|
||||
TestContext.WriteLine($"[BENCHMARK] {name}: {elapsedMs}ms ({result.MsPerItem:F2}ms/item, {itemCount} items)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a benchmark result without item count (for single-item operations).
|
||||
/// </summary>
|
||||
protected void RecordBenchmark(string name, long elapsedMs)
|
||||
=> RecordBenchmark(name, elapsedMs, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Measures and records a benchmark for the given action.
|
||||
/// </summary>
|
||||
/// <param name="name">Name of the benchmark.</param>
|
||||
/// <param name="itemCount">Number of items processed.</param>
|
||||
/// <param name="action">The action to benchmark.</param>
|
||||
/// <param name="skipWarmup">Skip warmup for destructive operations (delete, empty recycle bin).</param>
|
||||
/// <returns>Elapsed time in milliseconds.</returns>
|
||||
protected long MeasureAndRecord(string name, int itemCount, Action action, bool skipWarmup = false)
|
||||
{
|
||||
// Warmup iteration: triggers JIT compilation, warms connection pool and caches.
|
||||
// Skip for destructive operations that would fail on second execution.
|
||||
if (!skipWarmup)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Warmup failure is acceptable for some operations; continue to measured run
|
||||
}
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
action();
|
||||
sw.Stop();
|
||||
RecordBenchmark(name, sw.ElapsedMilliseconds, itemCount);
|
||||
return sw.ElapsedMilliseconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures and records a benchmark, returning the result of the function.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Performs a warmup call before measurement to trigger JIT compilation.
|
||||
/// Safe for read-only operations that can be repeated without side effects.
|
||||
/// </remarks>
|
||||
protected T MeasureAndRecord<T>(string name, int itemCount, Func<T> func)
|
||||
{
|
||||
// Warmup: triggers JIT compilation, warms caches
|
||||
try { func(); } catch { /* ignore warmup errors */ }
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = func();
|
||||
sw.Stop();
|
||||
RecordBenchmark(name, sw.ElapsedMilliseconds, itemCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void OutputBenchmarkResults()
|
||||
{
|
||||
if (_results.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON output for automated comparison
|
||||
// Wrapped in markers for easy extraction from test output
|
||||
var json = JsonSerializer.Serialize(_results, new JsonSerializerOptions { WriteIndented = true });
|
||||
TestContext.WriteLine($"[BENCHMARK_JSON]{json}[/BENCHMARK_JSON]");
|
||||
|
||||
_results.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single benchmark measurement.
|
||||
/// </summary>
|
||||
internal sealed record BenchmarkResult(string Name, long ElapsedMs, int ItemCount)
|
||||
{
|
||||
public double MsPerItem => ItemCount > 0 ? (double)ElapsedMs / ItemCount : 0;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,722 @@
|
||||
// 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
|
||||
|
||||
/// <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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ContentServiceBase (shared infrastructure for extracted services).
|
||||
/// These tests establish the expected contract for the base class before it's created.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ContentServiceBase will be created in Phase 1. These tests validate the design requirements:
|
||||
/// - Audit helper method behavior
|
||||
/// - Scope provider access patterns
|
||||
/// - Logger injection patterns
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public class ContentServiceBaseTests
|
||||
{
|
||||
// Note: These tests will be uncommented when ContentServiceBase is created in Phase 1.
|
||||
// For now, they serve as documentation of the expected behavior.
|
||||
|
||||
/*
|
||||
private Mock<ICoreScopeProvider> _scopeProviderMock;
|
||||
private Mock<IAuditService> _auditServiceMock;
|
||||
private Mock<IEventMessagesFactory> _eventMessagesFactoryMock;
|
||||
private Mock<ILogger<TestContentService>> _loggerMock;
|
||||
private TestContentService _service;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_scopeProviderMock = new Mock<ICoreScopeProvider>();
|
||||
_auditServiceMock = new Mock<IAuditService>();
|
||||
_eventMessagesFactoryMock = new Mock<IEventMessagesFactory>();
|
||||
_loggerMock = new Mock<ILogger<TestContentService>>();
|
||||
|
||||
_eventMessagesFactoryMock.Setup(x => x.Get()).Returns(new EventMessages());
|
||||
|
||||
_service = new TestContentService(
|
||||
_scopeProviderMock.Object,
|
||||
_auditServiceMock.Object,
|
||||
_eventMessagesFactoryMock.Object,
|
||||
_loggerMock.Object);
|
||||
}
|
||||
|
||||
#region Audit Helper Method Tests
|
||||
|
||||
[Test]
|
||||
public void Audit_WithValidParameters_CreatesAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var userId = 1;
|
||||
var objectId = 100;
|
||||
var message = "Test audit message";
|
||||
|
||||
// Act
|
||||
_service.TestAudit(AuditType.Save, userId, objectId, message);
|
||||
|
||||
// Assert
|
||||
_auditServiceMock.Verify(x => x.Write(
|
||||
userId,
|
||||
message,
|
||||
It.IsAny<string>(),
|
||||
objectId), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Audit_WithNullMessage_UsesDefaultMessage()
|
||||
{
|
||||
// Arrange
|
||||
var userId = 1;
|
||||
var objectId = 100;
|
||||
|
||||
// Act
|
||||
_service.TestAudit(AuditType.Save, userId, objectId, null);
|
||||
|
||||
// Assert
|
||||
_auditServiceMock.Verify(x => x.Write(
|
||||
userId,
|
||||
It.Is<string>(s => !string.IsNullOrEmpty(s)),
|
||||
It.IsAny<string>(),
|
||||
objectId), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scope Provider Access Pattern Tests
|
||||
|
||||
[Test]
|
||||
public void CreateScope_ReturnsValidCoreScope()
|
||||
{
|
||||
// Arrange
|
||||
var scopeMock = new Mock<ICoreScope>();
|
||||
_scopeProviderMock.Setup(x => x.CreateCoreScope(
|
||||
It.IsAny<IsolationLevel>(),
|
||||
It.IsAny<RepositoryCacheMode>(),
|
||||
It.IsAny<IEventDispatcher>(),
|
||||
It.IsAny<IScopedNotificationPublisher>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>()))
|
||||
.Returns(scopeMock.Object);
|
||||
|
||||
// Act
|
||||
var scope = _service.TestCreateScope();
|
||||
|
||||
// Assert
|
||||
Assert.That(scope, Is.Not.Null);
|
||||
_scopeProviderMock.Verify(x => x.CreateCoreScope(
|
||||
It.IsAny<IsolationLevel>(),
|
||||
It.IsAny<RepositoryCacheMode>(),
|
||||
It.IsAny<IEventDispatcher>(),
|
||||
It.IsAny<IScopedNotificationPublisher>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>()), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateScope_WithAmbientScope_ReusesExisting()
|
||||
{
|
||||
// Arrange
|
||||
var ambientScopeMock = new Mock<ICoreScope>();
|
||||
_scopeProviderMock.SetupGet(x => x.AmbientScope).Returns(ambientScopeMock.Object);
|
||||
|
||||
// When ambient scope exists, CreateCoreScope should still be called
|
||||
// but the scope provider handles the nesting
|
||||
_scopeProviderMock.Setup(x => x.CreateCoreScope(
|
||||
It.IsAny<IsolationLevel>(),
|
||||
It.IsAny<RepositoryCacheMode>(),
|
||||
It.IsAny<IEventDispatcher>(),
|
||||
It.IsAny<IScopedNotificationPublisher>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>()))
|
||||
.Returns(ambientScopeMock.Object);
|
||||
|
||||
// Act
|
||||
var scope = _service.TestCreateScope();
|
||||
|
||||
// Assert - scope should be the ambient scope (or nested in it)
|
||||
Assert.That(scope, Is.Not.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Logger Injection Tests
|
||||
|
||||
[Test]
|
||||
public void Logger_IsInjectedCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.That(_service.TestLogger, Is.Not.Null);
|
||||
Assert.That(_service.TestLogger, Is.EqualTo(_loggerMock.Object));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Logger_UsesCorrectCategoryName()
|
||||
{
|
||||
// The logger should be typed to the concrete service class
|
||||
// This is verified by the generic type parameter
|
||||
Assert.That(_service.TestLogger, Is.InstanceOf<ILogger<TestContentService>>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Repository Access Tests
|
||||
|
||||
[Test]
|
||||
public void DocumentRepository_IsAccessibleWithinScope()
|
||||
{
|
||||
// This test validates that the base class provides access to the document repository
|
||||
// The actual repository access pattern will be tested in integration tests
|
||||
Assert.Pass("Repository access validated in integration tests");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of ContentServiceBase for unit testing.
|
||||
/// </summary>
|
||||
private class TestContentService : ContentServiceBase
|
||||
{
|
||||
public TestContentService(
|
||||
ICoreScopeProvider scopeProvider,
|
||||
IAuditService auditService,
|
||||
IEventMessagesFactory eventMessagesFactory,
|
||||
ILogger<TestContentService> logger)
|
||||
: base(scopeProvider, auditService, eventMessagesFactory, logger)
|
||||
{
|
||||
}
|
||||
|
||||
// Expose protected members for testing
|
||||
public void TestAudit(AuditType type, int userId, int objectId, string? message)
|
||||
=> Audit(type, userId, objectId, message);
|
||||
|
||||
public ICoreScope TestCreateScope() => ScopeProvider.CreateCoreScope();
|
||||
|
||||
public ILogger<TestContentService> TestLogger => Logger;
|
||||
}
|
||||
*/
|
||||
|
||||
/// <summary>
|
||||
/// v1.3: Tracking test that fails when ContentServiceBase is created.
|
||||
/// When this test fails, uncomment all tests in this file and delete this placeholder.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ContentServiceBase_WhenCreated_UncommentTests()
|
||||
{
|
||||
// This tracking test uses reflection to detect when ContentServiceBase is created.
|
||||
// When you see this test fail, it means Phase 1 has created ContentServiceBase.
|
||||
// At that point:
|
||||
// 1. Uncomment all the tests in this file (the commented section above)
|
||||
// 2. Delete this tracking test
|
||||
// 3. Verify all tests pass
|
||||
|
||||
var type = Type.GetType("Umbraco.Cms.Infrastructure.Services.ContentServiceBase, Umbraco.Infrastructure");
|
||||
|
||||
Assert.That(type, Is.Null,
|
||||
"ContentServiceBase now exists! Uncomment all tests in this file and delete this tracking test.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user