From 0d6a54b14d70a665f6bbf13db43c5d660f47a0ab Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 1 Jul 2026 16:49:55 +0200 Subject: [PATCH 1/9] Retrieve only the current URL for display of document URLs on the Info document workspace. --- .../Document/DocumentUrlController.cs | 15 ++- .../Factories/DocumentUrlFactory.cs | 26 ++++- .../Factories/IDocumentUrlFactory.cs | 18 ++++ src/Umbraco.Cms.Api.Management/OpenApi.json | 7 ++ .../Routing/IPublishedUrlInfoProvider.cs | 12 +++ .../Routing/PublishedUrlInfoProvider.cs | 46 +++++++-- .../packages/core/backend-api/types.gen.ts | 1 + .../url/document-urls-data-resolver.ts | 39 +++++++- ...cument-links-workspace-info-app.element.ts | 17 +++- .../url/repository/document-url.repository.ts | 25 +++++ .../document-url.server.data-source.test.ts | 47 ++++++++++ .../document-url.server.data-source.ts | 4 +- .../Services/PublishedUrlInfoProviderTests.cs | 23 +++++ .../UrlAndDomains/DomainAndUrlsTests.cs | 60 ++++++++++++ .../Factories/DocumentUrlFactoryTests.cs | 94 +++++++++++++++++++ 15 files changed, 414 insertions(+), 20 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs index 974f93373bb7..e4743f733c6c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs @@ -37,15 +37,26 @@ public DocumentUrlController( /// /// A set of document IDs for which to retrieve URLs. /// A task representing the asynchronous operation. The task result contains an with a collection of URL information for each requested document. + [Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 19.")] + [ApiExplorerSettings(IgnoreApi = true)] + public Task GetUrls([FromQuery(Name = "id")] HashSet ids) + => GetUrls(ids, null); + + /// + /// Retrieves the URLs for the documents identified by the specified set of IDs. + /// + /// A set of document IDs for which to retrieve URLs. + /// An optional culture to restrict variant document URLs to. When omitted, URLs for all cultures are returned. + /// A task representing the asynchronous operation. The task result contains an with a collection of URL information for each requested document. [MapToApiVersion("1.0")] [HttpGet("urls")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [EndpointSummary("Gets URLs for a document.")] [EndpointDescription("Gets the URLs for the document identified by the provided Id.")] - public async Task GetUrls([FromQuery(Name = "id")] HashSet ids) + public async Task GetUrls([FromQuery(Name = "id")] HashSet ids, [FromQuery] string? culture = null) { IEnumerable items = _contentService.GetByIds(ids); - return Ok(await _documentUrlFactory.CreateUrlSetsAsync(items)); + return Ok(await _documentUrlFactory.CreateUrlSetsAsync(items, culture)); } } diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs index 8b7fc256791e..13817c8f33fa 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs @@ -48,9 +48,18 @@ public DocumentUrlFactory( /// /// The content item for which to generate URLs. /// A task representing the asynchronous operation. The task result contains an with the generated URLs. - public async Task> CreateUrlsAsync(IContent content) + public Task> CreateUrlsAsync(IContent content) + => CreateUrlsAsync(content, culture: null); + + /// + /// Asynchronously generates a collection of instances representing the URLs for the specified content item, optionally restricted to a single culture. + /// + /// The content item for which to generate URLs. + /// The culture to restrict variant content urls to, or null for all cultures. + /// A task representing the asynchronous operation. The task result contains an with the generated URLs. + public async Task> CreateUrlsAsync(IContent content, string? culture) { - ISet urlInfos = await _publishedUrlInfoProvider.GetAllAsync(content); + ISet urlInfos = await _publishedUrlInfoProvider.GetAllAsync(content, culture); return urlInfos .Select(CreateDocumentUrlInfo) .ToArray(); @@ -61,13 +70,22 @@ public async Task> CreateUrlsAsync(IContent content /// /// The collection of items for which to generate URL sets. /// A task representing the asynchronous operation. The task result contains a collection of objects, each representing the URL set for a content item. - public async Task> CreateUrlSetsAsync(IEnumerable contentItems) + public Task> CreateUrlSetsAsync(IEnumerable contentItems) + => CreateUrlSetsAsync(contentItems, culture: null); + + /// + /// Asynchronously creates URL sets for the specified collection of content items, optionally restricted to a single culture. + /// + /// The collection of items for which to generate URL sets. + /// The culture to restrict variant content urls to, or null for all cultures. + /// A task representing the asynchronous operation. The task result contains a collection of objects, each representing the URL set for a content item. + public async Task> CreateUrlSetsAsync(IEnumerable contentItems, string? culture) { var documentUrlInfoResourceSets = new List(); foreach (IContent content in contentItems) { - IEnumerable urls = await CreateUrlsAsync(content); + IEnumerable urls = await CreateUrlsAsync(content, culture); documentUrlInfoResourceSets.Add(new DocumentUrlInfoResponseModel(content.Key, urls)); } diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs index 4e2fea671755..38cd590e3bde 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs @@ -15,6 +15,15 @@ public interface IDocumentUrlFactory /// A task that represents the asynchronous operation. The task result contains a collection of DocumentUrlInfo. Task> CreateUrlsAsync(IContent content); + /// + /// Creates URLs asynchronously for the given content, optionally restricted to a single culture. + /// + /// The content to create URLs for. + /// The culture to restrict variant content urls to, or null for all cultures. + /// A task that represents the asynchronous operation. The task result contains a collection of DocumentUrlInfo. + Task> CreateUrlsAsync(IContent content, string? culture) + => CreateUrlsAsync(content); + /// /// Asynchronously creates URL sets for the specified content items. /// @@ -22,6 +31,15 @@ public interface IDocumentUrlFactory /// A task representing the asynchronous operation, with a result containing an enumerable of representing the URL sets. Task> CreateUrlSetsAsync(IEnumerable contentItems); + /// + /// Asynchronously creates URL sets for the specified content items, optionally restricted to a single culture. + /// + /// The content items for which to generate URL sets. + /// The culture to restrict variant content urls to, or null for all cultures. + /// A task representing the asynchronous operation, with a result containing an enumerable of representing the URL sets. + Task> CreateUrlSetsAsync(IEnumerable contentItems, string? culture) + => CreateUrlSetsAsync(contentItems); + /// /// Asynchronously retrieves preview URL information for the specified content item. /// diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 99f20ab4cc7b..3d03a6445c6b 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -11686,6 +11686,13 @@ "format": "uuid" } } + }, + { + "name": "culture", + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { diff --git a/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs index 7a62697aa79b..0da7dff6595c 100644 --- a/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs +++ b/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs @@ -13,4 +13,16 @@ public interface IPublishedUrlInfoProvider /// The content to get urls for. /// Set of all published url infos. Task> GetAllAsync(IContent content); + + /// + /// Gets the published urls for a content item, optionally restricted to a single culture. + /// + /// The content to get urls for. + /// + /// The culture to restrict variant content urls to, or null to return urls for all cultures. + /// Ignored for invariant content, which always returns all of its domain urls. + /// + /// Set of published url infos. + Task> GetAllAsync(IContent content, string? culture) + => GetAllAsync(content); } diff --git a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs index 6795538d5446..9618f783c2a1 100644 --- a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs +++ b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs @@ -56,16 +56,26 @@ public PublishedUrlInfoProvider( } /// - public async Task> GetAllAsync(IContent content) + public Task> GetAllAsync(IContent content) + => GetAllAsync(content, culture: null); + + /// + public async Task> GetAllAsync(IContent content, string? culture) { HashSet urlInfos = []; var isInvariant = !content.ContentType.VariesByCulture(); - IEnumerable cultures = await GetCulturesForUrlLookupAsync(content); + // For variant content, restrict to the requested culture when it is a valid installed culture. + // For invariant content, no culture, or an unknown culture, this is null and all cultures are resolved. + string? scopedCulture = await ResolveScopedCultureAsync(content, culture); + + IEnumerable cultures = scopedCulture is not null + ? [scopedCulture] + : await GetCulturesForUrlLookupAsync(content); - foreach (var culture in cultures) + foreach (var contentCulture in cultures) { - var url = _publishedUrlProvider.GetUrl(content.Key, culture: culture); + var url = _publishedUrlProvider.GetUrl(content.Key, culture: contentCulture); // Handle "could not get URL" if (url is Constants.Routing.Unroutable or Constants.Routing.UrlProviderException) @@ -77,12 +87,12 @@ public async Task> GetAllAsync(IContent content) continue; } - urlInfos.Add(UrlInfo.AsMessage(_localizedTextService.Localize("content", "getUrlException"), UrlProviderAlias, culture)); + urlInfos.Add(UrlInfo.AsMessage(_localizedTextService.Localize("content", "getUrlException"), UrlProviderAlias, contentCulture)); continue; } // Check for collision - Attempt hasCollision = await VerifyCollisionAsync(content, url, culture); + Attempt hasCollision = await VerifyCollisionAsync(content, url, contentCulture); if (hasCollision is { Success: true, Result: not null }) { @@ -90,7 +100,7 @@ public async Task> GetAllAsync(IContent content) continue; } - urlInfos.Add(UrlInfo.AsUrl(url, UrlProviderAlias, culture)); + urlInfos.Add(UrlInfo.AsUrl(url, UrlProviderAlias, contentCulture)); } // If the content is trashed, we can't get the other URLs, as we have no parent structure to navigate through. @@ -101,7 +111,10 @@ public async Task> GetAllAsync(IContent content) // Then get "other" urls - I.E. Not what you'd get with GetUrl(), this includes all the urls registered using domains. // for these 'other' URLs, we don't check whether they are routable, collide, anything - we just report them. - foreach (UrlInfo otherUrl in _publishedUrlProvider.GetOtherUrls(content.Id).OrderBy(x => x.Message).ThenBy(x => x.Culture)) + // When scoped to a single culture, only report the other urls for that culture. + foreach (UrlInfo otherUrl in _publishedUrlProvider.GetOtherUrls(content.Id) + .Where(x => scopedCulture is null || string.Equals(x.Culture, scopedCulture, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Message).ThenBy(x => x.Culture)) { urlInfos.Add(otherUrl); } @@ -109,6 +122,23 @@ public async Task> GetAllAsync(IContent content) return urlInfos; } + /// + /// Resolves the single culture to scope the URL lookup to, or null to resolve all cultures. + /// Returns null for invariant content, when no culture is requested, or when the requested culture + /// is not a valid installed culture (in which case all cultures are resolved). Otherwise returns the + /// installed culture matching the request (using the installed culture's casing). + /// + private async Task ResolveScopedCultureAsync(IContent content, string? culture) + { + if (culture is null || content.ContentType.VariesByCulture() is false) + { + return null; + } + + IEnumerable isoCodes = await _languageService.GetAllIsoCodesAsync(); + return isoCodes.FirstOrDefault(x => x.InvariantEquals(culture)); + } + /// /// Gets the cultures to query URLs for. /// For invariant content, returns only cultures that have a domain assigned to the content diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts index 9f2f8ed2b0c0..9b1f2bc24168 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts @@ -7455,6 +7455,7 @@ export type GetDocumentUrlsData = { path?: never; query?: { id?: Array; + culture?: string; }; url: '/umbraco/management/api/v1/document/urls'; }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts index 9f6bec2980dc..e1370a60caaf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts @@ -1,7 +1,7 @@ import type { UmbDocumentUrlModel } from './repository/types.js'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbArrayState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; import { UMB_VARIANT_CONTEXT, type UmbVariantId } from '@umbraco-cms/backoffice/variant'; /** @@ -26,13 +26,35 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { */ public readonly urls = this.#urls.asObservable(); + #requestCulture = new UmbStringState(undefined); + /** + * The culture to request urls for from the server. Emits whenever the displayed culture changes. + * Resolves to undefined for invariant documents (meaning all cultures). + * @returns {Observable} The culture to request urls for + * @memberof UmbDocumentUrlsDataResolver + */ + public readonly requestCulture = this.#requestCulture.asObservable(); + constructor(host: UmbControllerHost) { super(host); this.#init = Promise.all([ this.consumeContext(UMB_VARIANT_CONTEXT, async (context) => { this.#variantId = await context?.getVariantId(); - this.#displayVariantId = await this.observe(context?.displayVariantId)?.asPromise(); + + // Observe the display variant id so the resolver reacts to culture switches, rather than + // capturing the culture only once. + if (context) { + this.observe( + context.displayVariantId, + (displayVariantId) => { + this.#displayVariantId = displayVariantId; + this.#setCultureAwareValues(); + }, + 'observeDisplayVariantId', + ); + } + this.#setCultureAwareValues(); }).asPromise(), ]); @@ -73,8 +95,21 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { return this.#urls.getValue(); } + /** + * Gets the culture to request urls for from the server. + * Returns the current culture for variant documents, or undefined for invariant documents + * (which must return all of their domain urls). + * @returns {Promise} The culture to request, or undefined for all cultures + * @memberof UmbDocumentUrlsDataResolver + */ + async getRequestCulture(): Promise { + await this.#init; + return this.#requestCulture.getValue(); + } + #setCultureAwareValues() { this.#setUrls(); + this.#requestCulture.setValue(this.#variantId?.isCultureInvariant() ? undefined : this.#getCurrentCulture()); } #setUrls() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts index 5b3c61c1b69c..fafea3980c29 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts @@ -84,7 +84,7 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { if (unique !== this._unique) { this._unique = unique; - this.#requestUrls(); + this.#debounceRequestUrls(); } }, 'observeWorkspaceState', @@ -99,6 +99,16 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { }); }); + // Re-request when the displayed culture changes, so switching language fetches that culture. + this.observe( + this.#documentUrlsDataResolver?.requestCulture, + () => { + if (!this._unique || this._isNew) return; + this.#debounceRequestUrls(); + }, + 'observeRequestCulture', + ); + this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => { this.#propertyDataSetVariantId = context?.getVariantId(); this.#setLinks(); @@ -143,7 +153,10 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { this._loading = true; this.#documentUrlsDataResolver?.setData([]); - const { data } = await this.#documentUrlRepository.requestItems([this._unique]); + // Only request the culture currently being displayed for variant documents. Invariant documents + // return all of their domain urls, so no culture is passed (getRequestCulture resolves undefined). + const culture = await this.#documentUrlsDataResolver?.getRequestCulture(); + const { data } = await this.#documentUrlRepository.requestUrls([this._unique], culture); if (data?.length) { this.#documentUrlsDataResolver?.setData(data[0].urls); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts index 6b65fe354598..43d72179333d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts @@ -5,9 +5,34 @@ import { UmbItemRepositoryBase } from '@umbraco-cms/backoffice/repository'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; export class UmbDocumentUrlRepository extends UmbItemRepositoryBase { + // A dedicated data source is used for the culture-aware request, as the inherited `requestItems` + // does not carry a culture. It otherwise mirrors the base flow (store population included). + #urlSource = new UmbDocumentUrlServerDataSource(this); + constructor(host: UmbControllerHost) { super(host, UmbDocumentUrlServerDataSource, UMB_DOCUMENT_URL_STORE_CONTEXT); } + + /** + * Requests the urls for the given uniques, optionally restricted to a single culture. + * @param {Array} uniques The document uniques to request urls for. + * @param {string} [culture] The culture to restrict variant document urls to, or undefined for all cultures. + * @returns {*} The requested url data. + * @memberof UmbDocumentUrlRepository + */ + async requestUrls(uniques: Array, culture?: string) { + if (!uniques) throw new Error('Uniques are missing'); + + await this._init; + + const { data, error } = await this.#urlSource.getItems(uniques, culture); + + if (data) { + this._itemStore?.appendItems(data); + } + + return { data, error, asObservable: () => this._itemStore?.items(uniques) }; + } } export { UmbDocumentUrlRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts new file mode 100644 index 000000000000..5af358b0715f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts @@ -0,0 +1,47 @@ +import { UmbDocumentUrlServerDataSource } from './document-url.server.data-source.js'; +import { expect } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api'; + +@customElement('test-document-url-data-source-host') +class UmbTestDocumentUrlDataSourceHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbDocumentUrlServerDataSource', () => { + let hostElement: UmbTestDocumentUrlDataSourceHostElement; + let dataSource: UmbDocumentUrlServerDataSource; + + const originalGetUrls = DocumentService.getDocumentUrls; + let lastQuery: { id?: Array; culture?: string } | undefined; + + beforeEach(() => { + hostElement = new UmbTestDocumentUrlDataSourceHostElement(); + document.body.appendChild(hostElement); + dataSource = new UmbDocumentUrlServerDataSource(hostElement); + + lastQuery = undefined; + (DocumentService as any).getDocumentUrls = (options: { query: { id: Array; culture?: string } }) => { + lastQuery = options.query; + return Promise.resolve({ data: options.query.id.map((id: string) => ({ id, urlInfos: [] })) }); + }; + }); + + afterEach(() => { + (DocumentService as any).getDocumentUrls = originalGetUrls; + hostElement.remove(); + }); + + it('requests only the given culture when one is provided', async () => { + await dataSource.getItems(['doc-1'], 'da-DK'); + + expect(lastQuery?.id).to.eql(['doc-1']); + expect(lastQuery?.culture).to.equal('da-DK'); + }); + + it('omits the culture when none is provided, so all cultures are returned', async () => { + await dataSource.getItems(['doc-1']); + + expect(lastQuery?.id).to.eql(['doc-1']); + expect(lastQuery?.culture).to.equal(undefined); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts index 89df34cac81c..62eb65960d92 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts @@ -23,12 +23,12 @@ export class UmbDocumentUrlServerDataSource extends UmbItemServerDataSourceBase< super(host, { mapper }); } - override async getItems(uniques: Array) { + override async getItems(uniques: Array, culture?: string) { if (!uniques) throw new Error('Uniques are missing'); const itemRequestManager = new UmbItemDataApiGetRequestController(this, { // eslint-disable-next-line local-rules/no-direct-api-import - api: (args) => DocumentService.getDocumentUrls({ query: { id: args.uniques } }), + api: (args) => DocumentService.getDocumentUrls({ query: { id: args.uniques, culture } }), uniques, }); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs index 931d1cde66aa..1b47ffa2b56d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs @@ -44,6 +44,29 @@ public async Task Invariant_Content_Without_Domain_Returns_Only_Default_Language Assert.AreEqual("en-US", urls.First().Culture, "The URL should be for the default language (en-US)"); } + [Test] + public async Task Invariant_Content_Ignores_Requested_Culture() + { + // 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); + + // The base class creates invariant content + ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + + // Act: request with and without a culture + var allUrls = await PublishedUrlInfoProvider.GetAllAsync(Textpage); + var cultureScopedUrls = await PublishedUrlInfoProvider.GetAllAsync(Textpage, "da-DK"); + + // Assert: invariant content ignores the requested culture and returns the same urls + CollectionAssert.AreEquivalent( + allUrls.Select(x => x.Url?.ToString()), + cultureScopedUrls.Select(x => x.Url?.ToString())); + } + [Test] public async Task Invariant_Content_Under_Non_Default_Language_Domain_Returns_Only_Domain_Url() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs index 09c8aa605cc4..56153938a65b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs @@ -239,6 +239,66 @@ public async Task Can_Resolve_Urls_With_Domains_For_All_Cultures() }); } + [Test] + public async Task Can_Resolve_Urls_Restricted_To_A_Single_Culture() + { + var domainService = GetRequiredService(); + var updateModel = new DomainsUpdateModel + { + Domains = Cultures.Select(culture => new DomainModel + { + DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture + }) + }; + + var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsTrue(result.Success); + + var culture = Cultures[1]; + var publishedUrlInfoProvider = GetRequiredService(); + + var allUrls = await publishedUrlInfoProvider.GetAllAsync(Root); + var cultureUrls = await publishedUrlInfoProvider.GetAllAsync(Root, culture); + + Assert.Multiple(() => + { + // The culture-scoped result contains only the requested culture. + Assert.IsNotEmpty(cultureUrls); + CollectionAssert.AreEquivalent(new[] { culture }, cultureUrls.Select(x => x.Culture).Distinct()); + + // The culture-scoped result equals the all-cultures result filtered to that culture. + CollectionAssert.AreEquivalent( + allUrls.Where(x => x.Culture == culture).Select(x => x.Url?.ToString()), + cultureUrls.Select(x => x.Url?.ToString())); + }); + } + + [Test] + public async Task Resolving_Urls_For_An_Unknown_Culture_Falls_Back_To_All_Cultures() + { + var domainService = GetRequiredService(); + var updateModel = new DomainsUpdateModel + { + Domains = Cultures.Select(culture => new DomainModel + { + DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture + }) + }; + + var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsTrue(result.Success); + + var publishedUrlInfoProvider = GetRequiredService(); + + var allUrls = await publishedUrlInfoProvider.GetAllAsync(Root); + var unknownCultureUrls = await publishedUrlInfoProvider.GetAllAsync(Root, "xx-XX"); + + // An unknown culture is not a valid installed culture, so it falls back to all cultures. + CollectionAssert.AreEquivalent( + allUrls.Select(x => x.Url?.ToString()), + unknownCultureUrls.Select(x => x.Url?.ToString())); + } + [Test] public async Task Can_Resolve_Urls_For_Non_Default_Domain_Culture_Only() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs new file mode 100644 index 000000000000..a3b5beea649a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Factories; + +[TestFixture] +public class DocumentUrlFactoryTests +{ + private static (DocumentUrlFactory Factory, Mock Provider) CreateFactory() + { + var provider = new Mock(); + + // Return a single url tagged with the requested culture so assertions can distinguish calls. + provider + .Setup(x => x.GetAllAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IContent _, string? culture) => + new HashSet { UrlInfo.AsUrl($"/{culture ?? "all"}/", "test", culture) }); + + var factory = new DocumentUrlFactory( + provider.Object, + new UrlProviderCollection(() => []), + Mock.Of(), + Mock.Of(), + NullLogger.Instance); + + return (factory, provider); + } + + private static IContent CreateContent(Guid? key = null) + { + var content = new Mock(); + content.SetupGet(x => x.Key).Returns(key ?? Guid.NewGuid()); + return content.Object; + } + + [Test] + public async Task CreateUrlsAsync_Passes_Requested_Culture_To_Provider() + { + var (factory, provider) = CreateFactory(); + var content = CreateContent(); + + var result = (await factory.CreateUrlsAsync(content, "da-DK")).ToList(); + + provider.Verify(x => x.GetAllAsync(content, "da-DK"), Times.Once); + Assert.AreEqual(1, result.Count); + Assert.AreEqual("da-DK", result[0].Culture); + } + + [Test] + public async Task CreateUrlsAsync_Without_Culture_Requests_All_Cultures() + { + var (factory, provider) = CreateFactory(); + var content = CreateContent(); + + await factory.CreateUrlsAsync(content); + + // The parameterless overload must delegate with a null culture (all cultures). + provider.Verify(x => x.GetAllAsync(content, null), Times.Once); + } + + [Test] + public async Task CreateUrlSetsAsync_Passes_Requested_Culture_For_Every_Item() + { + var (factory, provider) = CreateFactory(); + var contentA = CreateContent(); + var contentB = CreateContent(); + + var result = (await factory.CreateUrlSetsAsync([contentA, contentB], "da-DK")).ToList(); + + provider.Verify(x => x.GetAllAsync(contentA, "da-DK"), Times.Once); + provider.Verify(x => x.GetAllAsync(contentB, "da-DK"), Times.Once); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual(contentA.Key, result[0].Id); + Assert.AreEqual(contentB.Key, result[1].Id); + } + + [Test] + public async Task CreateUrlSetsAsync_Without_Culture_Requests_All_Cultures() + { + var (factory, provider) = CreateFactory(); + var content = CreateContent(); + + await factory.CreateUrlSetsAsync([content]); + + provider.Verify(x => x.GetAllAsync(content, null), Times.Once); + } +} From 008c84b76443f4917ef1302ed4700eba5725b0f8 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 2 Jul 2026 06:32:11 +0200 Subject: [PATCH 2/9] Document URLs: return no URLs for an unknown requested culture instead of resolving all cultures. --- .../Routing/PublishedUrlInfoProvider.cs | 34 ++++++++----------- .../UrlAndDomains/DomainAndUrlsTests.cs | 10 +++--- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs index 9618f783c2a1..0cb09f5d8667 100644 --- a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs +++ b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs @@ -65,9 +65,20 @@ public async Task> GetAllAsync(IContent content, string? culture) HashSet urlInfos = []; var isInvariant = !content.ContentType.VariesByCulture(); - // For variant content, restrict to the requested culture when it is a valid installed culture. - // For invariant content, no culture, or an unknown culture, this is null and all cultures are resolved. - string? scopedCulture = await ResolveScopedCultureAsync(content, culture); + // For variant content, restrict to the requested culture, matched against the installed cultures + // (using their casing). Culture is ignored for invariant content, which returns all of its domain urls. + string? scopedCulture = null; + if (culture is not null && isInvariant is false) + { + scopedCulture = (await _languageService.GetAllIsoCodesAsync()) + .FirstOrDefault(x => x.InvariantEquals(culture)); + + // A specific culture was requested that is not an installed culture - there are no urls to report. + if (scopedCulture is null) + { + return urlInfos; + } + } IEnumerable cultures = scopedCulture is not null ? [scopedCulture] @@ -122,23 +133,6 @@ public async Task> GetAllAsync(IContent content, string? culture) return urlInfos; } - /// - /// Resolves the single culture to scope the URL lookup to, or null to resolve all cultures. - /// Returns null for invariant content, when no culture is requested, or when the requested culture - /// is not a valid installed culture (in which case all cultures are resolved). Otherwise returns the - /// installed culture matching the request (using the installed culture's casing). - /// - private async Task ResolveScopedCultureAsync(IContent content, string? culture) - { - if (culture is null || content.ContentType.VariesByCulture() is false) - { - return null; - } - - IEnumerable isoCodes = await _languageService.GetAllIsoCodesAsync(); - return isoCodes.FirstOrDefault(x => x.InvariantEquals(culture)); - } - /// /// Gets the cultures to query URLs for. /// For invariant content, returns only cultures that have a domain assigned to the content diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs index 56153938a65b..c21ec453e9d3 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs @@ -274,7 +274,7 @@ public async Task Can_Resolve_Urls_Restricted_To_A_Single_Culture() } [Test] - public async Task Resolving_Urls_For_An_Unknown_Culture_Falls_Back_To_All_Cultures() + public async Task Resolving_Urls_For_An_Unknown_Culture_Returns_No_Urls() { var domainService = GetRequiredService(); var updateModel = new DomainsUpdateModel @@ -290,13 +290,11 @@ public async Task Resolving_Urls_For_An_Unknown_Culture_Falls_Back_To_All_Cultur var publishedUrlInfoProvider = GetRequiredService(); - var allUrls = await publishedUrlInfoProvider.GetAllAsync(Root); var unknownCultureUrls = await publishedUrlInfoProvider.GetAllAsync(Root, "xx-XX"); - // An unknown culture is not a valid installed culture, so it falls back to all cultures. - CollectionAssert.AreEquivalent( - allUrls.Select(x => x.Url?.ToString()), - unknownCultureUrls.Select(x => x.Url?.ToString())); + // An unknown culture is not an installed culture, so there are no urls to report for it - + // rather than falling back to (expensively) resolving all cultures. + Assert.IsEmpty(unknownCultureUrls); } [Test] From aa7cfa1eefa61c784bb34c6bd799d76147f03c46 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 2 Jul 2026 06:52:34 +0200 Subject: [PATCH 3/9] Addressed code review feedback (improved comments, added TODO for default implementation removal). --- .../Factories/IDocumentUrlFactory.cs | 2 ++ src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs | 1 + .../documents/url/repository/document-url.repository.ts | 6 ++++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs index 38cd590e3bde..e4881b7bee34 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs @@ -21,6 +21,7 @@ public interface IDocumentUrlFactory /// The content to create URLs for. /// The culture to restrict variant content urls to, or null for all cultures. /// A task that represents the asynchronous operation. The task result contains a collection of DocumentUrlInfo. + // TODO (V19): Remove the default implementation. Task> CreateUrlsAsync(IContent content, string? culture) => CreateUrlsAsync(content); @@ -37,6 +38,7 @@ Task> CreateUrlsAsync(IContent content, string? cul /// The content items for which to generate URL sets. /// The culture to restrict variant content urls to, or null for all cultures. /// A task representing the asynchronous operation, with a result containing an enumerable of representing the URL sets. + // TODO (V19): Remove the default implementation. Task> CreateUrlSetsAsync(IEnumerable contentItems, string? culture) => CreateUrlSetsAsync(contentItems); diff --git a/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs index 0da7dff6595c..44cfc81ac2b7 100644 --- a/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs +++ b/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs @@ -23,6 +23,7 @@ public interface IPublishedUrlInfoProvider /// Ignored for invariant content, which always returns all of its domain urls. /// /// Set of published url infos. + // TODO (V19): Remove the default implementation. Task> GetAllAsync(IContent content, string? culture) => GetAllAsync(content); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts index 43d72179333d..57e40b3b8558 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts @@ -5,8 +5,10 @@ import { UmbItemRepositoryBase } from '@umbraco-cms/backoffice/repository'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; export class UmbDocumentUrlRepository extends UmbItemRepositoryBase { - // A dedicated data source is used for the culture-aware request, as the inherited `requestItems` - // does not carry a culture. It otherwise mirrors the base flow (store population included). + // The culture-aware request needs its own data source instance: `UmbItemRepositoryBase` keeps its + // data source private and the inherited `requestItems` signature cannot carry a culture. Extending + // the item base keeps this consistent with `UmbMediaUrlRepository`; `requestUrls` otherwise mirrors + // the base flow, including store population. #urlSource = new UmbDocumentUrlServerDataSource(this); constructor(host: UmbControllerHost) { From a4eec447da58b99019397006bd51c26c845afcdb Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 2 Jul 2026 07:05:31 +0200 Subject: [PATCH 4/9] Added unit tests and refactored to reduce cognitive complexity in GetAllAsync. --- .../Routing/PublishedUrlInfoProvider.cs | 101 ++++---- .../Services/PublishedUrlInfoProviderTests.cs | 2 +- .../UrlAndDomains/DomainAndUrlsTests.cs | 2 +- .../Factories/DocumentUrlFactoryTests.cs | 8 +- .../Routing/PublishedUrlInfoProviderTests.cs | 227 ++++++++++++++++++ 5 files changed, 291 insertions(+), 49 deletions(-) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs diff --git a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs index 0cb09f5d8667..bff3ae9b01a4 100644 --- a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs +++ b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs @@ -62,56 +62,43 @@ public Task> GetAllAsync(IContent content) /// public async Task> GetAllAsync(IContent content, string? culture) { - HashSet urlInfos = []; var isInvariant = !content.ContentType.VariesByCulture(); - // For variant content, restrict to the requested culture, matched against the installed cultures - // (using their casing). Culture is ignored for invariant content, which returns all of its domain urls. - string? scopedCulture = null; - if (culture is not null && isInvariant is false) + // Variant content restricted to a single culture, matched against the installed cultures (using their casing). + if (isInvariant is false && culture is not null) { - scopedCulture = (await _languageService.GetAllIsoCodesAsync()) + var matchedCulture = (await _languageService.GetAllIsoCodesAsync()) .FirstOrDefault(x => x.InvariantEquals(culture)); // A specific culture was requested that is not an installed culture - there are no urls to report. - if (scopedCulture is null) - { - return urlInfos; - } + return matchedCulture is null + ? new HashSet() + : await BuildUrlInfosAsync(content, [matchedCulture], scopedCulture: matchedCulture, isInvariant); } - IEnumerable cultures = scopedCulture is not null - ? [scopedCulture] - : await GetCulturesForUrlLookupAsync(content); + // Invariant content (culture ignored), or all cultures. + IReadOnlyCollection cultures = (await GetCulturesForUrlLookupAsync(content)).ToArray(); + return await BuildUrlInfosAsync(content, cultures, scopedCulture: null, isInvariant); + } + /// + /// Builds the set of for the given cultures, plus the "other" URLs (unless the content is + /// trashed). When is set, the "other" URLs are filtered to that culture. + /// + private async Task> BuildUrlInfosAsync( + IContent content, + IReadOnlyCollection cultures, + string? scopedCulture, + bool isInvariant) + { + var urlInfos = new HashSet(); foreach (var contentCulture in cultures) { - var url = _publishedUrlProvider.GetUrl(content.Key, culture: contentCulture); - - // Handle "could not get URL" - if (url is Constants.Routing.Unroutable or Constants.Routing.UrlProviderException) - { - // 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, contentCulture)); - continue; - } - - // Check for collision - Attempt hasCollision = await VerifyCollisionAsync(content, url, contentCulture); - - if (hasCollision is { Success: true, Result: not null }) + UrlInfo? urlInfo = await GetCultureUrlInfoAsync(content, contentCulture, isInvariant); + if (urlInfo is not null) { - urlInfos.Add(hasCollision.Result); - continue; + urlInfos.Add(urlInfo); } - - urlInfos.Add(UrlInfo.AsUrl(url, UrlProviderAlias, contentCulture)); } // If the content is trashed, we can't get the other URLs, as we have no parent structure to navigate through. @@ -120,12 +107,7 @@ public async Task> GetAllAsync(IContent content, string? culture) return urlInfos; } - // Then get "other" urls - I.E. Not what you'd get with GetUrl(), this includes all the urls registered using domains. - // for these 'other' URLs, we don't check whether they are routable, collide, anything - we just report them. - // When scoped to a single culture, only report the other urls for that culture. - foreach (UrlInfo otherUrl in _publishedUrlProvider.GetOtherUrls(content.Id) - .Where(x => scopedCulture is null || string.Equals(x.Culture, scopedCulture, StringComparison.OrdinalIgnoreCase)) - .OrderBy(x => x.Message).ThenBy(x => x.Culture)) + foreach (UrlInfo otherUrl in GetOtherUrls(content, scopedCulture)) { urlInfos.Add(otherUrl); } @@ -133,6 +115,39 @@ public async Task> GetAllAsync(IContent content, string? culture) return urlInfos; } + /// + /// Gets the for a single culture, or null if there is nothing to report + /// (an unroutable URL on invariant content). Reports a message for an unroutable URL or a collision. + /// + private async Task GetCultureUrlInfoAsync(IContent content, string culture, bool isInvariant) + { + var url = _publishedUrlProvider.GetUrl(content.Key, culture: culture); + + if (url is Constants.Routing.Unroutable or Constants.Routing.UrlProviderException) + { + // For invariant content, a missing URL just means there's no domain for this culture - not worth reporting. + return isInvariant + ? null + : UrlInfo.AsMessage(_localizedTextService.Localize("content", "getUrlException"), UrlProviderAlias, culture); + } + + Attempt hasCollision = await VerifyCollisionAsync(content, url, culture); + return hasCollision is { Success: true, Result: not null } + ? hasCollision.Result + : UrlInfo.AsUrl(url, UrlProviderAlias, culture); + } + + /// + /// Gets the "other" URLs - i.e. not what you'd get with GetUrl(), including all the URLs registered using domains. + /// These are not checked for routability or collisions - they are just reported. When scoped to a single culture, + /// only the other URLs for that culture are returned. + /// + private IEnumerable GetOtherUrls(IContent content, string? scopedCulture) + => _publishedUrlProvider.GetOtherUrls(content.Id) + .Where(x => scopedCulture is null || string.Equals(x.Culture, scopedCulture, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Message) + .ThenBy(x => x.Culture); + /// /// Gets the cultures to query URLs for. /// For invariant content, returns only cultures that have a domain assigned to the content diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs index 1b47ffa2b56d..cbb26e162b74 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs @@ -45,7 +45,7 @@ public async Task Invariant_Content_Without_Domain_Returns_Only_Default_Language } [Test] - public async Task Invariant_Content_Ignores_Requested_Culture() + public async Task Can_Ignore_Requested_Culture_For_Invariant_Content() { // Arrange: Add a second language (Danish) alongside the default English var danishLanguage = new LanguageBuilder() diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs index c21ec453e9d3..5e43df7cf550 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs @@ -274,7 +274,7 @@ public async Task Can_Resolve_Urls_Restricted_To_A_Single_Culture() } [Test] - public async Task Resolving_Urls_For_An_Unknown_Culture_Returns_No_Urls() + public async Task Cannot_Resolve_Urls_For_An_Unknown_Culture() { var domainService = GetRequiredService(); var updateModel = new DomainsUpdateModel diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs index a3b5beea649a..1590a2c050a5 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs @@ -40,7 +40,7 @@ private static IContent CreateContent(Guid? key = null) } [Test] - public async Task CreateUrlsAsync_Passes_Requested_Culture_To_Provider() + public async Task Can_Pass_Requested_Culture_To_Provider() { var (factory, provider) = CreateFactory(); var content = CreateContent(); @@ -53,7 +53,7 @@ public async Task CreateUrlsAsync_Passes_Requested_Culture_To_Provider() } [Test] - public async Task CreateUrlsAsync_Without_Culture_Requests_All_Cultures() + public async Task Can_Request_All_Cultures_When_No_Culture_Provided() { var (factory, provider) = CreateFactory(); var content = CreateContent(); @@ -65,7 +65,7 @@ public async Task CreateUrlsAsync_Without_Culture_Requests_All_Cultures() } [Test] - public async Task CreateUrlSetsAsync_Passes_Requested_Culture_For_Every_Item() + public async Task Can_Pass_Requested_Culture_For_Every_Item() { var (factory, provider) = CreateFactory(); var contentA = CreateContent(); @@ -82,7 +82,7 @@ public async Task CreateUrlSetsAsync_Passes_Requested_Culture_For_Every_Item() } [Test] - public async Task CreateUrlSetsAsync_Without_Culture_Requests_All_Cultures() + public async Task Can_Request_All_Cultures_For_Url_Sets_When_No_Culture_Provided() { var (factory, provider) = CreateFactory(); var content = CreateContent(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs new file mode 100644 index 000000000000..8768179919e8 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs @@ -0,0 +1,227 @@ +using System.Globalization; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Routing; + +[TestFixture] +public class PublishedUrlInfoProviderTests +{ + private static readonly Guid _contentKey = new("2b3f9a4d-0c1e-4b6a-9c2d-1e2f3a4b5c6d"); + private const int ContentId = 1234; + + private Mock _urlProvider = null!; + private Mock _languageService = null!; + private Mock _router = null!; + private Mock _textService = null!; + private IPublishedRequest _routeResult = null!; + + [SetUp] + public void SetUp() + { + _urlProvider = new Mock(); + _urlProvider + .Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Guid _, UrlMode _, string? culture, Uri? _) => $"https://example.com/{culture}/"); + _urlProvider.Setup(x => x.GetOtherUrls(It.IsAny())).Returns(Array.Empty()); + + _languageService = new Mock(); + _languageService.Setup(x => x.GetAllAsync()).ReturnsAsync([CreateLanguage("en-US"), CreateLanguage("da-DK")]); + _languageService.Setup(x => x.GetDefaultIsoCodeAsync()).ReturnsAsync("en-US"); + + // Default routing result: the generated URL routes back to the same content (no collision). + _routeResult = CreateRequest(ContentId); + _router = new Mock(); + _router.Setup(x => x.CreateRequestAsync(It.IsAny())).ReturnsAsync(Mock.Of()); + _router + .Setup(x => x.RouteRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => _routeResult); + + _textService = new Mock(); + _textService + .Setup(x => x.Localize(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) + .Returns((string? _, string? alias, CultureInfo? _, IDictionary? _) => alias ?? string.Empty); + } + + [Test] + public async Task Can_Get_Url_For_Each_Installed_Culture_When_No_Culture_Requested() + { + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true)); + + Assert.AreEqual(2, result.Count); + CollectionAssert.AreEquivalent(new[] { "en-US", "da-DK" }, result.Select(x => x.Culture)); + Assert.IsTrue(result.All(x => x.Url is not null)); + } + + [Test] + public async Task Can_Restrict_Urls_To_A_Single_Requested_Culture() + { + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("da-DK", result.Single().Culture); + } + + [Test] + public async Task Can_Match_Requested_Culture_Case_Insensitively() + { + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "DA-dk"); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("da-DK", result.Single().Culture, "The installed culture's casing should be used."); + } + + [Test] + public async Task Cannot_Get_Urls_For_An_Unknown_Culture() + { + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "xx-XX"); + + Assert.IsEmpty(result); + _urlProvider.Verify( + x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task Cannot_Get_Url_For_Unroutable_Content_And_Reports_Message() + { + _urlProvider + .Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), "da-DK", It.IsAny())) + .Returns(Constants.Routing.Unroutable); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + UrlInfo info = result.Single(); + Assert.IsNull(info.Url); + Assert.AreEqual("getUrlException", info.Message); + } + + [Test] + public async Task Cannot_Get_Url_When_Route_Collides_And_Reports_Message() + { + // The generated URL routes back to a different content item. + _routeResult = CreateRequest(resolvedContentId: 9999); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + UrlInfo info = result.Single(); + Assert.IsNull(info.Url); + Assert.AreEqual("routeError", info.Message); + } + + [Test] + public async Task Cannot_Get_Url_When_Route_Is_Unresolvable_And_Reports_Message() + { + // The generated URL does not route back to any content item. + _routeResult = CreateRequest(resolvedContentId: null); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + UrlInfo info = result.Single(); + Assert.IsNull(info.Url); + Assert.AreEqual("routeErrorCannotRoute", info.Message); + } + + [Test] + public async Task Can_Get_Url_When_Collisions_Are_Ignored() + { + // Routes back to different content, but the request opts out of collision checks. + _routeResult = CreateRequest(resolvedContentId: 9999, ignoreCollisions: true); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + UrlInfo info = result.Single(); + Assert.IsNotNull(info.Url); + Assert.AreEqual("da-DK", info.Culture); + } + + [Test] + public async Task Cannot_Get_Other_Urls_For_Trashed_Content() + { + _urlProvider + .Setup(x => x.GetOtherUrls(ContentId)) + .Returns(new[] { UrlInfo.AsUrl("https://example.com/other/", "content", "da-DK") }); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true, trashed: true), "da-DK"); + + _urlProvider.Verify(x => x.GetOtherUrls(It.IsAny()), Times.Never); + Assert.IsFalse(result.Any(x => x.Url is not null && x.Url.ToString().Contains("/other/"))); + } + + [Test] + public async Task Can_Get_Other_Urls_Filtered_To_The_Scoped_Culture() + { + _urlProvider.Setup(x => x.GetOtherUrls(ContentId)).Returns(new[] + { + UrlInfo.AsUrl("https://example.com/other-da/", "content", "da-DK"), + UrlInfo.AsUrl("https://example.com/other-en/", "content", "en-US"), + }); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + Assert.Multiple(() => + { + Assert.IsTrue(result.Any(x => x.Url is not null && x.Url.ToString().Contains("/other-da/"))); + Assert.IsFalse(result.Any(x => x.Url is not null && x.Url.ToString().Contains("/other-en/"))); + }); + } + + private PublishedUrlInfoProvider CreateSut() + { + var hostingEnvironment = new Mock(); + hostingEnvironment.Setup(x => x.ApplicationVirtualPath).Returns("/"); + + return new PublishedUrlInfoProvider( + _urlProvider.Object, + _languageService.Object, + _router.Object, + Mock.Of(), + _textService.Object, + NullLogger.Instance, + new UriUtility(hostingEnvironment.Object), + Mock.Of()); + } + + private static ILanguage CreateLanguage(string isoCode) + { + var language = new Mock(); + language.SetupGet(x => x.IsoCode).Returns(isoCode); + return language.Object; + } + + private static IContent CreateContent(bool variesByCulture, bool trashed = false) + { + var contentType = new Mock(); + contentType.SetupGet(x => x.Variations).Returns(variesByCulture ? ContentVariation.Culture : ContentVariation.Nothing); + + var content = new Mock(); + content.SetupGet(x => x.ContentType).Returns(contentType.Object); + content.SetupGet(x => x.Key).Returns(_contentKey); + content.SetupGet(x => x.Id).Returns(ContentId); + content.SetupGet(x => x.Trashed).Returns(trashed); + return content.Object; + } + + private static IPublishedRequest CreateRequest(int? resolvedContentId, bool ignoreCollisions = false) + { + var request = new Mock(); + if (resolvedContentId is not null) + { + var publishedContent = new Mock(); + publishedContent.SetupGet(x => x.Id).Returns(resolvedContentId.Value); + publishedContent.SetupGet(x => x.Key).Returns(Guid.NewGuid()); + request.SetupGet(x => x.PublishedContent).Returns(publishedContent.Object); + } + + request.SetupGet(x => x.IgnorePublishedContentCollisions).Returns(ignoreCollisions); + return request.Object; + } +} From 428ffab3e273fed0b08eb225d6bf8680c482aa8c Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 3 Jul 2026 08:39:19 +0200 Subject: [PATCH 5/9] Remove flicker of URL not resolved when loading Info workspace or switching languages. --- ...cument-links-workspace-info-app.element.ts | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts index fafea3980c29..7dfc480cbad3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts @@ -84,7 +84,7 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { if (unique !== this._unique) { this._unique = unique; - this.#debounceRequestUrls(); + this.#scheduleRequestUrls(); } }, 'observeWorkspaceState', @@ -102,10 +102,7 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { // Re-request when the displayed culture changes, so switching language fetches that culture. this.observe( this.#documentUrlsDataResolver?.requestCulture, - () => { - if (!this._unique || this._isNew) return; - this.#debounceRequestUrls(); - }, + () => this.#scheduleRequestUrls(), 'observeRequestCulture', ); @@ -146,23 +143,33 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { return url; } + // Show the loading indicator immediately (synchronously), before the debounced request runs, so no + // stale "no URL" message is shown while the URL is (re)resolved - e.g. on load or when switching culture. + #scheduleRequestUrls() { + if (this._isNew || !this._unique) return; + + this._loading = true; + this.#debounceRequestUrls(); + } + async #requestUrls() { - if (this._isNew) return; - if (!this._unique) return; + if (this._isNew || !this._unique) return; this._loading = true; this.#documentUrlsDataResolver?.setData([]); - // Only request the culture currently being displayed for variant documents. Invariant documents - // return all of their domain urls, so no culture is passed (getRequestCulture resolves undefined). - const culture = await this.#documentUrlsDataResolver?.getRequestCulture(); - const { data } = await this.#documentUrlRepository.requestUrls([this._unique], culture); + try { + // Only request the culture currently being displayed for variant documents. Invariant documents + // return all of their domain urls, so no culture is passed (getRequestCulture resolves undefined). + const culture = await this.#documentUrlsDataResolver?.getRequestCulture(); + const { data } = await this.#documentUrlRepository.requestUrls([this._unique], culture); - if (data?.length) { - this.#documentUrlsDataResolver?.setData(data[0].urls); + if (data?.length) { + this.#documentUrlsDataResolver?.setData(data[0].urls); + } + } finally { + this._loading = false; } - - this._loading = false; } #getStateLocalizationKey(state: UmbDocumentVariantState | null | undefined): string { @@ -186,7 +193,7 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { // TODO: Introduce "Published Event". We only need to update the url when the document is published. if (event.getUnique() !== this.#documentWorkspaceContext?.getUnique()) return; if (event.getEntityType() !== this.#documentWorkspaceContext.getEntityType()) return; - this.#debounceRequestUrls(); + this.#scheduleRequestUrls(); }; override render() { From 132760cd58b25841848b36c37d36bffcc959012d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Sat, 4 Jul 2026 14:02:48 +0200 Subject: [PATCH 6/9] Use mock sets for document URL data source tests. --- .../msw-handlers/document/url.handlers.ts | 17 +++++++--- .../document-url.server.data-source.test.ts | 33 +++++++++---------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts index ea3e91775aec..26b3378ca840 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts @@ -10,13 +10,20 @@ export const urlHandlers = [ const ids = url.searchParams.getAll('id'); if (!ids.length) return new HttpResponse(null, { status: 400 }); + // When a culture is provided, variant URLs are restricted to that culture (invariant URLs, which have + // no culture, are always kept). This mirrors the Management API's document URLs endpoint. + const culture = url.searchParams.get('culture'); + const response: GetDocumentUrlsResponse = ids.map((id) => ({ id, - urlInfos: umbDocumentMockDb.url.getUrls(id).map((urlInfo) => ({ - ...urlInfo, - message: null, - provider: 'Default', - })), + urlInfos: umbDocumentMockDb.url + .getUrls(id) + .filter((urlInfo) => !culture || urlInfo.culture === null || urlInfo.culture === culture) + .map((urlInfo) => ({ + ...urlInfo, + message: null, + provider: 'Default', + })), })); return HttpResponse.json(response); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts index 5af358b0715f..dc092a3d6a83 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts @@ -2,46 +2,45 @@ import { UmbDocumentUrlServerDataSource } from './document-url.server.data-sourc import { expect } from '@open-wc/testing'; import { customElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; -import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api'; +import { useMockSet } from '@umbraco-cms/internal/mock-manager'; @customElement('test-document-url-data-source-host') class UmbTestDocumentUrlDataSourceHostElement extends UmbControllerHostElementMixin(HTMLElement) {} +// A variant document from the 'documents' mock set with URLs in two cultures ('en-US' and 'da'). +const VARIANT_DOCUMENT_ID = 'variant-documents-variant-document-id'; + describe('UmbDocumentUrlServerDataSource', () => { let hostElement: UmbTestDocumentUrlDataSourceHostElement; let dataSource: UmbDocumentUrlServerDataSource; - const originalGetUrls = DocumentService.getDocumentUrls; - let lastQuery: { id?: Array; culture?: string } | undefined; + before(async () => { + await useMockSet('documents'); + }); beforeEach(() => { hostElement = new UmbTestDocumentUrlDataSourceHostElement(); document.body.appendChild(hostElement); dataSource = new UmbDocumentUrlServerDataSource(hostElement); - - lastQuery = undefined; - (DocumentService as any).getDocumentUrls = (options: { query: { id: Array; culture?: string } }) => { - lastQuery = options.query; - return Promise.resolve({ data: options.query.id.map((id: string) => ({ id, urlInfos: [] })) }); - }; }); afterEach(() => { - (DocumentService as any).getDocumentUrls = originalGetUrls; - hostElement.remove(); + document.body.innerHTML = ''; }); it('requests only the given culture when one is provided', async () => { - await dataSource.getItems(['doc-1'], 'da-DK'); + const { data, error } = await dataSource.getItems([VARIANT_DOCUMENT_ID], 'da'); - expect(lastQuery?.id).to.eql(['doc-1']); - expect(lastQuery?.culture).to.equal('da-DK'); + expect(error).to.be.undefined; + expect(data?.[0].unique).to.equal(VARIANT_DOCUMENT_ID); + expect(data?.[0].urls.map((url) => url.culture)).to.eql(['da']); }); it('omits the culture when none is provided, so all cultures are returned', async () => { - await dataSource.getItems(['doc-1']); + const { data, error } = await dataSource.getItems([VARIANT_DOCUMENT_ID]); - expect(lastQuery?.id).to.eql(['doc-1']); - expect(lastQuery?.culture).to.equal(undefined); + expect(error).to.be.undefined; + expect(data?.[0].unique).to.equal(VARIANT_DOCUMENT_ID); + expect(data?.[0].urls.map((url) => url.culture)).to.have.members(['en-US', 'da']); }); }); From 7947eced58b70af12f0f1b67e6be07757ee8dc51 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Sat, 4 Jul 2026 14:35:38 +0200 Subject: [PATCH 7/9] Defer document URL culture resolution to the variant context. --- .../documents/url/document-urls-data-resolver.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts index e1370a60caaf..13d4b6505686 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts @@ -11,7 +11,6 @@ import { UMB_VARIANT_CONTEXT, type UmbVariantId } from '@umbraco-cms/backoffice/ * @augments {UmbControllerBase} */ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { - #appCulture?: string; #variantId?: UmbVariantId; #displayVariantId?: UmbVariantId; #data?: Array | undefined; @@ -109,6 +108,9 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { #setCultureAwareValues() { this.#setUrls(); + + // TODO: when the variant context gains a dedicated "request/effective culture" accessor, resolve the + // invariant->all-cultures decision through it instead of here. this.#requestCulture.setValue(this.#variantId?.isCultureInvariant() ? undefined : this.#getCurrentCulture()); } @@ -118,7 +120,9 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { } #getCurrentCulture(): string | undefined { - return this.#variantId?.culture || this.#displayVariantId?.culture || this.#appCulture; + // Culture resolution (incl. inheritance/fallback) is owned by the variant context; we read its + // already-resolved display variant rather than re-deriving it here. + return this.#displayVariantId?.culture ?? undefined; } #getDataForCurrentCulture(): Array | undefined { From 1b4b9a1a8929c9d143a8cfaf571d882f45ca495e Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Sat, 4 Jul 2026 15:05:33 +0200 Subject: [PATCH 8/9] Addressed relevant Sonarqube feedback. --- .../Factories/DocumentUrlFactory.cs | 2 +- .../documents/documents/url/document-urls-data-resolver.ts | 6 +++--- .../documents/url/repository/document-url.repository.ts | 2 +- .../UrlAndDomains/DomainAndUrlsTests.cs | 7 ++++--- .../Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs | 6 ++++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs index 9ee0d704d0ec..67d896d3d8c1 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs @@ -52,7 +52,7 @@ public Task> CreateUrlsAsync(IContent content) => CreateUrlsAsync(content, culture: null); /// - /// Asynchronously generates a collection of instances representing the URLs for the specified content item, optionally restricted to a single culture. + /// Asynchronously generates a collection of instances representing the URLs for the specified content item, optionally restricted to a single culture. /// /// The content item for which to generate URLs. /// The culture to restrict variant content urls to, or null for all cultures. diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts index 13d4b6505686..200a2bac9064 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts @@ -15,9 +15,9 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { #displayVariantId?: UmbVariantId; #data?: Array | undefined; - #init: Promise; + readonly #init: Promise; - #urls = new UmbArrayState([], (url) => url.url); + readonly #urls = new UmbArrayState([], (url) => url.url); /** * The urls for the current culture * @returns {ObservableArray} The urls for the current culture @@ -25,7 +25,7 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { */ public readonly urls = this.#urls.asObservable(); - #requestCulture = new UmbStringState(undefined); + readonly #requestCulture = new UmbStringState(undefined); /** * The culture to request urls for from the server. Emits whenever the displayed culture changes. * Resolves to undefined for invariant documents (meaning all cultures). diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts index 57e40b3b8558..54e20d0b9500 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts @@ -9,7 +9,7 @@ export class UmbDocumentUrlRepository extends UmbItemRepositoryBase new DomainModel { - DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture + DomainName = GetDomainUrlFromCultureCode(culture), + IsoCode = culture, }) }; @@ -281,7 +281,8 @@ public async Task Cannot_Resolve_Urls_For_An_Unknown_Culture() { Domains = Cultures.Select(culture => new DomainModel { - DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture + DomainName = GetDomainUrlFromCultureCode(culture), + IsoCode = culture, }) }; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs index 8768179919e8..5c2e65a79090 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs @@ -18,6 +18,8 @@ public class PublishedUrlInfoProviderTests private static readonly Guid _contentKey = new("2b3f9a4d-0c1e-4b6a-9c2d-1e2f3a4b5c6d"); private const int ContentId = 1234; + private static readonly string[] _installedCultures = ["en-US", "da-DK"]; + private Mock _urlProvider = null!; private Mock _languageService = null!; private Mock _router = null!; @@ -34,7 +36,7 @@ public void SetUp() _urlProvider.Setup(x => x.GetOtherUrls(It.IsAny())).Returns(Array.Empty()); _languageService = new Mock(); - _languageService.Setup(x => x.GetAllAsync()).ReturnsAsync([CreateLanguage("en-US"), CreateLanguage("da-DK")]); + _languageService.Setup(x => x.GetAllAsync()).ReturnsAsync(_installedCultures.Select(CreateLanguage).ToArray()); _languageService.Setup(x => x.GetDefaultIsoCodeAsync()).ReturnsAsync("en-US"); // Default routing result: the generated URL routes back to the same content (no collision). @@ -57,7 +59,7 @@ public async Task Can_Get_Url_For_Each_Installed_Culture_When_No_Culture_Request ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true)); Assert.AreEqual(2, result.Count); - CollectionAssert.AreEquivalent(new[] { "en-US", "da-DK" }, result.Select(x => x.Culture)); + CollectionAssert.AreEquivalent(_installedCultures, result.Select(x => x.Culture)); Assert.IsTrue(result.All(x => x.Url is not null)); } From 07fecfeef3a14ce4c4da0aac1539f68976b49de9 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 14 Jul 2026 09:48:09 +0200 Subject: [PATCH 9/9] Remove default null value to avoid unreachable method warning. --- .../Controllers/Document/DocumentUrlController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs index e4743f733c6c..831086e2017c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs @@ -53,7 +53,7 @@ public Task GetUrls([FromQuery(Name = "id")] HashSet ids) [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [EndpointSummary("Gets URLs for a document.")] [EndpointDescription("Gets the URLs for the document identified by the provided Id.")] - public async Task GetUrls([FromQuery(Name = "id")] HashSet ids, [FromQuery] string? culture = null) + public async Task GetUrls([FromQuery(Name = "id")] HashSet ids, [FromQuery] string? culture) { IEnumerable items = _contentService.GetByIds(ids);