diff --git a/src/Umbraco.Core/Routing/IRedirectTracker.cs b/src/Umbraco.Core/Routing/IRedirectTracker.cs index 2b0c8649a958..c4e0f72a782c 100644 --- a/src/Umbraco.Core/Routing/IRedirectTracker.cs +++ b/src/Umbraco.Core/Routing/IRedirectTracker.cs @@ -12,8 +12,24 @@ public interface IRedirectTracker /// /// The content entity updated. /// The dictionary of routes for population. + [Obsolete("Use the overload accepting all parameters. Scheduled for removal in Umbraco 19.")] void StoreOldRoute(IContent entity, Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes); + /// + /// Stores the existing routes for a content item before update, with context about whether this is a move operation. + /// + /// The content entity updated. + /// The dictionary of routes for population. + /// Whether this is a move operation (always traverses descendants) or a publish (skips if URL segment unchanged). + // TODO (V19): Remove the default implementation when the obsolete overload is removed. + void StoreOldRoute( + IContent entity, + Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes, + bool isMove) +#pragma warning disable CS0618 // Type or member is obsolete + => StoreOldRoute(entity, oldRoutes); +#pragma warning restore CS0618 // Type or member is obsolete + /// /// Creates appropriate redirects for the content item following an update. /// diff --git a/src/Umbraco.Core/Services/DocumentUrlService.cs b/src/Umbraco.Core/Services/DocumentUrlService.cs index 46885fcf5d10..78b6526c13ab 100644 --- a/src/Umbraco.Core/Services/DocumentUrlService.cs +++ b/src/Umbraco.Core/Services/DocumentUrlService.cs @@ -442,21 +442,20 @@ private long CalculateCacheMemoryUsage() public string? GetUrlSegment(Guid documentKey, string culture, bool isDraft) { ThrowIfNotInitialized(); - if (TryGetLanguageIdFromCulture(culture, out int languageId) is false) - { - return null; - } - - // Try culture-specific lookup first - UrlCacheKey cacheKey = CreateCacheKey(documentKey, languageId, isDraft); - if (_documentUrlCache.TryGetValue(cacheKey, out UrlSegmentCache? cache)) + if (TryGetLanguageIdFromCulture(culture, out int languageId)) { - return cache.PrimarySegment; + // Try culture-specific lookup first. + UrlCacheKey cacheKey = CreateCacheKey(documentKey, languageId, isDraft); + if (_documentUrlCache.TryGetValue(cacheKey, out UrlSegmentCache? cache)) + { + return cache.PrimarySegment; + } } - // Try invariant lookup (NULL languageId) - for invariant content that stores with NULL. + // Try invariant lookup (NULL languageId) - for invariant content that stores with NULL, + // or when the culture couldn't be resolved to a language ID (e.g. empty string for invariant content). UrlCacheKey invariantKey = CreateCacheKey(documentKey, null, isDraft); - return _documentUrlCache.TryGetValue(invariantKey, out cache) ? cache.PrimarySegment : null; + return _documentUrlCache.TryGetValue(invariantKey, out UrlSegmentCache? invariantCache) ? invariantCache.PrimarySegment : null; } private bool TryGetLanguageIdFromCulture(string culture, out int languageId) @@ -483,22 +482,21 @@ private bool TryGetLanguageIdFromCulture(string culture, out int languageId) public IEnumerable GetUrlSegments(Guid documentKey, string culture, bool isDraft) { ThrowIfNotInitialized(); - if (TryGetLanguageIdFromCulture(culture, out int languageId) is false) - { - return Enumerable.Empty(); - } - - // Try culture-specific lookup first. - UrlCacheKey cacheKey = CreateCacheKey(documentKey, languageId, isDraft); - if (_documentUrlCache.TryGetValue(cacheKey, out UrlSegmentCache? cache)) + if (TryGetLanguageIdFromCulture(culture, out int languageId)) { - return cache.GetAllSegments(); + // Try culture-specific lookup first. + UrlCacheKey cacheKey = CreateCacheKey(documentKey, languageId, isDraft); + if (_documentUrlCache.TryGetValue(cacheKey, out UrlSegmentCache? cache)) + { + return cache.GetAllSegments(); + } } - // Try invariant lookup (NULL languageId) - for invariant content that stores with NULL. + // Try invariant lookup (NULL languageId) - for invariant content that stores with NULL, + // or when the culture couldn't be resolved to a language ID (e.g. empty string for invariant content). UrlCacheKey invariantKey = CreateCacheKey(documentKey, null, isDraft); - return _documentUrlCache.TryGetValue(invariantKey, out cache) - ? cache.GetAllSegments() + return _documentUrlCache.TryGetValue(invariantKey, out UrlSegmentCache? invariantCache) + ? invariantCache.GetAllSegments() : Enumerable.Empty(); } @@ -1218,13 +1216,14 @@ private bool TryGetPrimaryUrlSegment(Guid documentKey, string culture, bool isDr segment = cache.PrimarySegment; return true; } + } - // Try invariant lookup (NULL languageId) - for invariant content that stores with NULL. - if (_documentUrlCache.TryGetValue(CreateCacheKey(documentKey, null, isDraft), out cache)) - { - segment = cache.PrimarySegment; - return true; - } + // Try invariant lookup (NULL languageId) - for invariant content that stores with NULL, + // or when the culture couldn't be resolved to a language ID (e.g. empty string for invariant content). + if (_documentUrlCache.TryGetValue(CreateCacheKey(documentKey, null, isDraft), out UrlSegmentCache? invariantCache)) + { + segment = invariantCache.PrimarySegment; + return true; } segment = null; diff --git a/src/Umbraco.Core/Strings/DefaultUrlSegmentProvider.cs b/src/Umbraco.Core/Strings/DefaultUrlSegmentProvider.cs index 9e56f5fdde9d..366b3aa28bb2 100644 --- a/src/Umbraco.Core/Strings/DefaultUrlSegmentProvider.cs +++ b/src/Umbraco.Core/Strings/DefaultUrlSegmentProvider.cs @@ -57,9 +57,12 @@ public class DefaultUrlSegmentProvider : IUrlSegmentProvider if (string.IsNullOrWhiteSpace(source)) { - // If the name of a node has been updated, but it has not been published, the url should use the published name, not the current node name - // If this node has never been published (GetPublishName is null), use the unpublished name - source = content is IContent document && document.Edited && document.GetPublishName(culture) != null + // When the published segment is requested and the name has been updated but not yet published, + // use the published name so that the current live URL is returned (not the pending draft name). + // When the draft segment is requested (published: false), use the current name so callers + // (e.g. redirect tracking) can determine what the segment *will* be after publishing. + // If this node has never been published (GetPublishName is null), use the unpublished name. + source = content is IContent document && published && document.Edited && document.GetPublishName(culture) != null ? document.GetPublishName(culture) : content.GetCultureName(culture); } diff --git a/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs b/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs index cd1775cbdf88..52be8869a271 100644 --- a/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs +++ b/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs @@ -45,7 +45,46 @@ public interface IUrlSegmentProvider /// string? GetUrlSegment(IContentBase content, bool published, string? culture = null) => GetUrlSegment(content, culture); - // TODO: For the 301 tracking, we need to add another extended interface to this so that - // the RedirectTrackingEventHandler can ask the IUrlSegmentProvider if the URL is changing. - // Currently the way it works is very hacky, see notes in: RedirectTrackingEventHandler.ContentService_Publishing + /// + /// Determines whether the URL segment for the given content has changed compared to the + /// currently published segment. Used by redirect tracking to avoid unnecessary descendant + /// traversal when URL segments haven't changed. + /// + /// The content being published (carries new property values). + /// The currently published URL segment (from IDocumentUrlService). + /// The culture. + /// True if the segment has changed, false otherwise. + /// + /// The default implementation computes the new URL segment via + /// using draft values (published: false) and compares it to . + /// Draft values are used because this method is called during publishing, before the new values are committed + /// as published — so the draft values represent what the segment will be after publishing. + /// This is intentionally a permanent default so that custom providers automatically get correct change detection + /// without additional implementation. + /// Override only if you need custom change detection logic (e.g., URL segments derived from external state). + /// + bool HasUrlSegmentChanged(IContentBase content, string? currentPublishedSegment, string? culture) + => !string.Equals( + GetUrlSegment(content, published: false, culture), + currentPublishedSegment, + StringComparison.OrdinalIgnoreCase); + + /// + /// Determines whether changes to the given content item may affect URL segments of its + /// descendant content items. Used by redirect tracking to decide whether descendant + /// traversal can be skipped when the content item's own URL segment is unchanged. + /// + /// The content item being published. + /// + /// true if this provider may compute descendant segments based on data from this + /// content item; false if this provider only uses each content item's own data. + /// + /// + /// The default is false, meaning this provider derives segments solely from the + /// content item itself (e.g. its Name or properties). Custom providers that read ancestor + /// properties to compute descendant segments should override this — either returning + /// true unconditionally, or using logic (e.g. checking the content type or whether + /// relevant properties have changed) to limit the impact to affected subtrees. + /// + bool MayAffectDescendantSegments(IContentBase content) => false; } diff --git a/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs b/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs index 048ff98dbf5c..d0032ad55534 100644 --- a/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs +++ b/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs @@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Routing; @@ -28,6 +29,8 @@ internal sealed class RedirectTracker : IRedirectTracker private readonly IPublishedUrlProvider _publishedUrlProvider; private readonly IPublishedContentStatusFilteringService _publishedContentStatusFilteringService; private readonly IDomainCache _domainCache; + private readonly UrlSegmentProviderCollection _urlSegmentProviders; + private readonly IDocumentUrlService _documentUrlService; /// /// Initializes a new instance of the class. @@ -40,7 +43,9 @@ public RedirectTracker( ILogger logger, IPublishedUrlProvider publishedUrlProvider, IPublishedContentStatusFilteringService publishedContentStatusFilteringService, - IDomainCache domainCache) + IDomainCache domainCache, + UrlSegmentProviderCollection urlSegmentProviders, + IDocumentUrlService documentUrlService) { _languageService = languageService; _redirectUrlService = redirectUrlService; @@ -50,10 +55,21 @@ public RedirectTracker( _publishedUrlProvider = publishedUrlProvider; _publishedContentStatusFilteringService = publishedContentStatusFilteringService; _domainCache = domainCache; + _urlSegmentProviders = urlSegmentProviders; + _documentUrlService = documentUrlService; } /// +#pragma warning disable CS0618 // Type or member is obsolete public void StoreOldRoute(IContent entity, Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes) +#pragma warning restore CS0618 // Type or member is obsolete + => StoreOldRoute(entity, oldRoutes, isMove: true); + + /// + public void StoreOldRoute( + IContent entity, + Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes, + bool isMove) { IPublishedContent? entityContent = _contentCache.GetById(entity.Id); if (entityContent is null) @@ -61,6 +77,23 @@ public void StoreOldRoute(IContent entity, Dictionary<(int ContentId, string Cul return; } + // If this entity was already processed by an ancestor's traversal in this batch, + // all its descendants will also have been processed — skip entirely to avoid redundant + // cache lookups, segment checks, and navigation queries. + if (oldRoutes.Keys.Any(k => k.ContentId == entityContent.Id)) + { + return; + } + + // For publishes (not moves), check if URL segment actually changed and whether any provider + // derives descendant segments from this content's data. + // If the segment is unchanged and no provider affects descendants, we don't need to traverse. + // For moves, we have to assume all descendant URLs may have changed since the parent path is part of the URL. + if (ShouldIgnoreForOldRouteStorage(entity, isMove, entityContent)) + { + return; + } + // Get the default affected cultures by going up the tree until we find the first culture variant entity (default to no cultures). var defaultCultures = new Lazy(() => entityContent.AncestorsOrSelf(_navigationQueryService, _publishedContentStatusFilteringService) .FirstOrDefault(a => a.Cultures.Any())?.Cultures.Keys.ToArray() ?? []); @@ -73,6 +106,7 @@ public void StoreOldRoute(IContent entity, Dictionary<(int ContentId, string Cul foreach (IPublishedContent publishedContent in entityContent.DescendantsOrSelf(_navigationQueryService, _publishedContentStatusFilteringService)) { + // If this entity defines specific cultures, use those instead of the default ones IEnumerable cultures = publishedContent.Cultures.Any() ? publishedContent.Cultures.Keys : defaultCultures.Value; @@ -106,6 +140,82 @@ public void StoreOldRoute(IContent entity, Dictionary<(int ContentId, string Cul } } + private bool ShouldIgnoreForOldRouteStorage(IContent entity, bool isMove, IPublishedContent entityContent) => + isMove is false && + HasUrlSegmentChanged(entity, entityContent) is false && + HasProviderAffectingDescendantSegments(entity) is false; + + private bool HasUrlSegmentChanged(IContent entity, IPublishedContent publishedContent) + { + // During upgrades, the document URL service is not initialized (see DocumentUrlServiceInitializerNotificationHandler). + // If a migration triggers content publishing before initialization, fall back to full traversal. + if (_documentUrlService.IsInitialized is false) + { + return true; + } + + foreach (var culture in GetCultures(publishedContent)) + { + var currentPublishedSegment = _documentUrlService.GetUrlSegment(entity.Key, culture, isDraft: false); + + // In the unexpected case that the current published segment couldn't be retrieved (e.g. cache inconsistency), + // we can't confirm the segment is unchanged — fall back to full traversal. + // Otherwise, if the provider(s) that contribute to the segment detect a change, we need to traverse since the + // URL of the current node and all descendents has changed. + if (currentPublishedSegment is null || HasProviderDetectedSegmentChange(entity, currentPublishedSegment, culture)) + { + return true; + } + } + + return false; + } + + private static IEnumerable GetCultures(IPublishedContent publishedContent) => + publishedContent.Cultures.Any() + ? publishedContent.Cultures.Keys + : [string.Empty]; + + private bool HasProviderDetectedSegmentChange(IContent entity, string currentPublishedSegment, string culture) + { + // Check each provider to see if any detect a change in the URL segment for this content and culture. + foreach (IUrlSegmentProvider provider in _urlSegmentProviders) + { + // Skip providers that don't produce a segment for this content/culture. + if (string.IsNullOrEmpty(provider.GetUrlSegment(entity, published: false, culture))) + { + continue; + } + + if (provider.HasUrlSegmentChanged(entity, currentPublishedSegment, culture)) + { + return true; + } + + // This provider handled the segment — don't check further providers unless it allows additional segments. + if (provider.AllowAdditionalSegments is false) + { + return false; + } + } + + // No provider produced a segment, so none would have at publish time either — no change. + return false; + } + + private bool HasProviderAffectingDescendantSegments(IContent entity) + { + foreach (IUrlSegmentProvider provider in _urlSegmentProviders) + { + if (provider.MayAffectDescendantSegments(entity)) + { + return true; + } + } + + return false; + } + private bool TryGetNodeIdWithAssignedDomain(IPublishedContent entityContent, out int domainRootId) { domainRootId = GetNodeIdWithAssignedDomain(entityContent); diff --git a/src/Umbraco.Infrastructure/Routing/RedirectTrackingHandler.cs b/src/Umbraco.Infrastructure/Routing/RedirectTrackingHandler.cs index ba7b0fbf9e25..ba5aadc78310 100644 --- a/src/Umbraco.Infrastructure/Routing/RedirectTrackingHandler.cs +++ b/src/Umbraco.Infrastructure/Routing/RedirectTrackingHandler.cs @@ -54,7 +54,7 @@ public RedirectTrackingHandler( /// /// The notification containing information about the moved content. public void Handle(ContentMovingNotification notification) => - StoreOldRoutes(notification.MoveInfoCollection.Select(m => m.Entity), notification); + StoreOldRoutes(notification.MoveInfoCollection.Select(m => m.Entity), notification, isMove: true); /// /// Handles a to track and manage redirects when content is published. @@ -67,9 +67,9 @@ public void Handle(ContentMovingNotification notification) => /// /// The content moved notification. public void Handle(ContentPublishingNotification notification) => - StoreOldRoutes(notification.PublishedEntities, notification); + StoreOldRoutes(notification.PublishedEntities, notification, isMove: false); - private void StoreOldRoutes(IEnumerable entities, IStatefulNotification notification) + private void StoreOldRoutes(IEnumerable entities, IStatefulNotification notification, bool isMove) { // Don't let the notification handlers kick in if redirect tracking is turned off in the config. if (_webRoutingSettings.CurrentValue.DisableRedirectUrlTracking) @@ -80,7 +80,7 @@ private void StoreOldRoutes(IEnumerable entities, IStatefulNotificatio Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes = GetOldRoutes(notification); foreach (IContent entity in entities) { - _redirectTracker.StoreOldRoute(entity, oldRoutes); + _redirectTracker.StoreOldRoute(entity, oldRoutes, isMove); } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Routing/RedirectTrackerTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Routing/RedirectTrackerTests.cs index c25edec4f08a..e5383cf38a56 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Routing/RedirectTrackerTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Routing/RedirectTrackerTests.cs @@ -9,6 +9,7 @@ using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; using Umbraco.Cms.Infrastructure.Routing; using Umbraco.Cms.Infrastructure.Scoping; @@ -36,6 +37,10 @@ public override void CreateTestData() _testPage = subPages[0]; } + /// + /// Verifies that stores the correct content key + /// and relative route for a content item without domain assignment. + /// [Test] public void Can_Store_Old_Route() { @@ -43,7 +48,7 @@ public void Can_Store_Old_Route() var redirectTracker = CreateRedirectTracker(); - redirectTracker.StoreOldRoute(_testPage, dict); + redirectTracker.StoreOldRoute(_testPage, dict, isMove: true); Assert.AreEqual(1, dict.Count); var key = dict.Keys.First(); @@ -51,14 +56,18 @@ public void Can_Store_Old_Route() Assert.AreEqual("/new-route", dict[key].OldRoute); } + /// + /// Verifies that when a domain is assigned to the root node, the stored route is prefixed + /// with the root node ID. + /// [Test] public void Can_Store_Old_Route_With_Domain_Root_Prefix() { Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = []; - var redirectTracker = CreateRedirectTracker(assignDomain: true); + var redirectTracker = CreateRedirectTracker(new RedirectTrackerSetupOptions { AssignDomain = true }); - redirectTracker.StoreOldRoute(_testPage, dict); + redirectTracker.StoreOldRoute(_testPage, dict, isMove: true); Assert.AreEqual(1, dict.Count); var key = dict.Keys.First(); @@ -66,6 +75,10 @@ public void Can_Store_Old_Route_With_Domain_Root_Prefix() Assert.AreEqual($"{_rootPage.Id}/new-route", dict[key].OldRoute); } + /// + /// Verifies that registers a redirect URL + /// when the old route differs from the new route. + /// [Test] public void Can_Create_Redirects() { @@ -84,6 +97,10 @@ public void Can_Create_Redirects() Assert.AreEqual("/old-route", redirect.Url); } + /// + /// Verifies that creating a redirect removes any existing redirect whose URL matches the + /// content's current route, preventing self-referencing redirects. + /// [Test] public void Will_Remove_Self_Referencing_Redirects() { @@ -107,24 +124,35 @@ public void Will_Remove_Self_Referencing_Redirects() Assert.AreEqual("/old-route", redirect.Url); } + /// + /// Verifies that when a domain includes a path prefix (e.g. "example.com/en/"), the stored + /// route strips that prefix to avoid duplicating the culture segment. + /// [Test] public void Can_Store_Old_Route_With_Domain_Path_Does_Not_Duplicate_Segment() { Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = []; - // Domain configured as "example.com/en/" — GetUrl returns "/en/new-route" - var redirectTracker = CreateRedirectTracker(assignDomain: true, domainName: "example.com/en/", relativeUrl: "/en/new-route"); + // Domain configured as "example.com/en/" — GetUrl returns "/en/new-route". + var redirectTracker = CreateRedirectTracker(new RedirectTrackerSetupOptions + { + AssignDomain = true, DomainName = "example.com/en/", RelativeUrl = "/en/new-route", + }); - redirectTracker.StoreOldRoute(_testPage, dict); + redirectTracker.StoreOldRoute(_testPage, dict, isMove: true); Assert.AreEqual(1, dict.Count); var key = dict.Keys.First(); Assert.AreEqual(_testPage.Key, dict[key].ContentKey); - // The stored route should strip the domain path "/en" so the result is "{rootId}/new-route", NOT "{rootId}/en/new-route" + // The stored route should strip the domain path "/en" so the result is "{rootId}/new-route", NOT "{rootId}/en/new-route". Assert.AreEqual($"{_rootPage.Id}/new-route", dict[key].OldRoute); } + /// + /// Verifies that no redirect is created when the old route matches the new route after + /// correctly stripping the domain path prefix. + /// [Test] public void Create_Redirects_With_Domain_Path_Skips_When_Route_Unchanged() { @@ -137,8 +165,11 @@ public void Create_Redirects_With_Domain_Path_Skips_When_Route_Unchanged() [(_testPage.Id, "en")] = (_testPage.Key, $"{_rootPage.Id}/new-route"), }; - // Domain configured as "example.com/en/" — GetUrl returns "/en/new-route" - var redirectTracker = CreateRedirectTracker(assignDomain: true, domainName: "example.com/en/", relativeUrl: "/en/new-route"); + // Domain configured as "example.com/en/" — GetUrl returns "/en/new-route". + var redirectTracker = CreateRedirectTracker(new RedirectTrackerSetupOptions + { + AssignDomain = true, DomainName = "example.com/en/", RelativeUrl = "/en/new-route", + }); redirectTracker.CreateRedirects(dict); @@ -146,6 +177,180 @@ public void Create_Redirects_With_Domain_Path_Skips_When_Route_Unchanged() Assert.AreEqual(0, redirects.Count()); } + /// + /// Verifies that publishing content with an unchanged URL segment skips descendant traversal + /// entirely, storing no routes. + /// + [Test] + public void Publish_With_Unchanged_Segment_Skips_Descendants() + { + Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = []; + + var redirectTracker = CreateRedirectTracker(new RedirectTrackerSetupOptions + { + IncludeChild = true, + CurrentPublishedSegment = "test-page", + NewSegment = "test-page", + DocumentUrlServiceInitialized = true, + }); + + redirectTracker.StoreOldRoute(_testPage, dict, isMove: false); + + Assert.AreEqual(0, dict.Count); + } + + /// + /// Verifies that publishing content with a changed URL segment triggers full descendant + /// traversal, storing routes for the entity and its descendants. + /// + [Test] + public void Publish_With_Changed_Segment_Traverses_Descendants() + { + Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = []; + + var redirectTracker = CreateRedirectTracker(new RedirectTrackerSetupOptions + { + IncludeChild = true, + CurrentPublishedSegment = "old-name", + NewSegment = "new-name", + DocumentUrlServiceInitialized = true, + }); + + redirectTracker.StoreOldRoute(_testPage, dict, isMove: false); + + Assert.IsTrue(dict.Count > 0); + } + + /// + /// Verifies that move operations always traverse descendants even when the URL segment + /// is unchanged, since the parent path has changed. + /// + [Test] + public void Move_Always_Traverses_Descendants_Regardless_Of_Segment() + { + Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = []; + + var redirectTracker = CreateRedirectTracker(new RedirectTrackerSetupOptions + { + IncludeChild = true, + CurrentPublishedSegment = "test-page", + NewSegment = "test-page", + DocumentUrlServiceInitialized = true, + }); + + redirectTracker.StoreOldRoute(_testPage, dict, isMove: true); + + Assert.IsTrue(dict.Count > 0); + } + + /// + /// Verifies that when a parent and child are both processed in the same batch, the child's + /// URL is only resolved once (during the parent's traversal) and skipped on the second call. + /// + [Test] + public void Batch_Deduplication_Skips_Already_Processed_Descendants() + { + var childKey = Guid.NewGuid(); + const int childId = 99999; + + var childEntity = new Mock(); + childEntity.SetupGet(c => c.Id).Returns(childId); + childEntity.SetupGet(c => c.Key).Returns(childKey); + childEntity.SetupGet(c => c.Name).Returns("Child Page"); + + var getUrlForChildCallCount = 0; + var redirectTracker = CreateRedirectTracker(new RedirectTrackerSetupOptions + { + IncludeChild = true, + ChildKey = childKey, + ChildId = childId, + OnGetUrlForChild = () => getUrlForChildCallCount++, + }); + + Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = []; + + // Store routes for parent (traverses child), then for child. + redirectTracker.StoreOldRoute(_testPage, dict, isMove: true); + redirectTracker.StoreOldRoute(childEntity.Object, dict, isMove: true); + + // GetUrl for the child should only be called once (during parent's traversal). + // The second StoreOldRoute skips because the child is already in oldRoutes. + Assert.AreEqual(1, getUrlForChildCallCount); + Assert.IsTrue(dict.ContainsKey((_testPage.Id, "en"))); + Assert.IsTrue(dict.ContainsKey((childId, "en"))); + } + + /// + /// Verifies that when is not yet initialized (e.g. during + /// upgrades), the segment optimization is bypassed and full descendant traversal occurs. + /// + [Test] + public void Fallback_To_Full_Traversal_When_DocumentUrlService_Not_Initialized() + { + Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = []; + + var redirectTracker = CreateRedirectTracker(new RedirectTrackerSetupOptions + { + IncludeChild = true, + CurrentPublishedSegment = "test-page", + NewSegment = "test-page", + DocumentUrlServiceInitialized = false, + }); + + redirectTracker.StoreOldRoute(_testPage, dict, isMove: false); + + Assert.IsTrue(dict.Count > 0); + } + + /// + /// Verifies that a custom can override + /// to force descendant traversal + /// even when the default segment comparison would detect no change. + /// + [Test] + public void Custom_UrlSegmentProvider_Override_Of_HasUrlSegmentChanged() + { + Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = []; + + var redirectTracker = CreateRedirectTracker(new RedirectTrackerSetupOptions + { + IncludeChild = true, + CurrentPublishedSegment = "test-page", + NewSegment = "test-page", + HasUrlSegmentChangedOverride = true, + DocumentUrlServiceInitialized = true, + }); + + redirectTracker.StoreOldRoute(_testPage, dict, isMove: false); + + Assert.IsTrue(dict.Count > 0); + } + + /// + /// Verifies that when an reports that changes to the + /// published content may affect descendant URL segments (via ), + /// descendant traversal occurs even though the content's own URL segment is unchanged. + /// This supports custom providers that derive descendant segments from ancestor properties. + /// + [Test] + public void Provider_Affecting_Descendants_Forces_Traversal_Despite_Unchanged_Segment() + { + Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = []; + + var redirectTracker = CreateRedirectTracker(new RedirectTrackerSetupOptions + { + IncludeChild = true, + CurrentPublishedSegment = "test-page", + NewSegment = "test-page", + DocumentUrlServiceInitialized = true, + MayAffectDescendantSegments = true, + }); + + redirectTracker.StoreOldRoute(_testPage, dict, isMove: false); + + Assert.IsTrue(dict.Count > 0); + } + private RedirectUrlRepository CreateRedirectUrlRepository() => new( (IScopeAccessor)ScopeProvider, @@ -154,8 +359,16 @@ private RedirectUrlRepository CreateRedirectUrlRepository() => Mock.Of(), Mock.Of()); - private IRedirectTracker CreateRedirectTracker(bool assignDomain = false, string? domainName = null, string? relativeUrl = null) + /// + /// Creates and configures an instance of an object that tracks redirects for published content, using the specified + /// setup options. + /// + /// Configuration options for the redirect tracker. If null, default options are used. + private IRedirectTracker CreateRedirectTracker(RedirectTrackerSetupOptions? options = null) { + options ??= new RedirectTrackerSetupOptions(); + var resolvedChildKey = options.ChildKey ?? Guid.NewGuid(); + var contentType = new Mock(); contentType.SetupGet(c => c.Variations).Returns(ContentVariation.Nothing); @@ -189,7 +402,7 @@ private IRedirectTracker CreateRedirectTracker(bool assignDomain = false, string IPublishedUrlProvider publishedUrlProvider = Mock.Of(); Mock.Get(publishedUrlProvider) .Setup(x => x.GetUrl(_testPage.Key, UrlMode.Relative, "en", null)) - .Returns(relativeUrl ?? "/new-route"); + .Returns(options.RelativeUrl ?? "/new-route"); IDocumentNavigationQueryService documentNavigationQueryService = Mock.Of(); IEnumerable ancestorKeys = [_rootPage.Key]; @@ -202,23 +415,138 @@ private IRedirectTracker CreateRedirectTracker(bool assignDomain = false, string .Setup(x => x.FilterAvailable(It.IsAny>(), It.IsAny())) .Returns([rootContent.Object]); + // Set up child content if requested. + if (options.IncludeChild) + { + var childPublishedContent = new Mock(); + childPublishedContent.SetupGet(c => c.Id).Returns(options.ChildId); + childPublishedContent.SetupGet(c => c.Key).Returns(resolvedChildKey); + childPublishedContent.SetupGet(c => c.Name).Returns("Child Page"); + childPublishedContent.SetupGet(c => c.Path).Returns($"{_rootPage.Path},{_testPage.Id},{options.ChildId}"); + childPublishedContent.SetupGet(c => c.ContentType).Returns(contentType.Object); + childPublishedContent.SetupGet(c => c.Cultures).Returns(cultures); + + Mock.Get(contentCache) + .Setup(x => x.GetById(resolvedChildKey)) + .Returns(childPublishedContent.Object); + Mock.Get(contentCache) + .Setup(x => x.GetById(options.ChildId)) + .Returns(childPublishedContent.Object); + + var getUrlSetup = Mock.Get(publishedUrlProvider) + .Setup(x => x.GetUrl(resolvedChildKey, UrlMode.Relative, "en", null)) + .Returns("/new-route/child-page"); + if (options.OnGetUrlForChild is not null) + { + getUrlSetup.Callback(options.OnGetUrlForChild); + } + + IEnumerable descendantKeys = [resolvedChildKey]; + Mock.Get(documentNavigationQueryService) + .Setup(x => x.TryGetDescendantsKeys(_testPage.Key, out descendantKeys)) + .Returns(true); + + IEnumerable emptyKeys = []; + Mock.Get(documentNavigationQueryService) + .Setup(x => x.TryGetDescendantsKeys(resolvedChildKey, out emptyKeys)) + .Returns(true); + + IEnumerable childAncestorKeys = [_rootPage.Key, _testPage.Key]; + Mock.Get(documentNavigationQueryService) + .Setup(x => x.TryGetAncestorsKeys(resolvedChildKey, out childAncestorKeys)) + .Returns(true); + + Mock.Get(publishedContentStatusFilteringService) + .Setup(x => x.FilterAvailable(It.IsAny>(), It.IsAny())) + .Returns((IEnumerable keys, string? culture) => + { + var result = new List(); + foreach (var k in keys) + { + if (k == _rootPage.Key) + { + result.Add(rootContent.Object); + } + else if (k == resolvedChildKey) + { + result.Add(childPublishedContent.Object); + } + } + + return result; + }); + } + + // Domain setup. IDomainCache domainCache = Mock.Of(); Mock.Get(domainCache) .Setup(x => x.HasAssigned(_testPage.Id, It.IsAny())) .Returns(false); Mock.Get(domainCache) .Setup(x => x.HasAssigned(_rootPage.Id, It.IsAny())) - .Returns(assignDomain); + .Returns(options.AssignDomain); - if (assignDomain) + if (options.IncludeChild) { - var effectiveDomainName = domainName ?? "example.com"; + Mock.Get(domainCache) + .Setup(x => x.HasAssigned(options.ChildId, It.IsAny())) + .Returns(false); + } + + if (options.AssignDomain) + { + var effectiveDomainName = options.DomainName ?? "example.com"; var domains = new[] { new Domain(1, effectiveDomainName, _rootPage.Id, "en", false, 0) }; Mock.Get(domainCache) .Setup(x => x.GetAssigned(_rootPage.Id, false)) .Returns(domains); } + // URL segment provider setup. + UrlSegmentProviderCollection urlSegmentProviders; + if (options.NewSegment is not null) + { + var urlSegmentProvider = new Mock(); + urlSegmentProvider + .Setup(x => x.GetUrlSegment(It.IsAny(), false, It.IsAny())) + .Returns(options.NewSegment); + + if (options.HasUrlSegmentChangedOverride.HasValue) + { + urlSegmentProvider + .Setup(x => x.HasUrlSegmentChanged(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(options.HasUrlSegmentChangedOverride.Value); + } + else + { + var capturedNewSegment = options.NewSegment; + urlSegmentProvider + .Setup(x => x.HasUrlSegmentChanged(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((IContentBase _, string? currentSeg, string? _) => + !string.Equals(capturedNewSegment, currentSeg, StringComparison.OrdinalIgnoreCase)); + } + + urlSegmentProvider.SetupGet(x => x.AllowAdditionalSegments).Returns(false); + urlSegmentProvider + .Setup(x => x.MayAffectDescendantSegments(It.IsAny())) + .Returns(options.MayAffectDescendantSegments); + urlSegmentProviders = new UrlSegmentProviderCollection(() => [urlSegmentProvider.Object]); + } + else + { + urlSegmentProviders = new UrlSegmentProviderCollection(() => []); + } + + // Document URL service setup. + var documentUrlService = new Mock(); + documentUrlService.SetupGet(x => x.IsInitialized).Returns(options.DocumentUrlServiceInitialized); + if (options.CurrentPublishedSegment is not null) + { + documentUrlService + .Setup(x => x.GetUrlSegment(_testPage.Key, It.IsAny(), false)) + .Returns(options.CurrentPublishedSegment); + } + return new RedirectTracker( GetRequiredService(), RedirectUrlService, @@ -227,7 +555,9 @@ private IRedirectTracker CreateRedirectTracker(bool assignDomain = false, string GetRequiredService>(), publishedUrlProvider, publishedContentStatusFilteringService, - domainCache); + domainCache, + urlSegmentProviders, + documentUrlService.Object); } private void CreateExistingRedirect() @@ -237,4 +567,90 @@ private void CreateExistingRedirect() repository.Save(new RedirectUrl { ContentKey = _testPage.Key, Url = "/new-route", Culture = "en" }); scope.Complete(); } + + /// + /// Configuration options for , controlling which mock + /// dependencies are set up and how they behave. + /// + private class RedirectTrackerSetupOptions + { + /// + /// Gets a value indicating whether a domain should be assigned to the root content node. + /// When true, the stored route is prefixed with the root node ID. + /// + public bool AssignDomain { get; init; } + + /// + /// Gets the domain name to assign (e.g. "example.com" or "example.com/en/"). + /// Only used when is true. Defaults to "example.com". + /// + public string? DomainName { get; init; } + + /// + /// Gets the relative URL returned by for the test page. + /// Defaults to "/new-route". + /// + public string? RelativeUrl { get; init; } + + /// + /// Gets a value indicating whether a child published content node should be added as a + /// descendant of the test page, enabling tests that verify descendant traversal behavior. + /// + public bool IncludeChild { get; init; } + + /// + /// Gets the key to use for the child content node. A random key is generated if not specified. + /// Only used when is true. + /// + public Guid? ChildKey { get; init; } + + /// + /// Gets the integer ID to use for the child content node. + /// Only used when is true. + /// + public int ChildId { get; init; } = 99999; + + /// + /// Gets a callback invoked each time is called + /// for the child node. Useful for tracking call counts in deduplication tests. + /// Only used when is true. + /// + public Action? OnGetUrlForChild { get; init; } + + /// + /// Gets the URL segment returned by for + /// the test page (representing the currently published segment). When set together with + /// , enables segment change detection tests. + /// + public string? CurrentPublishedSegment { get; init; } + + /// + /// Gets the URL segment returned by the mock for the + /// test page (representing the segment being published). Compared against + /// to determine if the segment has changed. + /// + public string? NewSegment { get; init; } + + /// + /// Gets an explicit return value for , + /// overriding the default segment comparison logic. When null, the mock provider + /// compares against the current segment case-insensitively. + /// + public bool? HasUrlSegmentChangedOverride { get; init; } + + /// + /// Gets a value indicating whether the mock + /// returns true. When false, the redirect tracker falls back to full + /// descendant traversal regardless of segment changes. + /// + public bool DocumentUrlServiceInitialized { get; init; } + + /// + /// Gets an explicit return value for . + /// When true, the provider signals that changes to this content may affect descendant + /// segments, forcing descendant traversal even if the content's own segment is unchanged. + /// Defaults to false. + /// + public bool MayAffectDescendantSegments { get; init; } + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DocumentUrlServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DocumentUrlServiceTests.cs index 3acd7faa701b..fa7ce749d66e 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DocumentUrlServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DocumentUrlServiceTests.cs @@ -547,4 +547,176 @@ public async Task CreateOrUpdateUrlSegmentsAsync_TrashedInvariantContent_DoesNot } #endregion + + #region GetUrlSegment Tests + + /// + /// Creates a DocumentUrlService with its cache populated via , + /// suitable for testing and related lookup methods. + /// + private static async Task CreateInitializedDocumentUrlService( + IEnumerable segments, + IEnumerable languages) + { + var urlSegmentProvider = CreateFixedSegmentProvider("test-segment"); + var urlSegmentProviderCollection = new UrlSegmentProviderCollection(() => [urlSegmentProvider]); + + var loggerMock = Mock.Of>(); + var documentUrlRepositoryMock = new Mock(); + documentUrlRepositoryMock.Setup(x => x.GetAll()).Returns(segments); + + var documentRepositoryMock = Mock.Of(); + var globalSettingsMock = Options.Create(new GlobalSettings()); + var webRoutingSettingsMock = Options.Create(new WebRoutingSettings()); + var contentServiceMock = Mock.Of(); + + var languageServiceMock = new Mock(); + languageServiceMock.Setup(x => x.GetAllAsync()).ReturnsAsync(languages); + + // Return the provider type name so ShouldRebuildUrls() returns false (no rebuild needed). + var keyValueServiceMock = new Mock(); + keyValueServiceMock.Setup(x => x.GetValue(DocumentUrlService.RebuildKey)) + .Returns(string.Join("|", urlSegmentProviderCollection.Select(x => x.GetType().Name))); + + var idKeyMapMock = Mock.Of(); + var documentNavigationQueryServiceMock = Mock.Of(); + var publishStatusQueryServiceMock = Mock.Of(); + var domainCacheServiceMock = Mock.Of(); + var defaultCultureAccessorMock = Mock.Of(); + + // Set up scope context to immediately execute Enlist callbacks so the cache is populated. + var scopeContextMock = new Mock(); + scopeContextMock.Setup(x => x.Enlist( + It.IsAny(), + It.IsAny>(), + It.IsAny?>(), + It.IsAny())) + .Returns((string _, Func creator, Action? _, int _) => creator()); + + var coreScopeMock = new Mock(); + coreScopeMock.Setup(x => x.Complete()); + + var coreScopeProviderMock = new Mock(); + coreScopeProviderMock.Setup(x => x.CreateCoreScope( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(coreScopeMock.Object); + coreScopeProviderMock.Setup(x => x.Context).Returns(scopeContextMock.Object); + + var service = new DocumentUrlService( + loggerMock, + documentUrlRepositoryMock.Object, + documentRepositoryMock, + coreScopeProviderMock.Object, + globalSettingsMock, + webRoutingSettingsMock, + urlSegmentProviderCollection, + contentServiceMock, + new DefaultShortStringHelper(new DefaultShortStringHelperConfig()), + languageServiceMock.Object, + keyValueServiceMock.Object, + idKeyMapMock, + documentNavigationQueryServiceMock, + publishStatusQueryServiceMock, + domainCacheServiceMock, + defaultCultureAccessorMock); + + await service.InitAsync(forceEmpty: false, CancellationToken.None); + + return service; + } + + /// + /// Verifies that returns the correct segment for + /// invariant content when called with an empty culture string. Invariant content is stored with a + /// null language ID in the cache; passing an empty culture must still resolve to the invariant entry. + /// + [Test] + public async Task GetUrlSegment_InvariantContent_WithEmptyCulture_Returns_Segment() + { + var documentKey = Guid.NewGuid(); + var segments = new List + { + new() + { + DocumentKey = documentKey, + IsDraft = false, + IsPrimary = true, + NullableLanguageId = null, // Invariant content + UrlSegment = "invariant-page", + }, + }; + + var languages = new List { CreateMockLanguage(1, "en-US") }; + var service = await CreateInitializedDocumentUrlService(segments, languages); + + var result = service.GetUrlSegment(documentKey, string.Empty, isDraft: false); + + Assert.AreEqual("invariant-page", result); + } + + /// + /// Verifies that returns the correct segment for + /// variant content when called with a valid culture code. + /// + [Test] + public async Task GetUrlSegment_VariantContent_WithCulture_Returns_Segment() + { + var documentKey = Guid.NewGuid(); + var segments = new List + { + new() + { + DocumentKey = documentKey, + IsDraft = false, + IsPrimary = true, + NullableLanguageId = 1, + UrlSegment = "english-page", + }, + }; + + var languages = new List { CreateMockLanguage(1, "en-US") }; + var service = await CreateInitializedDocumentUrlService(segments, languages); + + var result = service.GetUrlSegment(documentKey, "en-US", isDraft: false); + + Assert.AreEqual("english-page", result); + } + + /// + /// Verifies that falls back to the invariant cache + /// entry when variant content doesn't have a culture-specific entry. This handles the case where + /// a valid culture is passed but the content is actually invariant. + /// + [Test] + public async Task GetUrlSegment_InvariantContent_WithValidCulture_Falls_Back_To_Invariant() + { + var documentKey = Guid.NewGuid(); + var segments = new List + { + new() + { + DocumentKey = documentKey, + IsDraft = false, + IsPrimary = true, + NullableLanguageId = null, // Stored as invariant + UrlSegment = "invariant-page", + }, + }; + + var languages = new List { CreateMockLanguage(1, "en-US") }; + var service = await CreateInitializedDocumentUrlService(segments, languages); + + // Pass a valid culture, but the content only has an invariant entry. + var result = service.GetUrlSegment(documentKey, "en-US", isDraft: false); + + Assert.AreEqual("invariant-page", result); + } + + #endregion } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/DefaultUrlSegmentProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/DefaultUrlSegmentProviderTests.cs new file mode 100644 index 000000000000..949e154ebe87 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/DefaultUrlSegmentProviderTests.cs @@ -0,0 +1,158 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Strings; + +[TestFixture] +public class DefaultUrlSegmentProviderTests +{ + private DefaultUrlSegmentProvider _provider = null!; + + [SetUp] + public void SetUp() => + _provider = new DefaultUrlSegmentProvider(new MockShortStringHelper()); + + /// + /// Verifies that requesting the draft segment (published: false) for invariant content + /// that has unpublished edits returns a segment based on the current (draft) name, not the + /// published name. This is critical for redirect tracking: when a node is renamed and published, + /// the draft name represents what the segment will be after publishing. + /// + [Test] + public void GetUrlSegment_Draft_InvariantContent_WithEdits_Returns_Draft_Name() + { + var content = CreateInvariantContent( + currentName: "New Name", + publishedName: "Old Name", + edited: true); + + var result = _provider.GetUrlSegment(content, published: false, culture: null); + + Assert.AreEqual("URL-SEGMENT-CULTURE::New Name", result); + } + + /// + /// Verifies that requesting the published segment (published: true) for invariant content + /// that has unpublished edits returns a segment based on the published name, preserving the + /// existing behavior for displaying the current live URL. + /// + [Test] + public void GetUrlSegment_Published_InvariantContent_WithEdits_Returns_Published_Name() + { + var content = CreateInvariantContent( + currentName: "New Name", + publishedName: "Old Name", + edited: true); + + var result = _provider.GetUrlSegment(content, published: true, culture: null); + + Assert.AreEqual("URL-SEGMENT-CULTURE::Old Name", result); + } + + /// + /// Verifies that when content has never been published (GetPublishName returns null), + /// both draft and published segments use the current name regardless of the published + /// parameter. + /// + [TestCase(true)] + [TestCase(false)] + public void GetUrlSegment_NeverPublishedContent_Returns_Current_Name(bool published) + { + var content = CreateInvariantContent( + currentName: "Draft Only Page", + publishedName: null, + edited: true); + + var result = _provider.GetUrlSegment(content, published, culture: null); + + Assert.AreEqual("URL-SEGMENT-CULTURE::Draft Only Page", result); + } + + /// + /// Verifies that when content is not edited (published values match draft values), + /// both draft and published segments return the same value based on the current name. + /// + [TestCase(true)] + [TestCase(false)] + public void GetUrlSegment_ContentNotEdited_Returns_Current_Name(bool published) + { + var content = CreateInvariantContent( + currentName: "Same Name", + publishedName: "Same Name", + edited: false); + + var result = _provider.GetUrlSegment(content, published, culture: null); + + Assert.AreEqual("URL-SEGMENT-CULTURE::Same Name", result); + } + + /// + /// Verifies that when the umbracoUrlName property is set, the draft segment uses + /// the draft property value, taking precedence over the content name. + /// + [Test] + public void GetUrlSegment_WithUrlNameProperty_Draft_Returns_Draft_PropertyValue() + { + var content = CreateInvariantContent( + currentName: "Page Name", + publishedName: "Page Name", + edited: true, + draftUrlName: "custom-draft-slug", + publishedUrlName: "custom-published-slug"); + + var result = _provider.GetUrlSegment(content, published: false, culture: null); + + Assert.AreEqual("URL-SEGMENT-CULTURE::custom-draft-slug", result); + } + + /// + /// Verifies that when the umbracoUrlName property is set, the published segment uses + /// the published property value. + /// + [Test] + public void GetUrlSegment_WithUrlNameProperty_Published_Returns_Published_PropertyValue() + { + var content = CreateInvariantContent( + currentName: "Page Name", + publishedName: "Page Name", + edited: true, + draftUrlName: "custom-draft-slug", + publishedUrlName: "custom-published-slug"); + + var result = _provider.GetUrlSegment(content, published: true, culture: null); + + Assert.AreEqual("URL-SEGMENT-CULTURE::custom-published-slug", result); + } + + private static IContent CreateInvariantContent( + string currentName, + string? publishedName, + bool edited, + string? draftUrlName = null, + string? publishedUrlName = null) + { + var hasUrlNameProperty = draftUrlName is not null || publishedUrlName is not null; + + var content = new Mock(); + content.Setup(c => c.GetCultureName(null)).Returns(currentName); + content.Setup(c => c.GetCultureName(string.Empty)).Returns(currentName); + content.SetupGet(c => c.Edited).Returns(edited); + content.Setup(c => c.GetPublishName(null)).Returns(publishedName); + content.Setup(c => c.GetPublishName(string.Empty)).Returns(publishedName); + content.Setup(c => c.HasProperty(Constants.Conventions.Content.UrlName)).Returns(hasUrlNameProperty); + + if (hasUrlNameProperty) + { + content.Setup(c => c.GetValue(Constants.Conventions.Content.UrlName, null, null, false)) + .Returns(draftUrlName); + content.Setup(c => c.GetValue(Constants.Conventions.Content.UrlName, null, null, true)) + .Returns(publishedUrlName); + } + + return content.Object; + } +}