diff --git a/uSync.Core/Serialization/Serializers/ContentTypeBaseSerializer.cs b/uSync.Core/Serialization/Serializers/ContentTypeBaseSerializer.cs index c10e509b..3ef9e283 100644 --- a/uSync.Core/Serialization/Serializers/ContentTypeBaseSerializer.cs +++ b/uSync.Core/Serialization/Serializers/ContentTypeBaseSerializer.cs @@ -1,12 +1,15 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using System.Reflection; using System.Xml.Linq; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Changes; using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; @@ -46,6 +49,26 @@ protected ContentTypeBaseSerializer( protected virtual Guid GetDefaultListType() => Guid.Empty; + protected override async Task> FindOrCreateAsync(XElement node) + { + var attempt = await base.FindOrCreateAsync(node); + + // before Umbraco 17.7 a content type loaded from the cache keeps its property types' + // dirty state from when they were created, so a variance-only change on import is + // not seen as structural and the published cache goes stale. clear it before we make + // our changes (17.7+ does this itself). + if (attempt.Success && attempt.Result is not null && attempt.Result.Id > 0) + { + foreach (var group in attempt.Result.PropertyGroups) + group.ResetDirtyProperties(false); + + foreach (var propertyType in attempt.Result.PropertyTypes) + propertyType.ResetDirtyProperties(false); + } + + return attempt; + } + #region Serialization protected XElement SerializeBase(TObject item) @@ -1404,6 +1427,8 @@ public override async Task SaveItemAsync(TObject item) return; } + var compositionsChanged = item.Id > 0 && item.IsPropertyDirty(nameof(item.ContentTypeComposition)); + var attempt = item.Id <= 0 ? await _baseService.CreateAsync(item, Constants.Security.SuperUserKey) : await _baseService.UpdateAsync(item, Constants.Security.SuperUserKey); @@ -1414,9 +1439,26 @@ public override async Task SaveItemAsync(TObject item) $"Could not save {typeof(TObject).Name} {item.Alias}: {attempt.Result}"); } + if (compositionsChanged) + RefreshComposedOf(item); + //if (item.IsDirty()) _baseService.Save(item); } + /// + /// before Umbraco 17.7 a composition change only refreshes the cache for the type itself, + /// so types composed of it (e.g. its children) keep a stale published content type. + /// + private void RefreshComposedOf(TObject item) + { + var composedOf = _baseService.GetComposedOf(item.Id).Where(x => x.Id != item.Id).ToArray(); + if (composedOf.Length == 0) return; + + StaticServiceProvider.Instance.GetRequiredService() + .RefreshByPayload(ContentTypeCacheRefresher.UniqueId, + composedOf.Select(x => new ContentTypeCacheRefresher.JsonPayload(typeof(TObject).Name, x.Id, ContentTypeChangeTypes.RefreshOther))); + } + public override async Task SaveAsync(IEnumerable items) { foreach (var item in items) diff --git a/uSync.Tests/Serializers/ContentTypeCacheRefreshTests.cs b/uSync.Tests/Serializers/ContentTypeCacheRefreshTests.cs new file mode 100644 index 00000000..998c5694 --- /dev/null +++ b/uSync.Tests/Serializers/ContentTypeCacheRefreshTests.cs @@ -0,0 +1,183 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Xml.Linq; + +using Microsoft.Extensions.Configuration; + +using NUnit.Framework; + +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks; +using Umbraco.Cms.Infrastructure.Serialization; +using Umbraco.Cms.Infrastructure.Sync; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Extensions; + +using uSync.BackOffice; +using uSync.Core.Serialization; + +using UmbConstants = Umbraco.Cms.Core.Constants; + +namespace uSync.Tests.Serializers; + +/// +/// a content type import has to leave the published cache up to date. before Umbraco 17.7 +/// core missed two cases (#23433, #23445), which the serializer works around. +/// +/// +/// run with and without a real (deep cloning) runtime cache, because the variance bug +/// depends on the content type coming back out of the cache. +/// +[TestFixture(true)] +[TestFixture(false)] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class ContentTypeCacheRefreshTests : UmbracoIntegrationTest +{ + private readonly bool _useRealCache; + + public ContentTypeCacheRefreshTests(bool useRealCache) => _useRealCache = useRealCache; + + protected override void SetUpTestConfiguration(IConfigurationBuilder configBuilder) + { + base.SetUpTestConfiguration(configBuilder); + configBuilder.AddInMemoryCollection(new Dictionary + { + ["Tests:Database:DatabaseType"] = "Sqlite", + ["Tests:Database:PrepareThreadCount"] = "1", + ["Tests:Database:SchemaDatabaseCount"] = "1", + ["Tests:Database:EmptyDatabasesCount"] = "0" + }); + } + + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + if (_useRealCache) + { + builder.Services.AddUnique(_ => new AppCaches( + new DeepCloneAppCache(new ObjectCacheAppCache()), + NoAppCache.Instance, + new IsolatedCaches(_ => new DeepCloneAppCache(new ObjectCacheAppCache())))); + } + + // the test host doesn't wire changes through to the published cache, so do what a site does. + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.Services.AddUnique(); + + // the RTE mapper needs the local link processor, which the test host doesn't register. + new ConvertLocalLinkComposer().Compose(builder); + + builder.AdduSync(settings => + { + settings.ExportAtStartup = "None"; + settings.ExportOnSave = "None"; + settings.ImportAtStartup = "None"; + settings.BackgroundStartup = false; + }); + } + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private ISyncSerializer Serializer + => GetRequiredService().GetSerializer("ContentTypeSerializer"); + + private ContentType BuildContentType(string alias, string propertyAlias, ContentVariation variation = ContentVariation.Nothing) + { + var shortStringHelper = GetRequiredService(); + var contentType = new ContentType(shortStringHelper, -1) { Alias = alias, Name = alias, Variations = variation }; + contentType.AddPropertyType(new PropertyType(shortStringHelper, "Umbraco.TextBox", ValueStorageType.Nvarchar, propertyAlias) + { + Name = propertyAlias, + DataTypeId = UmbConstants.DataTypes.Textbox + }); + return contentType; + } + + private async Task ImportAsync(XElement node) + { + var attempt = await Serializer.DeserializeAsync(node, new SyncSerializerOptions(SerializerFlags.Force | SerializerFlags.OnePass)); + Assert.That(attempt.Success, Is.True, attempt.Message); + } + + [Test] + public async Task AddComposition_ToParent_RefreshesChildPublishedType() + { + var composition = BuildContentType("composition", "compositionProp"); + ContentTypeService.Save(composition); + + var parent = BuildContentType("parent", "parentProp"); + ContentTypeService.Save(parent); + + var child = BuildContentType("child", "childProp"); + child.ParentId = parent.Id; + child.AddContentType(parent); + ContentTypeService.Save(child); + + // load the child's published type, so there is something to go stale. + var publishedTypes = GetRequiredService(); + Assert.That(publishedTypes.Get(PublishedItemType.Content, "child").GetPropertyType("compositionProp"), Is.Null); + + var node = (await Serializer.SerializeAsync(ContentTypeService.Get(parent.Key), new SyncSerializerOptions())).Item; + node.Element("Info").Element("Compositions").Add(new XElement("Composition", "composition", new XAttribute("Key", composition.Key))); + await ImportAsync(node); + + Assert.That(publishedTypes.Get(PublishedItemType.Content, "child").GetPropertyType("compositionProp"), Is.Not.Null); + } + + [Test] + public async Task PropertyVarianceChange_RefreshesPublishedContent() + { + await GetRequiredService().CreateAsync(new Language("da-DK", "Danish"), UmbConstants.Security.SuperUserKey); + + // the content type varies by culture, but the property doesn't (yet). + var contentType = BuildContentType("variantPage", "title", ContentVariation.Culture); + ContentTypeService.Save(contentType); + + var contentService = GetRequiredService(); + var content = contentService.Create("page", -1, contentType); + content.SetCultureName("page en", "en-US"); + content.SetCultureName("page da", "da-DK"); + content.SetValue("title", "invariant title"); + contentService.Save(content); + contentService.Publish(content, ["*"]); + + GetRequiredService().VariationContext = new VariationContext("en-US"); + var publishedContent = GetRequiredService(); + Assert.That((await publishedContent.GetByIdAsync(content.Key))!.Value("title", "da-DK"), Is.EqualTo("invariant title")); + + var node = (await Serializer.SerializeAsync(ContentTypeService.Get(contentType.Key), new SyncSerializerOptions())).Item; + node.Descendants("GenericProperty").Single(x => x.Element("Alias")?.Value == "title").Element("Variations").Value = "Culture"; + await ImportAsync(node); + + // Umbraco moves the invariant value to the default culture, which a stale cache doesn't have. + var updated = await publishedContent.GetByIdAsync(content.Key); + Assert.Multiple(() => + { + Assert.That(updated!.Value("title", "en-US"), Is.EqualTo("invariant title")); + Assert.That(updated!.Value("title", "da-DK"), Is.Empty); + }); + } + + private sealed class LocalServerMessenger : ServerMessengerBase + { + public LocalServerMessenger() + : base(false, new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory())) + { } + + public override void SendMessages() { } + + public override void Sync() { } + + protected override void DeliverRemote(ICacheRefresher refresher, MessageType messageType, IEnumerable ids = null, string json = null) { } + } +}