From ba0dcfa773acca8b50866b3b7964e535a1a462eb Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Thu, 24 Apr 2025 21:07:40 +0200 Subject: [PATCH] Avoid hybrid cache usage when traversing unpublished ancestors in a published context (non preview) (#19137) * Filter Available should not return items without published ancestors when not in preview * Update unittests mocks * Internal documentation and minor code tidy. * Tidied up integration tests and added new tests for the added method. --------- Co-authored-by: Andy Butland --- .../IPublishStatusQueryService.cs | 17 +- .../PublishStatus/PublishStatusService.cs | 70 +++++- .../PublishedContentStatusFilteringService.cs | 4 +- .../Services/DocumentCacheService.cs | 26 +-- .../Services/PublishStatusServiceTest.cs | 206 ------------------ .../PublishStatusServiceTests.Management.cs | 113 ++++++++++ .../PublishStatusServiceTests.Query.cs | 129 +++++++++++ .../Services/PublishStatusServiceTests.cs | 30 +++ .../Umbraco.Tests.Integration.csproj | 6 + .../DeliveryApi/DeliveryApiTests.cs | 3 + ...ishedContentStatusFilteringServiceTests.cs | 3 + 11 files changed, 371 insertions(+), 236 deletions(-) delete mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTest.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Management.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.cs diff --git a/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs b/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs index c0478d1a5a..d74d144395 100644 --- a/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs @@ -1,16 +1,29 @@ namespace Umbraco.Cms.Core.Services.Navigation; /// -/// +/// Verifies the published status of documents. /// public interface IPublishStatusQueryService { + /// + /// Checks if a document is published in a specific culture. + /// + /// The document's key. + /// The culture. + /// True if document is published in the specified culture. bool IsDocumentPublished(Guid documentKey, string culture); /// /// Checks if a document is published in any culture. /// - /// Key to check for. + /// The document's key. /// True if document has any published culture. bool IsDocumentPublishedInAnyCulture(Guid documentKey) => IsDocumentPublished(documentKey, string.Empty); + + /// + /// Verifies if a document has a published ancestor path (i.e. all ancestors are themselves published in at least one culture). + /// + /// The document's key. + /// True if document has a published ancestor path. + bool HasPublishedAncestorPath(Guid documentKey); } diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs index 98ea2c9958..d221149439 100644 --- a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.DependencyInjection; @@ -7,38 +6,75 @@ using Umbraco.Cms.Core.Scoping; namespace Umbraco.Cms.Core.Services.Navigation; +/// +/// Implements and verifying the published +/// status of documents. +/// public class PublishStatusService : IPublishStatusManagementService, IPublishStatusQueryService { private readonly ILogger _logger; private readonly IPublishStatusRepository _publishStatusRepository; private readonly ICoreScopeProvider _coreScopeProvider; private readonly ILanguageService _languageService; + private readonly IDocumentNavigationQueryService _documentNavigationQueryService; + private readonly IDictionary> _publishedCultures = new Dictionary>(); private string? DefaultCulture { get; set; } + /// + /// Initializes a new instance of the class. + /// [Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 17.")] public PublishStatusService( ILogger logger, IPublishStatusRepository publishStatusRepository, ICoreScopeProvider coreScopeProvider) - : this(logger, publishStatusRepository, coreScopeProvider, StaticServiceProvider.Instance.GetRequiredService()) + : this( + logger, + publishStatusRepository, + coreScopeProvider, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { - } + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 17.")] public PublishStatusService( ILogger logger, IPublishStatusRepository publishStatusRepository, ICoreScopeProvider coreScopeProvider, ILanguageService languageService) + : this( + logger, + publishStatusRepository, + coreScopeProvider, + languageService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Initializes a new instance of the class. + /// + public PublishStatusService( + ILogger logger, + IPublishStatusRepository publishStatusRepository, + ICoreScopeProvider coreScopeProvider, + ILanguageService languageService, + IDocumentNavigationQueryService documentNavigationQueryService) { _logger = logger; _publishStatusRepository = publishStatusRepository; _coreScopeProvider = coreScopeProvider; _languageService = languageService; + _documentNavigationQueryService = documentNavigationQueryService; } + /// public async Task InitializeAsync(CancellationToken cancellationToken) { _publishedCultures.Clear(); @@ -60,6 +96,7 @@ public class PublishStatusService : IPublishStatusManagementService, IPublishSta DefaultCulture = await _languageService.GetDefaultIsoCodeAsync(); } + /// public bool IsDocumentPublished(Guid documentKey, string culture) { if (string.IsNullOrEmpty(culture) && DefaultCulture is not null) @@ -88,6 +125,31 @@ public class PublishStatusService : IPublishStatusManagementService, IPublishSta return false; } + /// + public bool HasPublishedAncestorPath(Guid contentKey) + { + var success = _documentNavigationQueryService.TryGetAncestorsKeys(contentKey, out IEnumerable keys); + if (success is false) + { + // This might happen is certain cases, since notifications are not ordered, for instance, if you save and publish a content node in the same scope. + // In this case we'll try and update the node in the cache even though it hasn't been updated in the document navigation cache yet. + // It's okay to just return false here, since the node will be loaded later when it's actually requested. + return false; + } + + foreach (Guid key in keys) + { + + if (IsDocumentPublishedInAnyCulture(key) is false) + { + return false; + } + } + + return true; + } + + /// public async Task AddOrUpdateStatusAsync(Guid documentKey, CancellationToken cancellationToken) { using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); @@ -96,12 +158,14 @@ public class PublishStatusService : IPublishStatusManagementService, IPublishSta scope.Complete(); } + /// public Task RemoveAsync(Guid documentKey, CancellationToken cancellationToken) { _publishedCultures.Remove(documentKey); return Task.CompletedTask; } + /// public async Task AddOrUpdateStatusWithDescendantsAsync(Guid rootDocumentKey, CancellationToken cancellationToken) { IDictionary> publishStatus; diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs index e31ae8e347..860b4cb2f3 100644 --- a/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs @@ -36,7 +36,9 @@ internal sealed class PublishedContentStatusFilteringService : IPublishedContent var preview = _previewService.IsInPreview(); candidateKeys = preview ? candidateKeysAsArray - : candidateKeysAsArray.Where(key => _publishStatusQueryService.IsDocumentPublished(key, culture)); + : candidateKeysAsArray.Where(key => + _publishStatusQueryService.IsDocumentPublished(key, culture) + && _publishStatusQueryService.HasPublishedAncestorPath(key)); return WhereIsInvariantOrHasCulture(candidateKeys, culture, preview).ToArray(); } diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs index 4d735a9cd7..d083739b45 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs @@ -115,7 +115,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService // When unpublishing a node, a payload with RefreshBranch is published, so we don't have to worry about this. // Similarly, when a branch is published, next time the content is requested, the parent will be published, // this works because we don't cache null values. - if (preview is false && contentCacheNode is not null && HasPublishedAncestorPath(contentCacheNode.Key) is false) + if (preview is false && contentCacheNode is not null && _publishStatusQueryService.HasPublishedAncestorPath(contentCacheNode.Key) is false) { // Careful not to early return here. We need to complete the scope even if returning null. contentCacheNode = null; @@ -137,28 +137,6 @@ internal sealed class DocumentCacheService : IDocumentCacheService return _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory); } - private bool HasPublishedAncestorPath(Guid contentKey) - { - var success = _documentNavigationQueryService.TryGetAncestorsKeys(contentKey, out IEnumerable keys); - if (success is false) - { - // This might happen is certain cases, since 0notifications are not ordered, for instance, if you save and publish a content node in the same scope. - // In this case we'll try and update the node in the cache even though it hasn't been updated in the document navigation cache yet. - // It's okay to just return false here, since the node will be loaded later when it's actually requested. - return false; - } - - foreach (Guid key in keys) - { - if (_publishStatusQueryService.IsDocumentPublishedInAnyCulture(key) is false) - { - return false; - } - } - - return true; - } - private bool GetPreview() => _previewService.IsInPreview(); public IEnumerable GetByContentType(IPublishedContentType contentType) @@ -191,7 +169,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService } ContentCacheNode? publishedNode = await _databaseCacheRepository.GetContentSourceAsync(key, false); - if (publishedNode is not null && HasPublishedAncestorPath(publishedNode.Key)) + if (publishedNode is not null && _publishStatusQueryService.HasPublishedAncestorPath(publishedNode.Key)) { await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key, false), publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(key)); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTest.cs deleted file mode 100644 index 2edd244e38..0000000000 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTest.cs +++ /dev/null @@ -1,206 +0,0 @@ -using Microsoft.Extensions.Logging; -using NUnit.Framework; -using Umbraco.Cms.Core.Cache; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Notifications; -using Umbraco.Cms.Core.Persistence.Repositories; -using Umbraco.Cms.Core.Scoping; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Services.Navigation; -using Umbraco.Cms.Core.Sync; -using Umbraco.Cms.Tests.Common.Builders; -using Umbraco.Cms.Tests.Common.Testing; -using Umbraco.Cms.Tests.Integration.Testing; -using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; - -namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; - -[TestFixture] -[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Mock)] -internal sealed class PublishStatusServiceTest : UmbracoIntegrationTestWithContent -{ - protected IPublishStatusQueryService PublishStatusQueryService => GetRequiredService(); - - private const string DefaultCulture = "en-US"; - protected override void CustomTestSetup(IUmbracoBuilder builder) - { - builder.Services.AddUnique(); - builder.AddNotificationHandler(); - } - - [Test] - public async Task InitializeAsync_loads_from_db() - { - var randomCulture = "da-DK"; - var sut = new PublishStatusService( - GetRequiredService>(), - GetRequiredService(), - GetRequiredService()); - - Assert.Multiple(() => - { - Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage3.Key, DefaultCulture)); - - Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, DefaultCulture)); - - Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, randomCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, randomCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, randomCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, randomCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage3.Key, randomCulture)); - - Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, randomCulture)); - }); - - // Act - var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); - await sut.InitializeAsync(CancellationToken.None); - - Assert.Multiple(() => - { - Assert.IsTrue(publishResults.All(x=>x.Result == PublishResultType.SuccessPublish)); - Assert.IsTrue(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); - Assert.IsTrue(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); - Assert.IsTrue(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); - Assert.IsTrue(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); - Assert.IsTrue(sut.IsDocumentPublished(Subpage3.Key, DefaultCulture)); - - Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, DefaultCulture)); - - Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, randomCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, randomCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, randomCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, randomCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage3.Key, randomCulture)); - - Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, randomCulture)); - }); - } - - [Test] - public async Task AddOrUpdateStatusWithDescendantsAsync() - { - var randomCulture = "da-DK"; - var sut = new PublishStatusService( - GetRequiredService>(), - GetRequiredService(), - GetRequiredService(), - GetRequiredService() - ); - - Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); - - // Act - var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); - await sut.AddOrUpdateStatusWithDescendantsAsync(Textpage.Key, CancellationToken.None); - Assert.IsTrue(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); - Assert.IsTrue(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); // Updated due to being an descendant - Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, randomCulture)); // Do not exist - Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, randomCulture)); // Do not exist - } - - [Test] - public async Task AddOrUpdateStatusAsync() - { - var randomCulture = "da-DK"; - var sut = new PublishStatusService( - GetRequiredService>(), - GetRequiredService(), - GetRequiredService(), - GetRequiredService()); - - Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); - - // Act - var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); - await sut.AddOrUpdateStatusAsync(Textpage.Key, CancellationToken.None); - Assert.IsTrue(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); - Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); // Not updated - Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, randomCulture)); // Do not exist - Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, randomCulture)); // Do not exist - } - - [Test] - public void When_Nothing_is_publised_all_return_false() - { - var randomCulture = "da-DK"; - Assert.Multiple(() => - { - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, DefaultCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, DefaultCulture)); - - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, DefaultCulture)); - - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, randomCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, randomCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, randomCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, randomCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, randomCulture)); - - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, randomCulture)); - }); - - } - - [Test] - public void Unpublish_leads_to_unpublised_in_this_service() - { - var grandchild = ContentBuilder.CreateSimpleContent(ContentType, "Grandchild", Subpage2.Id); - - var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null); - ContentService.Save(grandchild, -1, contentSchedule); - - var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); - var randomCulture = "da-DK"; - - var subPage2FromDB = ContentService.GetById(Subpage2.Key); - var publishResult = ContentService.Unpublish(subPage2FromDB); - Assert.Multiple(() => - { - Assert.IsTrue(publishResults.All(x=>x.Result == PublishResultType.SuccessPublish)); - Assert.IsTrue(publishResult.Success); - Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); - Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(grandchild.Key, DefaultCulture)); // grandchild is still published, but it will not be routable - - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, randomCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, randomCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(grandchild.Key, randomCulture)); - }); - - } - - [Test] - public void When_Branch_is_publised_default_language_return_true() - { - var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); - var randomCulture = "da-DK"; - Assert.Multiple(() => - { - Assert.IsTrue(publishResults.All(x=>x.Result == PublishResultType.SuccessPublish)); - Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); - Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); - Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, DefaultCulture)); - Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); - Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, DefaultCulture)); - - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, DefaultCulture)); - - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, randomCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, randomCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, randomCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, randomCulture)); - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, randomCulture)); - - Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, randomCulture)); - }); - - } -} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Management.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Management.cs new file mode 100644 index 0000000000..59cb631a58 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Management.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +internal sealed partial class PublishStatusServiceTests +{ + [Test] + public async Task InitializeAsync_Loads_From_Database() + { + var sut = CreatePublishedStatusService(); + + Assert.Multiple(() => + { + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage3.Key, DefaultCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, DefaultCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, UnusedCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, UnusedCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, UnusedCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, UnusedCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage3.Key, UnusedCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, UnusedCulture)); + }); + + // Act + var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + await sut.InitializeAsync(CancellationToken.None); + + Assert.Multiple(() => + { + Assert.IsTrue(publishResults.All(x => x.Result == PublishResultType.SuccessPublish)); + Assert.IsTrue(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsTrue(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsTrue(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); + Assert.IsTrue(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsTrue(sut.IsDocumentPublished(Subpage3.Key, DefaultCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, DefaultCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, UnusedCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, UnusedCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, UnusedCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, UnusedCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage3.Key, UnusedCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, UnusedCulture)); + }); + } + + [Test] + public async Task AddOrUpdateStatusWithDescendantsAsync_Updates_Document_Path_Published_Status() + { + var sut = new PublishStatusService( + GetRequiredService>(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService()); + + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + + // Act + var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + await sut.AddOrUpdateStatusWithDescendantsAsync(Textpage.Key, CancellationToken.None); + + Assert.IsTrue(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsTrue(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); // Updated due to being an descendant + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, UnusedCulture)); // Do not exist + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, UnusedCulture)); // Do not exist + } + + [Test] + public async Task AddOrUpdateStatusAsync_Updates_Document_Published_Status() + { + var sut = new PublishStatusService( + GetRequiredService>(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService()); + + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + + // Act + var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + await sut.AddOrUpdateStatusAsync(Textpage.Key, CancellationToken.None); + + Assert.IsTrue(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); // Not updated + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, UnusedCulture)); // Do not exist + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, UnusedCulture)); // Do not exist + } + + private PublishStatusService CreatePublishedStatusService() + => new( + GetRequiredService>(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService()); +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs new file mode 100644 index 0000000000..2b514ad40a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs @@ -0,0 +1,129 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +internal sealed partial class PublishStatusServiceTests +{ + private IPublishStatusQueryService PublishStatusQueryService => GetRequiredService(); + + [Test] + public void When_Nothing_Is_Publised_All_Documents_Have_Unpublished_Status() + { + Assert.Multiple(() => + { + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, DefaultCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, DefaultCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, DefaultCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, UnusedCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, UnusedCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, UnusedCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, UnusedCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, UnusedCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(Textpage.Key)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(Subpage.Key)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(Subpage2.Key)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(Subpage3.Key)); + + }); + } + + [Test] + public void Unpublish_Updates_Document_Path_Published_Status() + { + var grandchild = ContentBuilder.CreateSimpleContent(ContentType, "Grandchild", Subpage2.Id); + + var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null); + ContentService.Save(grandchild, -1, contentSchedule); + + var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + + var subPage2FromDB = ContentService.GetById(Subpage2.Key); + var publishResult = ContentService.Unpublish(subPage2FromDB); + Assert.Multiple(() => + { + Assert.IsTrue(publishResults.All(x => x.Result == PublishResultType.SuccessPublish)); + Assert.IsTrue(publishResult.Success); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(grandchild.Key, DefaultCulture)); // grandchild is still published, but it will not be routable + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, UnusedCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, UnusedCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(grandchild.Key, UnusedCulture)); + + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(Textpage.Key)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(Subpage2.Key)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(grandchild.Key)); + }); + } + + [Test] + public void Publish_Branch_Updates_Document_Path_Published_Status() + { + var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + Assert.Multiple(() => + { + Assert.IsTrue(publishResults.All(x => x.Result == PublishResultType.SuccessPublish)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, DefaultCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, DefaultCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, UnusedCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, UnusedCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, UnusedCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, UnusedCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, UnusedCulture)); + + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(Textpage.Key)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(Subpage.Key)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(Subpage2.Key)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublishedInAnyCulture(Subpage3.Key)); + + Assert.IsTrue(PublishStatusQueryService.HasPublishedAncestorPath(Textpage.Key)); + Assert.IsTrue(PublishStatusQueryService.HasPublishedAncestorPath(Subpage.Key)); + }); + } + + [Test] + public void Published_Document_With_UnPublished_Parent_Has_Unpublished_Path() + { + Assert.Multiple(() => + { + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, DefaultCulture)); + }); + + ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + + Assert.Multiple(() => + { + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, DefaultCulture)); + }); + + ContentService.Unpublish(Textpage); + + // Unpublish the root item - the sub page will still be published but it won't have a published path. + Assert.Multiple(() => + { + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, DefaultCulture)); + + Assert.IsFalse(PublishStatusQueryService.HasPublishedAncestorPath(Subpage.Key)); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.cs new file mode 100644 index 0000000000..2d11c1aae1 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Mock)] +internal sealed partial class PublishStatusServiceTests : UmbracoIntegrationTestWithContent +{ + private const string DefaultCulture = "en-US"; + private const string UnusedCulture = "da-DK"; + + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + builder.Services.AddUnique(); + builder.AddNotificationHandler(); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index a05c3e0c07..4bb3bcb5c6 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -274,6 +274,12 @@ PublishedUrlInfoProviderTestsBase.cs + + PublishStatusServiceTests.cs + + + PublishStatusServiceTests.cs + UserStartNodeEntitiesServiceTests.cs diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs index 8d48d3016d..26cc1d7edc 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs @@ -65,6 +65,9 @@ public class DeliveryApiTests publishStatusQueryService .Setup(x => x.IsDocumentPublished(It.IsAny(), It.IsAny())) .Returns(true); + publishStatusQueryService + .Setup(x => x.HasPublishedAncestorPath(It.IsAny())) + .Returns(true); PublishStatusQueryService = publishStatusQueryService.Object; } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringServiceTests.cs index 674c06d643..6949d47c88 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringServiceTests.cs @@ -329,6 +329,9 @@ public partial class PublishedContentStatusFilteringServiceTests .TryGetValue(key, out var item) && idIsPublished(item.Id) && (item.ContentType.VariesByCulture() is false || item.Cultures.ContainsKey(culture))); + publishStatusQueryService + .Setup(s => s.HasPublishedAncestorPath(It.IsAny())) + .Returns(true); return publishStatusQueryService.Object; }