Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -46,6 +49,26 @@ protected ContentTypeBaseSerializer(

protected virtual Guid GetDefaultListType() => Guid.Empty;

protected override async Task<Attempt<TObject?>> 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)
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}

/// <summary>
/// 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.
/// </summary>
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<DistributedCache>()
.RefreshByPayload(ContentTypeCacheRefresher.UniqueId,
composedOf.Select(x => new ContentTypeCacheRefresher.JsonPayload(typeof(TObject).Name, x.Id, ContentTypeChangeTypes.RefreshOther)));
}

public override async Task SaveAsync(IEnumerable<TObject> items)
{
foreach (var item in items)
Expand Down
183 changes: 183 additions & 0 deletions uSync.Tests/Serializers/ContentTypeCacheRefreshTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<string, string>
{
["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<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>();
builder.AddNotificationHandler<ContentTypeChangedNotification, ContentTypeChangedDistributedCacheNotificationHandler>();
builder.Services.AddUnique<IServerMessenger, LocalServerMessenger>();

// the RTE mapper needs the local link processor, which the test host doesn't register.
new ConvertLocalLinkComposer().Compose(builder);

Check warning on line 78 in uSync.Tests/Serializers/ContentTypeCacheRefreshTests.cs

View workflow job for this annotation

GitHub Actions / build-project

'ConvertLocalLinkComposer' is obsolete: 'Scheduled for removal in Umbraco 18.'

builder.AdduSync(settings =>
{
settings.ExportAtStartup = "None";
settings.ExportOnSave = "None";
settings.ImportAtStartup = "None";
settings.BackgroundStartup = false;
});
}

private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();

private ISyncSerializer<IContentType> Serializer
=> GetRequiredService<SyncSerializerCollection>().GetSerializer<IContentType>("ContentTypeSerializer");

private ContentType BuildContentType(string alias, string propertyAlias, ContentVariation variation = ContentVariation.Nothing)
{
var shortStringHelper = GetRequiredService<IShortStringHelper>();
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<IPublishedContentTypeCache>();
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<ILanguageService>().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<IContentService>();
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<IVariationContextAccessor>().VariationContext = new VariationContext("en-US");
var publishedContent = GetRequiredService<IPublishedContentCache>();
Assert.That((await publishedContent.GetByIdAsync(content.Key))!.Value<string>("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<string>("title", "en-US"), Is.EqualTo("invariant title"));
Assert.That(updated!.Value<string>("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<object> ids = null, string json = null) { }
}
}
Loading