Files
Umbraco-CMS/docs/plans/2025-12-23-contentservice-refactor-phase3-implementation.md
yv01p 2653496530 docs: update Phase 3 implementation plan to v1.3
Applied all fixes from critical review v3:
- Issue 3.1: Added input validation to GetVersionIds
- Issue 3.2: Removed redundant WriteLock in Rollback
- Issue 3.3: Added audit entry for prior versions in DeleteVersion
- Issue 3.4: Fixed Rollback return type (compilation fix)
- Issue 3.6: Added clarifying comment for cancellation behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 03:06:17 +00:00

58 KiB

ContentService Version Operations Extraction - Phase 3 Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Extract version operations (GetVersion, GetVersions, GetVersionsSlim, GetVersionIds, Rollback, DeleteVersions, DeleteVersion) from ContentService into a dedicated IContentVersionOperationService interface and ContentVersionOperationService implementation.

Architecture: ContentVersionOperationService implements IContentVersionOperationService and inherits from ContentServiceBase. The existing ContentService facade will delegate to ContentVersionOperationService for version operations. The new service handles synchronous version operations extracted from IContentService, separate from the existing async IContentVersionService which provides API-layer functionality.

Tech Stack: .NET 10.0, NUnit, Microsoft.Extensions.DependencyInjection


Plan Version History

Version Date Author Changes
1.0 2025-12-23 Claude Initial plan creation
1.1 2025-12-23 Claude Applied critical review fixes (Issues 2.1-2.5, 3.2-3.4)
1.2 2025-12-23 Claude Applied critical review v2 fixes (Issues 2.1-2.2, 3.1-3.4)
1.3 2025-12-23 Claude Applied critical review v3 fixes (Issues 3.1-3.4, 3.6)

Changes in Version 1.1

Summary: Applied fixes from critical implementation review (2025-12-23-contentservice-refactor-phase3-implementation-critical-review-1.md)

Critical Fixes Applied:

  1. Issue 2.1 - Rollback TOCTOU Race Condition: Consolidated Rollback into a single scope to eliminate race condition between read and write operations. Removed PerformRollback helper method; all operations now occur within one scope.

  2. Issue 2.2 - Rollback Error Handling: Added try-catch around save operation with proper error logging ("was unable to rollback") and conditional notification (only fires ContentRolledBackNotification on success).

  3. Issue 2.3 - GetVersionIds Missing ReadLock: Added scope.ReadLock(Constants.Locks.ContentTree) for consistency with other read operations. Added code comment explaining this is a bug fix.

  4. Issue 2.4 - DeleteVersion Nested Scope: Changed deletePriorVersions branch to use DocumentRepository.GetVersion() and DocumentRepository.DeleteVersions() directly instead of calling the service methods that create nested scopes.

  5. Issue 2.5 - Flaky Test Pattern: Replaced Thread.Sleep(100) with deterministic date comparison using actual version update dates.

Minor Improvements Applied:

  1. Issue 3.2 - Cancellation Test: Added Rollback_WhenNotificationCancelled_ReturnsCancelledResult test.

  2. Issue 3.3 - Published Version Protection Test: Added DeleteVersion_PublishedVersion_DoesNotDelete test.

  3. Issue 3.4 - Interface Documentation: Enhanced GetVersionIds XML documentation to specify behavior for non-existent content and maxRows edge cases.


Changes in Version 1.2

Summary: Applied fixes from critical implementation review v2 (2025-12-23-contentservice-refactor-phase3-implementation-critical-review-2.md)

Critical Fixes Applied:

  1. Issue 2.1 - Rollback Notification Preservation: Changed Rollback to use CrudService.Save instead of DocumentRepository.Save to preserve ContentSavingNotification and ContentSavedNotification firing. This maintains backward compatibility with existing notification handlers and ensures proper validation and audit trail.

  2. Issue 2.2 - DeleteVersion Double-Notification Preservation: When deletePriorVersions=true, the method now fires notifications for both the prior versions deletion AND the specific version deletion, matching original behavior.

Minor Improvements Applied:

  1. Issue 3.1 - Simplified Lock Acquisition: Restructured DeleteVersion to acquire WriteLock once at the start instead of multiple times, avoiding lock upgrade pattern.

  2. Issue 3.2 - Test Notification Registration: Fixed the cancellation test to use the correct integration test pattern with CustomTestSetup and notification handler registration.

  3. Issue 3.3 - CrudService Dependency: Added IContentCrudService as a constructor dependency to support proper save operation in Rollback.

  4. Issue 3.4 - Publish Method Signature: Updated test to use correct SaveAndPublish method signature.


Changes in Version 1.3

Summary: Applied fixes from critical implementation review v3 (2025-12-23-contentservice-refactor-phase3-implementation-critical-review-3.md)

Must Fix Applied:

  1. Issue 3.4 - Rollback Return Type: Fixed OperationResult<OperationResultType> to OperationResult to match IContentCrudService.Save signature and avoid compilation error.

Should Fix Applied:

  1. Issue 3.1 - GetVersionIds Input Validation: Added ArgumentOutOfRangeException for maxRows <= 0 to match interface documentation contract.

  2. Issue 3.3 - Audit Gap in DeleteVersion: Added audit entry for prior versions deletion when deletePriorVersions=true to preserve original two-audit-entry behavior.

Consider Applied:

  1. Issue 3.2 - Redundant WriteLock in Rollback: Removed explicit WriteLock before CrudService.Save since CrudService handles its own locking internally.

  2. Issue 3.6 - Prior Versions Cancellation Comment: Added clarifying comment documenting that specific version deletion proceeds even if prior versions notification is cancelled (matching original behavior).


Naming Decision

Conflict Resolution: An IContentVersionService already exists in the codebase (src/Umbraco.Core/Services/ContentVersionService.cs). This is an async API-layer service that provides version cleanup, paged version listing, and rollback via IContentService.

Decision: Name the new interface IContentVersionOperationService to avoid collision, following the pattern established for IContentPublishOperationService. The existing IContentVersionService remains unchanged as the higher-level async API orchestrator.


Phase 3 Overview

Phase 3 extracts version-related operations following patterns established in Phases 1-2:

  1. IContentVersionOperationService - Interface for version operations (7 methods)
  2. ContentVersionOperationService - Implementation inheriting from ContentServiceBase
  3. DI Registration - Register new service alongside existing services
  4. ContentService Delegation - Update facade to delegate to new service

The extraction reduces ContentService complexity by ~150 lines.


Methods to Extract

Method Signature Notifications
GetVersion IContent? GetVersion(int versionId) None (read-only)
GetVersions IEnumerable<IContent> GetVersions(int id) None (read-only)
GetVersionsSlim IEnumerable<IContent> GetVersionsSlim(int id, int skip, int take) None (read-only)
GetVersionIds IEnumerable<int> GetVersionIds(int id, int maxRows) None (read-only)
Rollback OperationResult Rollback(int id, int versionId, string culture, int userId) ContentRollingBackNotification, ContentRolledBackNotification
DeleteVersions void DeleteVersions(int id, DateTime versionDate, int userId) ContentDeletingVersionsNotification, ContentDeletedVersionsNotification
DeleteVersion void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId) ContentDeletingVersionsNotification, ContentDeletedVersionsNotification

Task 1: Create IContentVersionOperationService Interface

Files:

  • Create: src/Umbraco.Core/Services/IContentVersionOperationService.cs

Step 1: Create the interface file

// src/Umbraco.Core/Services/IContentVersionOperationService.cs
using Umbraco.Cms.Core.Models;

namespace Umbraco.Cms.Core.Services;

/// <summary>
/// Service for content version operations (retrieving versions, rollback, deleting versions).
/// </summary>
/// <remarks>
/// <para>
/// <strong>Implementation Note:</strong> Do not implement this interface directly.
/// Instead, inherit from <see cref="ContentServiceBase"/> which provides required
/// infrastructure (scoping, repository access, auditing). Direct implementation
/// without this base class will result in missing functionality.
/// </para>
/// <para>
/// This interface is part of the ContentService refactoring initiative (Phase 3).
/// It extracts version operations into a focused, testable service.
/// </para>
/// <para>
/// <strong>Note:</strong> This interface provides synchronous version operations
/// extracted from <see cref="IContentService"/>. For async API-layer version operations,
/// see <see cref="IContentVersionService"/> which orchestrates via this service.
/// </para>
/// <para>
/// <strong>Versioning Policy:</strong> This interface follows additive-only changes.
/// New methods may be added with default implementations. Existing methods will not
/// be removed or have signatures changed without a 2 major version deprecation period.
/// </para>
/// <para>
/// <strong>Version History:</strong>
/// <list type="bullet">
///   <item><description>v1.0 (Phase 3): Initial interface with GetVersion, GetVersions, Rollback, DeleteVersions operations</description></item>
/// </list>
/// </para>
/// </remarks>
/// <since>1.0</since>
public interface IContentVersionOperationService : IService
{
    #region Version Retrieval

    /// <summary>
    /// Gets a specific version of content by version id.
    /// </summary>
    /// <param name="versionId">The version id to retrieve.</param>
    /// <returns>The content version, or null if not found.</returns>
    IContent? GetVersion(int versionId);

    /// <summary>
    /// Gets all versions of a content item.
    /// </summary>
    /// <param name="id">The content id.</param>
    /// <returns>All versions of the content, ordered by version date descending.</returns>
    IEnumerable<IContent> GetVersions(int id);

    /// <summary>
    /// Gets a paged subset of versions for a content item.
    /// </summary>
    /// <param name="id">The content id.</param>
    /// <param name="skip">Number of versions to skip.</param>
    /// <param name="take">Number of versions to take.</param>
    /// <returns>Paged versions of the content, ordered by version date descending.</returns>
    IEnumerable<IContent> GetVersionsSlim(int id, int skip, int take);

    /// <summary>
    /// Gets version ids for a content item, ordered with latest first.
    /// </summary>
    /// <param name="id">The content id.</param>
    /// <param name="maxRows">Maximum number of version ids to return. Must be positive.</param>
    /// <returns>Version ids ordered with latest first. Empty if content not found.</returns>
    /// <exception cref="ArgumentOutOfRangeException">Thrown if maxRows is less than or equal to zero.</exception>
    /// <remarks>
    /// This method acquires a read lock on the content tree for consistency with other
    /// version retrieval methods. If content with the specified id does not exist,
    /// an empty enumerable is returned rather than throwing an exception.
    /// </remarks>
    IEnumerable<int> GetVersionIds(int id, int maxRows);

    #endregion

    #region Rollback

    /// <summary>
    /// Rolls back content to a previous version.
    /// </summary>
    /// <param name="id">The content id to rollback.</param>
    /// <param name="versionId">The version id to rollback to.</param>
    /// <param name="culture">The culture to rollback, or "*" for all cultures.</param>
    /// <param name="userId">The user performing the rollback.</param>
    /// <returns>The operation result indicating success or failure.</returns>
    /// <remarks>
    /// Fires <see cref="Notifications.ContentRollingBackNotification"/> (cancellable) before rollback
    /// and <see cref="Notifications.ContentRolledBackNotification"/> after successful rollback.
    /// The rollback copies property values from the target version to the current content
    /// and saves it, creating a new version.
    /// </remarks>
    OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId);

    #endregion

    #region Version Deletion

    /// <summary>
    /// Permanently deletes versions of content prior to a specific date.
    /// </summary>
    /// <param name="id">The content id.</param>
    /// <param name="versionDate">Delete versions older than this date.</param>
    /// <param name="userId">The user performing the deletion.</param>
    /// <remarks>
    /// This method will never delete the latest version of a content item.
    /// Fires <see cref="Notifications.ContentDeletingVersionsNotification"/> (cancellable) before deletion
    /// and <see cref="Notifications.ContentDeletedVersionsNotification"/> after deletion.
    /// </remarks>
    void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId);

    /// <summary>
    /// Permanently deletes a specific version of content.
    /// </summary>
    /// <param name="id">The content id.</param>
    /// <param name="versionId">The version id to delete.</param>
    /// <param name="deletePriorVersions">If true, also deletes all versions prior to the specified version.</param>
    /// <param name="userId">The user performing the deletion.</param>
    /// <remarks>
    /// This method will never delete the current version or published version of a content item.
    /// Fires <see cref="Notifications.ContentDeletingVersionsNotification"/> (cancellable) before deletion
    /// and <see cref="Notifications.ContentDeletedVersionsNotification"/> after deletion.
    /// If deletePriorVersions is true, it first deletes all versions prior to the specified version's date,
    /// then deletes the specified version.
    /// </remarks>
    void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = Constants.Security.SuperUserId);

    #endregion
}

Step 2: Run build to verify interface compiles

Run: dotnet build src/Umbraco.Core --no-restore Expected: Build succeeds

Step 3: Commit

git add src/Umbraco.Core/Services/IContentVersionOperationService.cs
git commit -m "$(cat <<'EOF'
refactor(core): add IContentVersionOperationService interface

Part of ContentService refactoring Phase 3.
Defines version operations to be extracted from ContentService.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 2: Create ContentVersionOperationService Implementation

Files:

  • Create: src/Umbraco.Core/Services/ContentVersionOperationService.cs

Step 1: Create the implementation file

v1.1 Changes Applied:

  • Issue 2.1: Consolidated Rollback into single scope (eliminated TOCTOU race condition)
  • Issue 2.2: Added try-catch error handling with proper logging
  • Issue 2.3: Added ReadLock to GetVersionIds for consistency
  • Issue 2.4: Fixed DeleteVersion to use repository directly instead of nested service calls

v1.2 Changes Applied:

  • Issue 2.1: Use CrudService.Save instead of DocumentRepository.Save to preserve ContentSaving/ContentSaved notifications
  • Issue 2.2: Preserve double-notification behavior for DeleteVersion with deletePriorVersions=true
  • Issue 3.1: Simplified lock acquisition in DeleteVersion (single WriteLock at start)
  • Issue 3.3: Added IContentCrudService constructor dependency

v1.3 Changes Applied:

  • Issue 3.1: Added input validation to GetVersionIds (ArgumentOutOfRangeException for maxRows <= 0)
  • Issue 3.2: Removed redundant WriteLock in Rollback (CrudService.Save handles locking)
  • Issue 3.3: Added audit entry for prior versions in DeleteVersion with deletePriorVersions=true
  • Issue 3.4: Fixed Rollback return type from OperationResult to OperationResult
  • Issue 3.6: Added clarifying comment for prior versions cancellation behavior
// src/Umbraco.Core/Services/ContentVersionOperationService.cs
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;

namespace Umbraco.Cms.Core.Services;

/// <summary>
/// Implements content version operations (retrieving versions, rollback, deleting versions).
/// </summary>
public class ContentVersionOperationService : ContentServiceBase, IContentVersionOperationService
{
    private readonly ILogger<ContentVersionOperationService> _logger;
    // v1.2 Fix (Issue 3.3): Added IContentCrudService for proper save with notifications
    private readonly IContentCrudService _crudService;

    public ContentVersionOperationService(
        ICoreScopeProvider provider,
        ILoggerFactory loggerFactory,
        IEventMessagesFactory eventMessagesFactory,
        IDocumentRepository documentRepository,
        IAuditService auditService,
        IUserIdKeyResolver userIdKeyResolver,
        IContentCrudService crudService)  // v1.2: Added for Rollback save operation
        : base(provider, loggerFactory, eventMessagesFactory, documentRepository, auditService, userIdKeyResolver)
    {
        _logger = loggerFactory.CreateLogger<ContentVersionOperationService>();
        _crudService = crudService;
    }

    #region Version Retrieval

    /// <inheritdoc />
    public IContent? GetVersion(int versionId)
    {
        using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
        scope.ReadLock(Constants.Locks.ContentTree);
        return DocumentRepository.GetVersion(versionId);
    }

    /// <inheritdoc />
    public IEnumerable<IContent> GetVersions(int id)
    {
        using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
        scope.ReadLock(Constants.Locks.ContentTree);
        return DocumentRepository.GetAllVersions(id);
    }

    /// <inheritdoc />
    public IEnumerable<IContent> GetVersionsSlim(int id, int skip, int take)
    {
        using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
        scope.ReadLock(Constants.Locks.ContentTree);
        return DocumentRepository.GetAllVersionsSlim(id, skip, take);
    }

    /// <inheritdoc />
    public IEnumerable<int> GetVersionIds(int id, int maxRows)
    {
        // v1.3 Fix (Issue 3.1): Added input validation to match interface documentation.
        // The interface documents ArgumentOutOfRangeException for maxRows <= 0.
        if (maxRows <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(maxRows), maxRows, "Value must be greater than zero.");
        }

        using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
        // v1.1 Fix (Issue 2.3): Added ReadLock for consistency with other read operations.
        // The original ContentService.GetVersionIds did not acquire a ReadLock, which was
        // inconsistent with GetVersion, GetVersions, and GetVersionsSlim.
        scope.ReadLock(Constants.Locks.ContentTree);
        return DocumentRepository.GetVersionIds(id, maxRows);
    }

    #endregion

    #region Rollback

    /// <inheritdoc />
    public OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId)
    {
        EventMessages evtMsgs = EventMessagesFactory.Get();

        // v1.1 Fix (Issue 2.1): Use a single scope for the entire operation to eliminate
        // TOCTOU race condition. Previously used separate read and write scopes which
        // could allow concurrent modification between reading content and writing changes.
        using ICoreScope scope = ScopeProvider.CreateCoreScope();

        // Read operations - acquire read lock first
        scope.ReadLock(Constants.Locks.ContentTree);
        IContent? content = DocumentRepository.Get(id);
        // v1.1 Fix: Use DocumentRepository.GetVersion directly instead of calling
        // this.GetVersion() which would create a nested scope
        IContent? version = DocumentRepository.GetVersion(versionId);

        // Null checks - cannot rollback if content or version is missing, or if trashed
        if (content == null || version == null || content.Trashed)
        {
            scope.Complete();
            return new OperationResult(OperationResultType.FailedCannot, evtMsgs);
        }

        var rollingBackNotification = new ContentRollingBackNotification(content, evtMsgs);
        if (scope.Notifications.PublishCancelable(rollingBackNotification))
        {
            scope.Complete();
            return OperationResult.Cancel(evtMsgs);
        }

        // Copy the changes from the version
        content.CopyFrom(version, culture);

        // v1.2 Fix (Issue 2.1): Use CrudService.Save to preserve ContentSaving/ContentSaved notifications.
        // The original ContentService.Rollback called Save(content, userId) which fires these notifications.
        // Using DocumentRepository.Save directly would bypass validation, audit trail, and cache invalidation.
        // v1.3 Fix (Issue 3.2): Removed explicit WriteLock - CrudService.Save handles its own locking internally.
        // v1.3 Fix (Issue 3.4): Fixed return type from OperationResult<OperationResultType> to OperationResult.
        OperationResult saveResult = _crudService.Save(content, userId);
        if (!saveResult.Success)
        {
            _logger.LogError("User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, id, versionId);
            scope.Complete();
            return new OperationResult(OperationResultType.Failed, evtMsgs);
        }

        // Only publish success notification if save succeeded
        scope.Notifications.Publish(
            new ContentRolledBackNotification(content, evtMsgs).WithStateFrom(rollingBackNotification));

        // Logging & Audit
        _logger.LogInformation("User '{UserId}' rolled back content '{ContentId}' to version '{VersionId}'", userId, content.Id, version.VersionId);
        Audit(AuditType.RollBack, userId, content.Id, $"Content '{content.Name}' was rolled back to version '{version.VersionId}'");

        scope.Complete();

        return OperationResult.Succeed(evtMsgs);
    }

    #endregion

    #region Version Deletion

    /// <inheritdoc />
    public void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId)
    {
        EventMessages evtMsgs = EventMessagesFactory.Get();

        using ICoreScope scope = ScopeProvider.CreateCoreScope();

        var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: versionDate);
        if (scope.Notifications.PublishCancelable(deletingVersionsNotification))
        {
            scope.Complete();
            return;
        }

        scope.WriteLock(Constants.Locks.ContentTree);
        DocumentRepository.DeleteVersions(id, versionDate);

        scope.Notifications.Publish(
            new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: versionDate).WithStateFrom(deletingVersionsNotification));
        Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version date)");

        scope.Complete();
    }

    /// <inheritdoc />
    public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = Constants.Security.SuperUserId)
    {
        EventMessages evtMsgs = EventMessagesFactory.Get();

        using ICoreScope scope = ScopeProvider.CreateCoreScope();

        // v1.2 Fix (Issue 3.1): Acquire WriteLock once at the start instead of multiple times.
        // This simplifies the code and avoids the read→write lock upgrade pattern.
        scope.WriteLock(Constants.Locks.ContentTree);

        var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, versionId);
        if (scope.Notifications.PublishCancelable(deletingVersionsNotification))
        {
            scope.Complete();
            return;
        }

        // v1.2 Fix (Issue 2.2): Preserve original double-notification behavior for deletePriorVersions.
        // The original implementation called DeleteVersions() which fired its own notifications.
        // We inline the notification firing to maintain backward compatibility.
        // v1.3 Fix (Issue 3.6): Clarification - if prior versions deletion is cancelled, we still
        // proceed with deleting the specific version. This matches original ContentService behavior.
        if (deletePriorVersions)
        {
            IContent? versionContent = DocumentRepository.GetVersion(versionId);
            DateTime cutoffDate = versionContent?.UpdateDate ?? DateTime.UtcNow;

            // Publish notifications for prior versions (matching original behavior)
            var priorVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: cutoffDate);
            if (!scope.Notifications.PublishCancelable(priorVersionsNotification))
            {
                DocumentRepository.DeleteVersions(id, cutoffDate);
                scope.Notifications.Publish(
                    new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: cutoffDate)
                        .WithStateFrom(priorVersionsNotification));

                // v1.3 Fix (Issue 3.3): Add audit entry for prior versions deletion.
                // The original DeleteVersions() method created its own audit entry.
                Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version date)");
            }
        }

        IContent? c = DocumentRepository.Get(id);

        // Don't delete the current or published version
        if (c?.VersionId != versionId && c?.PublishedVersionId != versionId)
        {
            DocumentRepository.DeleteVersion(versionId);
        }

        scope.Notifications.Publish(
            new ContentDeletedVersionsNotification(id, evtMsgs, versionId).WithStateFrom(deletingVersionsNotification));
        Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version)");

        scope.Complete();
    }

    #endregion
}

Step 2: Run build to verify implementation compiles

Run: dotnet build src/Umbraco.Core --no-restore Expected: Build succeeds

Step 3: Commit

git add src/Umbraco.Core/Services/ContentVersionOperationService.cs
git commit -m "$(cat <<'EOF'
refactor(core): add ContentVersionOperationService implementation

Part of ContentService refactoring Phase 3.
Implements version retrieval, rollback, and version deletion operations.

v1.1 fixes applied:
- Consolidated Rollback into single scope (Issue 2.1)
- Added error handling to Rollback (Issue 2.2)
- Added ReadLock to GetVersionIds (Issue 2.3)
- Fixed DeleteVersion nested scope (Issue 2.4)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 3: Register Service in DI Container

Files:

  • Modify: src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs

Step 1: Add service registration

Locate the line:

Services.AddUnique<IContentQueryOperationService, ContentQueryOperationService>();

Add after it:

Services.AddUnique<IContentVersionOperationService, ContentVersionOperationService>();

Step 2: Update ContentService factory registration

Find the IContentService factory registration (starts with Services.AddUnique<IContentService>(sp =>).

Add the new parameter to the constructor call:

sp.GetRequiredService<IContentVersionOperationService>()

The updated registration should look like:

Services.AddUnique<IContentService>(sp =>
    new ContentService(
        sp.GetRequiredService<ICoreScopeProvider>(),
        sp.GetRequiredService<ILoggerFactory>(),
        sp.GetRequiredService<IEventMessagesFactory>(),
        sp.GetRequiredService<IDocumentRepository>(),
        sp.GetRequiredService<IEntityRepository>(),
        sp.GetRequiredService<IAuditService>(),
        sp.GetRequiredService<IContentTypeRepository>(),
        sp.GetRequiredService<IDocumentBlueprintRepository>(),
        sp.GetRequiredService<ILanguageRepository>(),
        sp.GetRequiredService<Lazy<IPropertyValidationService>>(),
        sp.GetRequiredService<IShortStringHelper>(),
        sp.GetRequiredService<ICultureImpactFactory>(),
        sp.GetRequiredService<IUserIdKeyResolver>(),
        sp.GetRequiredService<PropertyEditorCollection>(),
        sp.GetRequiredService<IIdKeyMap>(),
        sp.GetRequiredService<IOptionsMonitor<ContentSettings>>(),
        sp.GetRequiredService<IRelationService>(),
        sp.GetRequiredService<IContentCrudService>(),
        sp.GetRequiredService<IContentQueryOperationService>(),
        sp.GetRequiredService<IContentVersionOperationService>()));  // NEW

Step 3: Run build to verify registration

Run: dotnet build src/Umbraco.Core --no-restore Expected: Build fails because ContentService doesn't have the new constructor parameter yet (expected)

Step 4: Commit

git add src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs
git commit -m "$(cat <<'EOF'
refactor(core): register IContentVersionOperationService in DI

Part of ContentService refactoring Phase 3.
Adds service registration and updates ContentService factory.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 4: Add VersionOperationService Property to ContentService

Files:

  • Modify: src/Umbraco.Core/Services/ContentService.cs

Step 1: Add private fields for the service

After the existing _queryOperationServiceLazy field declaration, add:

// Version operation service fields (for Phase 3 extracted version operations)
private readonly IContentVersionOperationService? _versionOperationService;
private readonly Lazy<IContentVersionOperationService>? _versionOperationServiceLazy;

Step 2: Add property accessor

After the QueryOperationService property, add:

/// <summary>
/// Gets the version operation service.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the service was not properly initialized.</exception>
private IContentVersionOperationService VersionOperationService =>
    _versionOperationService ?? _versionOperationServiceLazy?.Value
    ?? throw new InvalidOperationException("VersionOperationService not initialized. Ensure the service is properly injected via constructor.");

Step 3: Update the primary constructor

Find the constructor marked with [ActivatorUtilitiesConstructor].

Add the new parameter:

IContentVersionOperationService versionOperationService)  // NEW PARAMETER - Phase 3 version operations

In the constructor body, add:

// Phase 3: Version operation service (direct injection)
ArgumentNullException.ThrowIfNull(versionOperationService);
_versionOperationService = versionOperationService;
_versionOperationServiceLazy = null;  // Not needed when directly injected

Step 4: Update the obsolete constructors

Find the obsolete constructors (marked with [Obsolete]). Add lazy resolution for the new service:

// Phase 3: Lazy resolution of IContentVersionOperationService
_versionOperationServiceLazy = new Lazy<IContentVersionOperationService>(() =>
    StaticServiceProvider.Instance.GetRequiredService<IContentVersionOperationService>(),
    LazyThreadSafetyMode.ExecutionAndPublication);

Step 5: Run build to verify

Run: dotnet build src/Umbraco.Core --no-restore Expected: Build succeeds

Step 6: Commit

git add src/Umbraco.Core/Services/ContentService.cs
git commit -m "$(cat <<'EOF'
refactor(core): add VersionOperationService property to ContentService

Part of ContentService refactoring Phase 3.
Adds constructor parameter and property for version operations delegation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 5: Delegate Version Retrieval Methods

Files:

  • Modify: src/Umbraco.Core/Services/ContentService.cs

Step 1: Delegate GetVersion

Find the GetVersion method (around line 572). Replace the implementation with:

public IContent? GetVersion(int versionId)
    => VersionOperationService.GetVersion(versionId);

Step 2: Delegate GetVersions

Find the GetVersions method (around line 586). Replace the implementation with:

public IEnumerable<IContent> GetVersions(int id)
    => VersionOperationService.GetVersions(id);

Step 3: Delegate GetVersionsSlim

Find the GetVersionsSlim method (around line 599). Replace the implementation with:

public IEnumerable<IContent> GetVersionsSlim(int id, int skip, int take)
    => VersionOperationService.GetVersionsSlim(id, skip, take);

Step 4: Delegate GetVersionIds

Find the GetVersionIds method (around line 614). Replace the implementation with:

public IEnumerable<int> GetVersionIds(int id, int maxRows)
    => VersionOperationService.GetVersionIds(id, maxRows);

Step 5: Run build to verify

Run: dotnet build src/Umbraco.Core --no-restore Expected: Build succeeds

Step 6: Run tests to verify behavior unchanged

Run: dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentService" --no-restore Expected: All tests pass

Step 7: Commit

git add src/Umbraco.Core/Services/ContentService.cs
git commit -m "$(cat <<'EOF'
refactor(core): delegate version retrieval methods to VersionOperationService

Part of ContentService refactoring Phase 3.
Delegates GetVersion, GetVersions, GetVersionsSlim, GetVersionIds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 6: Delegate Rollback Method

Files:

  • Modify: src/Umbraco.Core/Services/ContentService.cs

Step 1: Update Rollback to delegate to VersionOperationService

Find the Rollback method in the #region Rollback section (around line 243).

Note: The current Rollback implementation calls GetById and Save from the current ContentService. The VersionOperationService implementation must work independently. Review the implementation in Task 2 which directly uses DocumentRepository.

Replace the entire Rollback method with:

public OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId)
    => VersionOperationService.Rollback(id, versionId, culture, userId);

Step 2: Run build to verify

Run: dotnet build src/Umbraco.Core --no-restore Expected: Build succeeds

Step 3: Run tests to verify rollback behavior

Run: dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentService" --no-restore Expected: All tests pass

Step 4: Commit

git add src/Umbraco.Core/Services/ContentService.cs
git commit -m "$(cat <<'EOF'
refactor(core): delegate Rollback to VersionOperationService

Part of ContentService refactoring Phase 3.
Notification ordering preserved: RollingBack -> RolledBack.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 7: Delegate Version Deletion Methods

Files:

  • Modify: src/Umbraco.Core/Services/ContentService.cs

Step 1: Delegate DeleteVersions

Find the DeleteVersions method (around line 1978). Replace the implementation with:

public void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId)
    => VersionOperationService.DeleteVersions(id, versionDate, userId);

Step 2: Delegate DeleteVersion

Find the DeleteVersion method (around line 2012). Replace the implementation with:

public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = Constants.Security.SuperUserId)
    => VersionOperationService.DeleteVersion(id, versionId, deletePriorVersions, userId);

Step 3: Run build to verify

Run: dotnet build src/Umbraco.Core --no-restore Expected: Build succeeds

Step 4: Run full ContentService tests

Run: dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentService" --no-restore Expected: All tests pass

Step 5: Commit

git add src/Umbraco.Core/Services/ContentService.cs
git commit -m "$(cat <<'EOF'
refactor(core): delegate DeleteVersions and DeleteVersion to VersionOperationService

Part of ContentService refactoring Phase 3.
Completes version operations extraction.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 8: Create Integration Tests for ContentVersionOperationService

Files:

  • Create: tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentVersionOperationServiceTests.cs

Step 1: Create test file with comprehensive tests

v1.1 Changes Applied:

  • Issue 2.5: Replaced Thread.Sleep with deterministic date comparison
  • Issue 3.2: Added Rollback_WhenNotificationCancelled_ReturnsCancelledResult test
  • Issue 3.3: Added DeleteVersion_PublishedVersion_DoesNotDelete test
// tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentVersionOperationServiceTests.cs
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentPublishing;
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;

[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
public class ContentVersionOperationServiceTests : UmbracoIntegrationTest
{
    private IContentVersionOperationService VersionOperationService => GetRequiredService<IContentVersionOperationService>();
    private IContentService ContentService => GetRequiredService<IContentService>();
    private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
    private IContentPublishingService ContentPublishingService => GetRequiredService<IContentPublishingService>();

    // v1.2 Fix (Issue 3.2): Use CustomTestSetup to register notification handlers
    protected override void CustomTestSetup(IUmbracoBuilder builder)
        => builder.AddNotificationHandler<ContentRollingBackNotification, VersionNotificationHandler>();

    private IContentType CreateContentType()
    {
        var contentType = new ContentTypeBuilder()
            .WithAlias("testContentType")
            .WithName("Test Content Type")
            .AddPropertyType()
                .WithAlias("title")
                .WithName("Title")
                .WithDataTypeId(Constants.DataTypes.Textbox)
                .Done()
            .Build();
        ContentTypeService.Save(contentType);
        return contentType;
    }

    private IContent CreateAndSaveContent(IContentType contentType, string name = "Test Content")
    {
        var content = new ContentBuilder()
            .WithContentType(contentType)
            .WithName(name)
            .Build();
        ContentService.Save(content);
        return content;
    }

    #region GetVersion Tests

    [Test]
    public void GetVersion_ExistingVersion_ReturnsContent()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);
        var versionId = content.VersionId;

        // Act
        var result = VersionOperationService.GetVersion(versionId);

        // Assert
        Assert.That(result, Is.Not.Null);
        Assert.That(result!.Id, Is.EqualTo(content.Id));
    }

    [Test]
    public void GetVersion_NonExistentVersion_ReturnsNull()
    {
        // Act
        var result = VersionOperationService.GetVersion(999999);

        // Assert
        Assert.That(result, Is.Null);
    }

    #endregion

    #region GetVersions Tests

    [Test]
    public void GetVersions_ContentWithMultipleVersions_ReturnsAllVersions()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);

        // Create additional versions
        content.SetValue("title", "Version 2");
        ContentService.Save(content);
        content.SetValue("title", "Version 3");
        ContentService.Save(content);

        // Act
        var versions = VersionOperationService.GetVersions(content.Id).ToList();

        // Assert
        Assert.That(versions.Count, Is.GreaterThanOrEqualTo(3));
    }

    [Test]
    public void GetVersions_NonExistentContent_ReturnsEmpty()
    {
        // Act
        var versions = VersionOperationService.GetVersions(999999).ToList();

        // Assert
        Assert.That(versions, Is.Empty);
    }

    #endregion

    #region GetVersionsSlim Tests

    [Test]
    public void GetVersionsSlim_ReturnsPagedVersions()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);

        // Create additional versions
        for (int i = 2; i <= 5; i++)
        {
            content.SetValue("title", $"Version {i}");
            ContentService.Save(content);
        }

        // Act
        var versions = VersionOperationService.GetVersionsSlim(content.Id, skip: 1, take: 2).ToList();

        // Assert
        Assert.That(versions.Count, Is.EqualTo(2));
    }

    #endregion

    #region GetVersionIds Tests

    [Test]
    public void GetVersionIds_ReturnsVersionIdsOrderedByLatestFirst()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);
        var firstVersionId = content.VersionId;

        content.SetValue("title", "Version 2");
        ContentService.Save(content);
        var secondVersionId = content.VersionId;

        // Act
        var versionIds = VersionOperationService.GetVersionIds(content.Id, maxRows: 10).ToList();

        // Assert
        Assert.That(versionIds.Count, Is.GreaterThanOrEqualTo(2));
        Assert.That(versionIds[0], Is.EqualTo(secondVersionId)); // Latest first
    }

    #endregion

    #region Rollback Tests

    [Test]
    public void Rollback_ToEarlierVersion_RestoresPropertyValues()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);
        content.SetValue("title", "Original Value");
        ContentService.Save(content);
        var originalVersionId = content.VersionId;

        content.SetValue("title", "Changed Value");
        ContentService.Save(content);

        // Act
        var result = VersionOperationService.Rollback(content.Id, originalVersionId);

        // Assert
        Assert.That(result.Success, Is.True);
        var rolledBackContent = ContentService.GetById(content.Id);
        Assert.That(rolledBackContent!.GetValue<string>("title"), Is.EqualTo("Original Value"));
    }

    [Test]
    public void Rollback_NonExistentContent_Fails()
    {
        // Act
        var result = VersionOperationService.Rollback(999999, 1);

        // Assert
        Assert.That(result.Success, Is.False);
        Assert.That(result.Result, Is.EqualTo(OperationResultType.FailedCannot));
    }

    [Test]
    public void Rollback_TrashedContent_Fails()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);
        var versionId = content.VersionId;
        ContentService.MoveToRecycleBin(content);

        // Act
        var result = VersionOperationService.Rollback(content.Id, versionId);

        // Assert
        Assert.That(result.Success, Is.False);
        Assert.That(result.Result, Is.EqualTo(OperationResultType.FailedCannot));
    }

    /// <summary>
    /// v1.2 Fix (Issue 3.2): Test that cancellation notification works correctly.
    /// Uses the correct integration test pattern with CustomTestSetup and static action.
    /// </summary>
    [Test]
    public void Rollback_WhenNotificationCancelled_ReturnsCancelledResult()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);
        content.SetValue("title", "Original Value");
        ContentService.Save(content);
        var originalVersionId = content.VersionId;

        content.SetValue("title", "Changed Value");
        ContentService.Save(content);

        // Set up the notification handler to cancel the rollback
        VersionNotificationHandler.RollingBackContent = notification => notification.Cancel = true;

        try
        {
            // Act
            var result = VersionOperationService.Rollback(content.Id, originalVersionId);

            // Assert
            Assert.That(result.Success, Is.False);
            Assert.That(result.Result, Is.EqualTo(OperationResultType.Cancelled));

            // Verify content was not modified
            var unchangedContent = ContentService.GetById(content.Id);
            Assert.That(unchangedContent!.GetValue<string>("title"), Is.EqualTo("Changed Value"));
        }
        finally
        {
            // Clean up the static action
            VersionNotificationHandler.RollingBackContent = null;
        }
    }

    #endregion

    #region DeleteVersions Tests

    /// <summary>
    /// v1.1 Fix (Issue 2.5): Use deterministic date comparison instead of Thread.Sleep.
    /// </summary>
    [Test]
    public void DeleteVersions_ByDate_DeletesOlderVersions()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);
        var firstVersionId = content.VersionId;

        content.SetValue("title", "Version 2");
        ContentService.Save(content);

        // Get the actual update date of version 2 for deterministic comparison
        var version2 = VersionOperationService.GetVersion(content.VersionId);
        var cutoffDate = version2!.UpdateDate.AddMilliseconds(1);

        content.SetValue("title", "Version 3");
        ContentService.Save(content);
        var version3Id = content.VersionId;

        var versionCountBefore = VersionOperationService.GetVersions(content.Id).Count();

        // Act
        VersionOperationService.DeleteVersions(content.Id, cutoffDate);

        // Assert
        var remainingVersions = VersionOperationService.GetVersions(content.Id).ToList();
        Assert.That(remainingVersions.Any(v => v.VersionId == version3Id), Is.True, "Current version should remain");
        Assert.That(remainingVersions.Count, Is.LessThan(versionCountBefore));
    }

    #endregion

    #region DeleteVersion Tests

    [Test]
    public void DeleteVersion_SpecificVersion_DeletesOnlyThatVersion()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);

        content.SetValue("title", "Version 2");
        ContentService.Save(content);
        var versionToDelete = content.VersionId;

        content.SetValue("title", "Version 3");
        ContentService.Save(content);
        var currentVersionId = content.VersionId;

        // Act
        VersionOperationService.DeleteVersion(content.Id, versionToDelete, deletePriorVersions: false);

        // Assert
        var deletedVersion = VersionOperationService.GetVersion(versionToDelete);
        Assert.That(deletedVersion, Is.Null);
        var currentVersion = VersionOperationService.GetVersion(currentVersionId);
        Assert.That(currentVersion, Is.Not.Null);
    }

    [Test]
    public void DeleteVersion_CurrentVersion_DoesNotDelete()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);
        var currentVersionId = content.VersionId;

        // Act
        VersionOperationService.DeleteVersion(content.Id, currentVersionId, deletePriorVersions: false);

        // Assert
        var version = VersionOperationService.GetVersion(currentVersionId);
        Assert.That(version, Is.Not.Null); // Should not be deleted
    }

    /// <summary>
    /// v1.2 Fix (Issue 3.3, 3.4): Test that published version is protected from deletion.
    /// Uses the correct async ContentPublishingService.PublishAsync method.
    /// </summary>
    [Test]
    public async Task DeleteVersion_PublishedVersion_DoesNotDelete()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);

        // v1.2 Fix (Issue 3.4): Use ContentPublishingService.PublishAsync with correct signature
        var publishResult = await ContentPublishingService.PublishAsync(
            content.Key,
            new[] { new CulturePublishScheduleModel() },
            Constants.Security.SuperUserKey);
        Assert.That(publishResult.Success, Is.True, "Publish should succeed");

        // Refresh content to get the published version id
        content = ContentService.GetById(content.Id)!;
        var publishedVersionId = content.PublishedVersionId;
        Assert.That(publishedVersionId, Is.Not.Null, "Content should have a published version");

        // Create a newer draft version
        content.SetValue("title", "Draft");
        ContentService.Save(content);

        // Act
        VersionOperationService.DeleteVersion(content.Id, publishedVersionId!.Value, deletePriorVersions: false);

        // Assert
        var version = VersionOperationService.GetVersion(publishedVersionId!.Value);
        Assert.That(version, Is.Not.Null, "Published version should not be deleted");
    }

    #endregion

    #region Behavioral Equivalence Tests

    [Test]
    public void GetVersion_ViaService_MatchesContentService()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);
        var versionId = content.VersionId;

        // Act
        var viaService = VersionOperationService.GetVersion(versionId);
        var viaContentService = ContentService.GetVersion(versionId);

        // Assert
        Assert.That(viaService?.Id, Is.EqualTo(viaContentService?.Id));
        Assert.That(viaService?.VersionId, Is.EqualTo(viaContentService?.VersionId));
    }

    [Test]
    public void GetVersions_ViaService_MatchesContentService()
    {
        // Arrange
        var contentType = CreateContentType();
        var content = CreateAndSaveContent(contentType);
        content.SetValue("title", "Version 2");
        ContentService.Save(content);

        // Act
        var viaService = VersionOperationService.GetVersions(content.Id).ToList();
        var viaContentService = ContentService.GetVersions(content.Id).ToList();

        // Assert
        Assert.That(viaService.Count, Is.EqualTo(viaContentService.Count));
    }

    #endregion

    #region Notification Handler

    /// <summary>
    /// v1.2 Fix (Issue 3.2): Notification handler for testing using the correct integration test pattern.
    /// Uses static actions that can be set in individual tests.
    /// </summary>
    private class VersionNotificationHandler : INotificationHandler<ContentRollingBackNotification>
    {
        public static Action<ContentRollingBackNotification>? RollingBackContent { get; set; }

        public void Handle(ContentRollingBackNotification notification)
            => RollingBackContent?.Invoke(notification);
    }

    #endregion
}

Step 2: Run the new tests

Run: dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentVersionOperationServiceTests" --no-restore Expected: All tests pass

Step 3: Commit

git add tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentVersionOperationServiceTests.cs
git commit -m "$(cat <<'EOF'
test(integration): add ContentVersionOperationServiceTests

Part of ContentService refactoring Phase 3.
Covers version retrieval, rollback, and version deletion.

v1.1 fixes applied:
- Deterministic date comparison instead of Thread.Sleep (Issue 2.5)
- Added Rollback cancellation test (Issue 3.2)
- Added published version protection test (Issue 3.3)

v1.2 fixes applied:
- Fixed notification handler registration pattern (Issue 3.2)
- Fixed Publish method signature using ContentPublishingService (Issue 3.4)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 9: Run Phase Gate Tests

Step 1: Run ContentService refactoring tests

Run: dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentServiceRefactoringTests" --no-restore Expected: All tests pass

Step 2: Run all ContentService tests

Run: dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentService" --no-restore Expected: All tests pass (or same count as baseline)

Step 3: Run full integration test suite

Run: dotnet test tests/Umbraco.Tests.Integration --no-restore Expected: All tests pass (or same count as baseline)

Step 4: Verify build with no errors

Run: dotnet build src/Umbraco.Core --no-restore Expected: Build succeeds with no errors


Task 10: Update Design Document and Create Git Tag

Files:

  • Modify: docs/plans/2025-12-19-contentservice-refactor-design.md

Step 1: Update Phase 3 status in design document

Find the Implementation Order table and update Phase 3 row:

Change:

| 3 | Version Service | All ContentService*Tests | All pass | Pending |

To:

| 3 | Version Service | All ContentService*Tests | All pass | ✅ Complete |

Step 2: Add Phase 3 completion notes

Add after the Phase 2 notes in the Phase Details section:

4. **Phase 3: Version Service** ✅ - Complete! Created:
   - `IContentVersionOperationService.cs` - Interface (7 methods)
   - `ContentVersionOperationService.cs` - Implementation
   - Updated `ContentService.cs` to delegate version operations
   - Git tag: `phase-3-version-extraction`

Step 3: Commit design document update

git add docs/plans/2025-12-19-contentservice-refactor-design.md
git commit -m "$(cat <<'EOF'
docs: mark Phase 3 complete in design document

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Step 4: Create git tag

git tag -a phase-3-version-extraction -m "Phase 3: Version operations extracted to ContentVersionOperationService"

Task Summary

Task Description Files
1 Create IContentVersionOperationService interface IContentVersionOperationService.cs
2 Create ContentVersionOperationService implementation ContentVersionOperationService.cs
3 Register service in DI container UmbracoBuilder.cs
4 Add VersionOperationService property to ContentService ContentService.cs
5 Delegate version retrieval methods ContentService.cs
6 Delegate Rollback method ContentService.cs
7 Delegate version deletion methods ContentService.cs
8 Create integration tests ContentVersionOperationServiceTests.cs
9 Run phase gate tests N/A
10 Update design document and create tag design.md

v1.1 Changes Summary

Issue Severity Description Fix Applied
2.1 Critical Rollback TOCTOU race condition Consolidated into single scope
2.2 Critical Rollback missing error handling Added try-catch with logging
2.3 Important GetVersionIds missing ReadLock Added ReadLock
2.4 Critical DeleteVersion nested scope Use repository directly
2.5 Important Thread.Sleep in tests Deterministic date comparison
3.2 Minor Missing cancellation test Added test
3.3 Minor Missing published version test Added test
3.4 Minor Interface docs incomplete Enhanced GetVersionIds docs

v1.2 Changes Summary

Issue Severity Description Fix Applied
2.1 Critical Rollback bypasses ContentSaving/ContentSaved notifications Use CrudService.Save to preserve notifications
2.2 Important DeleteVersion with deletePriorVersions changes notification semantics Inline notification firing to preserve double-notification
3.1 Minor Redundant WriteLock acquisition in DeleteVersion Single WriteLock at start
3.2 Minor Test notification registration may not compile Use CustomTestSetup with static action pattern
3.3 Minor Missing IContentCrudService constructor dependency Added to constructor
3.4 Minor Publish method signature incorrect in test Use ContentPublishingService.PublishAsync

v1.3 Changes Summary

Issue Severity Description Fix Applied
3.1 Should Fix GetVersionIds missing input validation Added ArgumentOutOfRangeException for maxRows <= 0
3.2 Consider Redundant WriteLock in Rollback Removed explicit WriteLock (CrudService.Save handles locking)
3.3 Should Fix Audit gap in DeleteVersion with deletePriorVersions Added audit entry for prior versions deletion
3.4 Must Fix Rollback return type mismatch Fixed OperationResult to OperationResult
3.6 Consider Prior versions cancellation behavior unclear Added clarifying comment

Rollback Procedure

If tests fail during Phase 3:

  1. Revert all Phase 3 commits:

    git revert HEAD~N..HEAD  # Where N is number of Phase 3 commits
    
  2. Or reset to Phase 2 tag:

    git reset --hard phase-2-query-extraction
    
  3. Document the failure:

    • Which tests failed
    • What behavior changed
    • Root cause analysis
  4. Create fix and retry:

    • Fix the identified issue
    • Re-run all tasks from that point

Success Criteria

  • All 7 version methods delegated from ContentService
  • IContentVersionOperationService interface created with documentation
  • ContentVersionOperationService implementation with proper scoping
  • DI registration complete
  • All ContentService tests pass
  • Integration tests for new service pass
  • Design document updated with Phase 3 completion
  • Git tag phase-3-version-extraction created