diff --git a/src/Umbraco.Core/DeliveryApi/ApiContentBuilderBase.cs b/src/Umbraco.Core/DeliveryApi/ApiContentBuilderBase.cs index bc9b37414e..a978d63c8e 100644 --- a/src/Umbraco.Core/DeliveryApi/ApiContentBuilderBase.cs +++ b/src/Umbraco.Core/DeliveryApi/ApiContentBuilderBase.cs @@ -36,22 +36,6 @@ public abstract class ApiContentBuilderBase return default; } - // If a segment is requested and no segmented properties have any values, we consider the segment as not created or non-existing and return null. - // This aligns the behaviour of the API when it comes to "Accept-Segment" and "Accept-Language" requests, so 404 is returned for both when - // the segment or language is not created or does not exist. - // It also aligns with what we show in the backoffice for whether a segment is "Published" or "Not created". - // Requested languages that aren't created or don't exist will already have exited early in the route builder. - var segment = VariationContextAccessor.VariationContext?.Segment; - if (segment.IsNullOrWhiteSpace() is false - && content.ContentType.VariesBySegment() - && content - .Properties - .Where(p => p.PropertyType.VariesBySegment()) - .All(p => p.HasValue(VariationContextAccessor.VariationContext?.Culture, segment) is false)) - { - return default; - } - IDictionary properties = _outputExpansionStrategyAccessor.TryGetValue(out IOutputExpansionStrategy? outputExpansionStrategy) ? outputExpansionStrategy.MapContentProperties(content) diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs index 368e4a99ee..7265b763b8 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs @@ -32,6 +32,11 @@ public class PublishedValueFallback : IPublishedValueFallback { _variationContextAccessor.ContextualizeVariation(property.PropertyType.Variations, property.Alias, ref culture, ref segment); + if (TryGetValueForDefaultSegment(property, culture, segment, out value)) + { + return true; + } + foreach (var f in fallback) { switch (f) @@ -80,6 +85,11 @@ public class PublishedValueFallback : IPublishedValueFallback _variationContextAccessor.ContextualizeVariation(propertyType.Variations, alias, ref culture, ref segment); + if (TryGetValueForDefaultSegment(content, alias, culture, segment, out value)) + { + return true; + } + foreach (var f in fallback) { switch (f) @@ -128,6 +138,11 @@ public class PublishedValueFallback : IPublishedValueFallback noValueProperty = content.GetProperty(alias); } + if (propertyType != null && TryGetValueForDefaultSegment(content, alias, culture, segment, out value)) + { + return true; + } + // note: we don't support "recurse & language" which would walk up the tree, // looking at languages at each level - should someone need it... they'll have // to implement it. @@ -179,6 +194,30 @@ public class PublishedValueFallback : IPublishedValueFallback new NotSupportedException( $"Fallback {GetType().Name} does not support fallback code '{fallback}' at {level} level."); + private bool TryGetValueForDefaultSegment(IPublishedElement content, string alias, string? culture, string? segment, out T? value) + { + IPublishedProperty? property = content.GetProperty(alias); + if (property is not null) + { + return TryGetValueForDefaultSegment(property, culture, segment, out value); + } + + value = default; + return false; + } + + private bool TryGetValueForDefaultSegment(IPublishedProperty property, string? culture, string? segment, out T? value) + { + if (segment.IsNullOrWhiteSpace() is false && property.HasValue(culture, segment: string.Empty)) + { + value = property.Value(this, culture, segment: string.Empty); + return true; + } + + value = default; + return false; + } + // tries to get a value, recursing the tree // because we recurse, content may not even have the a property with the specified alias (but only some ancestor) // in case no value was found, noValueProperty contains the first property that was found (which does not have a value) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/PublishedContent/PublishedContentFallbackTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/PublishedContent/PublishedContentFallbackTests.cs new file mode 100644 index 0000000000..f051aa1557 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/PublishedContent/PublishedContentFallbackTests.cs @@ -0,0 +1,136 @@ +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.PublishedContent; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class PublishedContentFallbackTests : UmbracoIntegrationTest +{ + private IContentService ContentService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IUmbracoContextAccessor UmbracoContextAccessor => GetRequiredService(); + + private IUmbracoContextFactory UmbracoContextFactory => GetRequiredService(); + + private IVariationContextAccessor VariationContextAccessor => GetRequiredService(); + + private IPublishedValueFallback PublishedValueFallback => GetRequiredService(); + + private ContentCacheRefresher ContentCacheRefresher => GetRequiredService(); + + private IApiContentBuilder ApiContentBuilder => GetRequiredService(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) + => builder + .AddUmbracoHybridCache() + .AddDeliveryApi(); + + [SetUp] + public void SetUpTest() + { + var httpContextAccessor = GetRequiredService(); + + httpContextAccessor.HttpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/", + QueryString = new QueryString(string.Empty) + }, + RequestServices = Services + }; + } + + [TestCase("Invariant title", "Segmented title", "Segmented title")] + [TestCase(null, "Segmented title", "Segmented title")] + [TestCase("Invariant title", null, "Invariant title")] + [TestCase(null, null, null)] + public async Task Property_Value_Performs_Fallback_To_Default_Segment_For_Templated_Rendering(string? invariantTitle, string? segmentedTitle, string? expectedResult) + { + var publishedContent = await SetupSegmentedContentAsync(invariantTitle, segmentedTitle); + + // NOTE: the TextStringValueConverter.ConvertIntermediateToObject() explicitly converts a null source value to an empty string + + var segmentedResult = publishedContent.Value(PublishedValueFallback, "title", segment: "s1"); + Assert.AreEqual(expectedResult ?? string.Empty, segmentedResult); + + var invariantResult = publishedContent.Value(PublishedValueFallback, "title", segment: string.Empty); + Assert.AreEqual(invariantTitle ?? string.Empty, invariantResult); + } + + [TestCase("Invariant title", "Segmented title", "Segmented title")] + [TestCase(null, "Segmented title", "Segmented title")] + [TestCase("Invariant title", null, "Invariant title")] + [TestCase(null, null, null)] + public async Task Property_Value_Performs_Fallback_To_Default_Segment_For_Delivery_Api_Output(string? invariantTitle, string? segmentedTitle, string? expectedResult) + { + UmbracoContextFactory.EnsureUmbracoContext(); + + var publishedContent = await SetupSegmentedContentAsync(invariantTitle, segmentedTitle); + + VariationContextAccessor.VariationContext = new VariationContext(culture: null, segment: "s1"); + var apiContent = ApiContentBuilder.Build(publishedContent); + Assert.IsNotNull(apiContent); + Assert.IsTrue(apiContent.Properties.TryGetValue("title", out var segmentedValue)); + Assert.AreEqual(expectedResult, segmentedValue); + + VariationContextAccessor.VariationContext = new VariationContext(culture: null, segment: null); + apiContent = ApiContentBuilder.Build(publishedContent); + Assert.IsNotNull(apiContent); + Assert.IsTrue(apiContent.Properties.TryGetValue("title", out var invariantValue)); + Assert.AreEqual(invariantTitle, invariantValue); + } + + private async Task SetupSegmentedContentAsync(string? invariantTitle, string? segmentedTitle) + { + var contentType = new ContentTypeBuilder() + .WithAlias("theContentType") + .WithContentVariation(ContentVariation.Segment) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Segment) + .Done() + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var content = new ContentBuilder() + .WithContentType(contentType) + .WithName("Content") + .Build(); + content.SetValue("title", invariantTitle); + content.SetValue("title", segmentedTitle, segment: "s1"); + ContentService.Save(content); + ContentService.Publish(content, ["*"]); + + ContentCacheRefresher.Refresh([new ContentCacheRefresher.JsonPayload { ChangeTypes = TreeChangeTypes.RefreshAll }]); + + UmbracoContextAccessor.Clear(); + var umbracoContext = UmbracoContextFactory.EnsureUmbracoContext().UmbracoContext; + var publishedContent = umbracoContext.Content.GetById(content.Key); + Assert.IsNotNull(publishedContent); + + return publishedContent; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs index 7cca1c3606..6fad0a86d0 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs @@ -144,75 +144,4 @@ public class ContentBuilderTests : DeliveryApiTests Assert.Null(result); } - - [TestCase("Shared value", "Segmented value", false)] - [TestCase(null, "Segmented value", false)] - [TestCase("Shared value", null, true)] - [TestCase(null, null, true)] - public void ContentBuilder_ReturnsNullForRequestedSegmentThatIsNotCreated(object? sharedValue, object? segmentedValue, bool expectNull) - { - var content = new Mock(); - - var sharedPropertyValueConverter = new Mock(); - sharedPropertyValueConverter.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(sharedValue is not null); - sharedPropertyValueConverter.Setup(p => p.ConvertIntermediateToDeliveryApiObject( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())).Returns(sharedValue); - sharedPropertyValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); - sharedPropertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - sharedPropertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - - var sharedPropertyType = SetupPublishedPropertyType(sharedPropertyValueConverter.Object, "sharedMessage", "Umbraco.Textstring"); - var sharedProperty = new PublishedElementPropertyBase(sharedPropertyType, content.Object, false, PropertyCacheLevel.None, new VariationContext(), Mock.Of()); - - var segmentedPropertyValueConverter = new Mock(); - segmentedPropertyValueConverter.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(segmentedValue is not null); - segmentedPropertyValueConverter.Setup(p => p.ConvertIntermediateToDeliveryApiObject( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())).Returns(segmentedValue); - segmentedPropertyValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); - segmentedPropertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - segmentedPropertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - - var segmentedPropertyType = SetupPublishedPropertyType(segmentedPropertyValueConverter.Object, "segmentedMessage", "Umbraco.Textstring", contentVariation: ContentVariation.Segment); - var segmentedProperty = new PublishedElementPropertyBase(segmentedPropertyType, content.Object, false, PropertyCacheLevel.None, new VariationContext(), Mock.Of()); - - var contentType = new Mock(); - contentType.SetupGet(c => c.Alias).Returns("thePageType"); - contentType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Content); - contentType.SetupGet(c => c.Variations).Returns(ContentVariation.Segment); - - var key = Guid.NewGuid(); - var urlSegment = "url-segment"; - var name = "The page"; - ConfigurePublishedContentMock(content, key, name, urlSegment, contentType.Object, [sharedProperty, segmentedProperty]); - - var routeBuilderMock = new Mock(); - routeBuilderMock - .Setup(r => r.Build(content.Object, It.IsAny())) - .Returns(new ApiContentRoute(content.Object.UrlSegment!, new ApiContentStartItem(Guid.NewGuid(), "/"))); - - var variationContextAccessorMock = new Mock(); - variationContextAccessorMock.Setup(v => v.VariationContext).Returns(new VariationContext(segment: "missingSegment")); - - var builder = new ApiContentBuilder(new ApiContentNameProvider(), routeBuilderMock.Object, CreateOutputExpansionStrategyAccessor(), variationContextAccessorMock.Object); - var result = builder.Build(content.Object); - - if (expectNull) - { - Assert.IsNull(result); - } - else - { - Assert.IsNotNull(result); - } - } }