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>
1558 lines
58 KiB
Markdown
1558 lines
58 KiB
Markdown
# 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:
|
|
|
|
6. **Issue 3.2 - Cancellation Test**: Added `Rollback_WhenNotificationCancelled_ReturnsCancelledResult` test.
|
|
|
|
7. **Issue 3.3 - Published Version Protection Test**: Added `DeleteVersion_PublishedVersion_DoesNotDelete` test.
|
|
|
|
8. **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:
|
|
|
|
3. **Issue 3.1 - Simplified Lock Acquisition**: Restructured DeleteVersion to acquire WriteLock once at the start instead of multiple times, avoiding lock upgrade pattern.
|
|
|
|
4. **Issue 3.2 - Test Notification Registration**: Fixed the cancellation test to use the correct integration test pattern with `CustomTestSetup` and notification handler registration.
|
|
|
|
5. **Issue 3.3 - CrudService Dependency**: Added `IContentCrudService` as a constructor dependency to support proper save operation in Rollback.
|
|
|
|
6. **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:
|
|
|
|
2. **Issue 3.1 - GetVersionIds Input Validation**: Added `ArgumentOutOfRangeException` for `maxRows <= 0` to match interface documentation contract.
|
|
|
|
3. **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:
|
|
|
|
4. **Issue 3.2 - Redundant WriteLock in Rollback**: Removed explicit `WriteLock` before `CrudService.Save` since CrudService handles its own locking internally.
|
|
|
|
5. **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**
|
|
|
|
```csharp
|
|
// 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**
|
|
|
|
```bash
|
|
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<OperationResultType> to OperationResult
|
|
> - Issue 3.6: Added clarifying comment for prior versions cancellation behavior
|
|
|
|
```csharp
|
|
// 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**
|
|
|
|
```bash
|
|
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:
|
|
```csharp
|
|
Services.AddUnique<IContentQueryOperationService, ContentQueryOperationService>();
|
|
```
|
|
|
|
Add after it:
|
|
```csharp
|
|
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:
|
|
```csharp
|
|
sp.GetRequiredService<IContentVersionOperationService>()
|
|
```
|
|
|
|
The updated registration should look like:
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```csharp
|
|
// 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:
|
|
|
|
```csharp
|
|
/// <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:
|
|
```csharp
|
|
IContentVersionOperationService versionOperationService) // NEW PARAMETER - Phase 3 version operations
|
|
```
|
|
|
|
In the constructor body, add:
|
|
```csharp
|
|
// 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:
|
|
|
|
```csharp
|
|
// 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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```csharp
|
|
public IContent? GetVersion(int versionId)
|
|
=> VersionOperationService.GetVersion(versionId);
|
|
```
|
|
|
|
**Step 2: Delegate GetVersions**
|
|
|
|
Find the `GetVersions` method (around line 586). Replace the implementation with:
|
|
|
|
```csharp
|
|
public IEnumerable<IContent> GetVersions(int id)
|
|
=> VersionOperationService.GetVersions(id);
|
|
```
|
|
|
|
**Step 3: Delegate GetVersionsSlim**
|
|
|
|
Find the `GetVersionsSlim` method (around line 599). Replace the implementation with:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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
|
|
|
|
```csharp
|
|
// 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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```markdown
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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<OperationResultType> 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:**
|
|
```bash
|
|
git revert HEAD~N..HEAD # Where N is number of Phase 3 commits
|
|
```
|
|
|
|
2. **Or reset to Phase 2 tag:**
|
|
```bash
|
|
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
|