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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ public class PublishedUrlInfoProvider : IPublishedUrlInfoProvider
private readonly ILocalizedTextService _localizedTextService;
private readonly ILogger<PublishedUrlInfoProvider> _logger;
private readonly UriUtility _uriUtility;
private readonly IVariationContextAccessor _variationContextAccessor;

/// <summary>
/// Initializes a new instance of the <see cref="PublishedUrlInfoProvider" /> class.
Expand All @@ -43,7 +42,9 @@ public PublishedUrlInfoProvider(
ILocalizedTextService localizedTextService,
ILogger<PublishedUrlInfoProvider> 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;
Expand All @@ -52,28 +53,30 @@ public PublishedUrlInfoProvider(
_localizedTextService = localizedTextService;
_logger = logger;
_uriUtility = uriUtility;
_variationContextAccessor = variationContextAccessor;
}

/// <inheritdoc />
public async Task<ISet<UrlInfo>> GetAllAsync(IContent content)
{
HashSet<UrlInfo> 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<string> cultures = content.ContentType.VariesByCulture()
? await _languageService.GetAllIsoCodesAsync()
: [(await _languageService.GetDefaultIsoCodeAsync())];
IEnumerable<string> 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);

// 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;
}
Expand Down Expand Up @@ -106,6 +109,33 @@ public async Task<ISet<UrlInfo>> GetAllAsync(IContent content)
return urlInfos;
}

/// <summary>
/// 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.
/// </summary>
private async Task<IEnumerable<string>> 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<Attempt<UrlInfo?>> VerifyCollisionAsync(IContent content, string url, string culture)
{
var uri = new Uri(url.TrimEnd(Constants.CharArrays.ForwardSlash), UriKind.RelativeOrAbsolute);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
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;

internal sealed class PublishedUrlInfoProviderTests : PublishedUrlInfoProviderTestsBase
{
private ILanguageService LanguageService => GetRequiredService<ILanguageService>();

private IDomainService DomainService => GetRequiredService<IDomainService>();

public static void ConfigureHideTopLevelNodeFalse(IUmbracoBuilder builder)
=> builder.Services.Configure<GlobalSettings>(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()
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ private IUmbracoContext GetUmbracoContext(IServiceProvider serviceProvider)

mock.Setup(x => x.Content).Returns(serviceProvider.GetRequiredService<IPublishedContentCache>());
mock.Setup(x => x.CleanedUmbracoUrl).Returns(new Uri("https://localhost:44339"));
mock.Setup(x => x.Domains).Returns(serviceProvider.GetRequiredService<IDomainCache>());

return mock.Object;
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,6 @@
<Compile Update="Umbraco.Core\Services\MediaNavigationServiceTests.Update.cs">
<DependentUpon>MediaNavigationServiceTests.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Core\Services\PublishedUrlInfoProvider_hidetoplevel_false.cs">
<DependentUpon>PublishedUrlInfoProviderTestsBase.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Core\Services\PublishedUrlInfoProviderTests.cs">
<DependentUpon>PublishedUrlInfoProviderTestsBase.cs</DependentUpon>
</Compile>
Expand Down
Loading