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;
+ }
+}