diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs index a2277820bfbe..c008dad102d3 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentEditing.Validation; using Umbraco.Cms.Core.Services.OperationStatus; @@ -9,6 +11,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Content; public abstract class ContentControllerBase : ManagementApiControllerBase { + protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperationStatus status) => OperationStatusResult(status, problemDetailsBuilder => status switch { @@ -96,7 +99,8 @@ protected IActionResult ContentEditingOperationStatusResult(); - var missingPropertyAliases = new List(); + + var missingPropertyModels = new List(); foreach (PropertyValidationError validationError in validationResult.ValidationErrors) { TValueModel? requestValue = requestModel.Values.FirstOrDefault(value => @@ -105,7 +109,7 @@ protected IActionResult ContentEditingOperationStatusResult + new() + { + Alias = source.Alias, + Segment = source.Segment, + Culture = source.Culture, + Messages = source.ErrorMessages, + }; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Content/PropertyValidationResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Content/PropertyValidationResponseModel.cs new file mode 100644 index 000000000000..6f8d918c3e8c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Content/PropertyValidationResponseModel.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Content; + +public class PropertyValidationResponseModel +{ + public string[] Messages { get; set; } = Array.Empty(); + + public string Alias { get; set; } = string.Empty; + + public string? Culture { get; set; } + + public string? Segment { get; set; } +} diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index ffeafe17b7b5..c39f05cc5e8c 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -39,6 +39,7 @@ using Umbraco.Cms.Core.Security.Authorization; using Umbraco.Cms.Core.Services.FileSystem; using Umbraco.Cms.Core.Services.ImportExport; +using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Cms.Core.Services.Querying.RecycleBin; using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Core.Telemetry; @@ -338,8 +339,7 @@ private void AddCoreServices() factory.GetRequiredService(), factory.GetRequiredService(), factory.GetRequiredService(), - factory.GetRequiredService() - )); + factory.GetRequiredService())); Services.AddUnique(); Services.AddUnique(factory => factory.GetRequiredService()); Services.AddUnique(factory => new LocalizedTextService( @@ -352,6 +352,12 @@ private void AddCoreServices() Services.AddSingleton(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); + Services.AddUnique(x => x.GetRequiredService()); + Services.AddUnique(x => x.GetRequiredService()); + Services.AddUnique(); + Services.AddUnique(x => x.GetRequiredService()); + Services.AddUnique(x => x.GetRequiredService()); // Register a noop IHtmlSanitizer & IMarkdownSanitizer to be replaced Services.AddUnique(); diff --git a/src/Umbraco.Core/Factories/NavigationFactory.cs b/src/Umbraco.Core/Factories/NavigationFactory.cs new file mode 100644 index 000000000000..316c6031d692 --- /dev/null +++ b/src/Umbraco.Core/Factories/NavigationFactory.cs @@ -0,0 +1,45 @@ +using System.Collections.Concurrent; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Navigation; + +namespace Umbraco.Cms.Core.Factories; + +internal static class NavigationFactory +{ + /// + /// Builds a dictionary of NavigationNode objects from a given dataset. + /// + /// The objects used to build the navigation nodes dictionary. + /// A dictionary of objects with key corresponding to their unique Guid. + public static ConcurrentDictionary BuildNavigationDictionary(IEnumerable entities) + { + var nodesStructure = new ConcurrentDictionary(); + var entityList = entities.ToList(); + var idToKeyMap = entityList.ToDictionary(x => x.Id, x => x.Key); + + foreach (INavigationModel entity in entityList) + { + var node = new NavigationNode(entity.Key); + nodesStructure[entity.Key] = node; + + // We don't set the parent for items under root, it will stay null + if (entity.ParentId == -1) + { + continue; + } + + if (idToKeyMap.TryGetValue(entity.ParentId, out Guid parentKey) is false) + { + continue; + } + + // If the parent node exists in the nodesStructure, add the node to the parent's children (parent is set as well) + if (nodesStructure.TryGetValue(parentKey, out NavigationNode? parentNode)) + { + parentNode.AddChild(node); + } + } + + return nodesStructure; + } +} diff --git a/src/Umbraco.Core/Models/INavigationModel.cs b/src/Umbraco.Core/Models/INavigationModel.cs new file mode 100644 index 000000000000..bc33e22f0fca --- /dev/null +++ b/src/Umbraco.Core/Models/INavigationModel.cs @@ -0,0 +1,24 @@ +namespace Umbraco.Cms.Core.Models; + +public interface INavigationModel +{ + /// + /// Gets or sets the integer identifier of the entity. + /// + int Id { get; set; } + + /// + /// Gets or sets the Guid unique identifier of the entity. + /// + Guid Key { get; set; } + + /// + /// Gets or sets the integer identifier of the parent entity. + /// + int ParentId { get; set; } + + /// + /// Gets or sets a value indicating whether this entity is in the recycle bin. + /// + bool Trashed { get; set; } +} diff --git a/src/Umbraco.Core/Models/Navigation/NavigationNode.cs b/src/Umbraco.Core/Models/Navigation/NavigationNode.cs new file mode 100644 index 000000000000..9edf00d6fb66 --- /dev/null +++ b/src/Umbraco.Core/Models/Navigation/NavigationNode.cs @@ -0,0 +1,30 @@ +namespace Umbraco.Cms.Core.Models.Navigation; + +public sealed class NavigationNode +{ + private List _children; + + public Guid Key { get; private set; } + + public NavigationNode? Parent { get; private set; } + + public IEnumerable Children => _children.AsEnumerable(); + + public NavigationNode(Guid key) + { + Key = key; + _children = new List(); + } + + public void AddChild(NavigationNode child) + { + child.Parent = this; + _children.Add(child); + } + + public void RemoveChild(NavigationNode child) + { + _children.Remove(child); + child.Parent = null; + } +} diff --git a/src/Umbraco.Core/Persistence/Repositories/INavigationRepository.cs b/src/Umbraco.Core/Persistence/Repositories/INavigationRepository.cs new file mode 100644 index 000000000000..cc65d637a8b4 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/INavigationRepository.cs @@ -0,0 +1,20 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface INavigationRepository +{ + /// + /// Retrieves a collection of content nodes as navigation models based on the object type key. + /// + /// The unique identifier for the object type. + /// A collection of navigation models. + IEnumerable GetContentNodesByObjectType(Guid objectTypeKey); + + /// + /// Retrieves a collection of trashed content nodes as navigation models based on the object type key. + /// + /// The unique identifier for the object type. + /// A collection of navigation models. + IEnumerable GetTrashedContentNodesByObjectType(Guid objectTypeKey); +} diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index dd6158520354..0b629774a3af 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -12,6 +12,7 @@ using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; @@ -33,25 +34,27 @@ public class ContentService : RepositoryService, IContentService private readonly IShortStringHelper _shortStringHelper; private readonly ICultureImpactFactory _cultureImpactFactory; private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly IDocumentNavigationManagementService _documentNavigationManagementService; private IQuery? _queryNotTrashed; #region Constructors public ContentService( - ICoreScopeProvider provider, - ILoggerFactory loggerFactory, - IEventMessagesFactory eventMessagesFactory, - IDocumentRepository documentRepository, - IEntityRepository entityRepository, - IAuditRepository auditRepository, - IContentTypeRepository contentTypeRepository, - IDocumentBlueprintRepository documentBlueprintRepository, - ILanguageRepository languageRepository, - Lazy propertyValidationService, - IShortStringHelper shortStringHelper, - ICultureImpactFactory cultureImpactFactory, - IUserIdKeyResolver userIdKeyResolver) - : base(provider, loggerFactory, eventMessagesFactory) + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IDocumentRepository documentRepository, + IEntityRepository entityRepository, + IAuditRepository auditRepository, + IContentTypeRepository contentTypeRepository, + IDocumentBlueprintRepository documentBlueprintRepository, + ILanguageRepository languageRepository, + Lazy propertyValidationService, + IShortStringHelper shortStringHelper, + ICultureImpactFactory cultureImpactFactory, + IUserIdKeyResolver userIdKeyResolver, + IDocumentNavigationManagementService documentNavigationManagementService) + : base(provider, loggerFactory, eventMessagesFactory) { _documentRepository = documentRepository; _entityRepository = entityRepository; @@ -63,9 +66,43 @@ public ContentService( _shortStringHelper = shortStringHelper; _cultureImpactFactory = cultureImpactFactory; _userIdKeyResolver = userIdKeyResolver; + _documentNavigationManagementService = documentNavigationManagementService; _logger = loggerFactory.CreateLogger(); } + [Obsolete("Use non-obsolete constructor. Scheduled for removal in V16.")] + public ContentService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IDocumentRepository documentRepository, + IEntityRepository entityRepository, + IAuditRepository auditRepository, + IContentTypeRepository contentTypeRepository, + IDocumentBlueprintRepository documentBlueprintRepository, + ILanguageRepository languageRepository, + Lazy propertyValidationService, + IShortStringHelper shortStringHelper, + ICultureImpactFactory cultureImpactFactory, + IUserIdKeyResolver userIdKeyResolver) + : this( + provider, + loggerFactory, + eventMessagesFactory, + documentRepository, + entityRepository, + auditRepository, + contentTypeRepository, + documentBlueprintRepository, + languageRepository, + propertyValidationService, + shortStringHelper, + cultureImpactFactory, + userIdKeyResolver, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + [Obsolete("Use constructor that takes IUserIdKeyResolver as a parameter, scheduled for removal in V15")] public ContentService( ICoreScopeProvider provider, @@ -93,7 +130,8 @@ public ContentService( propertyValidationService, shortStringHelper, cultureImpactFactory, - StaticServiceProvider.Instance.GetRequiredService()) + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -1034,6 +1072,11 @@ public OperationResult Save(IContent content, int? userId = null, ContentSchedul // have always changed if it's been saved in the back office but that's not really fail safe. _documentRepository.Save(content); + // Updates in-memory navigation structure - we only handle new items, other updates are not a concern + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.ContentService.Save-with-contentSchedule", + () => _documentNavigationManagementService.Add(content.Key, GetParent(content)?.Key)); + if (contentSchedule != null) { _documentRepository.PersistContentSchedule(content, contentSchedule); @@ -1097,6 +1140,11 @@ public OperationResult Save(IEnumerable contents, int userId = Constan content.WriterId = userId; _documentRepository.Save(content); + + // Updates in-memory navigation structure - we only handle new items, other updates are not a concern + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.ContentService.Save", + () => _documentNavigationManagementService.Add(content.Key, GetParent(content)?.Key)); } scope.Notifications.Publish( @@ -2288,6 +2336,26 @@ void DoDelete(IContent c) } DoDelete(content); + + if (content.Trashed) + { + // Updates in-memory navigation structure for recycle bin items + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.ContentService.DeleteLocked-trashed", + () => _documentNavigationManagementService.RemoveFromBin(content.Key)); + } + else + { + // Updates in-memory navigation structure for both documents and recycle bin items + // as the item needs to be deleted whether it is in the recycle bin or not + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.ContentService.DeleteLocked", + () => + { + _documentNavigationManagementService.MoveToBin(content.Key); + _documentNavigationManagementService.RemoveFromBin(content.Key); + }); + } } // TODO: both DeleteVersions methods below have an issue. Sort of. They do NOT take care of files the way @@ -2512,6 +2580,8 @@ public OperationResult Move(IContent content, int parentId, int userId = Constan // trash indicates whether we are trashing, un-trashing, or not changing anything private void PerformMoveLocked(IContent content, int parentId, IContent? parent, int userId, ICollection<(IContent, string)> moves, bool? trash) { + // Needed to update the in-memory navigation structure + var cameFromRecycleBin = content.ParentId == Constants.System.RecycleBinContent; content.WriterId = userId; content.ParentId = parentId; @@ -2560,6 +2630,33 @@ private void PerformMoveLocked(IContent content, int parentId, IContent? parent, } } while (total > pageSize); + + if (parentId == Constants.System.RecycleBinContent) + { + // Updates in-memory navigation structure for both document items and recycle bin items + // as we are moving to recycle bin + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.ContentService.PerformMoveLocked-to-recycle-bin", + () => _documentNavigationManagementService.MoveToBin(content.Key)); + } + else + { + if (cameFromRecycleBin) + { + // Updates in-memory navigation structure for both document items and recycle bin items + // as we are restoring from recycle bin + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.ContentService.PerformMoveLocked-restore", + () => _documentNavigationManagementService.RestoreFromBin(content.Key, parent?.Key)); + } + else + { + // Updates in-memory navigation structure + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.ContentService.PerformMoveLocked", + () => _documentNavigationManagementService.Move(content.Key, parent?.Key)); + } + } } private void PerformMoveContentLocked(IContent content, int userId, bool? trash) @@ -2663,6 +2760,9 @@ public bool RecycleBinSmells() { EventMessages eventMessages = EventMessagesFactory.Get(); + // keep track of updates (copied item key and parent key) for the in-memory navigation structure + var navigationUpdates = new List>(); + IContent copy = content.DeepCloneWithResetIdentities(); copy.ParentId = parentId; @@ -2699,6 +2799,9 @@ public bool RecycleBinSmells() // save and flush because we need the ID for the recursive Copying events _documentRepository.Save(copy); + // store navigation update information for copied item + navigationUpdates.Add(Tuple.Create(copy.Key, GetParent(copy)?.Key)); + // add permissions if (currentPermissions.Count > 0) { @@ -2750,12 +2853,29 @@ public bool RecycleBinSmells() // save and flush (see above) _documentRepository.Save(descendantCopy); + // store navigation update information for descendants + navigationUpdates.Add(Tuple.Create(descendantCopy.Key, GetParent(descendantCopy)?.Key)); + copies.Add(Tuple.Create(descendant, descendantCopy)); idmap[descendant.Id] = descendantCopy.Id; } } } + if (navigationUpdates.Count > 0) + { + // Updates in-memory navigation structure + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.ContentService.Copy", + () => + { + foreach (Tuple update in navigationUpdates) + { + _documentNavigationManagementService.Add(update.Item1, update.Item2); + } + }); + } + // not handling tags here, because // - tags should be handled by the content repository // - a copy is unpublished and therefore has no impact on tags in DB @@ -3697,4 +3817,29 @@ public void DeleteBlueprintsOfType(int contentTypeId, int userId = Constants.Sec DeleteBlueprintsOfTypes(new[] { contentTypeId }, userId); #endregion + + /// + /// Enlists an action in the current scope context to update the in-memory navigation structure + /// when the scope completes successfully. + /// + /// The unique key identifying the action to be enlisted. + /// The action to be performed for updating the in-memory navigation structure. + /// Thrown when the scope context is null and therefore cannot be used. + private void UpdateInMemoryNavigationStructure(string enlistingActionKey, Action updateNavigation) + { + IScopeContext? scopeContext = ScopeProvider.Context; + + if (scopeContext is null) + { + throw new NullReferenceException($"The {nameof(scopeContext)} is null and cannot be used."); + } + + scopeContext.Enlist(enlistingActionKey, completed => + { + if (completed) + { + updateNavigation(); + } + }); + } } diff --git a/src/Umbraco.Core/Services/MediaService.cs b/src/Umbraco.Core/Services/MediaService.cs index c754aa244a43..ae334447e964 100644 --- a/src/Umbraco.Core/Services/MediaService.cs +++ b/src/Umbraco.Core/Services/MediaService.cs @@ -12,6 +12,7 @@ using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; @@ -28,6 +29,7 @@ public class MediaService : RepositoryService, IMediaService private readonly IEntityRepository _entityRepository; private readonly IShortStringHelper _shortStringHelper; private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly IMediaNavigationManagementService _mediaNavigationManagementService; private readonly MediaFileManager _mediaFileManager; @@ -43,7 +45,8 @@ public MediaService( IMediaTypeRepository mediaTypeRepository, IEntityRepository entityRepository, IShortStringHelper shortStringHelper, - IUserIdKeyResolver userIdKeyResolver) + IUserIdKeyResolver userIdKeyResolver, + IMediaNavigationManagementService mediaNavigationManagementService) : base(provider, loggerFactory, eventMessagesFactory) { _mediaFileManager = mediaFileManager; @@ -53,6 +56,34 @@ public MediaService( _entityRepository = entityRepository; _shortStringHelper = shortStringHelper; _userIdKeyResolver = userIdKeyResolver; + _mediaNavigationManagementService = mediaNavigationManagementService; + } + + [Obsolete("Use non-obsolete constructor. Scheduled for removal in V16.")] + public MediaService( + ICoreScopeProvider provider, + MediaFileManager mediaFileManager, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IMediaRepository mediaRepository, + IAuditRepository auditRepository, + IMediaTypeRepository mediaTypeRepository, + IEntityRepository entityRepository, + IShortStringHelper shortStringHelper, + IUserIdKeyResolver userIdKeyResolver) + : this( + provider, + mediaFileManager, + loggerFactory, + eventMessagesFactory, + mediaRepository, + auditRepository, + mediaTypeRepository, + entityRepository, + shortStringHelper, + userIdKeyResolver, + StaticServiceProvider.Instance.GetRequiredService()) + { } [Obsolete("Use constructor that takes IUserIdKeyResolver as a parameter, scheduled for removal in V15")] @@ -76,8 +107,8 @@ public MediaService( mediaTypeRepository, entityRepository, shortStringHelper, - StaticServiceProvider.Instance.GetRequiredService() - ) + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -769,6 +800,12 @@ public bool HasChildren(int id) media.WriterId = userId; _mediaRepository.Save(media); + + // Updates in-memory navigation structure - we only handle new items, other updates are not a concern + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.MediaService.Save", + () => _mediaNavigationManagementService.Add(media.Key, GetParent(media)?.Key)); + scope.Notifications.Publish(new MediaSavedNotification(media, eventMessages).WithStateFrom(savingNotification)); // TODO: See note about suppressing events in content service scope.Notifications.Publish(new MediaTreeChangeNotification(media, TreeChangeTypes.RefreshNode, eventMessages)); @@ -810,6 +847,11 @@ public bool HasChildren(int id) } _mediaRepository.Save(media); + + // Updates in-memory navigation structure - we only handle new items, other updates are not a concern + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.ContentService.Save-collection", + () => _mediaNavigationManagementService.Add(media.Key, GetParent(media)?.Key)); } scope.Notifications.Publish(new MediaSavedNotification(mediasA, messages).WithStateFrom(savingNotification)); @@ -881,6 +923,26 @@ void DoDelete(IMedia c) } DoDelete(media); + + if (media.Trashed) + { + // Updates in-memory navigation structure for recycle bin items + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.MediaService.DeleteLocked-trashed", + () => _mediaNavigationManagementService.RemoveFromBin(media.Key)); + } + else + { + // Updates in-memory navigation structure for both media and recycle bin items + // as the item needs to be deleted whether it is in the recycle bin or not + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.MediaService.DeleteLocked", + () => + { + _mediaNavigationManagementService.MoveToBin(media.Key); + _mediaNavigationManagementService.RemoveFromBin(media.Key); + }); + } } //TODO: both DeleteVersions methods below have an issue. Sort of. They do NOT take care of files the way @@ -1069,6 +1131,8 @@ public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int u // trash indicates whether we are trashing, un-trashing, or not changing anything private void PerformMoveLocked(IMedia media, int parentId, IMedia? parent, int userId, ICollection<(IMedia, string)> moves, bool? trash) { + // Needed to update the in-memory navigation structure + var cameFromRecycleBin = media.ParentId == Constants.System.RecycleBinMedia; media.ParentId = parentId; // get the level delta (old pos to new pos) @@ -1114,6 +1178,32 @@ private void PerformMoveLocked(IMedia media, int parentId, IMedia? parent, int u } while (total > pageSize); + if (parentId == Constants.System.RecycleBinMedia) + { + // Updates in-memory navigation structure for both media items and recycle bin items + // as we are moving to recycle bin + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.MediaService.PerformMoveLocked-to-recycle-bin", + () => _mediaNavigationManagementService.MoveToBin(media.Key)); + } + else + { + if (cameFromRecycleBin) + { + // Updates in-memory navigation structure for both media items and recycle bin items + // as we are restoring from recycle bin + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.MediaService.PerformMoveLocked-restore", + () => _mediaNavigationManagementService.RestoreFromBin(media.Key, parent?.Key)); + } + else + { + // Updates in-memory navigation structure + UpdateInMemoryNavigationStructure( + "Umbraco.Cms.Core.Services.MediaService.PerformMoveLocked", + () => _mediaNavigationManagementService.Move(media.Key, parent?.Key)); + } + } } private void PerformMoveMediaLocked(IMedia media, bool? trash) @@ -1421,6 +1511,29 @@ private IMediaType GetMediaType(string mediaTypeAlias) #endregion + /// + /// Enlists an action in the current scope context to update the in-memory navigation structure + /// when the scope completes successfully. + /// + /// The unique key identifying the action to be enlisted. + /// The action to be performed for updating the in-memory navigation structure. + /// Thrown when the scope context is null and therefore cannot be used. + private void UpdateInMemoryNavigationStructure(string enlistingActionKey, Action updateNavigation) + { + IScopeContext? scopeContext = ScopeProvider.Context; + if (scopeContext is null) + { + throw new NullReferenceException($"The {nameof(scopeContext)} is null and cannot be used."); + } + + scopeContext.Enlist(enlistingActionKey, completed => + { + if (completed) + { + updateNavigation(); + } + }); + } } } diff --git a/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs b/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs new file mode 100644 index 000000000000..e5755c8d8775 --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs @@ -0,0 +1,344 @@ +using System.Collections.Concurrent; +using Umbraco.Cms.Core.Factories; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Navigation; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services.Navigation; + +internal abstract class ContentNavigationServiceBase +{ + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly INavigationRepository _navigationRepository; + private ConcurrentDictionary _navigationStructure = new(); + private ConcurrentDictionary _recycleBinNavigationStructure = new(); + + protected ContentNavigationServiceBase(ICoreScopeProvider coreScopeProvider, INavigationRepository navigationRepository) + { + _coreScopeProvider = coreScopeProvider; + _navigationRepository = navigationRepository; + } + + /// + /// Rebuilds the entire main navigation structure. Implementations should define how the structure is rebuilt. + /// + public abstract Task RebuildAsync(); + + /// + /// Rebuilds the recycle bin navigation structure. Implementations should define how the bin structure is rebuilt. + /// + public abstract Task RebuildBinAsync(); + + public bool TryGetParentKey(Guid childKey, out Guid? parentKey) + => TryGetParentKeyFromStructure(_navigationStructure, childKey, out parentKey); + + public bool TryGetChildrenKeys(Guid parentKey, out IEnumerable childrenKeys) + => TryGetChildrenKeysFromStructure(_navigationStructure, parentKey, out childrenKeys); + + public bool TryGetDescendantsKeys(Guid parentKey, out IEnumerable descendantsKeys) + => TryGetDescendantsKeysFromStructure(_navigationStructure, parentKey, out descendantsKeys); + + public bool TryGetAncestorsKeys(Guid childKey, out IEnumerable ancestorsKeys) + => TryGetAncestorsKeysFromStructure(_navigationStructure, childKey, out ancestorsKeys); + + public bool TryGetSiblingsKeys(Guid key, out IEnumerable siblingsKeys) + => TryGetSiblingsKeysFromStructure(_navigationStructure, key, out siblingsKeys); + + public bool TryGetParentKeyInBin(Guid childKey, out Guid? parentKey) + => TryGetParentKeyFromStructure(_recycleBinNavigationStructure, childKey, out parentKey); + + public bool TryGetChildrenKeysInBin(Guid parentKey, out IEnumerable childrenKeys) + => TryGetChildrenKeysFromStructure(_recycleBinNavigationStructure, parentKey, out childrenKeys); + + public bool TryGetDescendantsKeysInBin(Guid parentKey, out IEnumerable descendantsKeys) + => TryGetDescendantsKeysFromStructure(_recycleBinNavigationStructure, parentKey, out descendantsKeys); + + public bool TryGetAncestorsKeysInBin(Guid childKey, out IEnumerable ancestorsKeys) + => TryGetAncestorsKeysFromStructure(_recycleBinNavigationStructure, childKey, out ancestorsKeys); + + public bool TryGetSiblingsKeysInBin(Guid key, out IEnumerable siblingsKeys) + => TryGetSiblingsKeysFromStructure(_recycleBinNavigationStructure, key, out siblingsKeys); + + public bool MoveToBin(Guid key) + { + if (TryRemoveNodeFromParentInStructure(_navigationStructure, key, out NavigationNode? nodeToRemove) is false || nodeToRemove is null) + { + return false; // Node doesn't exist + } + + // Recursively remove all descendants and add them to recycle bin + AddDescendantsToRecycleBinRecursively(nodeToRemove); + + return _recycleBinNavigationStructure.TryAdd(nodeToRemove.Key, nodeToRemove) && + _navigationStructure.TryRemove(key, out _); + } + + public bool Add(Guid key, Guid? parentKey = null) + { + NavigationNode? parentNode = null; + if (parentKey.HasValue) + { + if (_navigationStructure.TryGetValue(parentKey.Value, out parentNode) is false) + { + return false; // Parent node doesn't exist + } + } + + var newNode = new NavigationNode(key); + if (_navigationStructure.TryAdd(key, newNode) is false) + { + return false; // Node with this key already exists + } + + parentNode?.AddChild(newNode); + + return true; + } + + public bool Move(Guid key, Guid? targetParentKey = null) + { + if (_navigationStructure.TryGetValue(key, out NavigationNode? nodeToMove) is false) + { + return false; // Node doesn't exist + } + + if (key == targetParentKey) + { + return false; // Cannot move a node to itself + } + + NavigationNode? targetParentNode = null; + if (targetParentKey.HasValue && _navigationStructure.TryGetValue(targetParentKey.Value, out targetParentNode) is false) + { + return false; // Target parent doesn't exist + } + + // Remove the node from its current parent's children list + if (nodeToMove.Parent is not null && _navigationStructure.TryGetValue(nodeToMove.Parent.Key, out var currentParentNode)) + { + currentParentNode.RemoveChild(nodeToMove); + } + + // Set the new parent for the node (if parent node is null - the node is moved to root) + targetParentNode?.AddChild(nodeToMove); + + return true; + } + + public bool RemoveFromBin(Guid key) + { + if (TryRemoveNodeFromParentInStructure(_recycleBinNavigationStructure, key, out NavigationNode? nodeToRemove) is false || nodeToRemove is null) + { + return false; // Node doesn't exist + } + + RemoveDescendantsRecursively(nodeToRemove); + + return _recycleBinNavigationStructure.TryRemove(key, out _); + } + + public bool RestoreFromBin(Guid key, Guid? targetParentKey = null) + { + if (_recycleBinNavigationStructure.TryGetValue(key, out NavigationNode? nodeToRestore) is false) + { + return false; // Node doesn't exist + } + + // If a target parent is specified, try to find it in the main structure + NavigationNode? targetParentNode = null; + if (targetParentKey.HasValue && _navigationStructure.TryGetValue(targetParentKey.Value, out targetParentNode) is false) + { + return false; // Target parent doesn't exist + } + + // Set the new parent for the node (if parent node is null - the node is moved to root) + targetParentNode?.AddChild(nodeToRestore); + + // Restore the node and its descendants from the recycle bin to the main structure + RestoreNodeAndDescendantsRecursively(nodeToRestore); + + return _navigationStructure.TryAdd(nodeToRestore.Key, nodeToRestore) && + _recycleBinNavigationStructure.TryRemove(key, out _); + } + + /// + /// Rebuilds the navigation structure based on the specified object type key and whether the items are trashed. + /// Only relevant for items in the content and media trees (which have readLock values of -333 or -334). + /// + /// The read lock value, should be -333 or -334 for content and media trees. + /// The key of the object type to rebuild. + /// Indicates whether the items are in the recycle bin. + protected async Task HandleRebuildAsync(int readLock, Guid objectTypeKey, bool trashed) + { + // This is only relevant for items in the content and media trees + if (readLock != Constants.Locks.ContentTree && readLock != Constants.Locks.MediaTree) + { + return; + } + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(autoComplete: true); + scope.ReadLock(readLock); + + IEnumerable navigationModels = trashed ? + _navigationRepository.GetTrashedContentNodesByObjectType(objectTypeKey) : + _navigationRepository.GetContentNodesByObjectType(objectTypeKey); + + _navigationStructure = NavigationFactory.BuildNavigationDictionary(navigationModels); + } + + private bool TryGetParentKeyFromStructure(ConcurrentDictionary structure, Guid childKey, out Guid? parentKey) + { + if (structure.TryGetValue(childKey, out NavigationNode? childNode)) + { + parentKey = childNode.Parent?.Key; + return true; + } + + // Child doesn't exist + parentKey = null; + return false; + } + + private bool TryGetChildrenKeysFromStructure(ConcurrentDictionary structure, Guid parentKey, out IEnumerable childrenKeys) + { + if (structure.TryGetValue(parentKey, out NavigationNode? parentNode) is false) + { + // Parent doesn't exist + childrenKeys = []; + return false; + } + + childrenKeys = parentNode.Children.Select(child => child.Key); + return true; + } + + private bool TryGetDescendantsKeysFromStructure(ConcurrentDictionary structure, Guid parentKey, out IEnumerable descendantsKeys) + { + var descendants = new List(); + + if (structure.TryGetValue(parentKey, out NavigationNode? parentNode) is false) + { + // Parent doesn't exist + descendantsKeys = []; + return false; + } + + GetDescendantsRecursively(parentNode, descendants); + + descendantsKeys = descendants; + return true; + } + + private bool TryGetAncestorsKeysFromStructure(ConcurrentDictionary structure, Guid childKey, out IEnumerable ancestorsKeys) + { + var ancestors = new List(); + + if (structure.TryGetValue(childKey, out NavigationNode? childNode) is false) + { + // Child doesn't exist + ancestorsKeys = []; + return false; + } + + while (childNode?.Parent is not null) + { + ancestors.Add(childNode.Parent.Key); + childNode = childNode.Parent; + } + + ancestorsKeys = ancestors; + return true; + } + + private bool TryGetSiblingsKeysFromStructure(ConcurrentDictionary structure, Guid key, out IEnumerable siblingsKeys) + { + siblingsKeys = []; + + if (structure.TryGetValue(key, out NavigationNode? node) is false) + { + return false; // Node doesn't exist + } + + if (node.Parent is null) + { + // To find siblings of a node at root level, we need to iterate over all items and add those with null Parent + siblingsKeys = structure + .Where(kv => kv.Value.Parent is null && kv.Key != key) + .Select(kv => kv.Key) + .ToList(); + return true; + } + + if (TryGetChildrenKeys(node.Parent.Key, out IEnumerable childrenKeys) is false) + { + return false; // Couldn't retrieve children keys + } + + // Filter out the node itself to get its siblings + siblingsKeys = childrenKeys.Where(childKey => childKey != key).ToList(); + return true; + } + + private void GetDescendantsRecursively(NavigationNode node, List descendants) + { + foreach (NavigationNode child in node.Children) + { + descendants.Add(child.Key); + GetDescendantsRecursively(child, descendants); + } + } + + private bool TryRemoveNodeFromParentInStructure(ConcurrentDictionary structure, Guid key, out NavigationNode? nodeToRemove) + { + if (structure.TryGetValue(key, out nodeToRemove) is false) + { + return false; // Node doesn't exist + } + + // Remove the node from its parent's children list + if (nodeToRemove.Parent is not null && structure.TryGetValue(nodeToRemove.Parent.Key, out NavigationNode? parentNode)) + { + parentNode.RemoveChild(nodeToRemove); + } + + return true; + } + + private void AddDescendantsToRecycleBinRecursively(NavigationNode node) + { + foreach (NavigationNode child in node.Children) + { + AddDescendantsToRecycleBinRecursively(child); + + // Only remove the child from the main structure if it was successfully added to the recycle bin + if (_recycleBinNavigationStructure.TryAdd(child.Key, child)) + { + _navigationStructure.TryRemove(child.Key, out _); + } + } + } + + private void RemoveDescendantsRecursively(NavigationNode node) + { + foreach (NavigationNode child in node.Children) + { + RemoveDescendantsRecursively(child); + _recycleBinNavigationStructure.TryRemove(child.Key, out _); + } + } + + private void RestoreNodeAndDescendantsRecursively(NavigationNode node) + { + foreach (NavigationNode child in node.Children) + { + RestoreNodeAndDescendantsRecursively(child); + + // Only remove the child from the recycle bin structure if it was successfully added to the main one + if (_navigationStructure.TryAdd(child.Key, child)) + { + _recycleBinNavigationStructure.TryRemove(child.Key, out _); + } + } + } +} diff --git a/src/Umbraco.Core/Services/Navigation/DocumentNavigationService.cs b/src/Umbraco.Core/Services/Navigation/DocumentNavigationService.cs new file mode 100644 index 000000000000..44804d07c686 --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/DocumentNavigationService.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services.Navigation; + +internal sealed class DocumentNavigationService : ContentNavigationServiceBase, IDocumentNavigationQueryService, IDocumentNavigationManagementService +{ + public DocumentNavigationService(ICoreScopeProvider coreScopeProvider, INavigationRepository navigationRepository) + : base(coreScopeProvider, navigationRepository) + { + } + + public override async Task RebuildAsync() + => await HandleRebuildAsync(Constants.Locks.ContentTree, Constants.ObjectTypes.Document, false); + + public override async Task RebuildBinAsync() + => await HandleRebuildAsync(Constants.Locks.ContentTree, Constants.ObjectTypes.Document, true); +} diff --git a/src/Umbraco.Core/Services/Navigation/IDocumentNavigationManagementService.cs b/src/Umbraco.Core/Services/Navigation/IDocumentNavigationManagementService.cs new file mode 100644 index 000000000000..789256329ee5 --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/IDocumentNavigationManagementService.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Services.Navigation; + +public interface IDocumentNavigationManagementService : INavigationManagementService, IRecycleBinNavigationManagementService +{ +} diff --git a/src/Umbraco.Core/Services/Navigation/IDocumentNavigationQueryService.cs b/src/Umbraco.Core/Services/Navigation/IDocumentNavigationQueryService.cs new file mode 100644 index 000000000000..d8fb5e0c2951 --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/IDocumentNavigationQueryService.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Services.Navigation; + +public interface IDocumentNavigationQueryService : INavigationQueryService, IRecycleBinNavigationQueryService +{ +} diff --git a/src/Umbraco.Core/Services/Navigation/IMediaNavigationManagementService.cs b/src/Umbraco.Core/Services/Navigation/IMediaNavigationManagementService.cs new file mode 100644 index 000000000000..95cb1c96168b --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/IMediaNavigationManagementService.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Services.Navigation; + +public interface IMediaNavigationManagementService : INavigationManagementService, IRecycleBinNavigationManagementService +{ +} diff --git a/src/Umbraco.Core/Services/Navigation/IMediaNavigationQueryService.cs b/src/Umbraco.Core/Services/Navigation/IMediaNavigationQueryService.cs new file mode 100644 index 000000000000..e3f46f421148 --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/IMediaNavigationQueryService.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Services.Navigation; + +public interface IMediaNavigationQueryService : INavigationQueryService, IRecycleBinNavigationQueryService +{ +} diff --git a/src/Umbraco.Core/Services/Navigation/INavigationManagementService.cs b/src/Umbraco.Core/Services/Navigation/INavigationManagementService.cs new file mode 100644 index 000000000000..4ab8458f18ef --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/INavigationManagementService.cs @@ -0,0 +1,57 @@ +namespace Umbraco.Cms.Core.Services.Navigation; + +/// +/// Placeholder for sharing logic between the document and media navigation services +/// for managing the navigation structure. +/// +public interface INavigationManagementService +{ + /// + /// Rebuilds the entire navigation structure by refreshing the navigation tree based + /// on the current state of the underlying repository. + /// + Task RebuildAsync(); + + /// + /// Removes a node from the main navigation structure and moves it, along with + /// its descendants, to the root of the recycle bin structure. + /// + /// The unique identifier of the node to remove. + /// + /// true if the node and its descendants were successfully removed from the + /// main navigation structure and added to the recycle bin; otherwise, false. + /// + bool MoveToBin(Guid key); + + /// + /// Adds a new node to the main navigation structure. If a parent key is provided, + /// the new node is added as a child of the specified parent. If no parent key is + /// provided, the new node is added at the root level. + /// + /// The unique identifier of the new node to add. + /// + /// The unique identifier of the parent node. If null, the new node will be added to + /// the root level. + /// + /// + /// true if the node was successfully added to the main navigation structure; + /// otherwise, false. + /// + bool Add(Guid key, Guid? parentKey = null); + + /// + /// Moves an existing node to a new parent in the main navigation structure. If a + /// target parent key is provided, the node is moved under the specified parent. + /// If no target parent key is provided, the node is moved to the root level. + /// + /// The unique identifier of the node to move. + /// + /// The unique identifier of the new parent node. If null, the node will be moved to + /// the root level. + /// + /// + /// true if the node and its descendants were successfully moved to the new parent + /// in the main navigation structure; otherwise, false. + /// + bool Move(Guid key, Guid? targetParentKey = null); +} diff --git a/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs b/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs new file mode 100644 index 000000000000..4e28f80bb6cc --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs @@ -0,0 +1,18 @@ +namespace Umbraco.Cms.Core.Services.Navigation; + +/// +/// Placeholder for sharing logic between the document and media navigation services +/// for querying the navigation structure. +/// +public interface INavigationQueryService +{ + bool TryGetParentKey(Guid childKey, out Guid? parentKey); + + bool TryGetChildrenKeys(Guid parentKey, out IEnumerable childrenKeys); + + bool TryGetDescendantsKeys(Guid parentKey, out IEnumerable descendantsKeys); + + bool TryGetAncestorsKeys(Guid childKey, out IEnumerable ancestorsKeys); + + bool TryGetSiblingsKeys(Guid key, out IEnumerable siblingsKeys); +} diff --git a/src/Umbraco.Core/Services/Navigation/IRecycleBinNavigationManagementService.cs b/src/Umbraco.Core/Services/Navigation/IRecycleBinNavigationManagementService.cs new file mode 100644 index 000000000000..07c415136291 --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/IRecycleBinNavigationManagementService.cs @@ -0,0 +1,40 @@ +namespace Umbraco.Cms.Core.Services.Navigation; + +/// +/// Placeholder for sharing logic between the document and media navigation services +/// for managing the recycle bin navigation structure. +/// +public interface IRecycleBinNavigationManagementService +{ + /// + /// Rebuilds the recycle bin navigation structure by fetching the latest trashed nodes + /// from the underlying repository. + /// + Task RebuildBinAsync(); + + /// + /// Permanently removes a node and all of its descendants from the recycle bin navigation structure. + /// + /// The unique identifier of the node to remove. + /// + /// true if the node and its descendants were successfully removed from the recycle bin; + /// otherwise, false. + /// + bool RemoveFromBin(Guid key); + + /// + /// Restores a node and all of its descendants from the recycle bin navigation structure and moves them back + /// to the main navigation structure. The node can be restored to a specified target parent or to the root + /// level if no parent is specified. + /// + /// The unique identifier of the node to restore from the recycle bin navigation structure. + /// + /// The unique identifier of the target parent node in the main navigation structure to which the node + /// should be restored. If null, the node will be restored to the root level. + /// + /// + /// true if the node and its descendants were successfully restored to the main navigation structure; + /// otherwise, false. + /// + bool RestoreFromBin(Guid key, Guid? targetParentKey = null); +} diff --git a/src/Umbraco.Core/Services/Navigation/IRecycleBinNavigationQueryService.cs b/src/Umbraco.Core/Services/Navigation/IRecycleBinNavigationQueryService.cs new file mode 100644 index 000000000000..0a57f5346c02 --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/IRecycleBinNavigationQueryService.cs @@ -0,0 +1,18 @@ +namespace Umbraco.Cms.Core.Services.Navigation; + +/// +/// Placeholder for sharing logic between the document and media navigation services +/// for querying the recycle bin navigation structure. +/// +public interface IRecycleBinNavigationQueryService +{ + bool TryGetParentKeyInBin(Guid childKey, out Guid? parentKey); + + bool TryGetChildrenKeysInBin(Guid parentKey, out IEnumerable childrenKeys); + + bool TryGetDescendantsKeysInBin(Guid parentKey, out IEnumerable descendantsKeys); + + bool TryGetAncestorsKeysInBin(Guid childKey, out IEnumerable ancestorsKeys); + + bool TryGetSiblingsKeysInBin(Guid key, out IEnumerable siblingsKeys); +} diff --git a/src/Umbraco.Core/Services/Navigation/MediaNavigationService.cs b/src/Umbraco.Core/Services/Navigation/MediaNavigationService.cs new file mode 100644 index 000000000000..62ab5a1617e4 --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/MediaNavigationService.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services.Navigation; + +internal sealed class MediaNavigationService : ContentNavigationServiceBase, IMediaNavigationQueryService, IMediaNavigationManagementService +{ + public MediaNavigationService(ICoreScopeProvider coreScopeProvider, INavigationRepository navigationRepository) + : base(coreScopeProvider, navigationRepository) + { + } + + public override async Task RebuildAsync() + => await HandleRebuildAsync(Constants.Locks.MediaTree, Constants.ObjectTypes.Media, false); + + public override async Task RebuildBinAsync() + => await HandleRebuildAsync(Constants.Locks.MediaTree, Constants.ObjectTypes.Media, true); +} diff --git a/src/Umbraco.Core/Services/Navigation/NavigationInitializationHostedService.cs b/src/Umbraco.Core/Services/Navigation/NavigationInitializationHostedService.cs new file mode 100644 index 000000000000..274158d40d8a --- /dev/null +++ b/src/Umbraco.Core/Services/Navigation/NavigationInitializationHostedService.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Hosting; + +namespace Umbraco.Cms.Core.Services.Navigation; + +/// +/// Responsible for seeding the in-memory navigation structures at application's startup +/// by rebuild the navigation structures. +/// +public sealed class NavigationInitializationHostedService : IHostedLifecycleService +{ + private readonly IDocumentNavigationManagementService _documentNavigationManagementService; + private readonly IMediaNavigationManagementService _mediaNavigationManagementService; + + public NavigationInitializationHostedService(IDocumentNavigationManagementService documentNavigationManagementService, IMediaNavigationManagementService mediaNavigationManagementService) + { + _documentNavigationManagementService = documentNavigationManagementService; + _mediaNavigationManagementService = mediaNavigationManagementService; + } + + public async Task StartingAsync(CancellationToken cancellationToken) + { + await _documentNavigationManagementService.RebuildAsync(); + await _documentNavigationManagementService.RebuildBinAsync(); + await _mediaNavigationManagementService.RebuildAsync(); + await _mediaNavigationManagementService.RebuildBinAsync(); + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index f62227ddbaa6..757e1037270a 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.DynamicRoot.QuerySteps; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.DynamicRoot.QuerySteps; using Umbraco.Cms.Infrastructure.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; using Umbraco.Cms.Infrastructure.Services.Implement; @@ -79,6 +79,7 @@ internal static IUmbracoBuilder AddRepositories(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/NavigationDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/NavigationDto.cs new file mode 100644 index 000000000000..156a85b19cfc --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/NavigationDto.cs @@ -0,0 +1,25 @@ +using NPoco; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +// Used internally for representing the data needed for constructing the in-memory navigation structure. +[TableName(NodeDto.TableName)] +internal class NavigationDto : INavigationModel +{ + /// + [Column(NodeDto.IdColumnName)] + public int Id { get; set; } + + /// + [Column(NodeDto.KeyColumnName)] + public Guid Key { get; set; } + + /// + [Column(NodeDto.ParentIdColumnName)] + public int ParentId { get; set; } + + /// + [Column(NodeDto.TrashedColumnName)] + public bool Trashed { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/NodeDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/NodeDto.cs index 5bf3a2620789..2ac62429bad6 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/NodeDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/NodeDto.cs @@ -12,19 +12,26 @@ public class NodeDto { public const string TableName = Constants.DatabaseSchema.Tables.Node; public const int NodeIdSeed = 1060; + + // Public constants to bind properties between DTOs + public const string IdColumnName = "id"; + public const string KeyColumnName = "uniqueId"; + public const string ParentIdColumnName = "parentId"; + public const string TrashedColumnName = "trashed"; + private int? _userId; - [Column("id")] + [Column(IdColumnName)] [PrimaryKeyColumn(IdentitySeed = NodeIdSeed)] public int NodeId { get; set; } - [Column("uniqueId")] + [Column(KeyColumnName)] [NullSetting(NullSetting = NullSettings.NotNull)] [Index(IndexTypes.UniqueNonClustered, Name = "IX_" + TableName + "_UniqueId", IncludeColumns = "parentId,level,path,sortOrder,trashed,nodeUser,text,createDate")] [Constraint(Default = SystemMethods.NewGuid)] public Guid UniqueId { get; set; } - [Column("parentId")] + [Column(ParentIdColumnName)] [ForeignKey(typeof(NodeDto))] [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_parentId_nodeObjectType", ForColumns = "parentID,nodeObjectType", IncludeColumns = "trashed,nodeUser,level,path,sortOrder,uniqueID,text,createDate")] public int ParentId { get; set; } @@ -43,7 +50,7 @@ public class NodeDto [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_ObjectType_trashed_sorted", ForColumns = "nodeObjectType,trashed,sortOrder,id", IncludeColumns = "uniqueID,parentID,level,path,nodeUser,text,createDate")] public int SortOrder { get; set; } - [Column("trashed")] + [Column(TrashedColumnName)] [Constraint(Default = "0")] [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_Trashed")] public bool Trashed { get; set; } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentNavigationRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentNavigationRepository.cs new file mode 100644 index 000000000000..2f86d0014350 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentNavigationRepository.cs @@ -0,0 +1,37 @@ +using NPoco; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +public class ContentNavigationRepository : INavigationRepository +{ + private readonly IScopeAccessor _scopeAccessor; + + public ContentNavigationRepository(IScopeAccessor scopeAccessor) + => _scopeAccessor = scopeAccessor; + + private IScope? AmbientScope => _scopeAccessor.AmbientScope; + + /// + public IEnumerable GetContentNodesByObjectType(Guid objectTypeKey) + => FetchNavigationDtos(objectTypeKey, false); + + /// + public IEnumerable GetTrashedContentNodesByObjectType(Guid objectTypeKey) + => FetchNavigationDtos(objectTypeKey, true); + + private IEnumerable FetchNavigationDtos(Guid objectTypeKey, bool trashed) + { + Sql? sql = AmbientScope?.SqlContext.Sql() + .Select() + .From() + .Where(x => x.NodeObjectType == objectTypeKey && x.Trashed == trashed) + .OrderBy(x => x.Path); // make sure that we get the parent items first + + return AmbientScope?.Database.Fetch(sql) ?? Enumerable.Empty(); + } +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 85a9d7ded433..ea404d9703fa 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -30,6 +30,7 @@ using Umbraco.Cms.Core.Preview; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Cms.Core.Templates; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Infrastructure.BackgroundJobs; @@ -193,6 +194,7 @@ public static IUmbracoBuilder AddRecurringBackgroundJobs(this IUmbracoBuilder bu builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); return builder; } diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client index a9d3a4396968..a2fc54b77e99 160000 --- a/src/Umbraco.Web.UI.Client +++ b/src/Umbraco.Web.UI.Client @@ -1 +1 @@ -Subproject commit a9d3a4396968e4cc47c1d1cd290ca8b1cf764e12 +Subproject commit a2fc54b77e99de28a0669ab628ecfd7983df7ad8 diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index f7103ba5a478..d3d88edf6068 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -11,20 +11,14 @@ "@umbraco/playwright-testhelpers": "^2.0.0-beta.78", "camelize": "^1.0.0", "dotenv": "^16.3.1", - "faker": "^4.1.0", - "form-data": "^4.0.0", - "node-fetch": "^2.6.7", - "xhr2": "^0.2.1" + "node-fetch": "^2.6.7" }, "devDependencies": { "@playwright/test": "^1.43", "@types/node": "^20.9.0", - "del": "^6.0.0", - "ncp": "^2.0.0", "prompt": "^1.2.0", "tslib": "^2.4.0", - "typescript": "^4.8.3", - "wait-on": "^7.2.0" + "typescript": "^4.8.3" } }, "node_modules/@colors/colors": { @@ -36,96 +30,25 @@ "node": ">=0.1.90" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "dev": true - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dev": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@playwright/test": { - "version": "1.43.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", - "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz", + "integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==", "dev": true, "dependencies": { - "playwright": "1.43.1" + "playwright": "1.46.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" - } - }, - "node_modules/@sideway/address": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", - "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", - "dev": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" + "node": ">=18" } }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "dev": true - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true - }, "node_modules/@types/node": { - "version": "20.10.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.7.tgz", - "integrity": "sha512-fRbIKb8C/Y2lXxB5eVMj4IU7xpdox0Lh8bUPEdtLysaylsml1hOOx1+STloRs/B9nf7C6kPRmmg/V7aQW7usNg==", + "version": "20.14.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", + "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -148,78 +71,12 @@ "node-fetch": "^2.6.7" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/async": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", "dev": true }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/axios": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", - "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", - "dev": true, - "dependencies": { - "follow-redirects": "^1.15.4", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/camelize": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", @@ -228,15 +85,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/colors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", @@ -246,23 +94,6 @@ "node": ">=0.1.90" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, "node_modules/cycle": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", @@ -272,57 +103,15 @@ "node": ">=0.4.0" } }, - "node_modules/del": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", - "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", - "dev": true, - "dependencies": { - "globby": "^11.0.1", - "graceful-fs": "^4.2.4", - "is-glob": "^4.0.1", - "is-path-cwd": "^2.2.0", - "is-path-inside": "^3.0.2", - "p-map": "^4.0.0", - "rimraf": "^3.0.2", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/motdotla/dotenv?sponsor=1" + "url": "https://dotenvx.com" } }, "node_modules/eyes": { @@ -334,329 +123,24 @@ "node": "> 0.1.90" } }, - "node_modules/faker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/faker/-/faker-4.1.0.tgz", - "integrity": "sha512-ILKg69P6y/D8/wSmDXw35Ly0re8QzQ8pMfBCflsGiZG2ZjMUNLYNexA6lz5pkmJlepVdsiDFUxYAzPQ9/+iGLA==" - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fastq": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", - "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/ignore": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", - "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-cwd": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", - "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "dev": true }, - "node_modules/joi": { - "version": "17.11.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz", - "integrity": "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ==", - "dev": true, - "dependencies": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.3", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, - "node_modules/ncp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", - "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", - "dev": true, - "bin": { - "ncp": "bin/ncp" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -676,88 +160,34 @@ } } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/playwright": { - "version": "1.43.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", - "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz", + "integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==", "dev": true, "dependencies": { - "playwright-core": "1.43.1" + "playwright-core": "1.46.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.43.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", - "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz", + "integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==", "dev": true, "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/prompt": { @@ -776,32 +206,6 @@ "node": ">= 6.0.0" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", @@ -814,16 +218,6 @@ "node": ">=0.8" } }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/revalidator": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/revalidator/-/revalidator-0.1.8.tgz", @@ -833,62 +227,6 @@ "node": ">= 0.4.0" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -898,27 +236,15 @@ "node": "*" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", "dev": true }, "node_modules/typescript": { @@ -940,25 +266,6 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, - "node_modules/wait-on": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", - "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", - "dev": true, - "dependencies": { - "axios": "^1.6.1", - "joi": "^17.11.0", - "lodash": "^4.17.21", - "minimist": "^1.2.8", - "rxjs": "^7.8.1" - }, - "bin": { - "wait-on": "bin/wait-on" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -998,20 +305,6 @@ "dependencies": { "lodash": "^4.17.14" } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/xhr2": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz", - "integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==", - "engines": { - "node": ">= 6" - } } } } diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index d6877d558192..170bdac49134 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -13,21 +13,15 @@ "devDependencies": { "@playwright/test": "^1.43", "@types/node": "^20.9.0", - "del": "^6.0.0", - "ncp": "^2.0.0", "prompt": "^1.2.0", "tslib": "^2.4.0", - "typescript": "^4.8.3", - "wait-on": "^7.2.0" + "typescript": "^4.8.3" }, "dependencies": { "@umbraco/json-models-builders": "^2.0.17", "@umbraco/playwright-testhelpers": "^2.0.0-beta.78", "camelize": "^1.0.0", "dotenv": "^16.3.1", - "faker": "^4.1.0", - "form-data": "^4.0.0", - "node-fetch": "^2.6.7", - "xhr2": "^0.2.1" + "node-fetch": "^2.6.7" } } diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts new file mode 100644 index 000000000000..737cc34e9f55 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts @@ -0,0 +1,419 @@ +import {test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; + +const blockListEditorName = 'TestBlockListEditor'; +const elementTypeName = 'BlockListElement'; +const dataTypeName = 'Textstring'; +const groupName = 'testGroup'; + +test.beforeEach(async ({umbracoUi, umbracoApi}) => { + await umbracoApi.dataType.ensureNameNotExists(blockListEditorName); + await umbracoUi.goToBackOffice(); + await umbracoUi.dataType.goToSettingsTreeItem('Data Types'); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.dataType.ensureNameNotExists(blockListEditorName); +}); + +test('can add a label to a block', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const labelText = 'ThisIsALabel'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, elementTypeId); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.enterBlockLabelText(labelText); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesBlockEditorBlockContainLabel(blockListEditorName, elementTypeId, labelText)).toBeTruthy(); +}); + +test('can update a label for a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const labelText = 'ThisIsALabel'; + const newLabelText = 'ThisIsANewLabel'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListWithBlockWithEditorAppearance(blockListEditorName, elementTypeId, labelText); + expect(await umbracoApi.dataType.doesBlockEditorBlockContainLabel(blockListEditorName, elementTypeId, labelText)).toBeTruthy(); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.enterBlockLabelText(newLabelText); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesBlockEditorBlockContainLabel(blockListEditorName, elementTypeId, newLabelText)).toBeTruthy(); +}); + +test('can remove a label from a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const labelText = 'ThisIsALabel'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListWithBlockWithEditorAppearance(blockListEditorName, elementTypeId, labelText); + expect(await umbracoApi.dataType.doesBlockEditorBlockContainLabel(blockListEditorName, elementTypeId, labelText)).toBeTruthy(); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.enterBlockLabelText(""); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + expect(await umbracoApi.dataType.doesBlockEditorBlockContainLabel(blockListEditorName, elementTypeId, "")).toBeTruthy(); +}); + +test('can update overlay size for a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const overlaySize = 'medium'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListWithBlockWithEditorAppearance(blockListEditorName, elementTypeId, ""); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.updateBlockOverlaySize(overlaySize); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + const blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].editorSize).toEqual(overlaySize); +}); + +test('can open content model in a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, elementTypeId); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.openBlockContentModel(); + + // Assert + await umbracoUi.dataType.isElementWorkspaceOpenInBlock(elementTypeName); +}); + +// TODO: Is this an issue? should you be able to remove the contentModel so you have none? +// There is currently frontend issues +test.skip('can remove a content model from a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, elementTypeId); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.removeBlockContentModel(); + await umbracoUi.dataType.clickConfirmRemoveButton(); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + const blockData = await umbracoApi.dataType.getByName(blockListEditorName); +}); + +test('can add a settings model to a block', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + const secondElementName = 'SecondElementTest'; + const settingsElementTypeId = await umbracoApi.documentType.createDefaultElementType(secondElementName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, contentElementTypeId); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.addBlockSettingsModel(secondElementName); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesBlockEditorContainBlocksWithSettingsTypeIds(blockListEditorName, [settingsElementTypeId])).toBeTruthy(); +}); + +test('can remove a settings model from a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + const secondElementName = 'SecondElementTest'; + const settingsElementTypeId = await umbracoApi.documentType.createDefaultElementType(secondElementName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListDataTypeWithContentAndSettingsElementType(blockListEditorName, contentElementTypeId, settingsElementTypeId); + expect(await umbracoApi.dataType.doesBlockEditorContainBlocksWithSettingsTypeIds(blockListEditorName, [settingsElementTypeId])).toBeTruthy(); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.removeBlockSettingsModel(); + await umbracoUi.dataType.clickConfirmRemoveButton(); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesBlockEditorContainBlocksWithSettingsTypeIds(blockListEditorName, [settingsElementTypeId])).toBeFalsy(); +}); + +test('can add a background color to a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const backgroundColor = '#ff0000'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, contentElementTypeId); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.selectBlockBackgroundColor(backgroundColor); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + const blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].backgroundColor).toEqual(backgroundColor); +}); + +test('can update a background color for a block', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const backgroundColor = '#ff0000'; + const newBackgroundColor = '#ff4444'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListWithBlockWithCatalogueAppearance(blockListEditorName, contentElementTypeId, backgroundColor); + let blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].backgroundColor).toEqual(backgroundColor); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.selectBlockBackgroundColor(newBackgroundColor); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].backgroundColor).toEqual(newBackgroundColor); +}); + +test('can delete a background color from a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const backgroundColor = '#ff0000'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListWithBlockWithCatalogueAppearance(blockListEditorName, contentElementTypeId, backgroundColor); + let blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].backgroundColor).toEqual(backgroundColor); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.selectBlockBackgroundColor(''); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].backgroundColor).toEqual(''); +}); + +test('can add a icon color to a block', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const iconColor = '#ff0000'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, contentElementTypeId); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.selectBlockIconColor(iconColor); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + const blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].iconColor).toEqual(iconColor); +}); + +test('can update a icon color for a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const iconColor = '#ff0000'; + const newIconColor = '#ff4444'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListWithBlockWithCatalogueAppearance(blockListEditorName, contentElementTypeId, "", iconColor); + let blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].iconColor).toEqual(iconColor); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.selectBlockIconColor(newIconColor); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].iconColor).toEqual(newIconColor); +}); + +test('can delete a icon color from a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const iconColor = '#ff0000'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListWithBlockWithCatalogueAppearance(blockListEditorName, contentElementTypeId, '', iconColor); + let blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].iconColor).toEqual(iconColor); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.selectBlockIconColor(''); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].iconColor).toEqual(''); +}); + +// TODO: Currently it is not possible to update a stylesheet to a block +test.skip('can update a custom stylesheet for a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const stylesheetName = 'TestStylesheet.css'; + const stylesheetPath = '/wwwroot/css/' + stylesheetName; + const encodedStylesheetPath = await umbracoApi.stylesheet.encodeStylesheetPath(stylesheetPath); + const secondStylesheetName = 'SecondStylesheet.css'; + const secondStylesheetPath = '/wwwroot/css/' + secondStylesheetName; + const encodedSecondStylesheetPath = await umbracoApi.stylesheet.encodeStylesheetPath(secondStylesheetPath); + await umbracoApi.stylesheet.ensureNameNotExists(stylesheetName); + await umbracoApi.stylesheet.ensureNameNotExists(secondStylesheetName); + await umbracoApi.stylesheet.createDefaultStylesheet(stylesheetName); + await umbracoApi.stylesheet.createDefaultStylesheet(secondStylesheetName); + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListWithBlockWithCatalogueAppearance(blockListEditorName, contentElementTypeId, '', '', encodedStylesheetPath); + let blockData = await umbracoApi.dataType.getByName(blockListEditorName); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + // Removes first stylesheet + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].stylesheet[0]).toEqual(encodedSecondStylesheetPath); + + // Clean + await umbracoApi.stylesheet.ensureNameNotExists(stylesheetName); + await umbracoApi.stylesheet.ensureNameNotExists(secondStylesheetName); +}); + +// TODO: Currently it is not possible to delete a stylesheet to a block +test.skip('can delete a custom stylesheet from a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const stylesheetName = 'TestStylesheet.css'; + const stylesheetPath = '/wwwroot/css/' + stylesheetName; + const encodedStylesheetPath = await umbracoApi.stylesheet.encodeStylesheetPath(stylesheetPath); + await umbracoApi.stylesheet.ensureNameNotExists(stylesheetName); + await umbracoApi.stylesheet.createDefaultStylesheet(stylesheetName); + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListWithBlockWithCatalogueAppearance(blockListEditorName, contentElementTypeId, '', '', encodedStylesheetPath); + let blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].stylesheet[0]).toEqual(encodedStylesheetPath); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.clickRemoveCustomStylesheetWithName(stylesheetName); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].stylesheet[0]).toBeUndefined(); + + // Clean + await umbracoApi.stylesheet.ensureNameNotExists(stylesheetName); +}); + +test('can enable hide content editor in a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, contentElementTypeId); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.clickBlockListHideContentEditorButton(); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + const blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].forceHideContentEditorInOverlay).toEqual(true); +}); + +test('can disable hide content editor in a block', async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListWithBlockWithHideContentEditor(blockListEditorName, contentElementTypeId, true); + let blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].forceHideContentEditorInOverlay).toEqual(true); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.goToBlockWithName(elementTypeName); + await umbracoUi.dataType.clickBlockListHideContentEditorButton(); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + blockData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(blockData.values[0].value[0].forceHideContentEditorInOverlay).toEqual(false); +}); + +// TODO: Thumbnails are not showing in the UI +test.skip('can add a thumbnail to a block ', {tag: '@smoke'}, async ({page, umbracoApi, umbracoUi}) => { + +}); + +// TODO: Thumbnails are not showing in the UI +test.skip('can remove a thumbnail to a block ', {tag: '@smoke'}, async ({page, umbracoApi, umbracoUi}) => { + +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListEditor.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListEditor.spec.ts new file mode 100644 index 000000000000..24142bca82e6 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListEditor.spec.ts @@ -0,0 +1,311 @@ +import {test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; + +const blockListEditorName = 'TestBlockListEditor'; +const elementTypeName = 'BlockListElement'; +const dataTypeName = 'Textstring'; +const groupName = 'testGroup'; + +test.beforeEach(async ({umbracoUi, umbracoApi}) => { + await umbracoApi.dataType.ensureNameNotExists(blockListEditorName); + await umbracoUi.goToBackOffice(); + await umbracoUi.dataType.goToSettingsTreeItem('Data Types'); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.dataType.ensureNameNotExists(blockListEditorName); +}); + +test('can create a block list editor', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const blockListLocatorName = 'Block List'; + const blockListEditorAlias = 'Umbraco.BlockList'; + const blockListEditorUiAlias = 'Umb.PropertyEditorUi.BlockList'; + + // Act + await umbracoUi.dataType.clickActionsMenuAtRoot(); + await umbracoUi.dataType.clickCreateButton(); + await umbracoUi.dataType.clickNewDataTypeThreeDotsButton(); + await umbracoUi.dataType.enterDataTypeName(blockListEditorName); + await umbracoUi.dataType.clickSelectAPropertyEditorButton(); + await umbracoUi.dataType.selectAPropertyEditor(blockListLocatorName); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesNameExist(blockListEditorName)).toBeTruthy(); + const dataTypeData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(dataTypeData.editorAlias).toBe(blockListEditorAlias); + expect(dataTypeData.editorUiAlias).toBe(blockListEditorUiAlias); +}); + +test('can rename a block list editor', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const wrongName = 'BlockGridEditorTest'; + await umbracoApi.dataType.createEmptyBlockListDataType(wrongName); + + // Act + await umbracoUi.dataType.goToDataType(wrongName); + await umbracoUi.dataType.enterDataTypeName(blockListEditorName); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesNameExist(blockListEditorName)).toBeTruthy(); + expect(await umbracoApi.dataType.doesNameExist(wrongName)).toBeFalsy(); +}); + +test('can delete a block list editor', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const blockListId = await umbracoApi.dataType.createEmptyBlockListDataType(blockListEditorName); + + // Act + await umbracoUi.dataType.clickRootFolderCaretButton(); + await umbracoUi.dataType.clickActionsMenuForDataType(blockListEditorName); + await umbracoUi.dataType.clickDeleteExactButton(); + await umbracoUi.dataType.clickConfirmToDeleteButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesExist(blockListId)).toBeFalsy(); + await umbracoUi.dataType.isTreeItemVisible(blockListEditorName, false); +}); + +test('can add a block to a block list editor', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, 'testGroup', dataTypeName, textStringData.id); + await umbracoApi.dataType.createEmptyBlockListDataType(blockListEditorName); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.clickAddBlockButton(); + await umbracoUi.dataType.clickLabelWithName(elementTypeName); + await umbracoUi.dataType.clickChooseButton(); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesBlockEditorContainBlocksWithContentTypeIds(blockListEditorName, [elementTypeId])).toBeTruthy(); + + // Clean + await umbracoApi.documentType.ensureNameNotExists(elementTypeName); +}); + +test('can add multiple blocks to a block list editor', async ({umbracoApi, umbracoUi}) => { + // Arrange + const secondElementTypeName = 'SecondBlockListElement'; + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + const secondElementTypeId = await umbracoApi.documentType.createDefaultElementType(secondElementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, elementTypeId); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.clickAddBlockButton(); + await umbracoUi.dataType.clickLabelWithName(secondElementTypeName); + await umbracoUi.dataType.clickChooseButton(); + await umbracoUi.dataType.clickSubmitButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesBlockEditorContainBlocksWithContentTypeIds(blockListEditorName, [elementTypeId, secondElementTypeId])).toBeTruthy(); + + // Clean + await umbracoApi.documentType.ensureNameNotExists(elementTypeName); + await umbracoApi.documentType.ensureNameNotExists(secondElementTypeName); +}); + +test('can remove a block from a block list editor', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const textStringData = await umbracoApi.dataType.getByName(dataTypeName); + const elementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); + await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, elementTypeId); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.clickRemoveBlockWithName(elementTypeName); + await umbracoUi.dataType.clickConfirmRemoveButton(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesBlockEditorContainBlocksWithContentTypeIds(blockListEditorName, [elementTypeId])).toBeFalsy(); + + // Clean + await umbracoApi.documentType.ensureNameNotExists(elementTypeName); +}); + +test('can add a min and max amount to a block list editor', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const minAmount = 1; + const maxAmount = 2; + await umbracoApi.dataType.createEmptyBlockListDataType(blockListEditorName); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.enterMinAmount(minAmount.toString()); + await umbracoUi.dataType.enterMaxAmount(maxAmount.toString()); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + const dataTypeData = await umbracoApi.dataType.getByName(blockListEditorName); + expect(dataTypeData.values[0].value.min).toBe(minAmount); + expect(dataTypeData.values[0].value.max).toBe(maxAmount); +}); + +test('max can not be less than min', async ({umbracoApi, umbracoUi}) => { + // Arrange + const minAmount = 2; + const oldMaxAmount = 2; + const newMaxAmount = 1; + await umbracoApi.dataType.createBlockListDataTypeWithMinAndMaxAmount(blockListEditorName, minAmount, oldMaxAmount); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.enterMaxAmount(newMaxAmount.toString()); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(false); + const dataTypeData = await umbracoApi.dataType.getByName(blockListEditorName); + await umbracoUi.dataType.doesAmountContainErrorMessageWithText('The low value must not be exceed the high value'); + expect(dataTypeData.values[0].value.min).toBe(minAmount); + // The max value should not be updated + expect(dataTypeData.values[0].value.max).toBe(oldMaxAmount); +}); + +test('can enable single block mode', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoApi.dataType.createBlockListDataTypeWithSingleBlockMode(blockListEditorName, false); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.clickSingleBlockMode(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.isSingleBlockModeEnabledForBlockList(blockListEditorName, true)).toBeTruthy(); +}); + +test('can disable single block mode', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoApi.dataType.createBlockListDataTypeWithSingleBlockMode(blockListEditorName, true); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.clickSingleBlockMode(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.isSingleBlockModeEnabledForBlockList(blockListEditorName, false)).toBeTruthy(); +}); + +test('can enable live editing mode', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoApi.dataType.createBlockListDataTypeWithLiveEditingMode(blockListEditorName, false); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.clickLiveEditingMode(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.isLiveEditingModeEnabledForBlockEditor(blockListEditorName, true)).toBeTruthy(); +}); + +test('can disable live editing mode', async ({umbracoApi, umbracoUi}) => { +// Arrange + await umbracoApi.dataType.createBlockListDataTypeWithLiveEditingMode(blockListEditorName, true); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.clickLiveEditingMode(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.isLiveEditingModeEnabledForBlockEditor(blockListEditorName, false)).toBeTruthy(); +}); + +test('can enable inline editing mode', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoApi.dataType.createBlockListDataTypeWithInlineEditingMode(blockListEditorName, false); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.clickInlineEditingMode(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.isInlineEditingModeEnabledForBlockList(blockListEditorName, true)).toBeTruthy(); +}); + +test('can disable inline editing mode', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoApi.dataType.createBlockListDataTypeWithInlineEditingMode(blockListEditorName, true); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.clickInlineEditingMode(); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.isInlineEditingModeEnabledForBlockList(blockListEditorName, false)).toBeTruthy(); +}); + +test('can add a property editor width', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + // Arrange + const propertyWidth = '50%'; + await umbracoApi.dataType.createEmptyBlockListDataType(blockListEditorName); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.enterPropertyEditorWidth(propertyWidth); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesMaxPropertyContainWidthForBlockEditor(blockListEditorName, propertyWidth)).toBeTruthy(); +}); + +test('can update a property editor width', async ({umbracoApi, umbracoUi}) => { + // Arrange + const oldPropertyWidth = '50%'; + const newPropertyWidth = '100%'; + await umbracoApi.dataType.createBlockListDataTypeWithPropertyEditorWidth(blockListEditorName, oldPropertyWidth); + expect(await umbracoApi.dataType.doesMaxPropertyContainWidthForBlockEditor(blockListEditorName, oldPropertyWidth)).toBeTruthy(); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.enterPropertyEditorWidth(newPropertyWidth); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesMaxPropertyContainWidthForBlockEditor(blockListEditorName, newPropertyWidth)).toBeTruthy(); +}); + +test('can remove a property editor width', async ({umbracoApi, umbracoUi}) => { + // Arrange + const propertyWidth = '50%'; + await umbracoApi.dataType.createBlockListDataTypeWithPropertyEditorWidth(blockListEditorName, propertyWidth); + expect(await umbracoApi.dataType.doesMaxPropertyContainWidthForBlockEditor(blockListEditorName, propertyWidth)).toBeTruthy(); + + // Act + await umbracoUi.dataType.goToDataType(blockListEditorName); + await umbracoUi.dataType.enterPropertyEditorWidth(''); + await umbracoUi.dataType.clickSaveButton(); + + // Assert + await umbracoUi.dataType.isSuccessNotificationVisible(); + expect(await umbracoApi.dataType.doesMaxPropertyContainWidthForBlockEditor(blockListEditorName, '')).toBeTruthy(); +}); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Copy.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Copy.cs new file mode 100644 index 000000000000..b3ebfa221582 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Copy.cs @@ -0,0 +1,46 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +// TODO: test that it is added to its new parent - check parent's children +// TODO: test that it has the same amount of descendants - depending on value of includeDescendants param +// TODO: test that the number of target parent descendants updates when copying node with descendants +// TODO: test that copied node descendants have different keys than source node descendants +public partial class DocumentNavigationServiceTests +{ + [Test] + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB", "A1B1B217-B02F-4307-862C-A5E22DB729EB")] // Grandchild 2 to itself + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", null)] // Child 2 to content root + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", "C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Child 3 to Child 1 + public async Task Structure_Updates_When_Copying_Content(Guid nodeToCopy, Guid? targetParentKey) + { + // Arrange + DocumentNavigationQueryService.TryGetParentKey(nodeToCopy, out Guid? sourceParentKey); + + // Act + var copyAttempt = await ContentEditingService.CopyAsync(nodeToCopy, targetParentKey, false, false, Constants.Security.SuperUserKey); + Guid copiedItemKey = copyAttempt.Result.Key; + + // Assert + Assert.AreNotEqual(nodeToCopy, copiedItemKey); + + DocumentNavigationQueryService.TryGetParentKey(copiedItemKey, out Guid? copiedItemParentKey); + + Assert.Multiple(() => + { + if (targetParentKey is null) + { + // Verify the copied node's parent is null (it's been copied to content root) + Assert.IsNull(copiedItemParentKey); + } + else + { + Assert.IsNotNull(copiedItemParentKey); + } + + Assert.AreEqual(targetParentKey, copiedItemParentKey); + Assert.AreNotEqual(sourceParentKey, copiedItemParentKey); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Create.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Create.cs new file mode 100644 index 000000000000..2ee6c7cabe3f --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Create.cs @@ -0,0 +1,39 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class DocumentNavigationServiceTests +{ + [Test] + public async Task Structure_Updates_When_Creating_Content() + { + // Arrange + DocumentNavigationQueryService.TryGetSiblingsKeys(Root.Key, out IEnumerable initialSiblingsKeys); + var initialRootNodeSiblingsCount = initialSiblingsKeys.Count(); + + var createModel = new ContentCreateModel + { + ContentTypeKey = ContentType.Key, + ParentKey = Constants.System.RootKey, // Create node at content root + InvariantName = "Root 2", + }; + + // Act + var createAttempt = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Guid createdItemKey = createAttempt.Result.Content!.Key; + + // Verify that the structure has updated by checking the siblings list of the Root once again + DocumentNavigationQueryService.TryGetSiblingsKeys(Root.Key, out IEnumerable updatedSiblingsKeys); + List siblingsList = updatedSiblingsKeys.ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.IsNotEmpty(siblingsList); + Assert.AreEqual(initialRootNodeSiblingsCount + 1, siblingsList.Count); + Assert.AreEqual(createdItemKey, siblingsList.First()); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Delete.cs new file mode 100644 index 000000000000..5e6e655d7481 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Delete.cs @@ -0,0 +1,43 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +// TODO: Test that the descendants of the node have also been removed from both structures +public partial class DocumentNavigationServiceTests +{ + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF")] // Root + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF")] // Child 3 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7")] // Great-grandchild 1 + public async Task Structure_Updates_When_Deleting_Content(Guid nodeToDelete) + { + // Arrange + DocumentNavigationQueryService.TryGetDescendantsKeys(nodeToDelete, out IEnumerable initialDescendantsKeys); + + // Act + // Deletes the item whether it is in the recycle bin or not + var deleteAttempt = await ContentEditingService.DeleteAsync(nodeToDelete, Constants.Security.SuperUserKey); + Guid deletedItemKey = deleteAttempt.Result.Key; + + // Assert + var nodeExists = DocumentNavigationQueryService.TryGetDescendantsKeys(deletedItemKey, out _); + var nodeExistsInRecycleBin = DocumentNavigationQueryService.TryGetDescendantsKeysInBin(nodeToDelete, out _); + + Assert.Multiple(() => + { + Assert.AreEqual(nodeToDelete, deletedItemKey); + Assert.IsFalse(nodeExists); + Assert.IsFalse(nodeExistsInRecycleBin); + + foreach (Guid descendant in initialDescendantsKeys) + { + var descendantExists = DocumentNavigationQueryService.TryGetParentKey(descendant, out _); + Assert.IsFalse(descendantExists); + + var descendantExistsInRecycleBin = DocumentNavigationQueryService.TryGetParentKeyInBin(descendant, out _); + Assert.IsFalse(descendantExistsInRecycleBin); + } + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.DeleteFromRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.DeleteFromRecycleBin.cs new file mode 100644 index 000000000000..1f9b81936639 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.DeleteFromRecycleBin.cs @@ -0,0 +1,30 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +// TODO: check that the descendants have also been removed from both structures - navigation and trash +public partial class DocumentNavigationServiceTests +{ + [Test] + public async Task Structure_Updates_When_Deleting_From_Recycle_Bin() + { + // Arrange + Guid nodeToDelete = Child1.Key; + Guid nodeInRecycleBin = Grandchild4.Key; + + // Move nodes to recycle bin + await ContentEditingService.MoveToRecycleBinAsync(nodeInRecycleBin, Constants.Security.SuperUserKey); // Make sure we have an item already in the recycle bin to act as a sibling + await ContentEditingService.MoveToRecycleBinAsync(nodeToDelete, Constants.Security.SuperUserKey); // Make sure the item is in the recycle bin + DocumentNavigationQueryService.TryGetSiblingsKeysInBin(nodeInRecycleBin, out IEnumerable initialSiblingsKeys); + + // Act + await ContentEditingService.DeleteFromRecycleBinAsync(nodeToDelete, Constants.Security.SuperUserKey); + + // Assert + DocumentNavigationQueryService.TryGetSiblingsKeysInBin(nodeInRecycleBin, out IEnumerable updatedSiblingsKeys); + + // Verify siblings count has decreased by one + Assert.AreEqual(initialSiblingsKeys.Count() - 1, updatedSiblingsKeys.Count()); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Move.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Move.cs new file mode 100644 index 000000000000..078e06de2bfb --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Move.cs @@ -0,0 +1,39 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class DocumentNavigationServiceTests +{ + [Test] + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", "60E0E5C4-084E-4144-A560-7393BEAD2E96")] // Grandchild 1 to Child 2 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", null)] // Child 3 to content root + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", "C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Child 2 to Child 1 + public async Task Structure_Updates_When_Moving_Content(Guid nodeToMove, Guid? targetParentKey) + { + // Arrange + DocumentNavigationQueryService.TryGetParentKey(nodeToMove, out Guid? originalParentKey); + + // Act + var moveAttempt = await ContentEditingService.MoveAsync(nodeToMove, targetParentKey, Constants.Security.SuperUserKey); + + // Verify the node's new parent is updated + DocumentNavigationQueryService.TryGetParentKey(moveAttempt.Result!.Key, out Guid? updatedParentKey); + + // Assert + Assert.Multiple(() => + { + if (targetParentKey is null) + { + Assert.IsNull(updatedParentKey); + } + else + { + Assert.IsNotNull(updatedParentKey); + } + + Assert.AreNotEqual(originalParentKey, updatedParentKey); + Assert.AreEqual(targetParentKey, updatedParentKey); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.MoveToRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.MoveToRecycleBin.cs new file mode 100644 index 000000000000..1bd4bd9d834b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.MoveToRecycleBin.cs @@ -0,0 +1,32 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +// TODO: also check that initial siblings count's decreased +// TODO: and that descendants are still the same (i.e. they've also been moved to recycle bin) +public partial class DocumentNavigationServiceTests +{ + [Test] + public async Task Structure_Updates_When_Moving_Content_To_Recycle_Bin() + { + // Arrange + Guid nodeToMoveToRecycleBin = Child3.Key; + DocumentNavigationQueryService.TryGetParentKey(nodeToMoveToRecycleBin, out Guid? originalParentKey); + + // Act + await ContentEditingService.MoveToRecycleBinAsync(nodeToMoveToRecycleBin, Constants.Security.SuperUserKey); + + // Assert + var nodeExists = DocumentNavigationQueryService.TryGetParentKey(nodeToMoveToRecycleBin, out _); // Verify that the item is no longer in the document structure + var nodeExistsInRecycleBin = DocumentNavigationQueryService.TryGetParentKeyInBin(nodeToMoveToRecycleBin, out Guid? updatedParentKeyInRecycleBin); + + Assert.Multiple(() => + { + Assert.IsFalse(nodeExists); + Assert.IsTrue(nodeExistsInRecycleBin); + Assert.AreNotEqual(originalParentKey, updatedParentKeyInRecycleBin); + Assert.IsNull(updatedParentKeyInRecycleBin); // Verify the node's parent is now located at the root of the recycle bin (null) + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs new file mode 100644 index 000000000000..6d70870a3273 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs @@ -0,0 +1,59 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.Navigation; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class DocumentNavigationServiceTests +{ + [Test] + public async Task Structure_Can_Rebuild() + { + // Arrange + Guid nodeKey = Root.Key; + + // Capture original built state of DocumentNavigationService + DocumentNavigationQueryService.TryGetParentKey(nodeKey, out Guid? originalParentKey); + DocumentNavigationQueryService.TryGetChildrenKeys(nodeKey, out IEnumerable originalChildrenKeys); + DocumentNavigationQueryService.TryGetDescendantsKeys(nodeKey, out IEnumerable originalDescendantsKeys); + DocumentNavigationQueryService.TryGetAncestorsKeys(nodeKey, out IEnumerable originalAncestorsKeys); + DocumentNavigationQueryService.TryGetSiblingsKeys(nodeKey, out IEnumerable originalSiblingsKeys); + + // Im-memory navigation structure is empty here + var newDocumentNavigationService = new DocumentNavigationService(GetRequiredService(), GetRequiredService()); + var initialNodeExists = newDocumentNavigationService.TryGetParentKey(nodeKey, out _); + + // Act + await newDocumentNavigationService.RebuildAsync(); + + // Capture rebuilt state + var nodeExists = newDocumentNavigationService.TryGetParentKey(nodeKey, out Guid? parentKeyFromRebuild); + newDocumentNavigationService.TryGetChildrenKeys(nodeKey, out IEnumerable childrenKeysFromRebuild); + newDocumentNavigationService.TryGetDescendantsKeys(nodeKey, out IEnumerable descendantsKeysFromRebuild); + newDocumentNavigationService.TryGetAncestorsKeys(nodeKey, out IEnumerable ancestorsKeysFromRebuild); + newDocumentNavigationService.TryGetSiblingsKeys(nodeKey, out IEnumerable siblingsKeysFromRebuild); + + // Assert + Assert.Multiple(() => + { + Assert.IsFalse(initialNodeExists); + + // Verify that the item is present in the navigation structure after a rebuild + Assert.IsTrue(nodeExists); + + // Verify that we have the same items as in the original built state of DocumentNavigationService + Assert.AreEqual(originalParentKey, parentKeyFromRebuild); + CollectionAssert.AreEquivalent(originalChildrenKeys, childrenKeysFromRebuild); + CollectionAssert.AreEquivalent(originalDescendantsKeys, descendantsKeysFromRebuild); + CollectionAssert.AreEquivalent(originalAncestorsKeys, ancestorsKeysFromRebuild); + CollectionAssert.AreEquivalent(originalSiblingsKeys, siblingsKeysFromRebuild); + }); + } + + [Test] + // TODO: Test that you can rebuild bin structure as well + public async Task Bin_Structure_Can_Rebuild() + { + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Restore.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Restore.cs new file mode 100644 index 000000000000..3151fb83e4ab --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Restore.cs @@ -0,0 +1,50 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +// TODO: test that descendants are also restored in the right place +public partial class DocumentNavigationServiceTests +{ + [Test] + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", "C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Grandchild 1 to Child 1 + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB", "D63C1621-C74A-4106-8587-817DEE5FB732")] // Grandchild 2 to Grandchild 3 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", null)] // Child 3 to content root + public async Task Structure_Updates_When_Restoring_Content(Guid nodeToRestore, Guid? targetParentKey) + { + // Arrange + Guid nodeInRecycleBin = GreatGrandchild1.Key; + + // Move nodes to recycle bin + await ContentEditingService.MoveToRecycleBinAsync(nodeInRecycleBin, Constants.Security.SuperUserKey); // Make sure we have an item already in the recycle bin to act as a sibling + await ContentEditingService.MoveToRecycleBinAsync(nodeToRestore, Constants.Security.SuperUserKey); // Make sure the item is in the recycle bin + DocumentNavigationQueryService.TryGetParentKeyInBin(nodeToRestore, out Guid? initialParentKey); + DocumentNavigationQueryService.TryGetSiblingsKeysInBin(nodeInRecycleBin, out IEnumerable initialSiblingsKeys); + + // Act + var restoreAttempt = await ContentEditingService.RestoreAsync(nodeToRestore, targetParentKey, Constants.Security.SuperUserKey); + Guid restoredItemKey = restoreAttempt.Result.Key; + + // Assert + DocumentNavigationQueryService.TryGetParentKey(restoredItemKey, out Guid? restoredItemParentKey); + DocumentNavigationQueryService.TryGetSiblingsKeysInBin(nodeInRecycleBin, out IEnumerable updatedSiblingsKeys); + + Assert.Multiple(() => + { + // Verify siblings count has decreased by one + Assert.AreEqual(initialSiblingsKeys.Count() - 1, updatedSiblingsKeys.Count()); + + if (targetParentKey is null) + { + Assert.IsNull(restoredItemParentKey); + } + else + { + Assert.IsNotNull(restoredItemParentKey); + Assert.AreNotEqual(initialParentKey, restoredItemParentKey); + } + + Assert.AreEqual(targetParentKey, restoredItemParentKey); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Update.cs new file mode 100644 index 000000000000..8ed720000e37 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Update.cs @@ -0,0 +1,54 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class DocumentNavigationServiceTests +{ + [Test] + public async Task Structure_Does_Not_Update_When_Updating_Content() + { + // Arrange + Guid nodeToUpdate = Root.Key; + + // Capture initial state + DocumentNavigationQueryService.TryGetParentKey(nodeToUpdate, out Guid? initialParentKey); + DocumentNavigationQueryService.TryGetChildrenKeys(nodeToUpdate, out IEnumerable initialChildrenKeys); + DocumentNavigationQueryService.TryGetDescendantsKeys(nodeToUpdate, out IEnumerable initialDescendantsKeys); + DocumentNavigationQueryService.TryGetAncestorsKeys(nodeToUpdate, out IEnumerable initialAncestorsKeys); + DocumentNavigationQueryService.TryGetSiblingsKeys(nodeToUpdate, out IEnumerable initialSiblingsKeys); + + var updateModel = new ContentUpdateModel + { + InvariantName = "Updated Root", + }; + + // Act + var updateAttempt = await ContentEditingService.UpdateAsync(nodeToUpdate, updateModel, Constants.Security.SuperUserKey); + Guid updatedItemKey = updateAttempt.Result.Content!.Key; + + // Capture updated state + var nodeExists = DocumentNavigationQueryService.TryGetParentKey(updatedItemKey, out Guid? updatedParentKey); + DocumentNavigationQueryService.TryGetChildrenKeys(updatedItemKey, out IEnumerable childrenKeysAfterUpdate); + DocumentNavigationQueryService.TryGetDescendantsKeys(updatedItemKey, out IEnumerable descendantsKeysAfterUpdate); + DocumentNavigationQueryService.TryGetAncestorsKeys(updatedItemKey, out IEnumerable ancestorsKeysAfterUpdate); + DocumentNavigationQueryService.TryGetSiblingsKeys(updatedItemKey, out IEnumerable siblingsKeysAfterUpdate); + + // Assert + Assert.Multiple(() => + { + // Verify that the item is still present in the navigation structure + Assert.IsTrue(nodeExists); + + Assert.AreEqual(nodeToUpdate, updatedItemKey); + + // Verify that nothing's changed + Assert.AreEqual(initialParentKey, updatedParentKey); + CollectionAssert.AreEquivalent(initialChildrenKeys, childrenKeysAfterUpdate); + CollectionAssert.AreEquivalent(initialDescendantsKeys, descendantsKeysAfterUpdate); + CollectionAssert.AreEquivalent(initialAncestorsKeys, ancestorsKeysAfterUpdate); + CollectionAssert.AreEquivalent(initialSiblingsKeys, siblingsKeysAfterUpdate); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.cs new file mode 100644 index 000000000000..9fdedc52572e --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.cs @@ -0,0 +1,90 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class DocumentNavigationServiceTests : DocumentNavigationServiceTestsBase +{ + [SetUp] + public async Task Setup() + { + // Root + // - Child 1 + // - Grandchild 1 + // - Grandchild 2 + // - Child 2 + // - Grandchild 3 + // - Great-grandchild 1 + // - Child 3 + // - Grandchild 4 + + // Doc Type + ContentType = ContentTypeBuilder.CreateSimpleContentType("page", "Page"); + ContentType.Key = new Guid("DD72B8A6-2CE3-47F0-887E-B695A1A5D086"); + ContentType.AllowedAsRoot = true; + ContentType.AllowedTemplates = null; + ContentType.AllowedContentTypes = new[] { new ContentTypeSort(ContentType.Key, 0, ContentType.Alias) }; + await ContentTypeService.CreateAsync(ContentType, Constants.Security.SuperUserKey); + + // Content + var rootModel = CreateContentCreateModel("Root", new Guid("E48DD82A-7059-418E-9B82-CDD5205796CF")); + var rootCreateAttempt = await ContentEditingService.CreateAsync(rootModel, Constants.Security.SuperUserKey); + Root = rootCreateAttempt.Result.Content!; + + var child1Model = CreateContentCreateModel("Child 1", new Guid("C6173927-0C59-4778-825D-D7B9F45D8DDE"), Root.Key); + var child1CreateAttempt = await ContentEditingService.CreateAsync(child1Model, Constants.Security.SuperUserKey); + Child1 = child1CreateAttempt.Result.Content!; + + var grandchild1Model = CreateContentCreateModel("Grandchild 1", new Guid("E856AC03-C23E-4F63-9AA9-681B42A58573"), Child1.Key); + var grandchild1CreateAttempt = await ContentEditingService.CreateAsync(grandchild1Model, Constants.Security.SuperUserKey); + Grandchild1 = grandchild1CreateAttempt.Result.Content!; + + var grandchild2Model = CreateContentCreateModel("Grandchild 2", new Guid("A1B1B217-B02F-4307-862C-A5E22DB729EB"), Child1.Key); + var grandchild2CreateAttempt = await ContentEditingService.CreateAsync(grandchild2Model, Constants.Security.SuperUserKey); + Grandchild2 = grandchild2CreateAttempt.Result.Content!; + + var child2Model = CreateContentCreateModel("Child 2", new Guid("60E0E5C4-084E-4144-A560-7393BEAD2E96"), Root.Key); + var child2CreateAttempt = await ContentEditingService.CreateAsync(child2Model, Constants.Security.SuperUserKey); + Child2 = child2CreateAttempt.Result.Content!; + + var grandchild3Model = CreateContentCreateModel("Grandchild 3", new Guid("D63C1621-C74A-4106-8587-817DEE5FB732"), Child2.Key); + var grandchild3CreateAttempt = await ContentEditingService.CreateAsync(grandchild3Model, Constants.Security.SuperUserKey); + Grandchild3 = grandchild3CreateAttempt.Result.Content!; + + var greatGrandchild1Model = CreateContentCreateModel("Great-grandchild 1", new Guid("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7"), Grandchild3.Key); + var greatGrandchild1CreateAttempt = await ContentEditingService.CreateAsync(greatGrandchild1Model, Constants.Security.SuperUserKey); + GreatGrandchild1 = greatGrandchild1CreateAttempt.Result.Content!; + + var child3Model = CreateContentCreateModel("Child 3", new Guid("B606E3FF-E070-4D46-8CB9-D31352029FDF"), Root.Key); + var child3CreateAttempt = await ContentEditingService.CreateAsync(child3Model, Constants.Security.SuperUserKey); + Child3 = child3CreateAttempt.Result.Content!; + + var grandchild4Model = CreateContentCreateModel("Grandchild 4", new Guid("F381906C-223C-4466-80F7-B63B4EE073F8"), Child3.Key); + var grandchild4CreateAttempt = await ContentEditingService.CreateAsync(grandchild4Model, Constants.Security.SuperUserKey); + Grandchild4 = grandchild4CreateAttempt.Result.Content!; + } + + [Test] + public async Task Structure_Does_Not_Update_When_Scope_Is_Not_Completed() + { + // Arrange + Guid notCreatedRootKey = new Guid("516927E5-8574-497B-B45B-E27EFAB47DE4"); + + // Create node at content root + var createModel = CreateContentCreateModel("Root 2", notCreatedRootKey); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + } + + // Act + var nodeExists = DocumentNavigationQueryService.TryGetParentKey(notCreatedRootKey, out _); + + // Assert + Assert.IsFalse(nodeExists); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTestsBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTestsBase.cs new file mode 100644 index 000000000000..d4325f4674cb --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTestsBase.cs @@ -0,0 +1,51 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public abstract class DocumentNavigationServiceTestsBase : UmbracoIntegrationTest +{ + protected IContentTypeService ContentTypeService => GetRequiredService(); + + // Testing with IContentEditingService as it calls IContentService underneath + protected IContentEditingService ContentEditingService => GetRequiredService(); + + protected IDocumentNavigationQueryService DocumentNavigationQueryService => GetRequiredService(); + + protected IContentType ContentType { get; set; } + + protected IContent Root { get; set; } + + protected IContent Child1 { get; set; } + + protected IContent Grandchild1 { get; set; } + + protected IContent Grandchild2 { get; set; } + + protected IContent Child2 { get; set; } + + protected IContent Grandchild3 { get; set; } + + protected IContent GreatGrandchild1 { get; set; } + + protected IContent Child3 { get; set; } + + protected IContent Grandchild4 { get; set; } + + protected ContentCreateModel CreateContentCreateModel(string name, Guid key, Guid? parentKey = null) + => new() + { + ContentTypeKey = ContentType.Key, + ParentKey = parentKey ?? Constants.System.RootKey, + InvariantName = name, + Key = key, + }; +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Create.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Create.cs new file mode 100644 index 000000000000..1b3b6551a5f6 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Create.cs @@ -0,0 +1,9 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class MediaNavigationServiceTests +{ + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Delete.cs new file mode 100644 index 000000000000..1b3b6551a5f6 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Delete.cs @@ -0,0 +1,9 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class MediaNavigationServiceTests +{ + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.DeleteFromRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.DeleteFromRecycleBin.cs new file mode 100644 index 000000000000..1b3b6551a5f6 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.DeleteFromRecycleBin.cs @@ -0,0 +1,9 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class MediaNavigationServiceTests +{ + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Move.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Move.cs new file mode 100644 index 000000000000..1b3b6551a5f6 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Move.cs @@ -0,0 +1,9 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class MediaNavigationServiceTests +{ + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.MoveToRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.MoveToRecycleBin.cs new file mode 100644 index 000000000000..1b3b6551a5f6 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.MoveToRecycleBin.cs @@ -0,0 +1,9 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class MediaNavigationServiceTests +{ + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Rebuild.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Rebuild.cs new file mode 100644 index 000000000000..0f36e8b8ec85 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Rebuild.cs @@ -0,0 +1,59 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.Navigation; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class MediaNavigationServiceTests +{ + [Test] + public async Task Structure_Can_Rebuild() + { + // Arrange + Guid nodeKey = Album.Key; + + // Capture original built state of MediaNavigationService + MediaNavigationQueryService.TryGetParentKey(nodeKey, out Guid? originalParentKey); + MediaNavigationQueryService.TryGetChildrenKeys(nodeKey, out IEnumerable originalChildrenKeys); + MediaNavigationQueryService.TryGetDescendantsKeys(nodeKey, out IEnumerable originalDescendantsKeys); + MediaNavigationQueryService.TryGetAncestorsKeys(nodeKey, out IEnumerable originalAncestorsKeys); + MediaNavigationQueryService.TryGetSiblingsKeys(nodeKey, out IEnumerable originalSiblingsKeys); + + // Im-memory navigation structure is empty here + var newMediaNavigationService = new MediaNavigationService(GetRequiredService(), GetRequiredService()); + var initialNodeExists = newMediaNavigationService.TryGetParentKey(nodeKey, out _); + + // Act + await newMediaNavigationService.RebuildAsync(); + + // Capture rebuilt state + var nodeExists = newMediaNavigationService.TryGetParentKey(nodeKey, out Guid? parentKeyFromRebuild); + newMediaNavigationService.TryGetChildrenKeys(nodeKey, out IEnumerable childrenKeysFromRebuild); + newMediaNavigationService.TryGetDescendantsKeys(nodeKey, out IEnumerable descendantsKeysFromRebuild); + newMediaNavigationService.TryGetAncestorsKeys(nodeKey, out IEnumerable ancestorsKeysFromRebuild); + newMediaNavigationService.TryGetSiblingsKeys(nodeKey, out IEnumerable siblingsKeysFromRebuild); + + // Assert + Assert.Multiple(() => + { + Assert.IsFalse(initialNodeExists); + + // Verify that the item is present in the navigation structure after a rebuild + Assert.IsTrue(nodeExists); + + // Verify that we have the same items as in the original built state of MediaNavigationService + Assert.AreEqual(originalParentKey, parentKeyFromRebuild); + CollectionAssert.AreEquivalent(originalChildrenKeys, childrenKeysFromRebuild); + CollectionAssert.AreEquivalent(originalDescendantsKeys, descendantsKeysFromRebuild); + CollectionAssert.AreEquivalent(originalAncestorsKeys, ancestorsKeysFromRebuild); + CollectionAssert.AreEquivalent(originalSiblingsKeys, siblingsKeysFromRebuild); + }); + } + + [Test] + // TODO: Test that you can rebuild bin structure as well + public async Task Bin_Structure_Can_Rebuild() + { + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Restore.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Restore.cs new file mode 100644 index 000000000000..1b3b6551a5f6 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Restore.cs @@ -0,0 +1,9 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class MediaNavigationServiceTests +{ + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Update.cs new file mode 100644 index 000000000000..1b3b6551a5f6 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.Update.cs @@ -0,0 +1,9 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class MediaNavigationServiceTests +{ + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.cs new file mode 100644 index 000000000000..ebef7fe04657 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTests.cs @@ -0,0 +1,79 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class MediaNavigationServiceTests : MediaNavigationServiceTestsBase +{ + [SetUp] + public async Task Setup() + { + // Album + // - Image 1 + // - Sub-album 1 + // - Image 2 + // - Image 3 + // - Sub-album 2 + // - Sub-sub-album 1 + // - Image 4 + + // Media Types + FolderMediaType = MediaTypeService.Get(Constants.Conventions.MediaTypes.Folder); + ImageMediaType = MediaTypeService.Get(Constants.Conventions.MediaTypes.Image); + + // Media + var albumModel = CreateMediaCreateModel("Album", new Guid("1CD97C02-8534-4B72-AE9E-AE52EC94CF31"), FolderMediaType.Key); + var albumCreateAttempt = await MediaEditingService.CreateAsync(albumModel, Constants.Security.SuperUserKey); + Album = albumCreateAttempt.Result.Content!; + + var image1Model = CreateMediaCreateModel("Image 1", new Guid("03976EBE-A942-4F24-9885-9186E99AEF7C"), ImageMediaType.Key, Album.Key); + var image1CreateAttempt = await MediaEditingService.CreateAsync(image1Model, Constants.Security.SuperUserKey); + Image1 = image1CreateAttempt.Result.Content!; + + var subAlbum1Model = CreateMediaCreateModel("Sub-album 1", new Guid("139DC977-E50F-4382-9728-B278C4B7AC6A"), FolderMediaType.Key, Album.Key); + var subAlbum1CreateAttempt = await MediaEditingService.CreateAsync(subAlbum1Model, Constants.Security.SuperUserKey); + SubAlbum1 = subAlbum1CreateAttempt.Result.Content!; + + var image2Model = CreateMediaCreateModel("Image 2", new Guid("3E489C32-9315-42DA-95CE-823D154B09C8"), ImageMediaType.Key, SubAlbum1.Key); + var image2CreateAttempt = await MediaEditingService.CreateAsync(image2Model, Constants.Security.SuperUserKey); + Image2 = image2CreateAttempt.Result.Content!; + + var image3Model = CreateMediaCreateModel("Image 3", new Guid("6176BD70-2CD2-4AEE-A045-084C94E4AFF2"), ImageMediaType.Key, SubAlbum1.Key); + var image3CreateAttempt = await MediaEditingService.CreateAsync(image3Model, Constants.Security.SuperUserKey); + Image3 = image3CreateAttempt.Result.Content!; + + var subAlbum2Model = CreateMediaCreateModel("Sub-album 2", new Guid("DBCAFF2F-BFA4-4744-A948-C290C432D564"), FolderMediaType.Key, Album.Key); + var subAlbum2CreateAttempt = await MediaEditingService.CreateAsync(subAlbum2Model, Constants.Security.SuperUserKey); + SubAlbum2 = subAlbum2CreateAttempt.Result.Content!; + + var subSubAlbum1Model = CreateMediaCreateModel("Sub-sub-album 1", new Guid("E0B23D56-9A0E-4FC4-BD42-834B73B4C7AB"), FolderMediaType.Key, SubAlbum2.Key); + var subSubAlbum1CreateAttempt = await MediaEditingService.CreateAsync(subSubAlbum1Model, Constants.Security.SuperUserKey); + SubSubAlbum1 = subSubAlbum1CreateAttempt.Result.Content!; + + var image4Model = CreateMediaCreateModel("Image 4", new Guid("62BCE72F-8C18-420E-BCAC-112B5ECC95FD"), ImageMediaType.Key, SubSubAlbum1.Key); + var image4CreateAttempt = await MediaEditingService.CreateAsync(image4Model, Constants.Security.SuperUserKey); + Image4 = image4CreateAttempt.Result.Content!; + } + + [Test] + public async Task Structure_Does_Not_Update_When_Scope_Is_Not_Completed() + { + // Arrange + Guid notCreatedAlbumKey = new Guid("860EE748-BC7E-4A13-A1D9-C9160B25AD6E"); + + // Create node at media root + var createModel = CreateMediaCreateModel("Album 2", notCreatedAlbumKey, FolderMediaType.Key); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + await MediaEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + } + + // Act + var nodeExists = MediaNavigationQueryService.TryGetParentKey(notCreatedAlbumKey, out _); + + // Assert + Assert.IsFalse(nodeExists); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTestsBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTestsBase.cs new file mode 100644 index 000000000000..6d9e6933208c --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/MediaNavigationServiceTestsBase.cs @@ -0,0 +1,51 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public abstract class MediaNavigationServiceTestsBase : UmbracoIntegrationTest +{ + protected IMediaTypeService MediaTypeService => GetRequiredService(); + + // Testing with IMediaEditingService as it calls IMediaService underneath + protected IMediaEditingService MediaEditingService => GetRequiredService(); + + protected IMediaNavigationQueryService MediaNavigationQueryService => GetRequiredService(); + + protected IMediaType FolderMediaType { get; set; } + + protected IMediaType ImageMediaType { get; set; } + + protected IMedia Album { get; set; } + + protected IMedia Image1 { get; set; } + + protected IMedia SubAlbum1 { get; set; } + + protected IMedia Image2 { get; set; } + + protected IMedia Image3 { get; set; } + + protected IMedia SubAlbum2 { get; set; } + + protected IMedia SubSubAlbum1 { get; set; } + + protected IMedia Image4 { get; set; } + + protected MediaCreateModel CreateMediaCreateModel(string name, Guid key, Guid mediaTypeKey, Guid? parentKey = null) + => new() + { + ContentTypeKey = mediaTypeKey, + ParentKey = parentKey ?? Constants.System.RootKey, + InvariantName = name, + Key = key, + }; +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index b7ab21b0628d..de8274a94f9d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -154,6 +154,57 @@ MediaTypeEditingServiceTests.cs + + DocumentNavigationServiceTests.cs + + + DocumentNavigationServiceTests.cs + + + DocumentNavigationServiceTests.cs + + + DocumentNavigationServiceTests.cs + + + DocumentNavigationServiceTests.cs + + + DocumentNavigationServiceTests.cs + + + DocumentNavigationServiceTests.cs + + + DocumentNavigationServiceTests.cs + + + DocumentNavigationServiceTests.cs + + + MediaNavigationServiceTests.cs + + + MediaNavigationServiceTests.cs + + + MediaNavigationServiceTests.cs + + + MediaNavigationServiceTests.cs + + + MediaNavigationServiceTests.cs + + + MediaNavigationServiceTests.cs + + + MediaNavigationServiceTests.cs + + + MediaNavigationServiceTests.cs + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentNavigationServiceBaseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentNavigationServiceBaseTests.cs new file mode 100644 index 000000000000..7d9a2e8397ff --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentNavigationServiceBaseTests.cs @@ -0,0 +1,996 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.Navigation; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services; + +[TestFixture] +public class ContentNavigationServiceBaseTests +{ + private TestContentNavigationService _navigationService; + + private Guid Root { get; set; } + + private Guid Child1 { get; set; } + + private Guid Grandchild1 { get; set; } + + private Guid Grandchild2 { get; set; } + + private Guid Child2 { get; set; } + + private Guid Grandchild3 { get; set; } + + private Guid GreatGrandchild1 { get; set; } + + private Guid Child3 { get; set; } + + private Guid Grandchild4 { get; set; } + + [SetUp] + public void Setup() + { + // Root + // - Child 1 + // - Grandchild 1 + // - Grandchild 2 + // - Child 2 + // - Grandchild 3 + // - Great-grandchild 1 + // - Child 3 + // - Grandchild 4 + + _navigationService = new TestContentNavigationService( + Mock.Of(), + Mock.Of()); + + Root = new Guid("E48DD82A-7059-418E-9B82-CDD5205796CF"); + _navigationService.Add(Root); + + Child1 = new Guid("C6173927-0C59-4778-825D-D7B9F45D8DDE"); + _navigationService.Add(Child1, Root); + + Grandchild1 = new Guid("E856AC03-C23E-4F63-9AA9-681B42A58573"); + _navigationService.Add(Grandchild1, Child1); + + Grandchild2 = new Guid("A1B1B217-B02F-4307-862C-A5E22DB729EB"); + _navigationService.Add(Grandchild2, Child1); + + Child2 = new Guid("60E0E5C4-084E-4144-A560-7393BEAD2E96"); + _navigationService.Add(Child2, Root); + + Grandchild3 = new Guid("D63C1621-C74A-4106-8587-817DEE5FB732"); + _navigationService.Add(Grandchild3, Child2); + + GreatGrandchild1 = new Guid("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7"); + _navigationService.Add(GreatGrandchild1, Grandchild3); + + Child3 = new Guid("B606E3FF-E070-4D46-8CB9-D31352029FDF"); + _navigationService.Add(Child3, Root); + + Grandchild4 = new Guid("F381906C-223C-4466-80F7-B63B4EE073F8"); + _navigationService.Add(Grandchild4, Child3); + } + + [Test] + public void Cannot_Get_Parent_From_Non_Existing_Content_Key() + { + // Arrange + var nonExistingKey = Guid.NewGuid(); + + // Act + var result = _navigationService.TryGetParentKey(nonExistingKey, out Guid? parentKey); + + // Assert + Assert.Multiple(() => + { + Assert.IsFalse(result); + Assert.IsNull(parentKey); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", null)] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", "E48DD82A-7059-418E-9B82-CDD5205796CF")] // Child 1 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", "C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Grandchild 1 + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB", "C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Grandchild 2 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", "E48DD82A-7059-418E-9B82-CDD5205796CF")] // Child 2 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732", "60E0E5C4-084E-4144-A560-7393BEAD2E96")] // Grandchild 3 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", "D63C1621-C74A-4106-8587-817DEE5FB732")] // Great-grandchild 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", "E48DD82A-7059-418E-9B82-CDD5205796CF")] // Child 3 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8", "B606E3FF-E070-4D46-8CB9-D31352029FDF")] // Grandchild 4 + public void Can_Get_Parent_From_Existing_Content_Key(Guid childKey, Guid? expectedParentKey) + { + // Act + var result = _navigationService.TryGetParentKey(childKey, out Guid? parentKey); + + // Assert + Assert.Multiple(() => + { + Assert.IsTrue(result); + + if (expectedParentKey is null) + { + Assert.IsNull(parentKey); + } + else + { + Assert.IsNotNull(parentKey); + Assert.AreEqual(expectedParentKey, parentKey); + } + }); + } + + [Test] + public void Cannot_Get_Children_From_Non_Existing_Content_Key() + { + // Arrange + var nonExistingKey = Guid.NewGuid(); + + // Act + var result = _navigationService.TryGetChildrenKeys(nonExistingKey, out IEnumerable childrenKeys); + + // Assert + Assert.Multiple(() => + { + Assert.IsFalse(result); + Assert.IsEmpty(childrenKeys); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", 3)] // Root - Child 1, Child 2, Child 3 + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", 2)] // Child 1 - Grandchild 1, Grandchild 2 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", 0)] // Grandchild 1 + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB", 0)] // Grandchild 2 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", 1)] // Child 2 - Grandchild 3 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732", 1)] // Grandchild 3 - Great-grandchild 1 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", 0)] // Great-grandchild 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", 1)] // Child 3 - Grandchild 4 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8", 0)] // Grandchild 4 + public void Can_Get_Children_From_Existing_Content_Key(Guid parentKey, int childrenCount) + { + // Act + var result = _navigationService.TryGetChildrenKeys(parentKey, out IEnumerable childrenKeys); + + // Assert + Assert.Multiple(() => + { + Assert.IsTrue(result); + Assert.AreEqual(childrenCount, childrenKeys.Count()); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", + new[] + { + "C6173927-0C59-4778-825D-D7B9F45D8DDE", "60E0E5C4-084E-4144-A560-7393BEAD2E96", + "B606E3FF-E070-4D46-8CB9-D31352029FDF" + })] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", + new[] { "E856AC03-C23E-4F63-9AA9-681B42A58573", "A1B1B217-B02F-4307-862C-A5E22DB729EB" })] // Child 1 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", new string[0])] // Grandchild 1 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", new[] { "D63C1621-C74A-4106-8587-817DEE5FB732" })] // Child 2 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732", new[] { "56E29EA9-E224-4210-A59F-7C2C5C0C5CC7" })] // Grandchild 3 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", new string[0])] // Great-grandchild 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", new[] { "F381906C-223C-4466-80F7-B63B4EE073F8" })] // Child 3 + public void Can_Get_Children_From_Existing_Content_Key_In_Correct_Order(Guid parentKey, string[] children) + { + // Arrange + Guid[] expectedChildren = Array.ConvertAll(children, Guid.Parse); + + // Act + _navigationService.TryGetChildrenKeys(parentKey, out IEnumerable childrenKeys); + List childrenList = childrenKeys.ToList(); + + // Assert + for (var i = 0; i < expectedChildren.Length; i++) + { + Assert.AreEqual(expectedChildren[i], childrenList.ElementAt(i)); + } + } + + [Test] + public void Cannot_Get_Descendants_From_Non_Existing_Content_Key() + { + // Arrange + var nonExistingKey = Guid.NewGuid(); + + // Act + var result = _navigationService.TryGetDescendantsKeys(nonExistingKey, out IEnumerable descendantsKeys); + + // Assert + Assert.Multiple(() => + { + Assert.IsFalse(result); + Assert.IsEmpty(descendantsKeys); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", + 8)] // Root - Child 1, Grandchild 1, Grandchild 2, Child 2, Grandchild 3, Great-grandchild 1, Child 3, Grandchild 4 + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", 2)] // Child 1 - Grandchild 1, Grandchild 2 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", 0)] // Grandchild 1 + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB", 0)] // Grandchild 2 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", 2)] // Child 2 - Grandchild 3, Great-grandchild 1 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732", 1)] // Grandchild 3 - Great-grandchild 1 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", 0)] // Great-grandchild 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", 1)] // Child 3 - Grandchild 4 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8", 0)] // Grandchild 4 + public void Can_Get_Descendants_From_Existing_Content_Key(Guid parentKey, int descendantsCount) + { + // Act + var result = _navigationService.TryGetDescendantsKeys(parentKey, out IEnumerable descendantsKeys); + + // Assert + Assert.Multiple(() => + { + Assert.IsTrue(result); + Assert.AreEqual(descendantsCount, descendantsKeys.Count()); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", + new[] + { + "C6173927-0C59-4778-825D-D7B9F45D8DDE", "E856AC03-C23E-4F63-9AA9-681B42A58573", + "A1B1B217-B02F-4307-862C-A5E22DB729EB", "60E0E5C4-084E-4144-A560-7393BEAD2E96", + "D63C1621-C74A-4106-8587-817DEE5FB732", "56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", + "B606E3FF-E070-4D46-8CB9-D31352029FDF", "F381906C-223C-4466-80F7-B63B4EE073F8" + })] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", + new[] { "E856AC03-C23E-4F63-9AA9-681B42A58573", "A1B1B217-B02F-4307-862C-A5E22DB729EB" })] // Child 1 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", new string[0])] // Grandchild 1 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", + new[] { "D63C1621-C74A-4106-8587-817DEE5FB732", "56E29EA9-E224-4210-A59F-7C2C5C0C5CC7" })] // Child 2 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732", new[] { "56E29EA9-E224-4210-A59F-7C2C5C0C5CC7" })] // Grandchild 3 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", new string[0])] // Great-grandchild 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", new[] { "F381906C-223C-4466-80F7-B63B4EE073F8" })] // Child 3 + public void Can_Get_Descendants_From_Existing_Content_Key_In_Correct_Order(Guid parentKey, string[] descendants) + { + // Arrange + Guid[] expectedDescendants = Array.ConvertAll(descendants, Guid.Parse); + + // Act + _navigationService.TryGetDescendantsKeys(parentKey, out IEnumerable descendantsKeys); + List descendantsList = descendantsKeys.ToList(); + + // Assert + for (var i = 0; i < expectedDescendants.Length; i++) + { + Assert.AreEqual(expectedDescendants[i], descendantsList.ElementAt(i)); + } + } + + [Test] + public void Cannot_Get_Ancestors_From_Non_Existing_Content_Key() + { + // Arrange + var nonExistingKey = Guid.NewGuid(); + + // Act + var result = _navigationService.TryGetAncestorsKeys(nonExistingKey, out IEnumerable ancestorsKeys); + + // Assert + Assert.Multiple(() => + { + Assert.IsFalse(result); + Assert.IsEmpty(ancestorsKeys); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", 0)] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", 1)] // Child 1 - Root + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", 2)] // Grandchild 1 - Child 1, Root + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB", 2)] // Grandchild 2 - Child 1, Root + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", 1)] // Child 2 - Root + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732", 2)] // Grandchild 3 - Child 2, Root + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", 3)] // Great-grandchild 1 - Grandchild 3, Child 2, Root + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", 1)] // Child 3 - Root + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8", 2)] // Grandchild 4 - Child 3, Root + public void Can_Get_Ancestors_From_Existing_Content_Key(Guid childKey, int ancestorsCount) + { + // Act + var result = _navigationService.TryGetAncestorsKeys(childKey, out IEnumerable ancestorsKeys); + + // Assert + Assert.Multiple(() => + { + Assert.IsTrue(result); + Assert.AreEqual(ancestorsCount, ancestorsKeys.Count()); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", new string[0])] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", new[] { "E48DD82A-7059-418E-9B82-CDD5205796CF" })] // Child 1 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", + new[] { "C6173927-0C59-4778-825D-D7B9F45D8DDE", "E48DD82A-7059-418E-9B82-CDD5205796CF" })] // Grandchild 1 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", + new[] + { + "D63C1621-C74A-4106-8587-817DEE5FB732", "60E0E5C4-084E-4144-A560-7393BEAD2E96", + "E48DD82A-7059-418E-9B82-CDD5205796CF" + })] // Great-grandchild 1 + public void Can_Get_Ancestors_From_Existing_Content_Key_In_Correct_Order(Guid childKey, string[] ancestors) + { + // Arrange + Guid[] expectedAncestors = Array.ConvertAll(ancestors, Guid.Parse); + + // Act + _navigationService.TryGetAncestorsKeys(childKey, out IEnumerable ancestorsKeys); + List ancestorsList = ancestorsKeys.ToList(); + + // Assert + for (var i = 0; i < expectedAncestors.Length; i++) + { + Assert.AreEqual(expectedAncestors[i], ancestorsList.ElementAt(i)); + } + } + + [Test] + public void Cannot_Get_Siblings_Of_Non_Existing_Content_Key() + { + // Arrange + var nonExistingKey = Guid.NewGuid(); + + // Act + var result = _navigationService.TryGetSiblingsKeys(nonExistingKey, out IEnumerable siblingsKeys); + + // Assert + Assert.Multiple(() => + { + Assert.IsFalse(result); + Assert.IsEmpty(siblingsKeys); + }); + } + + [Test] + public void Can_Get_Siblings_Of_Existing_Content_Key_Without_Self() + { + // Arrange + Guid nodeKey = Child1; + + // Act + var result = _navigationService.TryGetSiblingsKeys(nodeKey, out IEnumerable siblingsKeys); + List siblingsList = siblingsKeys.ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.IsTrue(result); + Assert.IsNotEmpty(siblingsList); + Assert.IsFalse(siblingsList.Contains(nodeKey)); + }); + } + + [Test] + public void Can_Get_Siblings_Of_Existing_Content_Key_At_Content_Root() + { + // Arrange + Guid anotherRoot = new Guid("716380B9-DAA9-4930-A461-95EF39EBAB41"); + _navigationService.Add(anotherRoot); + + // Act + _navigationService.TryGetSiblingsKeys(anotherRoot, out IEnumerable siblingsKeys); + List siblingsList = siblingsKeys.ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.IsNotEmpty(siblingsList); + Assert.AreEqual(1, siblingsList.Count); + Assert.AreEqual(Root, siblingsList.First()); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", 0)] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", 2)] // Child 1 - Child 2, Child 3 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", 1)] // Grandchild 1 - Grandchild 2 + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB", 1)] // Grandchild 2 - Grandchild 1 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", 2)] // Child 2 - Child 1, Child 3 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732", 0)] // Grandchild 3 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", 0)] // Great-grandchild 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", 2)] // Child 3 - Child 1, Child 2 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8", 0)] // Grandchild 4 + public void Can_Get_Siblings_Of_Existing_Content_Key(Guid key, int siblingsCount) + { + // Act + var result = _navigationService.TryGetSiblingsKeys(key, out IEnumerable siblingsKeys); + + // Assert + Assert.Multiple(() => + { + Assert.IsTrue(result); + Assert.AreEqual(siblingsCount, siblingsKeys.Count()); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", new string[0])] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", new[] { "60E0E5C4-084E-4144-A560-7393BEAD2E96", "B606E3FF-E070-4D46-8CB9-D31352029FDF" })] // Child 1 - Child 2, Child 3 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", new[] { "A1B1B217-B02F-4307-862C-A5E22DB729EB" })] // Grandchild 1 - Grandchild 2 + public void Can_Get_Siblings_Of_Existing_Content_Key_In_Correct_Order(Guid childKey, string[] siblings) + { + // Arrange + Guid[] expectedSiblings = Array.ConvertAll(siblings, Guid.Parse); + + // Act + _navigationService.TryGetSiblingsKeys(childKey, out IEnumerable siblingsKeys); + List siblingsList = siblingsKeys.ToList(); + + // Assert + for (var i = 0; i < expectedSiblings.Length; i++) + { + Assert.AreEqual(expectedSiblings[i], siblingsList.ElementAt(i)); + } + } + + [Test] + public void Cannot_Move_Node_To_Bin_When_Non_Existing_Content_Key() + { + // Arrange + var nonExistingKey = Guid.NewGuid(); + + // Act + var result = _navigationService.MoveToBin(nonExistingKey); + + // Assert + Assert.IsFalse(result); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF")] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Child 1 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573")] // Grandchild 1 + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB")] // Grandchild 2 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96")] // Child 2 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732")] // Grandchild 3 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7")] // Great-grandchild 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF")] // Child 3 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8")] // Grandchild 4 + public void Can_Move_Node_To_Bin(Guid keyOfNodeToRemove) + { + // Act + var result = _navigationService.MoveToBin(keyOfNodeToRemove); + + // Assert + Assert.IsTrue(result); + + var nodeExists = _navigationService.TryGetParentKey(keyOfNodeToRemove, out Guid? parentKey); + + Assert.Multiple(() => + { + Assert.IsFalse(nodeExists); + Assert.IsNull(parentKey); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF")] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Child 1 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8")] // Grandchild 4 + public void Moving_Node_To_Bin_Removes_Its_Descendants_As_Well(Guid keyOfNodeToRemove) + { + // Arrange + _navigationService.TryGetDescendantsKeys(keyOfNodeToRemove, out IEnumerable initialDescendantsKeys); + + // Act + var result = _navigationService.MoveToBin(keyOfNodeToRemove); + + // Assert + Assert.IsTrue(result); + + _navigationService.TryGetDescendantsKeys(keyOfNodeToRemove, out IEnumerable descendantsKeys); + + Assert.Multiple(() => + { + Assert.AreEqual(0, descendantsKeys.Count()); + + foreach (Guid descendant in initialDescendantsKeys) + { + var descendantExists = _navigationService.TryGetParentKey(descendant, out _); + Assert.IsFalse(descendantExists); + } + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF")] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Child 1 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573")] // Grandchild 1 + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB")] // Grandchild 2 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96")] // Child 2 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732")] // Grandchild 3 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7")] // Great-grandchild 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF")] // Child 3 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8")] // Grandchild 4 + public void Moving_Node_To_Bin_Adds_It_To_Recycle_Bin_Root(Guid keyOfNodeToRemove) + { + // Act + _navigationService.MoveToBin(keyOfNodeToRemove); + + // Assert + var nodeExistsInBin = _navigationService.TryGetParentKeyInBin(keyOfNodeToRemove, out Guid? parentKeyInBin); + + Assert.Multiple(() => + { + Assert.IsTrue(nodeExistsInBin); + Assert.IsNull(parentKeyInBin); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF")] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Child 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF")] // Child 3 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7")] // Great-grandchild 1 + public void Moving_Node_To_Bin_Adds_Its_Descendants_To_Recycle_Bin_As_Well(Guid keyOfNodeToRemove) + { + // Arrange + _navigationService.TryGetDescendantsKeys(keyOfNodeToRemove, out IEnumerable initialDescendantsKeys); + List initialDescendantsList = initialDescendantsKeys.ToList(); + + // Act + _navigationService.MoveToBin(keyOfNodeToRemove); + + // Assert + var nodeExistsInBin = _navigationService.TryGetDescendantsKeysInBin(keyOfNodeToRemove, out IEnumerable descendantsKeysInBin); + + Assert.Multiple(() => + { + Assert.IsTrue(nodeExistsInBin); + CollectionAssert.AreEqual(initialDescendantsList, descendantsKeysInBin); + + foreach (Guid descendant in initialDescendantsList) + { + _navigationService.TryGetParentKeyInBin(descendant, out Guid? parentKeyInBin); + Assert.IsNotNull(parentKeyInBin); // The descendant kept its initial parent + } + }); + } + + [Test] + public void Cannot_Add_Node_When_Parent_Does_Not_Exist() + { + // Arrange + var newNodeKey = Guid.NewGuid(); + var nonExistentParentKey = Guid.NewGuid(); + + // Act + var result = _navigationService.Add(newNodeKey, nonExistentParentKey); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void Cannot_Add_When_Node_With_The_Same_Key_Already_Exists() + { + // Act + var result = _navigationService.Add(Child1); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void Can_Add_Node_To_Content_Root() + { + // Arrange + var newNodeKey = Guid.NewGuid(); + + // Act + var result = _navigationService.Add(newNodeKey); // parentKey is null + + // Assert + Assert.IsTrue(result); + + var nodeExists = _navigationService.TryGetParentKey(newNodeKey, out Guid? parentKey); + + Assert.Multiple(() => + { + Assert.IsTrue(nodeExists); + Assert.IsNull(parentKey); + }); + } + + [Test] + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Child 1 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8")] // Grandchild 4 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7")] // Great-grandchild 1 + public void Can_Add_Node_To_Parent(Guid parentKey) + { + // Arrange + var newNodeKey = Guid.NewGuid(); + _navigationService.TryGetChildrenKeys(parentKey, out IEnumerable currentChildrenKeys); + var currentChildrenCount = currentChildrenKeys.Count(); + + // Act + var result = _navigationService.Add(newNodeKey, parentKey); + + // Assert + Assert.IsTrue(result); + + _navigationService.TryGetChildrenKeys(parentKey, out IEnumerable newChildrenKeys); + var newChildrenList = newChildrenKeys.ToList(); + + Assert.Multiple(() => + { + Assert.AreEqual(currentChildrenCount + 1, newChildrenList.Count); + Assert.IsTrue(newChildrenList.Any(childKey => childKey == newNodeKey)); + }); + } + + [Test] + public void Cannot_Move_Node_When_Target_Parent_Does_Not_Exist() + { + // Arrange + Guid nodeToMove = Child1; + var nonExistentTargetParentKey = Guid.NewGuid(); + + // Act + var result = _navigationService.Move(nodeToMove, nonExistentTargetParentKey); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void Cannot_Move_Node_That_Does_Not_Exist() + { + // Arrange + var nonExistentNodeKey = Guid.NewGuid(); + Guid targetParentKey = Child1; + + // Act + var result = _navigationService.Move(nonExistentNodeKey, targetParentKey); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void Cannot_Move_Node_To_Itself() + { + // Arrange + Guid nodeToMove = Child1; + + // Act + var result = _navigationService.Move(nodeToMove, nodeToMove); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void Can_Move_Node_To_Content_Root() + { + // Arrange + Guid nodeToMove = Child1; + + // Act + var result = _navigationService.Move(nodeToMove); // parentKey is null + + // Assert + Assert.IsTrue(result); + + // Verify the node's new parent is null (moved to content root) + _navigationService.TryGetParentKey(nodeToMove, out Guid? newParentKey); + + Assert.IsNull(newParentKey); + } + + [Test] + public void Can_Move_Node_To_Existing_Target_Parent() + { + // Arrange + Guid nodeToMove = Grandchild4; + Guid targetParentKey = Child1; + + // Act + var result = _navigationService.Move(nodeToMove, targetParentKey); + + // Assert + Assert.IsTrue(result); + + // Verify the node's new parent is updated + _navigationService.TryGetParentKey(nodeToMove, out Guid? newParentKey); + + Assert.Multiple(() => + { + Assert.IsNotNull(newParentKey); + Assert.AreEqual(targetParentKey, newParentKey); + }); + } + + [Test] + public void Moved_Node_Has_Updated_Parent() + { + // Arrange + Guid nodeToMove = Grandchild1; + Guid targetParentKey = Child2; + _navigationService.TryGetParentKey(nodeToMove, out Guid? oldParentKey); + + // Act + var result = _navigationService.Move(nodeToMove, targetParentKey); + + // Assert + Assert.IsTrue(result); + + // Verify the node's new parent is updated + _navigationService.TryGetParentKey(nodeToMove, out Guid? newParentKey); + + Assert.Multiple(() => + { + Assert.IsNotNull(newParentKey); + Assert.AreEqual(targetParentKey, newParentKey); + + // Verify that the new parent is different from the old one + Assert.AreNotEqual(oldParentKey, targetParentKey); + }); + } + + [Test] + public void Moved_Node_Is_Removed_From_Its_Current_Parent() + { + // Arrange + Guid nodeToMove = Grandchild3; + Guid targetParentKey = Child3; + _navigationService.TryGetParentKey(nodeToMove, out Guid? oldParentKey); + _navigationService.TryGetChildrenKeys(oldParentKey!.Value, out IEnumerable oldParentChildrenKeys); + var oldParentChildrenCount = oldParentChildrenKeys.Count(); + + // Act + var result = _navigationService.Move(nodeToMove, targetParentKey); + + // Assert + Assert.IsTrue(result); + + // Verify the node is removed from its old parent's children list + _navigationService.TryGetChildrenKeys(oldParentKey.Value, out IEnumerable childrenKeys); + List childrenList = childrenKeys.ToList(); + + Assert.Multiple(() => + { + CollectionAssert.DoesNotContain(childrenList, nodeToMove); + Assert.AreEqual(oldParentChildrenCount - 1, childrenList.Count); + }); + } + + [Test] + public void Moved_Node_Is_Added_To_Its_New_Parent() + { + // Arrange + Guid nodeToMove = Grandchild2; + Guid targetParentKey = Child2; + _navigationService.TryGetChildrenKeys(targetParentKey, out IEnumerable targetParentChildrenKeys); + var targetParentChildrenCount = targetParentChildrenKeys.Count(); + + // Act + var result = _navigationService.Move(nodeToMove, targetParentKey); + + // Assert + Assert.IsTrue(result); + + // Verify the node is added to its new parent's children list + _navigationService.TryGetChildrenKeys(targetParentKey, out IEnumerable childrenKeys); + List childrenList = childrenKeys.ToList(); + + Assert.Multiple(() => + { + CollectionAssert.Contains(childrenList, nodeToMove); + Assert.AreEqual(targetParentChildrenCount + 1, childrenList.Count); + }); + } + + [Test] + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", "60E0E5C4-084E-4144-A560-7393BEAD2E96", 0)] // Grandchild 1 to Child 2 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", null, 1)] // Child 3 to content root + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", "C6173927-0C59-4778-825D-D7B9F45D8DDE", 2)] // Child 2 to Child 1 + public void Moved_Node_Has_The_Same_Amount_Of_Descendants(Guid nodeToMove, Guid? targetParentKey, int initialDescendantsCount) + { + // Act + var result = _navigationService.Move(nodeToMove, targetParentKey); + + // Assert + Assert.IsTrue(result); + + // Verify that the number of descendants remain the same after moving the node + _navigationService.TryGetDescendantsKeys(nodeToMove, out IEnumerable descendantsKeys); + var descendantsCountAfterMove = descendantsKeys.Count(); + + Assert.AreEqual(initialDescendantsCount, descendantsCountAfterMove); + } + + [Test] + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", "A1B1B217-B02F-4307-862C-A5E22DB729EB", 0)] // Child 3 to Grandchild 2 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", "B606E3FF-E070-4D46-8CB9-D31352029FDF", 1)] // Child 2 to Child 3 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", "60E0E5C4-084E-4144-A560-7393BEAD2E96", 2)] // Grandchild 1 to Child 2 + public void Number_Of_Target_Parent_Descendants_Updates_When_Moving_Node_With_Descendants(Guid nodeToMove, Guid targetParentKey, int initialDescendantsCountOfTargetParent) + { + // Arrange + // Get the number of descendants of the node to move + _navigationService.TryGetDescendantsKeys(nodeToMove, out IEnumerable descendantsKeys); + var descendantsCountOfNodeToMove = descendantsKeys.Count(); + + // Act + var result = _navigationService.Move(nodeToMove, targetParentKey); + + // Assert + Assert.IsTrue(result); + + _navigationService.TryGetDescendantsKeys(targetParentKey, out IEnumerable updatedTargetParentDescendantsKeys); + var updatedDescendantsCountOfTargetParent = updatedTargetParentDescendantsKeys.Count(); + + // Verify the number of descendants of the target parent has increased by the number of descendants of the moved node plus the node itself + Assert.AreEqual(initialDescendantsCountOfTargetParent + descendantsCountOfNodeToMove + 1, updatedDescendantsCountOfTargetParent); + } + + [Test] + public void Cannot_Restore_Node_When_Target_Parent_Does_Not_Exist() + { + // Arrange + Guid nodeToRestore = Grandchild1; + var nonExistentTargetParentKey = Guid.NewGuid(); + _navigationService.MoveToBin(nodeToRestore); + + // Act + var result = _navigationService.RestoreFromBin(nodeToRestore, nonExistentTargetParentKey); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void Cannot_Restore_Node_That_Does_Not_Exist() + { + // Arrange + Guid notDeletedNodeKey = Grandchild4; + Guid targetParentKey = Child3; + + // Act + var result = _navigationService.RestoreFromBin(notDeletedNodeKey, targetParentKey); + + // Assert + Assert.IsFalse(result); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", null)] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", "E48DD82A-7059-418E-9B82-CDD5205796CF")] // Child 1 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", "C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Grandchild 1 + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB", "C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Grandchild 2 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", "E48DD82A-7059-418E-9B82-CDD5205796CF")] // Child 2 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732", "60E0E5C4-084E-4144-A560-7393BEAD2E96")] // Grandchild 3 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", "D63C1621-C74A-4106-8587-817DEE5FB732")] // Great-grandchild 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", "E48DD82A-7059-418E-9B82-CDD5205796CF")] // Child 3 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8", "B606E3FF-E070-4D46-8CB9-D31352029FDF")] // Grandchild 4 + public void Can_Restore_Node_To_Existing_Target_Parent(Guid nodeToRestore, Guid? targetParentKey) + { + // Arrange + _navigationService.MoveToBin(nodeToRestore); + + // Act + var result = _navigationService.RestoreFromBin(nodeToRestore, targetParentKey); + + // Assert + Assert.IsTrue(result); + + // Verify the node's new parent is updated + _navigationService.TryGetParentKey(nodeToRestore, out Guid? parentKeyAfterRestore); + + Assert.Multiple(() => + { + if (targetParentKey is null) + { + Assert.IsNull(parentKeyAfterRestore); + } + else + { + Assert.IsNotNull(parentKeyAfterRestore); + } + + Assert.AreEqual(targetParentKey, parentKeyAfterRestore); + }); + } + + [Test] + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", "C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Grandchild 1 to Child 1 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", "60E0E5C4-084E-4144-A560-7393BEAD2E96")] // Great-grandchild 1 to Child 2 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", "E48DD82A-7059-418E-9B82-CDD5205796CF")] // Child 3 to Root + public void Restored_Node_Is_Added_To_Its_Target_Parent(Guid nodeToRestore, Guid targetParentKey) + { + // Arrange + _navigationService.MoveToBin(nodeToRestore); + _navigationService.TryGetChildrenKeys(targetParentKey, out IEnumerable targetParentChildrenKeys); + var targetParentChildrenCount = targetParentChildrenKeys.Count(); + + // Act + var result = _navigationService.RestoreFromBin(nodeToRestore, targetParentKey); + + // Assert + Assert.IsTrue(result); + + // Verify the node is added to its target parent's children list + _navigationService.TryGetChildrenKeys(targetParentKey, out IEnumerable childrenKeys); + List childrenList = childrenKeys.ToList(); + + Assert.Multiple(() => + { + CollectionAssert.Contains(childrenList, nodeToRestore); + Assert.AreEqual(targetParentChildrenCount + 1, childrenList.Count); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF")] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE")] // Child 1 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732")] // Grandchild 3 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8")] // Grandchild 4 + public void Restored_Node_And_Its_Descendants_Are_Removed_From_Bin(Guid nodeToRestore) + { + // Arrange + _navigationService.MoveToBin(nodeToRestore); + _navigationService.TryGetDescendantsKeysInBin(nodeToRestore, out IEnumerable descendantsKeysInBin); + + // Act + _navigationService.RestoreFromBin(nodeToRestore); + + // Assert + var nodeExistsInBin = _navigationService.TryGetParentKeyInBin(nodeToRestore, out Guid? parentKeyInBinAfterRestore); + + Assert.Multiple(() => + { + Assert.IsFalse(nodeExistsInBin); + Assert.IsNull(parentKeyInBinAfterRestore); + + foreach (Guid descendant in descendantsKeysInBin) + { + var descendantExistsInBin = _navigationService.TryGetParentKeyInBin(descendant, out _); + Assert.IsFalse(descendantExistsInBin); + } + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", null, 8)] // Root to content root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", "56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", 2)] // Child 1 to Great-grandchild 1 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8", "60E0E5C4-084E-4144-A560-7393BEAD2E96", 0)] // Grandchild 4 to Child 2 + public void Restored_Node_Has_The_Same_Amount_Of_Descendants(Guid nodeToRestore, Guid? targetParentKey, int initialDescendantsCount) + { + // Arrange + _navigationService.MoveToBin(nodeToRestore); + + // Act + _navigationService.RestoreFromBin(nodeToRestore, targetParentKey); + + // Assert + // Verify that the number of descendants remain the same after restoring the node + _navigationService.TryGetDescendantsKeys(nodeToRestore, out IEnumerable restoredDescendantsKeys); + var descendantsCountAfterRestore = restoredDescendantsKeys.Count(); + + Assert.AreEqual(initialDescendantsCount, descendantsCountAfterRestore); + } +} + +internal class TestContentNavigationService : ContentNavigationServiceBase +{ + public TestContentNavigationService(ICoreScopeProvider coreScopeProvider, INavigationRepository navigationRepository) + : base(coreScopeProvider, navigationRepository) + { + } + + // Not needed for testing here + public override Task RebuildAsync() => Task.CompletedTask; + + // Not needed for testing here + public override Task RebuildBinAsync() => Task.CompletedTask; +}