diff --git a/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs b/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs index 9dea29092faf..d48bd8b63e9f 100644 --- a/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs +++ b/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs @@ -153,7 +153,9 @@ public static bool IsComposedOf(this IPublishedContent content, string alias) => // parent key is null if content is at root return parentKey.HasValue - ? publishedStatusFilteringService.FilterAvailable([parentKey.Value], null).FirstOrDefault() +#pragma warning disable CS0618 // Type or member is obsolete (justification: temporary means to avoid breaking changes in the PublishedContentExtensions) + ? publishedStatusFilteringService.Unfiltered([parentKey.Value]).FirstOrDefault() +#pragma warning restore CS0618 // Type or member is obsolete : null; } @@ -2261,8 +2263,7 @@ private static IEnumerable EnumerateAncestorsOrSelfInternal( INavigationQueryService navigationQueryService, IPublishedStatusFilteringService publishedStatusFilteringService, bool orSelf, - string? contentTypeAlias = null, - string? culture = null) + string? contentTypeAlias = null) { if (orSelf) { @@ -2281,7 +2282,9 @@ private static IEnumerable EnumerateAncestorsOrSelfInternal( yield break; } - IEnumerable ancestors = publishedStatusFilteringService.FilterAvailable(ancestorsKeys, culture); +#pragma warning disable CS0618 // Type or member is obsolete (justification: temporary means to avoid breaking changes in the PublishedContentExtensions) + IEnumerable ancestors = publishedStatusFilteringService.Unfiltered(ancestorsKeys); +#pragma warning restore CS0618 // Type or member is obsolete foreach (IPublishedContent ancestor in ancestors) { yield return ancestor; diff --git a/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs b/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs index d74d14439561..2418099a33ee 100644 --- a/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs @@ -26,4 +26,14 @@ public interface IPublishStatusQueryService /// The document's key. /// True if document has a published ancestor path. bool HasPublishedAncestorPath(Guid documentKey); + + /// + /// Verifies if a document has a published ancestor path (i.e. all ancestors are themselves published in at specific culture). + /// + /// The document's key. + /// The culture. + /// True if document has a published ancestor path. + // TODO (V18): Remove the default implementation. + bool HasPublishedAncestorPath(Guid documentKey, string culture) + => HasPublishedAncestorPath(documentKey); } diff --git a/src/Umbraco.Core/Services/PublishStatus/IPublishedStatusFilteringService.cs b/src/Umbraco.Core/Services/PublishStatus/IPublishedStatusFilteringService.cs index 0df5b9ab50ae..295e02874a12 100644 --- a/src/Umbraco.Core/Services/PublishStatus/IPublishedStatusFilteringService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/IPublishedStatusFilteringService.cs @@ -14,4 +14,12 @@ public interface IPublishedStatusFilteringService /// The culture to filter by, or null to use the current culture context. /// A collection of items that are available for display. IEnumerable FilterAvailable(IEnumerable candidateKeys, string? culture); + + /// + /// Returns content for a collection of candidate content keys. + /// + /// The collection of content keys to return. + /// A collection of items that are available for display. + [Obsolete("This is an intermediate solution to avoid breaking changes. Use the IPublishedContentCache to get published content by key. Scheduled for removal in V19.")] + IEnumerable Unfiltered(IEnumerable candidateKeys) => throw new NotImplementedException(); } diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs index 97e060b2b457..6f19c9c8310c 100644 --- a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs @@ -107,6 +107,13 @@ public bool IsDocumentPublishedInAnyCulture(Guid documentKey) /// public bool HasPublishedAncestorPath(Guid contentKey) + => HasPublishedAncestorPathInternal(contentKey, null); + + /// + public bool HasPublishedAncestorPath(Guid contentKey, string culture) + => HasPublishedAncestorPathInternal(contentKey, culture); + + private bool HasPublishedAncestorPathInternal(Guid contentKey, string? culture) { var success = _documentNavigationQueryService.TryGetAncestorsKeys(contentKey, out IEnumerable keys); if (success is false) @@ -119,8 +126,11 @@ public bool HasPublishedAncestorPath(Guid contentKey) foreach (Guid key in keys) { + var isPublished = culture is null + ? IsDocumentPublishedInAnyCulture(key) + : IsDocumentPublished(key, culture); - if (IsDocumentPublishedInAnyCulture(key) is false) + if (isPublished is false) { return false; } diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs index 2d4d8ad965c7..baded5ecef34 100644 --- a/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs @@ -54,11 +54,18 @@ public IEnumerable FilterAvailable(IEnumerable candidat ? candidateKeysAsArray : candidateKeysAsArray.Where(key => _publishStatusQueryService.IsDocumentPublished(key, culture) - && _publishStatusQueryService.HasPublishedAncestorPath(key)); + && _publishStatusQueryService.HasPublishedAncestorPath(key, culture)); return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview).ToArray(); } + /// + public IEnumerable Unfiltered(IEnumerable candidateKeys) + { + var preview = _previewService.IsInPreview(); + return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull().ToArray(); + } + /// /// Filters content items to include only those that are invariant, have the requested culture, or when all cultures are requested. /// diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishedMediaStatusFilteringService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishedMediaStatusFilteringService.cs index d3e874d4bc45..825b20868df9 100644 --- a/src/Umbraco.Core/Services/PublishStatus/PublishedMediaStatusFilteringService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/PublishedMediaStatusFilteringService.cs @@ -26,4 +26,8 @@ public PublishedMediaStatusFilteringService(IPublishedMediaCache publishedMediaC /// public IEnumerable FilterAvailable(IEnumerable candidateKeys, string? culture) => candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray(); + + /// + public IEnumerable Unfiltered(IEnumerable candidateKeys) + => candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray(); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs index 2b514ad40a7c..e4e40f468d52 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs @@ -1,8 +1,10 @@ using NUnit.Framework; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; @@ -126,4 +128,57 @@ public void Published_Document_With_UnPublished_Parent_Has_Unpublished_Path() Assert.IsFalse(PublishStatusQueryService.HasPublishedAncestorPath(Subpage.Key)); }); } + + [TestCase("en-US")] + [TestCase("da-DK")] + public async Task Unpublished_Document_Culture_Yields_Correct_Published_Ancestor_Path(string cultureToUnpublish) + { + await GetRequiredService() + .CreateAsync(new Language("da-DK", "Danish"), Constants.Security.SuperUserKey); + + var contentTypeKey = Guid.NewGuid(); + var contentType = new ContentTypeBuilder() + .WithKey(contentTypeKey) + .WithAlias("variant") + .WithContentVariation(ContentVariation.Culture) + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + contentType.AllowedContentTypes = [new ContentTypeSort(contentTypeKey, 1, "variant")]; + await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + + IContent root = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "Root EN") + .WithCultureName("da-DK", "Root DA") + .Build(); + ContentService.Save(root); + + IContent child = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "Child EN") + .WithCultureName("da-DK", "Child DA") + .WithParent(root) + .Build(); + ContentService.Save(child); + + IContent grandchild = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "Grandchild EN") + .WithCultureName("da-DK", "Grandchild DA") + .WithParent(child) + .Build(); + ContentService.Save(grandchild); + + ContentService.PublishBranch(root, PublishBranchFilter.IncludeUnpublished, ["en-US", "da-DK"]); + + // must refresh the child instance before unpublishing it, to reflect the state changes from the branch publish above + child = ContentService.GetById(child.Key)!; + ContentService.Unpublish(child, cultureToUnpublish); + + var publishedCulture = cultureToUnpublish is "en-US" ? "da-DK" : "en-US"; + Assert.IsTrue(PublishStatusQueryService.HasPublishedAncestorPath(grandchild.Key, publishedCulture)); + Assert.IsFalse(PublishStatusQueryService.HasPublishedAncestorPath(grandchild.Key, cultureToUnpublish)); + Assert.IsTrue(PublishStatusQueryService.HasPublishedAncestorPath(grandchild.Key, Constants.System.InvariantCulture)); + } } 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 d73fff433b74..2d1a4f5b80bc 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringServiceTests.cs @@ -127,6 +127,91 @@ public void FilterAvailable_Variant_ForPreview_YieldsUnpublishedItemsInCulture(s } } + [TestCase("da-DK", 1)] + [TestCase("en-US", 2)] + [TestCase("*", 3)] + public void FilterAvailable_Variant_ForNonPreview_YieldsOnlyItemsWithPublishedAncestorPath(string culture, int expectedNumberOfChildren) + { + var (sut, items) = SetupVariant( + false, + culture == Constants.System.InvariantCulture ? "en-US" : culture, + (key, _, allItems) => allItems.Keys.IndexOf(key) > 2); + + var children = sut.FilterAvailable(items.Keys, culture).ToArray(); + Assert.AreEqual(expectedNumberOfChildren, children.Length); + + // IDs 0 through 3 exist in both en-US and da-DK, but none pass both the published and ancestor-path checks + + // IDs 4 through 6 exist only in en-US - only even IDs are published + if (culture == "en-US") + { + Assert.AreEqual(4, children[0].Id); + Assert.AreEqual(6, children[1].Id); + } + + // IDs 7 through 9 exist only in da-DK - only even IDs are published + if (culture == "da-DK") + { + Assert.AreEqual(8, children[0].Id); + } + + if (culture == Constants.System.InvariantCulture) + { + Assert.AreEqual(4, children[0].Id); + Assert.AreEqual(6, children[1].Id); + Assert.AreEqual(8, children[2].Id); + } + } + + [TestCase("da-DK", 7)] + [TestCase("en-US", 7)] + [TestCase("*", 10)] + public void FilterAvailable_Variant_ForPreview_IgnoresMissingPublishedAncestorPath(string culture, int expectedNumberOfChildren) + { + var (sut, items) = SetupVariant( + true, + culture == Constants.System.InvariantCulture ? "en-US" : culture, + (_, _, _) => false); + + var children = sut.FilterAvailable(items.Keys, culture).ToArray(); + Assert.AreEqual(expectedNumberOfChildren, children.Length); + + // IDs 0 through 3 exist in both en-US and da-DK + Assert.Multiple(() => + { + Assert.AreEqual(0, children[0].Id); + Assert.AreEqual(1, children[1].Id); + Assert.AreEqual(2, children[2].Id); + Assert.AreEqual(3, children[3].Id); + }); + + // IDs 4 through 6 exist only in en-US + if (culture == "en-US") + { + Assert.AreEqual(4, children[4].Id); + Assert.AreEqual(5, children[5].Id); + Assert.AreEqual(6, children[6].Id); + } + + // IDs 7 through 9 exist only in da-DK + if (culture == "da-DK") + { + Assert.AreEqual(7, children[4].Id); + Assert.AreEqual(8, children[5].Id); + Assert.AreEqual(9, children[6].Id); + } + + if (culture == Constants.System.InvariantCulture) + { + Assert.AreEqual(4, children[4].Id); + Assert.AreEqual(5, children[5].Id); + Assert.AreEqual(6, children[6].Id); + Assert.AreEqual(7, children[7].Id); + Assert.AreEqual(8, children[8].Id); + Assert.AreEqual(9, children[9].Id); + } + } + [TestCase("da-DK", 4)] [TestCase("en-US", 4)] [TestCase("*", 5)] @@ -260,7 +345,7 @@ public void FilterAvailable_MixedVariance_ForPreview_YieldsPublishedItemsInCultu // - IDs 4 through 6 exist only in en-US // - IDs 7 through 9 exist only in da-DK // - even IDs (0, 2, ...) are published, odd are unpublished - private (PublishedContentStatusFilteringService PublishedContentStatusFilteringService, Dictionary Items) SetupVariant(bool forPreview, string requestCulture) + private (PublishedContentStatusFilteringService PublishedContentStatusFilteringService, Dictionary Items) SetupVariant(bool forPreview, string requestCulture, Func, bool>? hasPublishedAncestorPath = null) { var contentType = new Mock(); contentType.SetupGet(c => c.Variations).Returns(ContentVariation.Culture); @@ -287,7 +372,7 @@ public void FilterAvailable_MixedVariance_ForPreview_YieldsPublishedItemsInCultu var publishedContentCache = SetupPublishedContentCache(forPreview, items); var previewService = SetupPreviewService(forPreview); - var publishStatusQueryService = SetupPublishStatusQueryService(items); + var publishStatusQueryService = SetupPublishStatusQueryService(items, hasPublishedAncestorPath); var variationContextAccessor = SetupVariantContextAccessor(requestCulture); return ( @@ -353,10 +438,10 @@ public void FilterAvailable_MixedVariance_ForPreview_YieldsPublishedItemsInCultu items); } - private IPublishStatusQueryService SetupPublishStatusQueryService(Dictionary items) - => SetupPublishStatusQueryService(items, id => id % 2 == 0); + private IPublishStatusQueryService SetupPublishStatusQueryService(Dictionary items, Func, bool>? hasPublishedAncestorPath = null) + => SetupPublishStatusQueryService(items, id => id % 2 == 0, hasPublishedAncestorPath); - private IPublishStatusQueryService SetupPublishStatusQueryService(Dictionary items, Func idIsPublished) + private IPublishStatusQueryService SetupPublishStatusQueryService(Dictionary items, Func idIsPublished, Func, bool>? hasPublishedAncestorPath = null) { var publishStatusQueryService = new Mock(); publishStatusQueryService @@ -366,8 +451,8 @@ private IPublishStatusQueryService SetupPublishStatusQueryService(Dictionary s.HasPublishedAncestorPath(It.IsAny())) - .Returns(true); + .Setup(s => s.HasPublishedAncestorPath(It.IsAny(), It.IsAny())) + .Returns((Guid key, string culture) => hasPublishedAncestorPath?.Invoke(key, culture, items) ?? true); return publishStatusQueryService.Object; }