diff --git a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs index 438c66a2c1..f1a925da75 100644 --- a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs +++ b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs @@ -142,7 +142,9 @@ public static class DistributedCacheExtensions Id = x.Item.Id, Key = x.Item.Key, ChangeTypes = x.ChangeTypes, - Blueprint = x.Item.Blueprint + Blueprint = x.Item.Blueprint, + PublishedCultures = x.PublishedCultures?.ToArray(), + UnpublishedCultures = x.UnpublishedCultures?.ToArray() }); dc.RefreshByPayload(ContentCacheRefresher.UniqueId, payloads); diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs index 779b22fe68..c99987e9fc 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs @@ -182,6 +182,10 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase : base(new TreeChange(target, changeTypes), messages) { } + + public ContentTreeChangeNotification( + IContent target, + TreeChangeTypes changeTypes, + IEnumerable? publishedCultures, + IEnumerable? unpublishedCultures, + EventMessages messages) + : base(new TreeChange(target, changeTypes, publishedCultures, unpublishedCultures), messages) + { + } } diff --git a/src/Umbraco.Core/Services/Changes/TreeChange.cs b/src/Umbraco.Core/Services/Changes/TreeChange.cs index bb722dce24..70adf1e005 100644 --- a/src/Umbraco.Core/Services/Changes/TreeChange.cs +++ b/src/Umbraco.Core/Services/Changes/TreeChange.cs @@ -8,10 +8,22 @@ public class TreeChange ChangeTypes = changeTypes; } + public TreeChange(TItem changedItem, TreeChangeTypes changeTypes, IEnumerable? publishedCultures, IEnumerable? unpublishedCultures) + { + Item = changedItem; + ChangeTypes = changeTypes; + PublishedCultures = publishedCultures; + UnpublishedCultures = unpublishedCultures; + } + public TItem Item { get; } public TreeChangeTypes ChangeTypes { get; } + public IEnumerable? PublishedCultures { get; } + + public IEnumerable? UnpublishedCultures { get; } + public EventArgs ToEventArgs() => new EventArgs(this); public class EventArgs : System.EventArgs diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 59656de6b5..5ff81a2165 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1595,7 +1595,12 @@ public class ContentService : RepositoryService, IContentService // events and audit scope.Notifications.Publish( new ContentUnpublishedNotification(content, eventMessages).WithState(notificationState)); - scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages)); + scope.Notifications.Publish(new ContentTreeChangeNotification( + content, + TreeChangeTypes.RefreshBranch, + variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : null, + variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : ["*"], + eventMessages)); if (culturesUnpublishing != null) { @@ -1654,7 +1659,12 @@ public class ContentService : RepositoryService, IContentService if (!branchOne) { scope.Notifications.Publish( - new ContentTreeChangeNotification(content, changeType, eventMessages)); + new ContentTreeChangeNotification( + content, + changeType, + variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : ["*"], + variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : null, + eventMessages)); scope.Notifications.Publish( new ContentPublishedNotification(content, eventMessages).WithState(notificationState)); } @@ -2118,7 +2128,8 @@ public class ContentService : RepositoryService, IContentService } // deal with the branch root - if it fails, abort - PublishResult? result = SaveAndPublishBranchItem(scope, document, shouldPublish, publishCultures, true, publishedDocuments, eventMessages, userId, allLangs, out IDictionary notificationState); + HashSet? culturesToPublish = shouldPublish(document); + PublishResult? result = SaveAndPublishBranchItem(scope, document, culturesToPublish, publishCultures, true, publishedDocuments, eventMessages, userId, allLangs, out IDictionary notificationState); if (result != null) { results.Add(result); @@ -2128,6 +2139,8 @@ public class ContentService : RepositoryService, IContentService } } + HashSet culturesPublished = culturesToPublish ?? []; + // deal with descendants // if one fails, abort its branch var exclude = new HashSet(); @@ -2153,12 +2166,14 @@ public class ContentService : RepositoryService, IContentService } // no need to check path here, parent has to be published here - result = SaveAndPublishBranchItem(scope, d, shouldPublish, publishCultures, false, publishedDocuments, eventMessages, userId, allLangs, out _); + culturesToPublish = shouldPublish(d); + result = SaveAndPublishBranchItem(scope, d, culturesToPublish, publishCultures, false, publishedDocuments, eventMessages, userId, allLangs, out _); if (result != null) { results.Add(result); if (result.Success) { + culturesPublished.UnionWith(culturesToPublish ?? []); continue; } } @@ -2175,8 +2190,14 @@ public class ContentService : RepositoryService, IContentService // trigger events for the entire branch // (SaveAndPublishBranchOne does *not* do it) + var variesByCulture = document.ContentType.VariesByCulture(); scope.Notifications.Publish( - new ContentTreeChangeNotification(document, TreeChangeTypes.RefreshBranch, eventMessages)); + new ContentTreeChangeNotification( + document, + TreeChangeTypes.RefreshBranch, + variesByCulture ? culturesPublished.IsCollectionEmpty() ? null : culturesPublished : ["*"], + null, + eventMessages)); scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages).WithState(notificationState)); scope.Complete(); @@ -2191,7 +2212,7 @@ public class ContentService : RepositoryService, IContentService private PublishResult? SaveAndPublishBranchItem( ICoreScope scope, IContent document, - Func?> shouldPublish, + HashSet? culturesToPublish, Func, IReadOnlyCollection, bool> publishCultures, bool isRoot, @@ -2202,7 +2223,6 @@ public class ContentService : RepositoryService, IContentService out IDictionary notificationState) { notificationState = new Dictionary(); - HashSet? culturesToPublish = shouldPublish(document); // null = do not include if (culturesToPublish == null) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceNotificationTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceNotificationTests.cs index f7fbe57185..9f6ca6cee9 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceNotificationTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceNotificationTests.cs @@ -55,7 +55,8 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() - .AddNotificationHandler(); + .AddNotificationHandler() + .AddNotificationHandler(); private void CreateTestData() { @@ -177,6 +178,67 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest } } + [Test] + public void Publishing_Invariant() + { + IContent document = new Content("content", -1, _contentType); + + var treeChangeWasCalled = false; + + ContentNotificationHandler.TreeChange += notification => + { + var change = notification.Changes.FirstOrDefault(); + var publishedCultures = change?.PublishedCultures?.ToArray(); + Assert.IsNotNull(publishedCultures); + Assert.AreEqual(1, publishedCultures.Length); + Assert.IsTrue(publishedCultures.InvariantContains("*")); + Assert.IsNull(change.UnpublishedCultures); + + treeChangeWasCalled = true; + }; + + try + { + ContentService.SaveAndPublish(document); + Assert.IsTrue(treeChangeWasCalled); + } + finally + { + ContentNotificationHandler.TreeChange = null; + } + } + + [Test] + public void Unpublishing_Invariant() + { + IContent document = new Content("content", -1, _contentType); + ContentService.SaveAndPublish(document); + + var treeChangeWasCalled = false; + + ContentNotificationHandler.TreeChange += notification => + { + var change = notification.Changes.FirstOrDefault(); + Assert.IsNull(change?.PublishedCultures); + var unpublishedCultures = change?.UnpublishedCultures?.ToArray(); + Assert.IsNotNull(unpublishedCultures); + Assert.AreEqual(1, unpublishedCultures.Length); + Assert.IsTrue(unpublishedCultures.InvariantContains("*")); + + treeChangeWasCalled = true; + }; + + try + { + ContentService.Unpublish(document); + Assert.IsTrue(treeChangeWasCalled); + } + finally + { + ContentNotificationHandler.TreeChange = null; + } + } + [Test] public void Publishing_Culture() { @@ -203,6 +265,7 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest var publishingWasCalled = false; var publishedWasCalled = false; + var treeChangeWasCalled = false; ContentNotificationHandler.PublishingContent += notification => { @@ -228,16 +291,30 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest publishedWasCalled = true; }; + ContentNotificationHandler.TreeChange += notification => + { + var change = notification.Changes.FirstOrDefault(); + var publishedCultures = change?.PublishedCultures?.ToArray(); + Assert.IsNotNull(publishedCultures); + Assert.AreEqual(1, publishedCultures.Length); + Assert.IsTrue(publishedCultures.InvariantContains("fr-FR")); + Assert.IsNull(change.UnpublishedCultures); + + treeChangeWasCalled = true; + }; + try { ContentService.SaveAndPublish(document, "fr-FR"); Assert.IsTrue(publishingWasCalled); Assert.IsTrue(publishedWasCalled); + Assert.IsTrue(treeChangeWasCalled); } finally { ContentNotificationHandler.PublishingContent = null; ContentNotificationHandler.PublishedContent = null; + ContentNotificationHandler.TreeChange = null; } document = ContentService.GetById(document.Id); @@ -366,6 +443,7 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest var publishingWasCalled = false; var publishedWasCalled = false; + var treeChangeWasCalled = false; // TODO: revisit this - it was migrated when removing static events, but the expected result seems illogic - why does this test bind to Published and not Unpublished? @@ -399,16 +477,30 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest publishedWasCalled = true; }; + ContentNotificationHandler.TreeChange += notification => + { + var change = notification.Changes.FirstOrDefault(); + var unpublishedCultures = change?.UnpublishedCultures?.ToArray(); + Assert.IsNotNull(unpublishedCultures); + Assert.AreEqual(1, unpublishedCultures.Length); + Assert.IsTrue(unpublishedCultures.InvariantContains("fr-FR")); + Assert.IsNull(change.PublishedCultures); + + treeChangeWasCalled = true; + }; + try { ContentService.CommitDocumentChanges(document); Assert.IsTrue(publishingWasCalled); Assert.IsTrue(publishedWasCalled); + Assert.IsTrue(treeChangeWasCalled); } finally { ContentNotificationHandler.PublishingContent = null; ContentNotificationHandler.PublishedContent = null; + ContentNotificationHandler.TreeChange = null; } document = ContentService.GetById(document.Id); @@ -423,7 +515,8 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest INotificationHandler, INotificationHandler, INotificationHandler, - INotificationHandler + INotificationHandler, + INotificationHandler { public static Action SavingContent { get; set; } @@ -437,6 +530,8 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest public static Action UnpublishedContent { get; set; } + public static Action TreeChange { get; set; } + public void Handle(ContentPublishedNotification notification) => PublishedContent?.Invoke(notification); public void Handle(ContentPublishingNotification notification) => PublishingContent?.Invoke(notification); @@ -447,5 +542,7 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest public void Handle(ContentUnpublishedNotification notification) => UnpublishedContent?.Invoke(notification); public void Handle(ContentUnpublishingNotification notification) => UnpublishingContent?.Invoke(notification); + + public void Handle(ContentTreeChangeNotification notification) => TreeChange?.Invoke(notification); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs index e509742cb9..93e59c9719 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs @@ -27,26 +27,68 @@ public class RefresherTests Assert.AreEqual(source[0].ChangeTypes, payload[0].ChangeTypes); } - [Test] - public void ContentCacheRefresherCanDeserializeJsonPayload() + [TestCase(TreeChangeTypes.None, false)] + [TestCase(TreeChangeTypes.RefreshAll, true)] + [TestCase(TreeChangeTypes.RefreshBranch, false)] + [TestCase(TreeChangeTypes.Remove, true)] + [TestCase(TreeChangeTypes.RefreshNode, false)] + public void ContentCacheRefresherCanDeserializeJsonPayload(TreeChangeTypes changeTypes, bool blueprint) { + var key = Guid.NewGuid(); ContentCacheRefresher.JsonPayload[] source = { new ContentCacheRefresher.JsonPayload() { Id = 1234, - Key = Guid.NewGuid(), - ChangeTypes = TreeChangeTypes.None + Key = key, + ChangeTypes = changeTypes, + Blueprint = blueprint } }; var json = JsonConvert.SerializeObject(source); var payload = JsonConvert.DeserializeObject(json); - Assert.AreEqual(source[0].Id, payload[0].Id); - Assert.AreEqual(source[0].Key, payload[0].Key); - Assert.AreEqual(source[0].ChangeTypes, payload[0].ChangeTypes); - Assert.AreEqual(source[0].Blueprint, payload[0].Blueprint); + Assert.AreEqual(1234, payload[0].Id); + Assert.AreEqual(key, payload[0].Key); + Assert.AreEqual(changeTypes, payload[0].ChangeTypes); + Assert.AreEqual(blueprint, payload[0].Blueprint); + Assert.IsNull(payload[0].PublishedCultures); + Assert.IsNull(payload[0].UnpublishedCultures); + } + + [Test] + public void ContentCacheRefresherCanDeserializeJsonPayloadWithCultures() + { + var key = Guid.NewGuid(); + ContentCacheRefresher.JsonPayload[] source = + { + new ContentCacheRefresher.JsonPayload() + { + Id = 1234, + Key = key, + PublishedCultures = ["en-US", "da-DK"], + UnpublishedCultures = ["de-DE"] + } + }; + + var json = JsonConvert.SerializeObject(source); + var payload = JsonConvert.DeserializeObject(json); + + Assert.IsNotNull(payload[0].PublishedCultures); + Assert.Multiple(() => + { + Assert.AreEqual(2, payload[0].PublishedCultures.Length); + Assert.AreEqual("en-US", payload[0].PublishedCultures.First()); + Assert.AreEqual("da-DK", payload[0].PublishedCultures.Last()); + }); + + Assert.IsNotNull(payload[0].UnpublishedCultures); + Assert.Multiple(() => + { + Assert.AreEqual(1, payload[0].UnpublishedCultures.Length); + Assert.AreEqual("de-DE", payload[0].UnpublishedCultures.First()); + }); } [Test]