From 4fb011e0fcb4da865960000ec96dc601efb881f3 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Wed, 15 Mar 2023 10:28:23 +0100 Subject: [PATCH] Domains and hosts API (#13963) * API for domains and hostnames incl. unit tests * Update Open API json * Update other unit tests to use new domain service methods where applicable * Fix merge + update models to new naming scheme * Handle attempts to add the same domain twice + unit tests for duplicate domain handling * Review fixes --- .../Controllers/Document/DomainsController.cs | 30 ++ .../Document/UpdateDomainsController.cs | 51 ++++ .../DocumentBuilderExtensions.cs | 4 +- .../Mapping/Document/DomainMapDefinition.cs | 40 +++ src/Umbraco.Cms.Api.Management/OpenApi.json | 109 +++++++ .../Document/DomainPresentationModel.cs | 8 + .../Document/DomainsPresentationModelBase.cs | 8 + .../Document/DomainsResponseModel.cs | 5 + .../Document/UpdateDomainsRequestModel.cs | 5 + .../Models/ContentEditing/DomainModel.cs | 8 + .../ContentEditing/DomainsUpdateModel.cs | 8 + .../DomainDeletedNotification.cs | 5 + src/Umbraco.Core/Services/DomainService.cs | 275 ++++++++++++++++-- src/Umbraco.Core/Services/IDomainService.cs | 27 ++ .../OperationStatus/DomainOperationStatus.cs | 10 + .../Services/TelemetryProviderTests.cs | 21 +- .../Services/ContentServiceTests.cs | 14 +- .../Controllers/ContentControllerTests.cs | 47 ++- .../UrlAndDomains/DomainAndUrlsTests.cs | 249 +++++++++++++++- 19 files changed, 862 insertions(+), 62 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/DomainsController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDomainsController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Mapping/Document/DomainMapDefinition.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainPresentationModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainsPresentationModelBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainsResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateDomainsRequestModel.cs create mode 100644 src/Umbraco.Core/Models/ContentEditing/DomainModel.cs create mode 100644 src/Umbraco.Core/Models/ContentEditing/DomainsUpdateModel.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/DomainOperationStatus.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DomainsController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DomainsController.cs new file mode 100644 index 0000000000..2979332dfe --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DomainsController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public class DomainsController : DocumentControllerBase +{ + private readonly IDomainService _domainService; + private readonly IUmbracoMapper _umbracoMapper; + + public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper) + { + _domainService = domainService; + _umbracoMapper = umbracoMapper; + } + + [HttpGet("{key:guid}/domains")] + public async Task DomainsAsync(Guid key) + { + IDomain[] assignedDomains = (await _domainService.GetAssignedDomainsAsync(key, true)) + .OrderBy(d => d.SortOrder) + .ToArray(); + + DomainsResponseModel responseModel = _umbracoMapper.Map(assignedDomains)!; + return Ok(responseModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDomainsController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDomainsController.cs new file mode 100644 index 0000000000..638aac5d1c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDomainsController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public class UpdateDomainsController : DocumentControllerBase +{ + private readonly IDomainService _domainService; + private readonly IUmbracoMapper _umbracoMapper; + + public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper) + { + _domainService = domainService; + _umbracoMapper = umbracoMapper; + } + + [HttpPut("{key:guid}/domains")] + public async Task UpdateDomainsAsync(Guid key, UpdateDomainsRequestModel updateModel) + { + DomainsUpdateModel domainsUpdateModel = _umbracoMapper.Map(updateModel)!; + + Attempt, DomainOperationStatus> result = await _domainService.UpdateDomainsAsync(key, domainsUpdateModel); + + return result.Status switch + { + DomainOperationStatus.Success => Ok(), + DomainOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cancelled by notification") + .WithDetail("A notification handler prevented the domain update operation.") + .Build()), + DomainOperationStatus.ContentNotFound => NotFound("The targeted content item was not found."), + DomainOperationStatus.LanguageNotFound => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid language specified") + .WithDetail("One or more of the specified language ISO codes could not be found.") + .Build()), + DomainOperationStatus.DuplicateDomainName => Conflict(new ProblemDetailsBuilder() + .WithTitle("Duplicate domain name detected") + .WithDetail("One or more of the specified domain names were duplicates, possibly of assignments to other content items.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown domain update operation status") + }; + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs index 082add196a..604626cfc9 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs @@ -14,7 +14,9 @@ internal static class DocumentBuilderExtensions builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.WithCollectionBuilder().Add(); + builder.WithCollectionBuilder() + .Add() + .Add(); return builder; } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DomainMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DomainMapDefinition.cs new file mode 100644 index 0000000000..d6848c80ea --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DomainMapDefinition.cs @@ -0,0 +1,40 @@ +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using CoreDomainModel = Umbraco.Cms.Core.Models.ContentEditing.DomainModel; + +namespace Umbraco.Cms.Api.Management.Mapping.Document; + +public class DomainMapDefinition : IMapDefinition +{ + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define, DomainsResponseModel>((_, _) => new DomainsResponseModel { Domains = Enumerable.Empty() }, Map); + mapper.Define((_, _) => new DomainsUpdateModel { Domains = Enumerable.Empty() }, Map); + } + + // Umbraco.Code.MapAll + private void Map(IEnumerable source, DomainsResponseModel target, MapperContext context) + { + IDomain[] sourceAsArray = source.ToArray(); + IDomain[] wildcardsDomains = sourceAsArray.Where(d => d.IsWildcard).ToArray(); + + target.DefaultIsoCode = wildcardsDomains.FirstOrDefault()?.LanguageIsoCode; + target.Domains = sourceAsArray.Except(wildcardsDomains).Select(domain => new DomainPresentationModel + { + DomainName = domain.DomainName, + IsoCode = domain.LanguageIsoCode ?? string.Empty + }).ToArray(); + } + + private void Map(UpdateDomainsRequestModel source, DomainsUpdateModel target, MapperContext context) + { + target.DefaultIsoCode = source.DefaultIsoCode; + target.Domains = source.Domains.Select(domain => new Core.Models.ContentEditing.DomainModel + { + DomainName = domain.DomainName, + IsoCode = domain.IsoCode + }).ToArray(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 0b773268a5..995430606c 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -1745,6 +1745,65 @@ } } }, + "/umbraco/management/api/v1/document/{key}/domains": { + "get": { + "tags": [ + "Document" + ], + "operationId": "GetDocumentByKeyDomains", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "put": { + "tags": [ + "Document" + ], + "operationId": "PutDocumentByKeyDomains", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateDomainsRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/umbraco/management/api/v1/recycle-bin/document/children": { "get": { "tags": [ @@ -7775,6 +7834,47 @@ } } }, + "DomainPresentationModel": { + "type": "object", + "properties": { + "domainName": { + "type": "string" + }, + "isoCode": { + "type": "string" + } + }, + "additionalProperties": false + }, + "DomainsPresentationModelBaseModel": { + "type": "object", + "properties": { + "defaultIsoCode": { + "type": "string", + "nullable": true + }, + "domains": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DomainPresentationModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "DomainsResponseModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/DomainsPresentationModelBaseModel" + } + ], + "additionalProperties": false + }, "EntityTreeItemResponseModel": { "required": [ "$type" @@ -10307,6 +10407,15 @@ }, "additionalProperties": false }, + "UpdateDomainsRequestModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/DomainsPresentationModelBaseModel" + } + ], + "additionalProperties": false + }, "UpdateFolderReponseModel": { "type": "object", "allOf": [ diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainPresentationModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainPresentationModel.cs new file mode 100644 index 0000000000..b7b7fd2560 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainPresentationModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class DomainPresentationModel +{ + public required string DomainName { get; set; } + + public required string IsoCode { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainsPresentationModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainsPresentationModelBase.cs new file mode 100644 index 0000000000..eafa975c67 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainsPresentationModelBase.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public abstract class DomainsPresentationModelBase +{ + public string? DefaultIsoCode { get; set; } + + public required IEnumerable Domains { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainsResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainsResponseModel.cs new file mode 100644 index 0000000000..76b08df3d7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DomainsResponseModel.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class DomainsResponseModel : DomainsPresentationModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateDomainsRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateDomainsRequestModel.cs new file mode 100644 index 0000000000..3756a89c2e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateDomainsRequestModel.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class UpdateDomainsRequestModel : DomainsPresentationModelBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/DomainModel.cs b/src/Umbraco.Core/Models/ContentEditing/DomainModel.cs new file mode 100644 index 0000000000..f989b90421 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/DomainModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class DomainModel +{ + public required string DomainName { get; set; } + + public required string IsoCode { get; set; } +} diff --git a/src/Umbraco.Core/Models/ContentEditing/DomainsUpdateModel.cs b/src/Umbraco.Core/Models/ContentEditing/DomainsUpdateModel.cs new file mode 100644 index 0000000000..e5f7012a60 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/DomainsUpdateModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class DomainsUpdateModel +{ + public string? DefaultIsoCode { get; set; } + + public required IEnumerable Domains { get; set; } +} diff --git a/src/Umbraco.Core/Notifications/DomainDeletedNotification.cs b/src/Umbraco.Core/Notifications/DomainDeletedNotification.cs index c569afc7b4..dfab6f9c58 100644 --- a/src/Umbraco.Core/Notifications/DomainDeletedNotification.cs +++ b/src/Umbraco.Core/Notifications/DomainDeletedNotification.cs @@ -12,4 +12,9 @@ public class DomainDeletedNotification : DeletedNotification : base(target, messages) { } + + public DomainDeletedNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } } diff --git a/src/Umbraco.Core/Services/DomainService.cs b/src/Umbraco.Core/Services/DomainService.cs index 202d51d648..fa09bfc987 100644 --- a/src/Umbraco.Core/Services/DomainService.cs +++ b/src/Umbraco.Core/Services/DomainService.cs @@ -1,23 +1,52 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services; public class DomainService : RepositoryService, IDomainService { private readonly IDomainRepository _domainRepository; + private readonly ILanguageService _languageService; + private readonly IContentService _contentService; + [Obsolete("Please use the constructor that accepts ILanguageService and IContentService. Will be removed in V15.")] public DomainService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, IDomainRepository domainRepository) + : this( + provider, + loggerFactory, + eventMessagesFactory, + domainRepository, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public DomainService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IDomainRepository domainRepository, + ILanguageService languageService, + IContentService contentService) : base(provider, loggerFactory, eventMessagesFactory) - => _domainRepository = domainRepository; + { + _domainRepository = domainRepository; + _languageService = languageService; + _contentService = contentService; + } public bool Exists(string domainName) { @@ -27,26 +56,16 @@ public class DomainService : RepositoryService, IDomainService } } + [Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")] public Attempt Delete(IDomain domain) { EventMessages eventMessages = EventMessagesFactory.Get(); + using ICoreScope scope = ScopeProvider.CreateCoreScope(); - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - var deletingNotification = new DomainDeletingNotification(domain, eventMessages); - if (scope.Notifications.PublishCancelable(deletingNotification)) - { - scope.Complete(); - return OperationResult.Attempt.Cancel(eventMessages); - } + var result = DeleteAll(new[] { domain }, scope, eventMessages); + scope.Complete(); - _domainRepository.Delete(domain); - scope.Complete(); - - scope.Notifications.Publish(new DomainDeletedNotification(domain, eventMessages).WithStateFrom(deletingNotification)); - } - - return OperationResult.Attempt.Succeed(eventMessages); + return result ? OperationResult.Attempt.Succeed(eventMessages) : OperationResult.Attempt.Cancel(eventMessages); } public IDomain? GetByName(string name) @@ -65,6 +84,7 @@ public class DomainService : RepositoryService, IDomainService } } + [Obsolete($"Please use {nameof(GetAllAsync)}. Will be removed in V15")] public IEnumerable GetAll(bool includeWildcards) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) @@ -73,6 +93,7 @@ public class DomainService : RepositoryService, IDomainService } } + [Obsolete($"Please use {nameof(GetAssignedDomainsAsync)}. Will be removed in V15")] public IEnumerable GetAssignedDomains(int contentId, bool includeWildcards) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) @@ -81,28 +102,19 @@ public class DomainService : RepositoryService, IDomainService } } + [Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")] public Attempt Save(IDomain domainEntity) { EventMessages eventMessages = EventMessagesFactory.Get(); + using ICoreScope scope = ScopeProvider.CreateCoreScope(); - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - var savingNotification = new DomainSavingNotification(domainEntity, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return OperationResult.Attempt.Cancel(eventMessages); - } + var result = SaveAll(new[] { domainEntity }, scope, eventMessages); + scope.Complete(); - _domainRepository.Save(domainEntity); - scope.Complete(); - - scope.Notifications.Publish(new DomainSavedNotification(domainEntity, eventMessages).WithStateFrom(savingNotification)); - } - - return OperationResult.Attempt.Succeed(eventMessages); + return result ? OperationResult.Attempt.Succeed(eventMessages) : OperationResult.Attempt.Cancel(eventMessages); } + [Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")] public Attempt Sort(IEnumerable items) { EventMessages eventMessages = EventMessagesFactory.Get(); @@ -144,4 +156,205 @@ public class DomainService : RepositoryService, IDomainService return OperationResult.Attempt.Succeed(eventMessages); } + + /// + public async Task> GetAssignedDomainsAsync(Guid contentKey, bool includeWildcards) + { + IContent? content = _contentService.GetById(contentKey); + if (content == null) + { + return await Task.FromResult(Enumerable.Empty()); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _domainRepository.GetAssignedDomains(content.Id, includeWildcards); + } + + /// + public async Task> GetAllAsync(bool includeWildcards) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return await Task.FromResult(_domainRepository.GetAll(includeWildcards)); + } + + /// + public async Task, DomainOperationStatus>> UpdateDomainsAsync(Guid contentKey, DomainsUpdateModel updateModel) + { + IContent? content = _contentService.GetById(contentKey); + if (content == null) + { + return Attempt.FailWithStatus(DomainOperationStatus.ContentNotFound, Enumerable.Empty()); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + IEnumerable allLanguages = await _languageService.GetAllAsync(); + var languageIdByIsoCode = allLanguages.ToDictionary(l => l.IsoCode, l => l.Id); + + // validate language ISO codes + if (HasInvalidIsoCode(updateModel, languageIdByIsoCode.Keys)) + { + return Attempt.FailWithStatus(DomainOperationStatus.LanguageNotFound, Enumerable.Empty()); + } + + // ensure all domain names in the update model are lowercased + foreach (DomainModel domainModel in updateModel.Domains) + { + domainModel.DomainName = domainModel.DomainName.ToLowerInvariant(); + } + + // make sure we're not attempting to assign duplicate domains + if (updateModel.Domains.GroupBy(domain => domain.DomainName).Any(group => group.Count() > 1)) + { + return Attempt.FailWithStatus(DomainOperationStatus.DuplicateDomainName, Enumerable.Empty()); + } + + // grab all current domain assignments + IDomain[] allDomains = (await GetAllAsync(true)).ToArray(); + + // validate the domain names in the update model + if (HasDomainNameConflicts(content.Id, updateModel, allDomains)) + { + return Attempt.FailWithStatus(DomainOperationStatus.DuplicateDomainName, Enumerable.Empty()); + } + + // find the domains currently assigned to the content item + IDomain[] currentlyAssignedDomains = allDomains.Where(domain => domain.RootContentId == content.Id).ToArray(); + + // calculate the new domain assignments + IDomain[] newAssignedDomains = CalculateNewAssignedDomains(content.Id, updateModel, currentlyAssignedDomains, languageIdByIsoCode); + + EventMessages eventMessages = EventMessagesFactory.Get(); + + scope.WriteLock(Constants.Locks.Domains); + + // delete any obsolete domain assignments + if (DeleteAll(currentlyAssignedDomains.Except(newAssignedDomains).ToArray(), scope, eventMessages) == false) + { + scope.Complete(); + + // this is the only error scenario in DeleteAll + return Attempt.FailWithStatus(DomainOperationStatus.CancelledByNotification, newAssignedDomains.AsEnumerable()); + } + + // update all domain assignments (also current ones, in case sort order or ISO code has changed) + var result = SaveAll(newAssignedDomains, scope, eventMessages); + scope.Complete(); + + return result + ? Attempt.SucceedWithStatus(DomainOperationStatus.Success, newAssignedDomains.AsEnumerable()) + : Attempt.FailWithStatus(DomainOperationStatus.CancelledByNotification, newAssignedDomains.AsEnumerable()); + } + + /// + /// Tests if any of the ISO codes in the update model are invalid + /// + private bool HasInvalidIsoCode(DomainsUpdateModel updateModel, IEnumerable allIsoCodes) + => new[] { updateModel.DefaultIsoCode } + .Union(updateModel.Domains.Select(domainModel => domainModel.IsoCode)) + .WhereNotNull() + .Except(allIsoCodes) + .Any(); + + /// + /// Tests if any of the domain names in the update model are assigned to other content items + /// + private bool HasDomainNameConflicts(int contentId, DomainsUpdateModel updateModel, IEnumerable allDomains) + { + var domainNamesAssignedToOtherContent = allDomains + .Where(domain => domain.IsWildcard == false && domain.RootContentId != contentId) + .Select(domain => domain.DomainName.ToLowerInvariant()) + .ToArray(); + + return updateModel.Domains.Any(domainModel => domainNamesAssignedToOtherContent.InvariantContains(domainModel.DomainName)); + } + + /// + /// Calculates the new domain assignment incl. wildcard domains + /// + private IDomain[] CalculateNewAssignedDomains(int contentId, DomainsUpdateModel updateModel, IDomain[] currentlyAssignedDomains, IDictionary languageIdByIsoCode) + { + // calculate the assigned domains as they should be after updating (including wildcard domains) + var newAssignedDomains = new List(); + if (updateModel.DefaultIsoCode.IsNullOrWhiteSpace() == false) + { + IDomain defaultDomain = currentlyAssignedDomains.FirstOrDefault(domain => domain.IsWildcard && domain.LanguageIsoCode == updateModel.DefaultIsoCode) + ?? new UmbracoDomain($"*{contentId}") + { + LanguageId = languageIdByIsoCode[updateModel.DefaultIsoCode], + RootContentId = contentId + }; + + // wildcard domains should have sort order -1 (lowest possible sort order) + defaultDomain.SortOrder = -1; + + newAssignedDomains.Add(defaultDomain); + } + + var sortOrder = 0; + foreach (DomainModel domainModel in updateModel.Domains) + { + IDomain assignedDomain = currentlyAssignedDomains.FirstOrDefault(domain => domainModel.DomainName.InvariantEquals(domain.DomainName)) + ?? new UmbracoDomain(domainModel.DomainName) + { + LanguageId = languageIdByIsoCode[domainModel.IsoCode], + RootContentId = contentId + }; + + assignedDomain.SortOrder = sortOrder++; + newAssignedDomains.Add(assignedDomain); + } + + return newAssignedDomains.ToArray(); + } + + /// + /// Handles deletion of one or more domains incl. notifications + /// + private bool DeleteAll(IDomain[] domainsToDelete, ICoreScope scope, EventMessages eventMessages) + { + if (domainsToDelete.Any() == false) + { + return true; + } + + var deletingNotification = new DomainDeletingNotification(domainsToDelete, eventMessages); + if (scope.Notifications.PublishCancelable(deletingNotification)) + { + return false; + } + + foreach (IDomain domainToDelete in domainsToDelete) + { + _domainRepository.Delete(domainToDelete); + } + + scope.Notifications.Publish(new DomainDeletedNotification(domainsToDelete, eventMessages).WithStateFrom(deletingNotification)); + return true; + } + + /// + /// Handles saving of one or more domains incl. notifications + /// + private bool SaveAll(IDomain[] domainsToSave, ICoreScope scope, EventMessages eventMessages) + { + if (domainsToSave.Any() == false) + { + return true; + } + + var savingNotification = new DomainSavingNotification(domainsToSave, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + return false; + } + + foreach (IDomain assignedDomain in domainsToSave) + { + _domainRepository.Save(assignedDomain); + } + + scope.Notifications.Publish(new DomainSavedNotification(domainsToSave, eventMessages).WithStateFrom(savingNotification)); + return true; + } } diff --git a/src/Umbraco.Core/Services/IDomainService.cs b/src/Umbraco.Core/Services/IDomainService.cs index 3b7cd29f80..f5a5370a6c 100644 --- a/src/Umbraco.Core/Services/IDomainService.cs +++ b/src/Umbraco.Core/Services/IDomainService.cs @@ -1,5 +1,7 @@ using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -7,18 +9,43 @@ public interface IDomainService : IService { bool Exists(string domainName); + [Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")] Attempt Delete(IDomain domain); IDomain? GetByName(string name); IDomain? GetById(int id); + [Obsolete($"Please use {nameof(GetAllAsync)}. Will be removed in V15")] IEnumerable GetAll(bool includeWildcards); + [Obsolete($"Please use {nameof(GetAssignedDomainsAsync)}. Will be removed in V15")] IEnumerable GetAssignedDomains(int contentId, bool includeWildcards); + [Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")] Attempt Save(IDomain domainEntity); + [Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")] Attempt Sort(IEnumerable items) => Attempt.Fail(new OperationResult(OperationResultType.Failed, new EventMessages())); // TODO Remove default implmentation in a future version + + /// + /// Gets all assigned domains for content item. + /// + /// The key of the content item. + /// Whether or not to include wildcard domains. + Task> GetAssignedDomainsAsync(Guid contentKey, bool includeWildcards); + + /// + /// Gets all assigned domains. + /// + /// Whether or not to include wildcard domains. + Task> GetAllAsync(bool includeWildcards); + + /// + /// Updates the domain assignments for a content item. + /// + /// The key of the content item. + /// The domain assignments to apply. + Task, DomainOperationStatus>> UpdateDomainsAsync(Guid contentKey, DomainsUpdateModel updateModel); } diff --git a/src/Umbraco.Core/Services/OperationStatus/DomainOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/DomainOperationStatus.cs new file mode 100644 index 0000000000..c54f356c04 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/DomainOperationStatus.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum DomainOperationStatus +{ + Success, + CancelledByNotification, + ContentNotFound, + LanguageNotFound, + DuplicateDomainName +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs index 4e88998c09..8afcbcbdf9 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; @@ -82,18 +83,28 @@ public class TelemetryProviderTests : UmbracoIntegrationTest } [Test] - public void Domain_Telemetry_Provider_Can_Get_Domains() + public async Task Domain_Telemetry_Provider_Can_Get_Domains() { // Arrange - DomainService.Save(new UmbracoDomain("danish", "da-DK")); + var contentType = ContentTypeBuilder.CreateBasicContentType(); + ContentTypeService.Save(contentType); + + var content = ContentBuilder.CreateBasicContent(contentType); + ContentService.Save(content); + + await DomainService.UpdateDomainsAsync( + content.Key, + new DomainsUpdateModel + { + Domains = new[] { new DomainModel { DomainName = "english", IsoCode = "en-US" } } + }); - IEnumerable result = null; // Act - result = DetailedTelemetryProviders.GetInformation(); - + IEnumerable result = DetailedTelemetryProviders.GetInformation(); // Assert Assert.AreEqual(1, result.First().Data); + Assert.AreEqual("DomainCount", result.First().Name); } [Test] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTests.cs index cf977c5fcf..deeb078221 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTests.cs @@ -13,6 +13,7 @@ using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.PropertyEditors; @@ -26,6 +27,7 @@ using Umbraco.Cms.Tests.Common.Extensions; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; using Umbraco.Extensions; +using Language = Umbraco.Cms.Core.Models.Language; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; @@ -1945,7 +1947,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent } [Test] - public void Can_Empty_RecycleBin_With_Content_That_Has_All_Related_Data() + public async Task Can_Empty_RecycleBin_With_Content_That_Has_All_Related_Data() { // Arrange // need to: @@ -1994,9 +1996,13 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent Assert.IsNotNull(NotificationService.CreateNotification(user, content1, "X")); ContentService.SetPermission(content1, 'A', new[] { userGroup.Id }); - - Assert.IsTrue(DomainService.Save(new UmbracoDomain("www.test.com", "en-AU") { RootContentId = content1.Id }) - .Success); + var updateDomainResult = await DomainService.UpdateDomainsAsync( + content1.Key, + new DomainsUpdateModel + { + Domains = new[] { new DomainModel { DomainName = "www.test.com", IsoCode = "en-US" } } + }); + Assert.IsTrue(updateDomainResult.Success); // Act ContentService.MoveToRecycleBin(content1); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs index a04ecefab8..08ca19f249 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs @@ -496,13 +496,23 @@ public class ContentControllerTests : UmbracoTestServerTestBase .Build(); var enLanguage = await languageService.GetAsync(UsIso); - var domainService = GetRequiredService(); - var enDomain = new UmbracoDomain("/en") {RootContentId = content.Id, LanguageId = enLanguage.Id}; - domainService.Save(enDomain); - var dkLanguage = await languageService.GetAsync(DkIso); - var dkDomain = new UmbracoDomain("/dk") {RootContentId = childContent.Id, LanguageId = dkLanguage.Id}; - domainService.Save(dkDomain); + + var domainService = GetRequiredService(); + + await domainService.UpdateDomainsAsync( + content.Key, + new DomainsUpdateModel + { + Domains = new[] { new DomainModel { DomainName = "/en", IsoCode = enLanguage.IsoCode } } + }); + + await domainService.UpdateDomainsAsync( + childContent.Key, + new DomainsUpdateModel + { + Domains = new[] { new DomainModel { DomainName = "/dk", IsoCode = dkLanguage.IsoCode } } + }); var url = PrepareApiControllerUrl(x => x.PostSave(null)); @@ -559,8 +569,13 @@ public class ContentControllerTests : UmbracoTestServerTestBase var dkLanguage = await languageService.GetAsync(DkIso); var domainService = GetRequiredService(); - var dkDomain = new UmbracoDomain("/") {RootContentId = content.Id, LanguageId = dkLanguage.Id}; - domainService.Save(dkDomain); + + await domainService.UpdateDomainsAsync( + content.Key, + new DomainsUpdateModel + { + Domains = new[] { new DomainModel { DomainName = "/", IsoCode = dkLanguage.IsoCode } } + }); var url = PrepareApiControllerUrl(x => x.PostSave(null)); @@ -631,12 +646,20 @@ public class ContentControllerTests : UmbracoTestServerTestBase var dkLanguage = await languageService.GetAsync(DkIso); var usLanguage = await languageService.GetAsync(UsIso); var domainService = GetRequiredService(); - var dkDomain = new UmbracoDomain("/") {RootContentId = rootNode.Id, LanguageId = dkLanguage.Id}; - var usDomain = new UmbracoDomain("/en") {RootContentId = childNode.Id, LanguageId = usLanguage.Id}; + await domainService.UpdateDomainsAsync( + rootNode.Key, + new DomainsUpdateModel + { + Domains = new[] { new DomainModel { DomainName = "/", IsoCode = dkLanguage.IsoCode } } + }); - domainService.Save(dkDomain); - domainService.Save(usDomain); + await domainService.UpdateDomainsAsync( + childNode.Key, + new DomainsUpdateModel + { + Domains = new[] { new DomainModel { DomainName = "/en", IsoCode = usLanguage.IsoCode } } + }); var url = PrepareApiControllerUrl(x => x.PostSave(null)); 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 c5bee58962..420ffa9c32 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs @@ -1,19 +1,17 @@ -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using NUnit.Framework; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Packaging; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Tests.Common; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; -using Umbraco.Extensions; namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.UrlAndDomains; @@ -74,17 +72,147 @@ public class DomainAndUrlsTests : UmbracoIntegrationTest private readonly TestVariationContextAccessor _variationContextAccessor = new(); public IContent Root { get; set; } + public string[] Cultures { get; set; } + [Test] + public async Task Can_Update_Domains_For_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); + Assert.AreEqual(DomainOperationStatus.Success, result.Status); + + void VerifyDomains(IDomain[] domains) + { + Assert.AreEqual(3, domains.Length); + for (var i = 0; i < domains.Length; i++) + { + Assert.AreEqual(Cultures[i], domains[i].LanguageIsoCode); + Assert.AreEqual(GetDomainUrlFromCultureCode(Cultures[i]), domains[i].DomainName); + } + } + + VerifyDomains(result.Result.ToArray()); + + // re-get and verify again + var domains = await domainService.GetAssignedDomainsAsync(Root.Key, true); + VerifyDomains(domains.ToArray()); + } [Test] - public void Having_three_cultures_and_set_domain_on_all_of_them() + public async Task Can_Sort_Domains() { - foreach (var culture in Cultures) + var domainService = GetRequiredService(); + var reversedCultures = Cultures.Reverse().ToArray(); + var updateModel = new DomainsUpdateModel { - SetDomainOnContent(Root, culture, GetDomainUrlFromCultureCode(culture)); + Domains = reversedCultures.Select(culture => new DomainModel + { + DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture + }) + }; + + var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsTrue(result.Success); + Assert.AreEqual(DomainOperationStatus.Success, result.Status); + + void VerifyDomains(IDomain[] domains) + { + Assert.AreEqual(3, domains.Length); + for (var i = 0; i < domains.Length; i++) + { + Assert.AreEqual(reversedCultures[i], domains[i].LanguageIsoCode); + Assert.AreEqual(GetDomainUrlFromCultureCode(reversedCultures[i]), domains[i].DomainName); + } } + VerifyDomains(result.Result.ToArray()); + + // re-get and verify again + var domains = await domainService.GetAssignedDomainsAsync(Root.Key, true); + VerifyDomains(domains.ToArray()); + } + + [Test] + public async Task Can_Remove_All_Domains() + { + 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); + Assert.AreEqual(DomainOperationStatus.Success, result.Status); + Assert.AreEqual(3, result.Result.Count()); + + updateModel.Domains = Enumerable.Empty(); + + result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsTrue(result.Success); + Assert.AreEqual(DomainOperationStatus.Success, result.Status); + Assert.AreEqual(0, result.Result.Count()); + + // re-get and verify again + var domains = await domainService.GetAssignedDomainsAsync(Root.Key, true); + Assert.AreEqual(0, domains.Count()); + } + + [Test] + public async Task Can_Remove_Single_Domain() + { + 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); + Assert.AreEqual(DomainOperationStatus.Success, result.Status); + Assert.AreEqual(3, result.Result.Count()); + + updateModel.Domains = new[] { updateModel.Domains.First(), updateModel.Domains.Last() }; + + result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsTrue(result.Success); + Assert.AreEqual(DomainOperationStatus.Success, result.Status); + Assert.AreEqual(2, result.Result.Count()); + Assert.AreEqual(Cultures.First(), result.Result.First().LanguageIsoCode); + Assert.AreEqual(Cultures.Last(), result.Result.Last().LanguageIsoCode); + } + + [Test] + public async Task Can_Resolve_Urls_With_Domains_For_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 rootUrls = GetContentUrlsAsync(Root).ToArray(); Assert.Multiple(() => @@ -100,11 +228,21 @@ public class DomainAndUrlsTests : UmbracoIntegrationTest } [Test] - public void Having_three_cultures_but_set_domain_on_a_non_default_language() + public async Task Can_Resolve_Urls_For_Non_Default_Domain_Culture_Only() { var culture = Cultures[1]; var domain = GetDomainUrlFromCultureCode(culture); - SetDomainOnContent(Root, culture, domain); + var domainService = GetRequiredService(); + var updateModel = new DomainsUpdateModel + { + Domains = new[] + { + new DomainModel { DomainName = domain, IsoCode = culture } + } + }; + + var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsTrue(result.Success); var rootUrls = GetContentUrlsAsync(Root).ToArray(); @@ -124,6 +262,99 @@ public class DomainAndUrlsTests : UmbracoIntegrationTest }); } + [Test] + public async Task Can_Set_Default_Culture() + { + var domainService = GetRequiredService(); + var culture = Cultures[1]; + var updateModel = new DomainsUpdateModel + { + DefaultIsoCode = culture, + Domains = Enumerable.Empty() + }; + + var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsTrue(result.Success); + Assert.AreEqual(1, result.Result.Count()); + + // default culture is represented as a wildcard domain + var domain = result.Result.First(); + Assert.IsTrue(domain.IsWildcard); + Assert.AreEqual(culture, domain.LanguageIsoCode); + Assert.AreEqual("*" + Root.Id, domain.DomainName); + } + + [Test] + public void Can_Use_Obsolete_Save() + { + foreach (var culture in Cultures) + { + SetDomainOnContent(Root, culture, GetDomainUrlFromCultureCode(culture)); + } + + var domains = GetRequiredService().GetAssignedDomains(Root.Id, true); + Assert.AreEqual(3, domains.Count()); + } + + [Test] + public void Can_Use_Obsolete_Delete() + { + foreach (var culture in Cultures) + { + SetDomainOnContent(Root, culture, GetDomainUrlFromCultureCode(culture)); + } + + var domainService = GetRequiredService(); + + var domains = domainService.GetAssignedDomains(Root.Id, true); + Assert.AreEqual(3, domains.Count()); + + var result = domainService.Delete(domains.First()); + Assert.IsTrue(result.Success); + + domains = domainService.GetAssignedDomains(Root.Id, true); + Assert.AreEqual(2, domains.Count()); + } + + [TestCase("/domain")] + [TestCase("/")] + [TestCase("some.domain.com")] + public async Task Cannot_Assign_Duplicate_Domains(string domainName) + { + var domainService = GetRequiredService(); + var updateModel = new DomainsUpdateModel + { + Domains = Cultures.Select(culture => new DomainModel { DomainName = domainName, IsoCode = culture }).ToArray() + }; + + var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsFalse(result.Success); + Assert.AreEqual(DomainOperationStatus.DuplicateDomainName, result.Status); + } + + [Test] + public async Task Cannot_Assign_Already_Used_Domains() + { + var copy = ContentService.Copy(Root, Root.ParentId, false); + ContentService.SaveAndPublish(copy!); + + 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); + + result = await domainService.UpdateDomainsAsync(copy.Key, updateModel); + Assert.IsFalse(result.Success); + Assert.AreEqual(DomainOperationStatus.DuplicateDomainName, result.Status); + } + private static string GetDomainUrlFromCultureCode(string culture) => "/" + culture.Replace("-", string.Empty).ToLower() + "/";