diff --git a/OrchardCore.slnx b/OrchardCore.slnx index 6d7f2c617f1..e09406755dd 100644 --- a/OrchardCore.slnx +++ b/OrchardCore.slnx @@ -77,6 +77,7 @@ + diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/AdminMenu.cs new file mode 100644 index 00000000000..dafab7aab41 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/AdminMenu.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using OrchardCore.Contents.VersionPruning.Drivers; +using OrchardCore.Navigation; + +namespace OrchardCore.Contents.VersionPruning; + +public sealed class AdminMenu : AdminNavigationProvider +{ + private static readonly RouteValueDictionary _routeValues = new() + { + { "area", "OrchardCore.Settings" }, + { "groupId", ContentVersionPruningSettingsDisplayDriver.GroupId }, + }; + + internal readonly IStringLocalizer S; + + public AdminMenu(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + protected override ValueTask BuildAsync(NavigationBuilder builder) + { + if (NavigationHelper.UseLegacyFormat()) + { + builder + .Add(S["Configuration"], configuration => configuration + .Add(S["Settings"], settings => settings + .Add(S["Content Version Pruning"], S["Content Version Pruning"], pruning => pruning + .Action("Index", "Admin", _routeValues) + .Permission(ContentVersionPruningPermissions.ManageContentVersionPruningSettings) + .LocalNav() + ) + ) + ); + + return ValueTask.CompletedTask; + } + + builder + .Add(S["Settings"], settings => settings + .Add(S["Content Version Pruning"], S["Content Version Pruning"].PrefixPosition(), pruning => pruning + .Action("Index", "Admin", _routeValues) + .Permission(ContentVersionPruningPermissions.ManageContentVersionPruningSettings) + .LocalNav() + ) + ); + + return ValueTask.CompletedTask; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/ContentVersionPruningPermissions.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/ContentVersionPruningPermissions.cs new file mode 100644 index 00000000000..b681e428707 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/ContentVersionPruningPermissions.cs @@ -0,0 +1,10 @@ +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Contents.VersionPruning; + +public static class ContentVersionPruningPermissions +{ + public static readonly Permission ManageContentVersionPruningSettings = new( + "ManageContentVersionPruningSettings", + "Manage Content Version Pruning settings"); +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Controllers/AdminController.cs new file mode 100644 index 00000000000..a792f01200e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Controllers/AdminController.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using OrchardCore.Contents.VersionPruning.Drivers; +using OrchardCore.Contents.VersionPruning.Models; +using OrchardCore.Contents.VersionPruning.Services; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Entities; +using OrchardCore.Modules; +using OrchardCore.Settings; + +namespace OrchardCore.Contents.VersionPruning.Controllers; + +public sealed class AdminController : Controller +{ + private readonly IAuthorizationService _authorizationService; + private readonly IContentVersionPruningService _pruningService; + private readonly ISiteService _siteService; + private readonly IClock _clock; + private readonly INotifier _notifier; + + internal readonly IHtmlLocalizer H; + + public AdminController( + IAuthorizationService authorizationService, + IContentVersionPruningService pruningService, + ISiteService siteService, + IClock clock, + INotifier notifier, + IHtmlLocalizer htmlLocalizer) + { + _authorizationService = authorizationService; + _pruningService = pruningService; + _siteService = siteService; + _clock = clock; + _notifier = notifier; + H = htmlLocalizer; + } + + [HttpPost] + public async Task Prune() + { + if (!await _authorizationService.AuthorizeAsync(User, ContentVersionPruningPermissions.ManageContentVersionPruningSettings)) + { + return Forbid(); + } + + var settings = await _siteService.GetSettingsAsync(); + + var pruned = await _pruningService.PruneVersionsAsync(settings); + + var container = await _siteService.LoadSiteSettingsAsync(); + container.Alter(nameof(ContentVersionPruningSettings), settings => + { + settings.LastRunUtc = _clock.UtcNow; + }); + + await _siteService.UpdateSiteSettingsAsync(container); + + await _notifier.SuccessAsync(H["Content version pruning completed. {0} version(s) deleted.", pruned]); + + return RedirectToAction("Index", "Admin", new + { + area = "OrchardCore.Settings", + groupId = ContentVersionPruningSettingsDisplayDriver.GroupId, + }); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Drivers/ContentVersionPruningSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Drivers/ContentVersionPruningSettingsDisplayDriver.cs new file mode 100644 index 00000000000..fd1570813a6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Drivers/ContentVersionPruningSettingsDisplayDriver.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using OrchardCore.Contents.VersionPruning.Models; +using OrchardCore.Contents.VersionPruning.ViewModels; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Settings; + +namespace OrchardCore.Contents.VersionPruning.Drivers; + +public sealed class ContentVersionPruningSettingsDisplayDriver : SiteDisplayDriver +{ + public const string GroupId = "ContentVersionPruningSettings"; + + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + + public ContentVersionPruningSettingsDisplayDriver( + IAuthorizationService authorizationService, + IHttpContextAccessor httpContextAccessor) + { + _authorizationService = authorizationService; + _httpContextAccessor = httpContextAccessor; + } + + protected override string SettingsGroupId => GroupId; + + public override async Task EditAsync(ISite site, ContentVersionPruningSettings settings, BuildEditorContext context) + { + if (!await _authorizationService.AuthorizeAsync( + _httpContextAccessor.HttpContext?.User, + ContentVersionPruningPermissions.ManageContentVersionPruningSettings)) + { + return null; + } + + return Initialize("ContentVersionPruningSettings_Edit", model => + { + model.RetentionDays = settings.RetentionDays; + model.VersionsToKeep = settings.VersionsToKeep; + model.Disabled = settings.Disabled; + model.ContentTypes = settings.ContentTypes; + model.LastRunUtc = settings.LastRunUtc; + }).Location("Content:5") + .OnGroup(GroupId); + } + + public override async Task UpdateAsync(ISite site, ContentVersionPruningSettings settings, UpdateEditorContext context) + { + if (!await _authorizationService.AuthorizeAsync( + _httpContextAccessor.HttpContext?.User, + ContentVersionPruningPermissions.ManageContentVersionPruningSettings)) + { + return null; + } + + var model = new ContentVersionPruningSettingsViewModel(); + await context.Updater.TryUpdateModelAsync(model, Prefix); + + settings.RetentionDays = model.RetentionDays; + settings.VersionsToKeep = model.VersionsToKeep; + settings.Disabled = model.Disabled; + settings.ContentTypes = model.ContentTypes ?? []; + + return await EditAsync(site, settings, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Manifest.cs new file mode 100644 index 00000000000..65d559682f8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Manifest.cs @@ -0,0 +1,11 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "Content Version Pruning", + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion, + Description = "Provides a background task to prune old content item versions.", + Dependencies = ["OrchardCore.Contents"], + Category = "Content Management" +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Models/ContentVersionPruningSettings.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Models/ContentVersionPruningSettings.cs new file mode 100644 index 00000000000..125df0b9934 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Models/ContentVersionPruningSettings.cs @@ -0,0 +1,30 @@ +namespace OrchardCore.Contents.VersionPruning.Models; + +public class ContentVersionPruningSettings +{ + /// + /// The number of days after which non-latest, non-published content item versions are deleted. + /// + public int RetentionDays { get; set; } = 30; + + /// + /// The number of the most-recent archived (non-latest, non-published) versions to retain + /// per content item, regardless of age. + /// + public int VersionsToKeep { get; set; } = 1; + + /// + /// The content types to prune. When empty, all content types are pruned. + /// + public string[] ContentTypes { get; set; } = []; + + /// + /// Whether the pruning background task is disabled. + /// + public bool Disabled { get; set; } + + /// + /// The last time the pruning task was run. + /// + public DateTime? LastRunUtc { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/OrchardCore.Contents.VersionPruning.csproj b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/OrchardCore.Contents.VersionPruning.csproj new file mode 100644 index 00000000000..def469cdbd4 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/OrchardCore.Contents.VersionPruning.csproj @@ -0,0 +1,27 @@ + + + + true + + OrchardCore Contents Version Pruning + $(OCCMSDescription) + + Provides a background task to prune old content item versions. + $(PackageTags) OrchardCoreCMS ContentManagement + + + + + + + + + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Permissions.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Permissions.cs new file mode 100644 index 00000000000..1aec02a17b7 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Permissions.cs @@ -0,0 +1,23 @@ +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Contents.VersionPruning; + +public sealed class Permissions : IPermissionProvider +{ + private readonly IEnumerable _allPermissions = + [ + ContentVersionPruningPermissions.ManageContentVersionPruningSettings, + ]; + + public Task> GetPermissionsAsync() + => Task.FromResult(_allPermissions); + + public IEnumerable GetDefaultStereotypes() => + [ + new PermissionStereotype + { + Name = OrchardCoreConstants.Roles.Administrator, + Permissions = _allPermissions, + }, + ]; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Services/ContentVersionPruningSelector.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Services/ContentVersionPruningSelector.cs new file mode 100644 index 00000000000..4b09914b7ac --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Services/ContentVersionPruningSelector.cs @@ -0,0 +1,28 @@ +using OrchardCore.ContentManagement; + +namespace OrchardCore.Contents.VersionPruning.Services; + +public static class ContentVersionPruningSelector +{ + public static List SelectForDeletion(IEnumerable candidates, int minVersionsToKeep) + { + var result = new List(); + + foreach (var group in candidates.GroupBy(x => x.ContentItemId)) + { + // Sort newest-first so Skip() protects the most-recent N versions. + // Null ModifiedUtc is treated as oldest: Nullable.Compare places null before + // any non-null value, so descending order puts null-dated versions last. + var ordered = group + .Where(x => + !x.Latest && + !x.Published) + .OrderByDescending(x => x.ModifiedUtc, Comparer.Create(Nullable.Compare)) + .Skip(minVersionsToKeep); + + result.AddRange(ordered); + } + + return result; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Services/ContentVersionPruningService.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Services/ContentVersionPruningService.cs new file mode 100644 index 00000000000..1ffa1703a55 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Services/ContentVersionPruningService.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Logging; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Records; +using OrchardCore.Contents.VersionPruning.Models; +using OrchardCore.Modules; +using YesSql; +using YesSql.Services; + +namespace OrchardCore.Contents.VersionPruning.Services; + +public class ContentVersionPruningService : IContentVersionPruningService +{ + private readonly ISession _session; + private readonly IClock _clock; + private readonly ILogger _logger; + + public ContentVersionPruningService( + ISession session, + IClock clock, + ILogger logger) + { + _session = session; + _clock = clock; + _logger = logger; + } + + public async Task PruneVersionsAsync(ContentVersionPruningSettings settings) + { + var candidates = await GetDeletionCandidatesAsync(settings); + + var deleted = 0; + foreach (var version in candidates) + { + try + { + _session.Delete(version); + deleted++; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to prune content item {ContentItemId} version {ContentItemVersionId}", version.ContentItemId, version.ContentItemVersionId); + } + } + + if (candidates.Count != deleted && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Content version pruning count doesn't match - expected to delete {ToDeleteCount}, actually deleted only {Deleted}", candidates.Count, deleted); + } + + return deleted; + } + + private async Task> GetDeletionCandidatesAsync(ContentVersionPruningSettings settings) + { + // Fetch all candidate archived versions (neither latest nor published) + // that are older than the retention threshold. Versions with a null ModifiedUtc + // are treated as oldest (they predate any known modification) and are always included. + var dateThreshold = _clock.UtcNow.AddDays(-settings.RetentionDays); + var query = _session + .Query(x => + !x.Latest && + !x.Published && + (x.ModifiedUtc == null || x.ModifiedUtc < dateThreshold)); + + var filterByType = settings.ContentTypes?.Length > 0; + if (filterByType) + { + query = query.Where(x => x.ContentType.IsIn(settings.ContentTypes)); + } + + var candidates = await query.ListAsync(); + + var versionsToKeep = Math.Max(0, settings.VersionsToKeep); + + return ContentVersionPruningSelector.SelectForDeletion(candidates, versionsToKeep); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Services/IContentVersionPruningService.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Services/IContentVersionPruningService.cs new file mode 100644 index 00000000000..de6bf0e3ed2 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Services/IContentVersionPruningService.cs @@ -0,0 +1,15 @@ +using OrchardCore.Contents.VersionPruning.Models; + +namespace OrchardCore.Contents.VersionPruning.Services; + +public interface IContentVersionPruningService +{ + /// + /// Deletes old content item versions that are neither the latest nor published, + /// and whose ModifiedUtc is older than the retention period defined in + /// , while honouring . + /// + /// The pruning settings to apply. + /// The number of versions deleted. + Task PruneVersionsAsync(ContentVersionPruningSettings settings); +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Startup.cs new file mode 100644 index 00000000000..ea1f818368a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Startup.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.BackgroundTasks; +using OrchardCore.Contents.VersionPruning.Controllers; +using OrchardCore.Contents.VersionPruning.Drivers; +using OrchardCore.Contents.VersionPruning.Services; +using OrchardCore.Contents.VersionPruning.Tasks; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Navigation; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Contents.VersionPruning; + +public sealed class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddSingleton(); + services.AddSiteDisplayDriver(); + services.AddNavigationProvider(); + services.AddPermissionProvider(); + } + + public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + var controllerName = typeof(AdminController).ControllerName(); + + routes.MapAreaControllerRoute( + name: "ContentsVersionPruningPrune", + areaName: "OrchardCore.Contents.VersionPruning", + pattern: "Contents/VersionPruning/Prune", + defaults: new { controller = controllerName, action = nameof(AdminController.Prune) } + ); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Tasks/ContentVersionPruningBackgroundTask.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Tasks/ContentVersionPruningBackgroundTask.cs new file mode 100644 index 00000000000..864c4eefb26 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Tasks/ContentVersionPruningBackgroundTask.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.BackgroundTasks; +using OrchardCore.Contents.VersionPruning.Models; +using OrchardCore.Contents.VersionPruning.Services; +using OrchardCore.Entities; +using OrchardCore.Modules; +using OrchardCore.Settings; + +namespace OrchardCore.Contents.VersionPruning.Tasks; + +[BackgroundTask( + Schedule = "0 0 * * *", + Title = "Content Version Pruning Background Task", + Description = "Regularly deletes old non-latest, non-published content item versions.", + Enable = false, + LockTimeout = 3_000, + LockExpiration = 30_000)] +public sealed class ContentVersionPruningBackgroundTask : IBackgroundTask +{ + public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var siteService = serviceProvider.GetRequiredService(); + + var settings = await siteService.GetSettingsAsync(); + if (settings.Disabled) + { + return; + } + + var logger = serviceProvider.GetRequiredService>(); + + try + { + var clock = serviceProvider.GetRequiredService(); + var pruningService = serviceProvider.GetRequiredService(); + + logger.LogDebug("Starting content version pruning."); + + var pruned = await pruningService.PruneVersionsAsync(settings); + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Content version pruning completed. {PrunedCount} versions were deleted.", pruned); + } + + var container = await siteService.LoadSiteSettingsAsync(); + container.Alter(nameof(ContentVersionPruningSettings), settings => + { + settings.LastRunUtc = clock.UtcNow; + }); + + await siteService.UpdateSiteSettingsAsync(container); + } + catch (Exception ex) when (!ex.IsFatal()) + { + logger.LogError(ex, "Error while pruning content item versions."); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/ViewModels/ContentVersionPruningSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/ViewModels/ContentVersionPruningSettingsViewModel.cs new file mode 100644 index 00000000000..bb5a9d2811e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/ViewModels/ContentVersionPruningSettingsViewModel.cs @@ -0,0 +1,11 @@ +namespace OrchardCore.Contents.VersionPruning.ViewModels; + +public class ContentVersionPruningSettingsViewModel +{ + public int RetentionDays { get; set; } + public int VersionsToKeep { get; set; } + public string[] ContentTypes { get; set; } = []; + + public bool Disabled { get; set; } + public DateTime? LastRunUtc { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Views/ContentVersionPruningSettings_Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Views/ContentVersionPruningSettings_Edit.cshtml new file mode 100644 index 00000000000..826a8fe41cb --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Views/ContentVersionPruningSettings_Edit.cshtml @@ -0,0 +1,48 @@ +@model ContentVersionPruningSettingsViewModel + +
+
+ + + @T["Whether the pruning task is disabled."] +
+
+ +
+ + + @T["Non-latest, non-published versions older than this number of days will be deleted."] +
+ +
+ + + @T["The number of most-recent archived (non-latest, non-published) versions to always retain per content item, regardless of age. Set to 0 to disable this protection."] +
+ +
+ + @T["Select the content types whose old archived versions should be pruned. Leave all unchecked to prune all content types."] + @await Component.InvokeAsync("SelectContentTypes", new { selectedContentTypes = Model.ContentTypes, htmlName = Html.NameFor(m => m.ContentTypes) }) +
+ +
+ +
+ @(Model.LastRunUtc.HasValue + ? await DisplayAsync(await New.DateTime(Utc: Model.LastRunUtc.Value, Format: "g")) + : T["Never"]) +
+ @T["The last time the content version pruning task was run."] +
+ +
+ + @T["Immediately run the pruning task using the current settings."] +
diff --git a/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Views/_ViewImports.cshtml b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Views/_ViewImports.cshtml new file mode 100644 index 00000000000..2d01c0a6e24 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents.VersionPruning/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement +@using OrchardCore.Contents.VersionPruning.ViewModels +@using OrchardCore.DisplayManagement.Views +@using Microsoft.Extensions.Localization +@using Microsoft.AspNetCore.Mvc.Localization diff --git a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj index f98984a6726..3358eae4eb4 100644 --- a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj +++ b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj @@ -51,6 +51,7 @@ + diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Contents.VersionPruning/ContentVersionPruningSelectorTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Contents.VersionPruning/ContentVersionPruningSelectorTests.cs new file mode 100644 index 00000000000..0b440cc528c --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Contents.VersionPruning/ContentVersionPruningSelectorTests.cs @@ -0,0 +1,244 @@ +using OrchardCore.ContentManagement; +using OrchardCore.Contents.VersionPruning.Services; + +namespace OrchardCore.Tests.Modules.Contents.VersionPruning; + +public class ContentVersionPruningSelectorTests +{ + // ── no candidates ────────────────────────────────────────────────────── + + [Fact] + public void SelectForDeletion_EmptyInput_ReturnsEmpty() + { + var result = ContentVersionPruningSelector.SelectForDeletion(new List(), 1); + Assert.Empty(result); + } + + // ── single item, single version ──────────────────────────────────────── + + [Fact] + public void SelectForDeletion_SingleVersion_MinKeep1_ReturnsEmpty() + { + // Only one version exists for this item. MinVersionsToKeep=1 means keep it. + var versions = new List + { + MakeVersion("item-1", "v1", DateTime.UtcNow.AddDays(-40)), + }; + + var result = ContentVersionPruningSelector.SelectForDeletion(versions, minVersionsToKeep: 1); + + Assert.Empty(result); + } + + [Fact] + public void SelectForDeletion_SingleVersion_MinKeep0_WouldReturnIt_ButMinimumIs1AfterNormalize() + { + // The selector itself does not normalize; passing 0 means keep nothing. + // In practice Normalize() is called first, so this tests the raw selector behaviour. + var versions = new List + { + MakeVersion("item-1", "v1", DateTime.UtcNow.AddDays(-40)), + }; + + var result = ContentVersionPruningSelector.SelectForDeletion(versions, minVersionsToKeep: 0); + + Assert.Single(result); + Assert.Equal("v1", result[0].ContentItemVersionId); + } + + // ── multiple versions for one item ───────────────────────────────────── + + [Fact] + public void SelectForDeletion_ThreeVersions_MinKeep1_DeletesTwoOldest() + { + var now = DateTime.UtcNow; + var versions = new List + { + MakeVersion("item-1", "v1", now.AddDays(-90)), // oldest + MakeVersion("item-1", "v2", now.AddDays(-60)), + MakeVersion("item-1", "v3", now.AddDays(-31)), // most recent in the group + }; + + var result = ContentVersionPruningSelector.SelectForDeletion(versions, minVersionsToKeep: 1); + + // v3 must be kept (it is the 1 most-recent version to keep) + Assert.Equal(2, result.Count); + Assert.DoesNotContain(result, r => r.ContentItemVersionId == "v3"); + Assert.Contains(result, r => r.ContentItemVersionId == "v1"); + Assert.Contains(result, r => r.ContentItemVersionId == "v2"); + } + + [Fact] + public void SelectForDeletion_ThreeVersions_MinKeep2_DeletesOnlyOldest() + { + var now = DateTime.UtcNow; + var versions = new List + { + MakeVersion("item-1", "v1", now.AddDays(-90)), + MakeVersion("item-1", "v2", now.AddDays(-60)), + MakeVersion("item-1", "v3", now.AddDays(-31)), + }; + + var result = ContentVersionPruningSelector.SelectForDeletion(versions, minVersionsToKeep: 2); + + Assert.Single(result); + Assert.Equal("v1", result[0].ContentItemVersionId); + } + + [Fact] + public void SelectForDeletion_ThreeVersions_MinKeep3_DeletesNothing() + { + var now = DateTime.UtcNow; + var versions = new List + { + MakeVersion("item-1", "v1", now.AddDays(-90)), + MakeVersion("item-1", "v2", now.AddDays(-60)), + MakeVersion("item-1", "v3", now.AddDays(-31)), + }; + + var result = ContentVersionPruningSelector.SelectForDeletion(versions, minVersionsToKeep: 3); + + Assert.Empty(result); + } + + // ── multiple items ───────────────────────────────────────────────────── + + [Fact] + public void SelectForDeletion_TwoItems_EachKeepsMinVersions() + { + var now = DateTime.UtcNow; + var versions = new List + { + // item-A: 3 versions, keep 1 → delete 2 + MakeVersion("item-A", "A-v1", now.AddDays(-120)), + MakeVersion("item-A", "A-v2", now.AddDays(-90)), + MakeVersion("item-A", "A-v3", now.AddDays(-40)), + // item-B: 2 versions, keep 1 → delete 1 + MakeVersion("item-B", "B-v1", now.AddDays(-80)), + MakeVersion("item-B", "B-v2", now.AddDays(-35)), + }; + + var result = ContentVersionPruningSelector.SelectForDeletion(versions, minVersionsToKeep: 1); + + Assert.Equal(3, result.Count); + Assert.DoesNotContain(result, r => r.ContentItemVersionId == "A-v3"); + Assert.DoesNotContain(result, r => r.ContentItemVersionId == "B-v2"); + } + + // ── Latest / Published guard ─────────────────────────────────────────── + + [Fact] + public void SelectForDeletion_NeverSetsLatestOrPublished_OnResults() + { + // Verify that all output items have Latest=false and Published=false. + var now = DateTime.UtcNow; + var versions = new List + { + MakeVersion("item-1", "v1", now.AddDays(-90)), + MakeVersion("item-1", "v2", now.AddDays(-60)), + MakeVersion("item-1", "v3", now.AddDays(-31)), + }; + + var result = ContentVersionPruningSelector.SelectForDeletion(versions, minVersionsToKeep: 1); + + Assert.All(result, r => + { + Assert.False(r.Latest); + Assert.False(r.Published); + }); + } + + [Fact] + public void SelectForDeletion_FiltersOutLatestVersion() + { + // The selector guards against Latest versions even when mistakenly passed in. + var now = DateTime.UtcNow; + var latestVersion = new ContentItem + { + ContentItemId = "item-1", + ContentItemVersionId = "v-latest", + ContentType = "TestPage", + ModifiedUtc = now.AddDays(-40), + Latest = true, + Published = false, + }; + var oldVersion = MakeVersion("item-1", "v-old", now.AddDays(-90)); + + var versions = new List { latestVersion, oldVersion }; + + var result = ContentVersionPruningSelector.SelectForDeletion(versions, minVersionsToKeep: 0); + + Assert.Single(result); + Assert.Equal("v-old", result[0].ContentItemVersionId); + } + + [Fact] + public void SelectForDeletion_FiltersOutPublishedVersion() + { + // The selector guards against Published versions even when mistakenly passed in. + var now = DateTime.UtcNow; + var publishedVersion = new ContentItem + { + ContentItemId = "item-1", + ContentItemVersionId = "v-published", + ContentType = "TestPage", + ModifiedUtc = now.AddDays(-40), + Latest = false, + Published = true, + }; + var oldVersion = MakeVersion("item-1", "v-old", now.AddDays(-90)); + + var versions = new List { publishedVersion, oldVersion }; + + var result = ContentVersionPruningSelector.SelectForDeletion(versions, minVersionsToKeep: 0); + + Assert.Single(result); + Assert.Equal("v-old", result[0].ContentItemVersionId); + } + + [Fact] + public void SelectForDeletion_AllVersionsLatestOrPublished_ReturnsEmpty() + { + // If every version in a group is Latest or Published the result should be empty. + var now = DateTime.UtcNow; + var versions = new List + { + new() + { + ContentItemId = "item-1", + ContentItemVersionId = "v1", + ContentType = "TestPage", + ModifiedUtc = now.AddDays(-90), + Latest = true, + Published = false, + }, + new() + { + ContentItemId = "item-1", + ContentItemVersionId = "v2", + ContentType = "TestPage", + ModifiedUtc = now.AddDays(-40), + Latest = false, + Published = true, + }, + }; + + var result = ContentVersionPruningSelector.SelectForDeletion(versions, minVersionsToKeep: 0); + + Assert.Empty(result); + } + + // ── helpers ──────────────────────────────────────────────────────────── + + private static ContentItem MakeVersion(string contentItemId, string versionId, DateTime? modifiedUtc) + => new ContentItem + { + ContentItemId = contentItemId, + ContentItemVersionId = versionId, + ContentType = "TestPage", + DisplayText = $"{contentItemId} – {versionId}", + ModifiedUtc = modifiedUtc, + Latest = false, + Published = false, + }; +}