diff --git a/src/Umbraco.Core/Configuration/Models/HostingSettings.cs b/src/Umbraco.Core/Configuration/Models/HostingSettings.cs index c8df39b49a..99c1aa5649 100644 --- a/src/Umbraco.Core/Configuration/Models/HostingSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/HostingSettings.cs @@ -25,6 +25,12 @@ public class HostingSettings [DefaultValue(StaticLocalTempStorageLocation)] public LocalTempStorage LocalTempStorageLocation { get; set; } = Enum.Parse(StaticLocalTempStorageLocation); + /// + /// Gets or sets a value for the location of temporary file uploads. + /// + /// /umbraco/Data/TEMP/TemporaryFile if nothing is specified. + public string? TemporaryFileUploadLocation { get; set; } + /// /// Gets or sets a value indicating whether umbraco is running in [debug mode]. /// diff --git a/src/Umbraco.Core/Hosting/IHostingEnvironment.cs b/src/Umbraco.Core/Hosting/IHostingEnvironment.cs index 1dfa72039c..15a7ef74e3 100644 --- a/src/Umbraco.Core/Hosting/IHostingEnvironment.cs +++ b/src/Umbraco.Core/Hosting/IHostingEnvironment.cs @@ -43,7 +43,12 @@ public interface IHostingEnvironment string LocalTempPath { get; } /// - /// The web application's hosted path + /// Gets the location of temporary file uploads. + /// + public string TemporaryFileUploadPath => Path.Combine(LocalTempPath, "TemporaryFile"); + + /// + /// The web application's hosted path. /// /// /// In most cases this will return "/" but if the site is hosted in a virtual directory then this will return the diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LocalFileSystemTemporaryFileRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LocalFileSystemTemporaryFileRepository.cs index 0190cd2034..a369a6e87d 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LocalFileSystemTemporaryFileRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LocalFileSystemTemporaryFileRepository.cs @@ -27,7 +27,7 @@ internal sealed class LocalFileSystemTemporaryFileRepository : ITemporaryFileRep private DirectoryInfo GetRootDirectory() { - var path = Path.Combine(_hostingEnvironment.LocalTempPath, "TemporaryFile"); + var path = _hostingEnvironment.TemporaryFileUploadPath; if (!Directory.Exists(path)) { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs index d39d13e243..65c6835f6b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs @@ -72,6 +72,19 @@ public class RteBlockRenderingValueConverter : SimpleRichTextValueConverter, IDe // to be cached at the published snapshot level, because we have no idea what the block renderings may depend on actually. PropertyCacheLevel.Snapshot; + /// + public override bool? IsValue(object? value, PropertyValueLevel level) + => level switch + { + // we cannot determine if an RTE has a value at source level, because some RTEs might + // be saved with an "empty" representation like {"markup":"","blocks":null}. + PropertyValueLevel.Source => null, + // we assume the RTE has a value if the intermediate value has markup beyond an empty paragraph tag. + PropertyValueLevel.Inter => value is IRichTextEditorIntermediateValue { Markup.Length: > 0 } intermediateValue + && intermediateValue.Markup != "

", + _ => throw new ArgumentOutOfRangeException(nameof(level), level, null) + }; + // to counterweigh the cache level, we're going to do as much of the heavy lifting as we can while converting source to intermediate public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview) { diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs index f6ff872ad1..a6c6273645 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs @@ -1,10 +1,7 @@ -using Microsoft.Extensions.Logging; -using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Extensions; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; -using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.HybridCache.Factories; @@ -16,8 +13,6 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory private readonly IElementsCache _elementsCache; private readonly IVariationContextAccessor _variationContextAccessor; private readonly IPublishedContentTypeCache _publishedContentTypeCache; - private readonly ILogger _logger; - private readonly AppCaches _appCaches; /// /// Initializes a new instance of the class. @@ -25,40 +20,16 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory public PublishedContentFactory( IElementsCache elementsCache, IVariationContextAccessor variationContextAccessor, - IPublishedContentTypeCache publishedContentTypeCache, - ILogger logger, - AppCaches appCaches) + IPublishedContentTypeCache publishedContentTypeCache) { _elementsCache = elementsCache; _variationContextAccessor = variationContextAccessor; _publishedContentTypeCache = publishedContentTypeCache; - _logger = logger; - _appCaches = appCaches; } /// public IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview) { - var cacheKey = $"{nameof(PublishedContentFactory)}DocumentCache_{contentCacheNode.Id}_{preview}_{contentCacheNode.Data?.VersionDate.Ticks ?? 0}"; - IPublishedContent? publishedContent = null; - if (_appCaches.RequestCache.IsAvailable) - { - publishedContent = _appCaches.RequestCache.GetCacheItem(cacheKey); - if (publishedContent is not null) - { - _logger.LogDebug( - "Using cached IPublishedContent for document {ContentCacheNodeName} ({ContentCacheNodeId}).", - contentCacheNode.Data?.Name ?? "No Name", - contentCacheNode.Id); - return publishedContent; - } - } - - _logger.LogDebug( - "Creating IPublishedContent for document {ContentCacheNodeName} ({ContentCacheNodeId}).", - contentCacheNode.Data?.Name ?? "No Name", - contentCacheNode.Id); - IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Content, contentCacheNode.ContentTypeId); var contentNode = new ContentNode( @@ -71,44 +42,19 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory preview ? contentCacheNode.Data : null, preview ? null : contentCacheNode.Data); - publishedContent = GetModel(contentNode, preview); + IPublishedContent? publishedContent = GetModel(contentNode, preview); if (preview) { publishedContent ??= GetPublishedContentAsDraft(publishedContent); } - if (_appCaches.RequestCache.IsAvailable && publishedContent is not null) - { - _appCaches.RequestCache.Set(cacheKey, publishedContent); - } - return publishedContent; } /// public IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode) { - var cacheKey = $"{nameof(PublishedContentFactory)}MediaCache_{contentCacheNode.Id}"; - IPublishedContent? publishedContent = null; - if (_appCaches.RequestCache.IsAvailable) - { - publishedContent = _appCaches.RequestCache.GetCacheItem(cacheKey); - if (publishedContent is not null) - { - _logger.LogDebug( - "Using cached IPublishedContent for media {ContentCacheNodeName} ({ContentCacheNodeId}).", - contentCacheNode.Data?.Name ?? "No Name", - contentCacheNode.Id); - return publishedContent; - } - } - - _logger.LogDebug( - "Creating IPublishedContent for media {ContentCacheNodeName} ({ContentCacheNodeId}).", - contentCacheNode.Data?.Name ?? "No Name", - contentCacheNode.Id); - IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Media, contentCacheNode.ContentTypeId); var contentNode = new ContentNode( @@ -121,40 +67,12 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory null, contentCacheNode.Data); - publishedContent = GetModel(contentNode, false); - - if (_appCaches.RequestCache.IsAvailable && publishedContent is not null) - { - _appCaches.RequestCache.Set(cacheKey, publishedContent); - } - - return publishedContent; + return GetModel(contentNode, false); } /// public IPublishedMember ToPublishedMember(IMember member) { - string cacheKey = $"{nameof(PublishedContentFactory)}MemberCache_{member.Id}"; - IPublishedMember? publishedMember = null; - if (_appCaches.RequestCache.IsAvailable) - { - publishedMember = _appCaches.RequestCache.GetCacheItem(cacheKey); - if (publishedMember is not null) - { - _logger.LogDebug( - "Using cached IPublishedMember for member {MemberName} ({MemberId}).", - member.Username, - member.Id); - - return publishedMember; - } - } - - _logger.LogDebug( - "Creating IPublishedMember for member {MemberName} ({MemberId}).", - member.Username, - member.Id); - IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Member, member.ContentTypeId); @@ -179,14 +97,7 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory contentType, null, contentData); - publishedMember = new PublishedMember(member, contentNode, _elementsCache, _variationContextAccessor); - - if (_appCaches.RequestCache.IsAvailable) - { - _appCaches.RequestCache.Set(cacheKey, publishedMember); - } - - return publishedMember; + return new PublishedMember(member, contentNode, _elementsCache, _variationContextAccessor); } private static Dictionary GetPropertyValues(IPublishedContentType contentType, IMember member) diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs index 2879be5c8d..79d9235e28 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs @@ -1,6 +1,7 @@ #if DEBUG using System.Diagnostics; #endif +using System.Collections.Concurrent; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -35,6 +36,8 @@ internal sealed class DocumentCacheService : IDocumentCacheService private readonly ILogger _logger; private HashSet? _seedKeys; + private readonly ConcurrentDictionary _publishedContentCache = []; + private HashSet SeedKeys { get @@ -108,6 +111,11 @@ internal sealed class DocumentCacheService : IDocumentCacheService { var cacheKey = GetCacheKey(key, preview); + if (preview is false && _publishedContentCache.TryGetValue(cacheKey, out IPublishedContent? cached)) + { + return cached; + } + ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( cacheKey, async cancel => @@ -137,7 +145,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService return null; } - return _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory); + IPublishedContent? result = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory); + if (result is not null) + { + _publishedContentCache[cacheKey] = result; + } + + return result; } private bool GetPreview() => _previewService.IsInPreview(); @@ -174,7 +188,9 @@ internal sealed class DocumentCacheService : IDocumentCacheService ContentCacheNode? publishedNode = await _databaseCacheRepository.GetContentSourceAsync(key, false); if (publishedNode is not null && _publishStatusQueryService.HasPublishedAncestorPath(publishedNode.Key)) { - await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key, false), publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(key)); + var cacheKey = GetCacheKey(publishedNode.Key, false); + await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(key)); + _publishedContentCache.Remove(cacheKey, out _); } scope.Complete(); @@ -183,7 +199,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService public async Task RemoveFromMemoryCacheAsync(Guid key) { await _hybridCache.RemoveAsync(GetCacheKey(key, true)); - await _hybridCache.RemoveAsync(GetCacheKey(key, false)); + await ClearPublishedCacheAsync(key); } public async Task SeedAsync(CancellationToken cancellationToken) @@ -300,7 +316,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService if (content.PublishedState == PublishedState.Unpublishing) { - await _hybridCache.RemoveAsync(GetCacheKey(publishedCacheNode.Key, false)); + await ClearPublishedCacheAsync(publishedCacheNode.Key); } } @@ -338,12 +354,19 @@ internal sealed class DocumentCacheService : IDocumentCacheService foreach (ContentCacheNode content in contentByContentTypeKey) { - _hybridCache.RemoveAsync(GetCacheKey(content.Key, true)).GetAwaiter().GetResult(); + await _hybridCache.RemoveAsync(GetCacheKey(content.Key, true)); if (content.IsDraft is false) { - _hybridCache.RemoveAsync(GetCacheKey(content.Key, false)).GetAwaiter().GetResult(); + await ClearPublishedCacheAsync(content.Key); } } } + + private async Task ClearPublishedCacheAsync(Guid key) + { + var cacheKey = GetCacheKey(key, false); + await _hybridCache.RemoveAsync(cacheKey); + _publishedContentCache.Remove(cacheKey, out _); + } } diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs index 46d782bdbe..9fe3dc5990 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs @@ -1,6 +1,7 @@ #if DEBUG using System.Diagnostics; #endif +using System.Collections.Concurrent; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -31,6 +32,8 @@ internal sealed class MediaCacheService : IMediaCacheService private readonly ILogger _logger; private readonly CacheSettings _cacheSettings; + private readonly ConcurrentDictionary _publishedContentCache = []; + private HashSet? _seedKeys; private HashSet SeedKeys { @@ -103,6 +106,12 @@ internal sealed class MediaCacheService : IMediaCacheService private async Task GetNodeAsync(Guid key) { var cacheKey = $"{key}"; + + if (_publishedContentCache.TryGetValue(cacheKey, out IPublishedContent? cached)) + { + return cached; + } + ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( cacheKey, // Unique key to the cache entry async cancel => @@ -122,7 +131,13 @@ internal sealed class MediaCacheService : IMediaCacheService return null; } - return _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory); + IPublishedContent? result = _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory); + if (result is not null) + { + _publishedContentCache[cacheKey] = result; + } + + return result; } public async Task HasContentByIdAsync(int id) @@ -141,6 +156,7 @@ internal sealed class MediaCacheService : IMediaCacheService using ICoreScope scope = _scopeProvider.CreateCoreScope(); var cacheNode = _cacheNodeFactory.ToContentCacheNode(media); await _databaseCacheRepository.RefreshMediaAsync(cacheNode); + _publishedContentCache.Remove(GetCacheKey(media.Key, false), out _); scope.Complete(); } @@ -219,7 +235,9 @@ internal sealed class MediaCacheService : IMediaCacheService ContentCacheNode? publishedNode = await _databaseCacheRepository.GetMediaSourceAsync(key); if (publishedNode is not null) { - await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key, false), publishedNode, GetEntryOptions(publishedNode.Key)); + var cacheKey = GetCacheKey(publishedNode.Key, false); + await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key)); + _publishedContentCache.Remove(cacheKey, out _); } scope.Complete(); @@ -234,7 +252,7 @@ internal sealed class MediaCacheService : IMediaCacheService } public async Task RemoveFromMemoryCacheAsync(Guid key) - => await _hybridCache.RemoveAsync(GetCacheKey(key, false)); + => await ClearPublishedCacheAsync(key); public async Task RebuildMemoryCacheByContentTypeAsync(IEnumerable mediaTypeIds) { @@ -244,11 +262,11 @@ internal sealed class MediaCacheService : IMediaCacheService foreach (ContentCacheNode content in contentByContentTypeKey) { - _hybridCache.RemoveAsync(GetCacheKey(content.Key, true)).GetAwaiter().GetResult(); + await _hybridCache.RemoveAsync(GetCacheKey(content.Key, true)); if (content.IsDraft is false) { - _hybridCache.RemoveAsync(GetCacheKey(content.Key, false)).GetAwaiter().GetResult(); + await ClearPublishedCacheAsync(content.Key); } } @@ -269,7 +287,7 @@ internal sealed class MediaCacheService : IMediaCacheService foreach (ContentCacheNode media in mediaCacheNodesByContentTypeKey) { - _hybridCache.RemoveAsync(GetCacheKey(media.Key, false)); + ClearPublishedCacheAsync(media.Key).GetAwaiter().GetResult(); } scope.Complete(); @@ -313,4 +331,11 @@ internal sealed class MediaCacheService : IMediaCacheService // We use the tags to be able to clear all cache entries that are related to a given content item. // Tags for now are only content/media, but can be expanded with draft/published later. private static HashSet GenerateTags(Guid? key) => key is null ? [] : [Constants.Cache.Tags.Media]; + + private async Task ClearPublishedCacheAsync(Guid key) + { + var cacheKey = GetCacheKey(key, false); + await _hybridCache.RemoveAsync(cacheKey); + _publishedContentCache.Remove(cacheKey, out _); + } } diff --git a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs index 324781b5a3..9a57147bcc 100644 --- a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs +++ b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs @@ -132,6 +132,10 @@ public class AspNetCoreHostingEnvironment : IHostingEnvironment } } + /// + public string TemporaryFileUploadPath => _hostingSettings.CurrentValue.TemporaryFileUploadLocation + ?? Path.Combine(MapPathContentRoot(Core.Constants.SystemDirectories.TempData), "TemporaryFile"); + /// public string MapPathWebRoot(string path) => _webHostEnvironment.MapPathWebRoot(path); diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.element.ts index d35e86ba08..2511524766 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/modals/data-type-picker-flow/data-type-picker-flow-modal.element.ts @@ -142,7 +142,10 @@ export class UmbDataTypePickerFlowModalElement extends UmbModalBaseElement< } this.removeUmbController(contentContextConsumer); this.removeUmbController(propContextConsumer); - const propertyEditorName = this.#propertyEditorUIs.find((ui) => ui.alias === params.uiAlias)?.name; + const propertyEditorUiManifest = this.#propertyEditorUIs.find((ui) => ui.alias === params.uiAlias); + const propertyEditorName = this.localize.string( + propertyEditorUiManifest?.meta?.label || propertyEditorUiManifest?.name || '#general_notFound', + ); const dataTypeName = `${contentContext?.getName() ?? ''} - ${propContext.getName() ?? ''} - ${propertyEditorName}`; return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace.context.ts index 19d507f51e..0c924e6458 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/data-type-workspace.context.ts @@ -208,7 +208,7 @@ export class UmbDataTypeWorkspaceContext umbExtensionsRegistry.byTypeAndAlias('propertyEditorUi', propertyEditorUIAlias), (manifest) => { this.#propertyEditorUiIcon.setValue(manifest?.meta.icon || null); - this.#propertyEditorUiName.setValue(manifest?.name || null); + this.#propertyEditorUiName.setValue(manifest?.meta?.label || manifest?.name || null); // Maps properties to have a weight, so they can be sorted, notice UI properties have a +1000 weight compared to schema properties. this.#propertyEditorUISettingsProperties = (manifest?.meta.settings?.properties ?? []).map((x, i) => ({ diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/details/data-type-details-workspace-property-editor-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/details/data-type-details-workspace-property-editor-picker.element.ts index 706f8a9a37..1f493682bf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/details/data-type-details-workspace-property-editor-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/details/data-type-details-workspace-property-editor-picker.element.ts @@ -83,7 +83,7 @@ export class UmbDataTypeDetailsWorkspacePropertyEditorPickerElement extends UmbF #renderPropertyEditorReference() { if (!this.propertyEditorUiAlias || !this.propertyEditorSchemaAlias) return nothing; - let name = this.propertyEditorUiName; + let name = this.localize.string(this.propertyEditorUiName); let alias = this.propertyEditorUiAlias; let error = false; diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts index 8a02804572..57382d6eac 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts @@ -2,7 +2,6 @@ import {expect} from "@playwright/test"; const dataTypeName = 'Richtext editor'; -const tipTapPropertyEditorName = 'Rich Text Editor [Tiptap] Property Editor UI'; const tipTapAlias = 'Umbraco.RichText'; const tipTapUiAlias = 'Umb.PropertyEditorUi.Tiptap'; const extensionsDefaultValue = [ @@ -81,10 +80,9 @@ test('tiptap is the default property editor in rich text editor', async ({umbrac // Act await umbracoUi.dataType.goToDataType(dataTypeName); - // Assert + // Assert await umbracoUi.dataType.doesSettingHaveValue(ConstantHelper.tipTapSettings); await umbracoUi.dataType.doesSettingItemsHaveCount(ConstantHelper.tipTapSettings); - await umbracoUi.dataType.doesPropertyEditorHaveName(tipTapPropertyEditorName); await umbracoUi.dataType.doesPropertyEditorHaveAlias(tipTapAlias); await umbracoUi.dataType.doesPropertyEditorHaveUiAlias(tipTapUiAlias); const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName); @@ -95,4 +93,4 @@ test('tiptap is the default property editor in rich text editor', async ({umbrac expect(await umbracoApi.dataType.doesTiptapExtensionsItemsMatchCount(dataTypeName, extensionsDefaultValue.length)).toBeTruthy(); expect(await umbracoApi.dataType.doesTiptapExtensionsHaveItems(dataTypeName, extensionsDefaultValue)).toBeTruthy(); expect(await umbracoApi.dataType.doesTiptapToolbarHaveItems(dataTypeName, toolbarDefaultValue)).toBeTruthy(); -}); \ No newline at end of file +}); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs index b1f908d852..41ee397548 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs @@ -1,13 +1,19 @@ using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; 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; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors; @@ -23,6 +29,14 @@ internal sealed class RichTextPropertyEditorTests : UmbracoIntegrationTest private IJsonSerializer JsonSerializer => GetRequiredService(); + private IPublishedContentCache PublishedContentCache => GetRequiredService(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + builder.AddNotificationHandler(); + builder.Services.AddUnique(); + } + [Test] public void Can_Use_Markup_String_As_Value() { @@ -180,4 +194,103 @@ internal sealed class RichTextPropertyEditorTests : UmbracoIntegrationTest Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag Two")); Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag Three")); } + + [TestCase(null, false)] + [TestCase("", false)] + [TestCase("""{"markup":"","blocks":null}""", false)] + [TestCase("""{"markup":"

","blocks":null}""", false)] + [TestCase("abc", true)] + [TestCase("""{"markup":"abc","blocks":null}""", true)] + public async Task Can_Handle_Empty_Value_Representations_For_Invariant_Content(string? rteValue, bool expectedHasValue) + { + var contentType = await CreateContentTypeForEmptyValueTests(); + + var content = new ContentBuilder() + .WithContentType(contentType) + .WithName("Page") + .WithPropertyValues( + new + { + rte = rteValue + }) + .Build(); + + var contentResult = ContentService.Save(content); + Assert.IsTrue(contentResult.Success); + + var publishResult = ContentService.Publish(content, []); + Assert.IsTrue(publishResult.Success); + + var publishedContent = await PublishedContentCache.GetByIdAsync(content.Key); + Assert.IsNotNull(publishedContent); + + var publishedProperty = publishedContent.Properties.First(property => property.Alias == "rte"); + Assert.AreEqual(expectedHasValue, publishedProperty.HasValue()); + + Assert.AreEqual(expectedHasValue, publishedContent.HasValue("rte")); + } + + [TestCase(null, false)] + [TestCase("", false)] + [TestCase("""{"markup":"","blocks":null}""", false)] + [TestCase("""{"markup":"

","blocks":null}""", false)] + [TestCase("abc", true)] + [TestCase("""{"markup":"abc","blocks":null}""", true)] + public async Task Can_Handle_Empty_Value_Representations_For_Variant_Content(string? rteValue, bool expectedHasValue) + { + var contentType = await CreateContentTypeForEmptyValueTests(ContentVariation.Culture); + + var content = new ContentBuilder() + .WithContentType(contentType) + .WithName("Page") + .WithCultureName("en-US", "Page") + .WithPropertyValues( + new + { + rte = rteValue + }, + "en-US") + .Build(); + + var contentResult = ContentService.Save(content); + Assert.IsTrue(contentResult.Success); + + var publishResult = ContentService.Publish(content, ["en-US"]); + Assert.IsTrue(publishResult.Success); + + var publishedContent = await PublishedContentCache.GetByIdAsync(content.Key); + Assert.IsNotNull(publishedContent); + + var publishedProperty = publishedContent.Properties.First(property => property.Alias == "rte"); + Assert.AreEqual(expectedHasValue, publishedProperty.HasValue("en-US")); + + Assert.AreEqual(expectedHasValue, publishedContent.HasValue("rte", "en-US")); + } + + private async Task CreateContentTypeForEmptyValueTests(ContentVariation contentVariation = ContentVariation.Nothing) + { + var contentType = new ContentTypeBuilder() + .WithAlias("myPage") + .WithName("My Page") + .WithContentVariation(contentVariation) + .AddPropertyGroup() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .AddPropertyType() + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.RichText) + .WithDataTypeId(Constants.DataTypes.RichtextEditor) + .WithValueStorageType(ValueStorageType.Ntext) + .WithAlias("rte") + .WithName("RTE") + .WithVariations(contentVariation) + .Done() + .Done() + .Build(); + + var contentTypeResult = await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + Assert.IsTrue(contentTypeResult.Success); + + return contentType; + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs index 45d192587f..4cfd8b5a8f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs @@ -85,17 +85,50 @@ internal sealed class DocumentHybridCacheTests : UmbracoIntegrationTestWithConte Assert.IsFalse(textPage.IsPublished()); } - [Test] - public async Task Cannot_get_unpublished_content() + [TestCase(true)] + [TestCase(false)] + public async Task Can_Get_Unpublished_Content_By_Key(bool preview) { // Arrange var unpublishAttempt = await ContentPublishingService.UnpublishAsync(PublishedTextPage.Key.Value, null, Constants.Security.SuperUserKey); + Assert.IsTrue(unpublishAttempt.Success); - //Act - var textPage = await PublishedContentHybridCache.GetByIdAsync(PublishedTextPageId, false); + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(PublishedTextPage.Key.Value, preview); // Assert - Assert.IsNull(textPage); + if (preview) + { + Assert.IsNotNull(textPage); + Assert.IsFalse(textPage.IsPublished()); + } + else + { + Assert.IsNull(textPage); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Get_Unpublished_Content_By_Id(bool preview) + { + // Arrange + var unpublishAttempt = await ContentPublishingService.UnpublishAsync(PublishedTextPage.Key.Value, null, Constants.Security.SuperUserKey); + Assert.IsTrue(unpublishAttempt.Success); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(PublishedTextPageId, preview); + + // Assert + if (preview) + { + Assert.IsNotNull(textPage); + Assert.IsFalse(textPage.IsPublished()); + } + else + { + Assert.IsNull(textPage); + } } [Test] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentPropertyCacheLevelTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentPropertyCacheLevelTests.cs new file mode 100644 index 0000000000..a621290519 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentPropertyCacheLevelTests.cs @@ -0,0 +1,130 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.ContentTypeEditing; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +public class DocumentPropertyCacheLevelTests : PropertyCacheLevelTestsBase +{ + private static readonly Guid _documentKey = new("9A526E75-DE41-4A81-8883-3E63F11A388D"); + + private IDocumentCacheService DocumentCacheService => GetRequiredService(); + + private IContentEditingService ContentEditingService => GetRequiredService(); + + private IContentPublishingService ContentPublishingService => GetRequiredService(); + + private IContentTypeEditingService ContentTypeEditingService => GetRequiredService(); + + [SetUp] + public async Task SetUpTest() + { + PropertyValueLevelDetectionTestsConverter.Reset(); + + var contentTypeCreateModel = ContentTypeEditingBuilder.CreateSimpleContentType(); + var contentTypeAttempt = await ContentTypeEditingService.CreateAsync(contentTypeCreateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(contentTypeAttempt.Success); + + var contentCreateModel = ContentEditingBuilder.CreateSimpleContent(contentTypeAttempt.Result.Key); + contentCreateModel.Key = _documentKey; + var contentAttempt = await ContentEditingService.CreateAsync(contentCreateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(contentAttempt.Success); + + await PublishPage(); + } + + [TestCase(PropertyCacheLevel.None, false, 1, 10)] + [TestCase(PropertyCacheLevel.None, true, 2, 10)] + [TestCase(PropertyCacheLevel.Element, false, 1, 1)] + [TestCase(PropertyCacheLevel.Element, true, 2, 2)] + [TestCase(PropertyCacheLevel.Elements, false, 1, 1)] + [TestCase(PropertyCacheLevel.Elements, true, 1, 1)] + public async Task Property_Value_Conversion_Respects_Property_Cache_Level(PropertyCacheLevel cacheLevel, bool preview, int expectedSourceConverts, int expectedInterConverts) + { + PropertyValueLevelDetectionTestsConverter.SetCacheLevel(cacheLevel); + + var publishedContent1 = await DocumentCacheService.GetByKeyAsync(_documentKey, preview); + Assert.IsNotNull(publishedContent1); + + var publishedContent2 = await DocumentCacheService.GetByKeyAsync(_documentKey, preview); + Assert.IsNotNull(publishedContent2); + + if (preview) + { + Assert.AreNotSame(publishedContent1, publishedContent2); + } + else + { + Assert.AreSame(publishedContent1, publishedContent2); + } + + var titleValue1 = publishedContent1.Value("title"); + Assert.IsNotNull(titleValue1); + + var titleValue2 = publishedContent2.Value("title"); + Assert.IsNotNull(titleValue2); + + Assert.AreEqual(titleValue1, titleValue2); + + // fetch title values 10 times in total, 5 times from each published content instance + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + + Assert.AreEqual(expectedSourceConverts, PropertyValueLevelDetectionTestsConverter.SourceConverts); + Assert.AreEqual(expectedInterConverts, PropertyValueLevelDetectionTestsConverter.InterConverts); + } + + [TestCase(PropertyCacheLevel.None, false)] + [TestCase(PropertyCacheLevel.None, true)] + [TestCase(PropertyCacheLevel.Element, false)] + [TestCase(PropertyCacheLevel.Element, true)] + [TestCase(PropertyCacheLevel.Elements, false)] + [TestCase(PropertyCacheLevel.Elements, true)] + public async Task Property_Value_Conversion_Is_Triggered_After_Cache_Refresh(PropertyCacheLevel cacheLevel, bool preview) + { + PropertyValueLevelDetectionTestsConverter.SetCacheLevel(cacheLevel); + + var publishedContent1 = await DocumentCacheService.GetByKeyAsync(_documentKey, preview); + Assert.IsNotNull(publishedContent1); + + var titleValue1 = publishedContent1.Value("title"); + Assert.IsNotNull(titleValue1); + + // re-publish the page to trigger a cache refresh for the page + await PublishPage(); + + var publishedContent2 = await DocumentCacheService.GetByKeyAsync(_documentKey, preview); + Assert.IsNotNull(publishedContent2); + + Assert.AreNotSame(publishedContent1, publishedContent2); + + var titleValue2 = publishedContent2.Value("title"); + Assert.IsNotNull(titleValue2); + + Assert.AreEqual(titleValue1, titleValue2); + + // expect conversions for each published content instance, due to the cache refresh + Assert.AreEqual(2, PropertyValueLevelDetectionTestsConverter.SourceConverts); + Assert.AreEqual(2, PropertyValueLevelDetectionTestsConverter.InterConverts); + } + + private async Task PublishPage() + { + var publishAttempt = await ContentPublishingService.PublishAsync( + _documentKey, + [new() { Culture = "*", }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactoryTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactoryTests.cs deleted file mode 100644 index f9f604658c..0000000000 --- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactoryTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using NUnit.Framework; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Cache; -using Umbraco.Cms.Core.Models.PublishedContent; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Infrastructure.HybridCache; -using Umbraco.Cms.Infrastructure.HybridCache.Factories; -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.PublishedCache.HybridCache; - -[TestFixture] -[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] -internal sealed class PublishedContentFactoryTests : UmbracoIntegrationTestWithContent -{ - private IPublishedContentFactory PublishedContentFactory => GetRequiredService(); - - private IPublishedValueFallback PublishedValueFallback => GetRequiredService(); - - private IMediaService MediaService => GetRequiredService(); - - private IMediaTypeService MediaTypeService => GetRequiredService(); - - private IMemberService MemberService => GetRequiredService(); - - private IMemberTypeService MemberTypeService => GetRequiredService(); - - protected override void CustomTestSetup(IUmbracoBuilder builder) - { - var requestCache = new DictionaryAppCache(); - var appCaches = new AppCaches( - NoAppCache.Instance, - requestCache, - new IsolatedCaches(type => NoAppCache.Instance)); - builder.Services.AddUnique(appCaches); - } - - [Test] - public void Can_Create_Published_Content_For_Document() - { - var contentCacheNode = new ContentCacheNode - { - Id = Textpage.Id, - Key = Textpage.Key, - ContentTypeId = Textpage.ContentType.Id, - CreateDate = Textpage.CreateDate, - CreatorId = Textpage.CreatorId, - SortOrder = Textpage.SortOrder, - Data = new ContentData( - Textpage.Name, - "text-page", - Textpage.VersionId, - Textpage.UpdateDate, - Textpage.WriterId, - Textpage.TemplateId, - true, - new Dictionary - { - { - "title", new[] - { - new PropertyData - { - Value = "Test title", - Culture = string.Empty, - Segment = string.Empty, - }, - } - }, - }, - null), - }; - var result = PublishedContentFactory.ToIPublishedContent(contentCacheNode, false); - Assert.IsNotNull(result); - Assert.AreEqual(Textpage.Id, result.Id); - Assert.AreEqual(Textpage.Name, result.Name); - Assert.AreEqual("Test title", result.Properties.Single(x => x.Alias == "title").Value(PublishedValueFallback)); - - // Verify that requesting the same content again returns the same instance (from request cache). - var result2 = PublishedContentFactory.ToIPublishedContent(contentCacheNode, false); - Assert.AreSame(result, result2); - } - - [Test] - public async Task Can_Create_Published_Content_For_Media() - { - var mediaType = new MediaTypeBuilder().Build(); - mediaType.AllowedAsRoot = true; - await MediaTypeService.CreateAsync(mediaType, Constants.Security.SuperUserKey); - - var media = new MediaBuilder() - .WithMediaType(mediaType) - .WithName("Media 1") - .Build(); - MediaService.Save(media); - - var contentCacheNode = new ContentCacheNode - { - Id = media.Id, - Key = media.Key, - ContentTypeId = media.ContentType.Id, - Data = new ContentData( - media.Name, - null, - 0, - media.UpdateDate, - media.WriterId, - null, - false, - new Dictionary(), - null), - }; - var result = PublishedContentFactory.ToIPublishedMedia(contentCacheNode); - Assert.IsNotNull(result); - Assert.AreEqual(media.Id, result.Id); - Assert.AreEqual(media.Name, result.Name); - - // Verify that requesting the same content again returns the same instance (from request cache). - var result2 = PublishedContentFactory.ToIPublishedMedia(contentCacheNode); - Assert.AreSame(result, result2); - } - - [Test] - public async Task Can_Create_Published_Member_For_Member() - { - var memberType = new MemberTypeBuilder().Build(); - await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); - - var member = new MemberBuilder() - .WithMemberType(memberType) - .WithName("Member 1") - .Build(); - MemberService.Save(member); - - var result = PublishedContentFactory.ToPublishedMember(member); - Assert.IsNotNull(result); - Assert.AreEqual(member.Id, result.Id); - Assert.AreEqual(member.Name, result.Name); - - // Verify that requesting the same content again returns the same instance (from request cache). - var result2 = PublishedContentFactory.ToPublishedMember(member); - Assert.AreSame(result, result2); - } -} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaPropertyCacheLevelTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaPropertyCacheLevelTests.cs new file mode 100644 index 0000000000..dea2f00881 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaPropertyCacheLevelTests.cs @@ -0,0 +1,111 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.ContentTypeEditing; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +public class MediaPropertyCacheLevelTests : PropertyCacheLevelTestsBase +{ + private static readonly Guid _mediaKey = new("B4507763-591F-4E32-AD14-7EA67C6AE0D3"); + + private IMediaCacheService MediaCacheService => GetRequiredService(); + + private IMediaEditingService MediaEditingService => GetRequiredService(); + + private IMediaTypeEditingService MediaTypeEditingService => GetRequiredService(); + + [SetUp] + public async Task SetUpTest() + { + PropertyValueLevelDetectionTestsConverter.Reset(); + + var mediaTypeCreateModel = MediaTypeEditingBuilder.CreateMediaTypeWithOneProperty(propertyAlias: "title"); + var mediaTypeAttempt = await MediaTypeEditingService.CreateAsync(mediaTypeCreateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(mediaTypeAttempt.Success); + + var mediaCreateModel = MediaEditingBuilder.CreateMediaWithAProperty(mediaTypeAttempt.Result.Key, "My Media", null, propertyAlias: "title", propertyValue: "The title"); + mediaCreateModel.Key = _mediaKey; + var mediaAttempt = await MediaEditingService.CreateAsync(mediaCreateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(mediaAttempt.Success); + } + + [TestCase(PropertyCacheLevel.None, 1, 10)] + [TestCase(PropertyCacheLevel.Element, 1, 1)] + [TestCase(PropertyCacheLevel.Elements, 1, 1)] + public async Task Property_Value_Conversion_Respects_Property_Cache_Level(PropertyCacheLevel cacheLevel, int expectedSourceConverts, int expectedInterConverts) + { + PropertyValueLevelDetectionTestsConverter.SetCacheLevel(cacheLevel); + + var publishedContent1 = await MediaCacheService.GetByKeyAsync(_mediaKey); + Assert.IsNotNull(publishedContent1); + + var publishedContent2 = await MediaCacheService.GetByKeyAsync(_mediaKey); + Assert.IsNotNull(publishedContent2); + + Assert.AreSame(publishedContent1, publishedContent2); + + var titleValue1 = publishedContent1.Value("title"); + Assert.IsNotNull(titleValue1); + + var titleValue2 = publishedContent2.Value("title"); + Assert.IsNotNull(titleValue2); + + Assert.AreEqual(titleValue1, titleValue2); + + // fetch title values 10 times in total, 5 times from each published content instance + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + + Assert.AreEqual(expectedSourceConverts, PropertyValueLevelDetectionTestsConverter.SourceConverts); + Assert.AreEqual(expectedInterConverts, PropertyValueLevelDetectionTestsConverter.InterConverts); + } + + [TestCase(PropertyCacheLevel.None)] + [TestCase(PropertyCacheLevel.Element)] + [TestCase(PropertyCacheLevel.Elements)] + public async Task Property_Value_Conversion_Is_Triggered_After_Cache_Refresh(PropertyCacheLevel cacheLevel) + { + PropertyValueLevelDetectionTestsConverter.SetCacheLevel(cacheLevel); + + var publishedContent1 = await MediaCacheService.GetByKeyAsync(_mediaKey); + Assert.IsNotNull(publishedContent1); + + var titleValue1 = publishedContent1.Value("title"); + Assert.AreEqual("The title", titleValue1); + + // save the media to trigger a cache refresh for the media + var mediaAttempt = await MediaEditingService.UpdateAsync( + _mediaKey, + new () + { + Properties = [new () { Alias = "title", Value = "New title" }], + Variants = [new() { Name = publishedContent1.Name }], + }, + Constants.Security.SuperUserKey); + Assert.IsTrue(mediaAttempt.Success); + + var publishedContent2 = await MediaCacheService.GetByKeyAsync(_mediaKey); + Assert.IsNotNull(publishedContent2); + + Assert.AreNotSame(publishedContent1, publishedContent2); + + var titleValue2 = publishedContent2.Value("title"); + Assert.AreEqual("New title", titleValue2); + + // expect conversions for each published content instance, due to the cache refresh + Assert.AreEqual(2, PropertyValueLevelDetectionTestsConverter.SourceConverts); + Assert.AreEqual(2, PropertyValueLevelDetectionTestsConverter.InterConverts); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberPropertyCacheLevelTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberPropertyCacheLevelTests.cs new file mode 100644 index 0000000000..20ea900414 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberPropertyCacheLevelTests.cs @@ -0,0 +1,96 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.ContentTypeEditing; +using Umbraco.Cms.Infrastructure.HybridCache.Services; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +public class MemberPropertyCacheLevelTests : PropertyCacheLevelTestsBase +{ + private static readonly Guid _memberKey = new("1ADC9048-E437-460B-95DC-3B8E19239CBD"); + + private IMemberCacheService MemberCacheService => GetRequiredService(); + + private IMemberEditingService MemberEditingService => GetRequiredService(); + + private IMemberTypeService MemberTypeService => GetRequiredService(); + + [SetUp] + public void SetUpTest() + => PropertyValueLevelDetectionTestsConverter.Reset(); + + [TestCase(PropertyCacheLevel.None, 2, 10)] + [TestCase(PropertyCacheLevel.Element, 2, 2)] + [TestCase(PropertyCacheLevel.Elements, 2, 10)] + public async Task Property_Value_Conversion_Respects_Property_Cache_Level(PropertyCacheLevel cacheLevel, int expectedSourceConverts, int expectedInterConverts) + { + PropertyValueLevelDetectionTestsConverter.SetCacheLevel(cacheLevel); + + var member = await CreateMember(); + + var publishedMember1 = await MemberCacheService.Get(member); + Assert.IsNotNull(publishedMember1); + + var publishedMember2 = await MemberCacheService.Get(member); + Assert.IsNotNull(publishedMember2); + + Assert.AreNotSame(publishedMember1, publishedMember2); + + var titleValue1 = publishedMember1.Value("title"); + Assert.AreEqual("The title", titleValue1); + + var titleValue2 = publishedMember2.Value("title"); + Assert.IsNotNull(titleValue2); + + Assert.AreEqual("The title", titleValue2); + + // fetch title values 10 times in total, 5 times from each published member instance + titleValue1 = publishedMember1.Value("title"); + titleValue1 = publishedMember1.Value("title"); + titleValue1 = publishedMember1.Value("title"); + titleValue1 = publishedMember1.Value("title"); + + titleValue2 = publishedMember2.Value("title"); + titleValue2 = publishedMember2.Value("title"); + titleValue2 = publishedMember2.Value("title"); + titleValue2 = publishedMember2.Value("title"); + + Assert.AreEqual(expectedSourceConverts, PropertyValueLevelDetectionTestsConverter.SourceConverts); + Assert.AreEqual(expectedInterConverts, PropertyValueLevelDetectionTestsConverter.InterConverts); + } + + private IUser SuperUser() => GetRequiredService().GetAsync(Constants.Security.SuperUserKey).GetAwaiter().GetResult(); + + private async Task CreateMember() + { + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + var memberTypeCreateResult = await MemberTypeService.UpdateAsync(memberType, Constants.Security.SuperUserKey); + Assert.IsTrue(memberTypeCreateResult.Success); + + var createModel = new MemberCreateModel + { + Key = _memberKey, + Email = "test@test.com", + Username = "test", + Password = "SuperSecret123", + IsApproved = true, + ContentTypeKey = memberType.Key, + Roles = [], + Variants = [new() { Name = "T. Est" }], + Properties = [new() { Alias = "title", Value = "The title" }], + }; + + var memberCreateResult = await MemberEditingService.CreateAsync(createModel, SuperUser()); + Assert.IsTrue(memberCreateResult.Success); + Assert.IsNotNull(memberCreateResult.Result.Content); + + return memberCreateResult.Result.Content; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyCacheLevelTestsBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyCacheLevelTestsBase.cs new file mode 100644 index 0000000000..92836f2aaf --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyCacheLevelTestsBase.cs @@ -0,0 +1,69 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public abstract class PropertyCacheLevelTestsBase : UmbracoIntegrationTest +{ + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.Services.AddUnique(); + + builder.PropertyValueConverters().Append(); + } + + [HideFromTypeFinder] + public class PropertyValueLevelDetectionTestsConverter : PropertyValueConverterBase + { + private static PropertyCacheLevel _cacheLevel; + + public static void Reset() + => SourceConverts = InterConverts = 0; + + public static void SetCacheLevel(PropertyCacheLevel cacheLevel) + => _cacheLevel = cacheLevel; + + public static int SourceConverts { get; private set; } + + public static int InterConverts { get; private set; } + + public override bool IsConverter(IPublishedPropertyType propertyType) + => propertyType.EditorAlias is Constants.PropertyEditors.Aliases.TextBox or Constants.PropertyEditors.Aliases.TextArea; + + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) + => typeof(string); + + public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) + => _cacheLevel; + + public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object source, bool preview) + { + SourceConverts++; + return base.ConvertSourceToIntermediate(owner, propertyType, source, preview); + } + + public override object? ConvertIntermediateToObject( + IPublishedElement owner, + IPublishedPropertyType propertyType, + PropertyCacheLevel referenceCacheLevel, + object inter, + bool preview) + { + InterConverts++; + return base.ConvertIntermediateToObject(owner, propertyType, referenceCacheLevel, inter, preview); + } + } +}