diff --git a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs index a3f318b15604..7aa08a9a96e4 100644 --- a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs +++ b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs @@ -22,7 +22,6 @@ public class PublishedUrlInfoProvider : IPublishedUrlInfoProvider private readonly ILocalizedTextService _localizedTextService; private readonly ILogger _logger; private readonly UriUtility _uriUtility; - private readonly IVariationContextAccessor _variationContextAccessor; /// /// Initializes a new instance of the class. @@ -43,7 +42,9 @@ public PublishedUrlInfoProvider( ILocalizedTextService localizedTextService, ILogger logger, UriUtility uriUtility, - IVariationContextAccessor variationContextAccessor) +#pragma warning disable IDE0060 // Remove unused parameter + IVariationContextAccessor variationContextAccessor) // TODO (V18): Remove this unused parameter. +#pragma warning restore IDE0060 // Remove unused parameter { _publishedUrlProvider = publishedUrlProvider; _languageService = languageService; @@ -52,21 +53,16 @@ public PublishedUrlInfoProvider( _localizedTextService = localizedTextService; _logger = logger; _uriUtility = uriUtility; - _variationContextAccessor = variationContextAccessor; } /// public async Task> GetAllAsync(IContent content) { HashSet urlInfos = []; + var isInvariant = !content.ContentType.VariesByCulture(); - // For invariant content, only return the URL for the default language. - // Invariant content doesn't vary by culture, so it only has one URL. - IEnumerable cultures = content.ContentType.VariesByCulture() - ? await _languageService.GetAllIsoCodesAsync() - : [(await _languageService.GetDefaultIsoCodeAsync())]; + IEnumerable cultures = await GetCulturesForUrlLookupAsync(content); - // First we get the urls of all cultures, using the published router, meaning we respect any extensions. foreach (var culture in cultures) { var url = _publishedUrlProvider.GetUrl(content.Key, culture: culture); @@ -74,6 +70,13 @@ public async Task> GetAllAsync(IContent content) // Handle "could not get URL" if (url is "#" or "#ex") { + // For invariant content, a missing URL just means there's no domain + // for this culture — not a problem worth reporting. + if (isInvariant) + { + continue; + } + urlInfos.Add(UrlInfo.AsMessage(_localizedTextService.Localize("content", "getUrlException"), UrlProviderAlias, culture)); continue; } @@ -106,6 +109,33 @@ public async Task> GetAllAsync(IContent content) return urlInfos; } + /// + /// Gets the cultures to query URLs for. + /// For invariant content, returns only cultures that have a domain assigned to the content + /// or one of its ancestors. If no domains exist, returns only the default culture. + /// For variant content, returns all cultures. + /// + private async Task> GetCulturesForUrlLookupAsync(IContent content) + { + if (content.ContentType.VariesByCulture()) + { + return await _languageService.GetAllIsoCodesAsync(); + } + + IUmbracoContext umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext(); + var ancestorOrSelfIds = content.AncestorIds().Append(content.Id).ToHashSet(); + var domainCultures = umbracoContext.Domains.GetAll(true) + .Where(d => ancestorOrSelfIds.Contains(d.ContentId)) + .Select(d => d.Culture) + .WhereNotNull() + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return domainCultures.Count > 0 + ? domainCultures + : [await _languageService.GetDefaultIsoCodeAsync()]; + } + private async Task> VerifyCollisionAsync(IContent content, string url, string culture) { var uri = new Uri(url.TrimEnd(Constants.CharArrays.ForwardSlash), UriKind.RelativeOrAbsolute); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs index 36b9ef11215f..931d1cde66aa 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs @@ -1,9 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Integration.Attributes; namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; @@ -11,8 +15,13 @@ internal sealed class PublishedUrlInfoProviderTests : PublishedUrlInfoProviderTe { private ILanguageService LanguageService => GetRequiredService(); + private IDomainService DomainService => GetRequiredService(); + + public static void ConfigureHideTopLevelNodeFalse(IUmbracoBuilder builder) + => builder.Services.Configure(x => x.HideTopLevelNodeFromPath = false); + [Test] - public async Task Invariant_content_returns_only_default_language_url() + public async Task Invariant_Content_Without_Domain_Returns_Only_Default_Language_Url() { // Arrange: Add a second language (Danish) alongside the default English var danishLanguage = new LanguageBuilder() @@ -36,7 +45,71 @@ public async Task Invariant_content_returns_only_default_language_url() } [Test] - public async Task Two_items_in_level_1_with_same_name_will_have_conflicting_routes() + public async Task Invariant_Content_Under_Non_Default_Language_Domain_Returns_Only_Domain_Url() + { + // Arrange: Add a second language (Danish) alongside the default English + var danishLanguage = new LanguageBuilder() + .WithCultureInfo("da-DK") + .WithCultureName("Danish") + .Build(); + await LanguageService.CreateAsync(danishLanguage, Constants.Security.SuperUserKey); + + // Publish the branch (invariant content from base class) + ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + + // Assign a domain with the non-default culture (da-DK) to the root node + var updateDomainResult = await DomainService.UpdateDomainsAsync( + Textpage.Key, + new DomainsUpdateModel + { + Domains = [new DomainModel { DomainName = "test.dk", IsoCode = "da-DK" }], + }); + Assert.IsTrue(updateDomainResult.Success, "Domain assignment should succeed"); + + // Act: Get all URLs for a child of the root with the da-DK domain + var urls = await PublishedUrlInfoProvider.GetAllAsync(Subpage); + + // Assert: Should contain only the da-DK domain URL, not the default culture fallback + Assert.AreEqual(1, urls.Count, "Should return exactly one URL (the domain-based URL)"); + Assert.IsNotNull(urls.First().Url); + Assert.AreEqual("da-DK", urls.First().Culture, "The URL should be for the domain culture (da-DK)"); + Assert.That(urls.First().Url!.Host, Is.EqualTo("test.dk"), "The URL should use the assigned domain"); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureHideTopLevelNodeFalse))] + public async Task Two_Items_In_Level_1_With_Same_Name_Will_Not_Have_Conflicting_Routes_When_HideTopLevel_False() + { + // Create a second root + var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null); + var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null); + ContentService.Save(secondRoot, -1, contentSchedule); + + // Create a child of second root + var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, Subpage.Name, secondRoot); + childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6"); + ContentService.Save(childOfSecondRoot, -1, contentSchedule); + + // Publish both the main root and the second root with descendants + ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + ContentService.PublishBranch(secondRoot, PublishBranchFilter.IncludeUnpublished, ["*"]); + + var subPageUrls = await PublishedUrlInfoProvider.GetAllAsync(Subpage); + var childOfSecondRootUrls = await PublishedUrlInfoProvider.GetAllAsync(childOfSecondRoot); + + Assert.AreEqual(1, subPageUrls.Count); + Assert.IsNotNull(subPageUrls.First().Url); + Assert.AreEqual("/textpage/text-page-1/", subPageUrls.First().Url!.ToString()); + Assert.AreEqual(Constants.UrlProviders.Content, subPageUrls.First().Provider); + + Assert.AreEqual(1, childOfSecondRootUrls.Count); + Assert.IsNotNull(childOfSecondRootUrls.First().Url); + Assert.AreEqual("/second-root/text-page-1/", childOfSecondRootUrls.First().Url!.ToString()); + Assert.AreEqual(Constants.UrlProviders.Content, childOfSecondRootUrls.First().Provider); + } + + [Test] + public async Task Two_Items_In_Level_1_With_Same_Name_Will_Have_Conflicting_Routes() { // Create a second root var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTestsBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTestsBase.cs index e6c33d705f2f..dd65140891db 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTestsBase.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTestsBase.cs @@ -45,6 +45,7 @@ private IUmbracoContext GetUmbracoContext(IServiceProvider serviceProvider) mock.Setup(x => x.Content).Returns(serviceProvider.GetRequiredService()); mock.Setup(x => x.CleanedUmbracoUrl).Returns(new Uri("https://localhost:44339")); + mock.Setup(x => x.Domains).Returns(serviceProvider.GetRequiredService()); return mock.Object; } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProvider_hidetoplevel_false.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProvider_hidetoplevel_false.cs deleted file mode 100644 index d3040fa55b93..000000000000 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProvider_hidetoplevel_false.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Tests.Common.Builders; - -namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; - -internal sealed class PublishedUrlInfoProvider_hidetoplevel_false : PublishedUrlInfoProviderTestsBase -{ - protected override void CustomTestSetup(IUmbracoBuilder builder) - { - builder.Services.Configure(x => x.HideTopLevelNodeFromPath = false); - base.CustomTestSetup(builder); - } - - [Test] - public async Task Two_items_in_level_1_with_same_name_will_not_have_conflicting_routes() - { - // Create a second root - var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null); - var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null); - ContentService.Save(secondRoot, -1, contentSchedule); - - // Create a child of second root - var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, Subpage.Name, secondRoot); - childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6"); - ContentService.Save(childOfSecondRoot, -1, contentSchedule); - - // Publish both the main root and the second root with descendants - ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); - ContentService.PublishBranch(secondRoot, PublishBranchFilter.IncludeUnpublished, ["*"]); - - var subPageUrls = await PublishedUrlInfoProvider.GetAllAsync(Subpage); - var childOfSecondRootUrls = await PublishedUrlInfoProvider.GetAllAsync(childOfSecondRoot); - - Assert.AreEqual(1, subPageUrls.Count); - Assert.IsNotNull(subPageUrls.First().Url); - Assert.AreEqual("/textpage/text-page-1/", subPageUrls.First().Url!.ToString()); - Assert.AreEqual(Constants.UrlProviders.Content, subPageUrls.First().Provider); - - Assert.AreEqual(1, childOfSecondRootUrls.Count); - Assert.IsNotNull(childOfSecondRootUrls.First().Url); - Assert.AreEqual("/second-root/text-page-1/", childOfSecondRootUrls.First().Url!.ToString()); - Assert.AreEqual(Constants.UrlProviders.Content, childOfSecondRootUrls.First().Provider); - } -} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 0be20d62889b..ecc972ce09e8 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -268,9 +268,6 @@ MediaNavigationServiceTests.cs - - PublishedUrlInfoProviderTestsBase.cs - PublishedUrlInfoProviderTestsBase.cs