diff --git a/src/Umbraco.Cms.Api.Management/CLAUDE.md b/src/Umbraco.Cms.Api.Management/CLAUDE.md index f9908cc6f65c..72a78f32df72 100644 --- a/src/Umbraco.Cms.Api.Management/CLAUDE.md +++ b/src/Umbraco.Cms.Api.Management/CLAUDE.md @@ -26,7 +26,8 @@ RESTful API for Umbraco backoffice operations. Manages content, media, users, an - **Validation**: FluentValidation via base controllers - **Serialization**: System.Text.Json with custom converters - **Mapping**: Manual presentation factories (no AutoMapper) -- **Patching**: JsonPatch.Net for PATCH operations +- **Patching**: Custom patch engine for PATCH operations (Umbraco.Cms.Api.Management.Patching) + - ⚠️ Legacy JsonPatch.Net support (IJsonPatchService) still available but **obsolete** - scheduled for removal in v19 - **Real-time**: SignalR hubs (`BackofficeHub`, `ServerEventHub`) - **DI**: Microsoft.Extensions.DependencyInjection via `ManagementApiComposer` @@ -70,7 +71,6 @@ src/Umbraco.Cms.Api.Management/ - **Umbraco.Cms.Api.Common** - Shared API infrastructure (base controllers, OpenAPI config) - **Umbraco.Infrastructure** - Service implementations, data access - **Umbraco.PublishedCache.HybridCache** - Published content queries -- **JsonPatch.Net** - JSON Patch (RFC 6902) support - **Swashbuckle.AspNetCore** - OpenAPI generation ### Design Patterns diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs new file mode 100644 index 000000000000..fd525d4f719f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -0,0 +1,75 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.OperationStatus; +using Umbraco.Cms.Api.Management.Patchers; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +[ApiVersion("1.0")] +public class PatchDocumentController : PatchDocumentControllerBase +{ + private readonly IContentEditingService _contentEditingService; + private readonly IDocumentPatcher _documentPatcher; + private readonly IDocumentEditingPresentationFactory _presentationFactory; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public PatchDocumentController( + IAuthorizationService authorizationService, + IContentEditingService contentEditingService, + IDocumentPatcher documentPatcher, + IDocumentEditingPresentationFactory presentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService) + { + _contentEditingService = contentEditingService; + _documentPatcher = documentPatcher; + _presentationFactory = presentationFactory; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPatch("{id:guid}/patch")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + [EndpointSummary("Make partial updates to a document. For more information, see the documentation at https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-guide or https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-spec")] + [Consumes("application/json-patch+json")] + public async Task Patch( + CancellationToken cancellationToken, + Guid id, + PatchDocumentRequestModel requestModel) + => await HandleRequest(id, async () => + { + ContentPatchModel patchModel = _presentationFactory.MapPatchModel(requestModel); + + // Apply PATCH operations to create an update request model + Attempt patchResult = + await _documentPatcher.ApplyPatchAsync(id, patchModel); + + if (patchResult.Success is false) + { + return ContentPatchingOperationStatusResult(patchResult.Status); + } + + ContentUpdateModel contentUpdateModel = _presentationFactory.MapUpdateModel(patchResult.Result); + + // Use the standard update method to save the patched content + Attempt updateResult = + await _contentEditingService.UpdateAsync(id, contentUpdateModel, CurrentUserKey(_backOfficeSecurityAccessor)); + + return updateResult.Success + ? Ok() + : ContentEditingOperationStatusResult(updateResult.Status); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs new file mode 100644 index 000000000000..342157e9080e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public abstract class PatchDocumentControllerBase : UpdateDocumentControllerBase +{ + protected PatchDocumentControllerBase(IAuthorizationService authorizationService) + : base(authorizationService) + { + } + + /// + /// Maps ContentPatchingOperationStatus to appropriate HTTP responses for PATCH operations. + /// + protected IActionResult ContentPatchingOperationStatusResult(ContentPatchingOperationStatus status) + => OperationStatusResult(status, problemDetailsBuilder => status switch + { + ContentPatchingOperationStatus.InvalidOperation => BadRequest(problemDetailsBuilder + .WithTitle("Invalid operation") + .WithDetail("One or more PATCH operations were invalid. Check operation structure, path syntax, and operation types.") + .Build()), + ContentPatchingOperationStatus.NotFound => NotFound(problemDetailsBuilder + .WithTitle("The document could not be found") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder + .WithTitle("Unknown error") + .WithDetail("An unexpected error occurred during the PATCH operation.") + .Build()), + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs index b3e6d99976e3..5210c9819b11 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs @@ -20,6 +20,9 @@ protected UpdateDocumentControllerBase(IAuthorizationService authorizationServic => _authorizationService = authorizationService; protected async Task HandleRequest(Guid id, UpdateDocumentRequestModel requestModel, Func> authorizedHandler) + => await HandleRequest(id, authorizedHandler); + + protected async Task HandleRequest(Guid id, Func> authorizedHandler) { // We intentionally don't pass in cultures here. // This is to support the client sending values for all cultures even if the user doesn't have access to the language. diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs index 864f450ad1b0..a9ee4d0b6c14 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Mapping.Document; +using Umbraco.Cms.Api.Management.Patchers; using Umbraco.Cms.Api.Management.Services.PermissionFilter; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; @@ -21,6 +22,7 @@ internal static IUmbracoBuilder AddDocuments(this IUmbracoBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.WithCollectionBuilder() .Add() diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs index 89bba5efefc3..74566d960538 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs @@ -5,14 +5,22 @@ namespace Umbraco.Cms.Api.Management.DependencyInjection; /// -/// Provides extension methods for configuring JSON serialization in the Umbraco CMS Management API. +/// Extension methods for registering JSON-related services. /// +[Obsolete("JsonPatch.Net dependency and IJsonPatchService are being removed. Use the custom patch engine (IDocumentPatcher) instead. Scheduled for removal in Umbraco 19.")] public static class JsonBuilderExtensions { + /// + /// Adds JSON-related services to the Umbraco builder. + /// + /// The Umbraco builder. + /// The Umbraco builder. internal static IUmbracoBuilder AddJson(this IUmbracoBuilder builder) { +#pragma warning disable CS0618 // Type or member is obsolete builder.Services .AddTransient(); +#pragma warning restore CS0618 // Type or member is obsolete return builder; } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs index 847a0a5a27a5..3b1a45888a06 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs @@ -31,10 +31,14 @@ public static IUmbracoBuilder AddUmbracoManagementApi(this IUmbracoBuilder build builder.Services.AddUnique(); builder.AddUmbracoApiOpenApiUI(); +#pragma warning disable CS0618 // Type or member is obsolete if (!services.Any(x => !x.IsKeyedService && x.ImplementationType == typeof(JsonPatchService))) +#pragma warning restore CS0618 // Type or member is obsolete { +#pragma warning disable CS0618 // Type or member is obsolete ModelsBuilderBuilderExtensions.AddModelsBuilder(builder) .AddJson() +#pragma warning restore CS0618 // Type or member is obsolete .AddInstaller() .AddUpgrader() .AddSearchManagement() diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index bddfd6852334..39463cd70b8a 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -1,15 +1,41 @@ +using Umbraco.Cms.Api.Management.Patching; +using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Factories; +/// +/// Factory for creating and mapping presentation models used in document editing operations. +/// internal sealed class DocumentEditingPresentationFactory : ContentEditingPresentationFactory, IDocumentEditingPresentationFactory { + private readonly PropertyEditorCollection _propertyEditorCollection; + private readonly IDataValueEditorFactory _dataValueEditorFactory; + private readonly ITemplateService _templateService; + /// - /// Maps a to a . + /// Initializes a new instance of the class. /// - /// The request model containing data to create the content. - /// A representing the content to be created. + /// The collection of available property editors. + /// The factory for creating data value editors. + /// The service for retrieving templates. + public DocumentEditingPresentationFactory( + PropertyEditorCollection propertyEditorCollection, + IDataValueEditorFactory dataValueEditorFactory, + ITemplateService templateService) + { + _propertyEditorCollection = propertyEditorCollection; + _dataValueEditorFactory = dataValueEditorFactory; + _templateService = templateService; + } + + /// public ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel) { ContentCreateModel model = MapContentEditingModel(requestModel); @@ -21,19 +47,46 @@ public ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel return model; } - /// - /// Maps the given to a . - /// - /// The update document request model to map from. - /// The mapped instance. + /// public ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel) => MapUpdateContentModel(requestModel); - /// - /// Maps a to a , copying relevant validation data. - /// - /// The request model containing the document update validation data. - /// A populated with validation data from the request model. + /// + public async Task CreateUpdateRequestModelAsync(IContent content) + { + DocumentValueModel[] values = MapValuesToRequestModel(content.Properties); + + DocumentVariantRequestModel[] variants = MapVariantsToRequestModel(content); + + Guid? templateKey = content.TemplateId.HasValue + ? (await _templateService.GetAsync(content.TemplateId.Value))?.Key + : null; + + return new UpdateDocumentRequestModel + { + Values = values, + Variants = variants, + Template = templateKey.HasValue ? new ReferenceByIdModel { Id = templateKey.Value } : null, + }; + } + + /// + public ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) + { + PatchOperationModel[] operations = requestModel.Operations.Select(op => new PatchOperationModel + { + Op = MapOperationType(op.Op), + Path = op.Path, + Value = op.Value, + }).ToArray(); + + return new ContentPatchModel + { + Operations = operations, + }; + } + + /// public ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel) { ValidateContentUpdateModel model = MapUpdateContentModel(requestModel); @@ -42,6 +95,61 @@ public ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentR return model; } + private DocumentValueModel[] MapValuesToRequestModel(IPropertyCollection properties) + { + Dictionary missingPropertyEditors = []; + return properties + .SelectMany(property => property + .Values + .Select(propertyValue => + { + IDataEditor? propertyEditor = _propertyEditorCollection[property.PropertyType.PropertyEditorAlias]; + if (propertyEditor is null && !missingPropertyEditors.TryGetValue(property.PropertyType.PropertyEditorAlias, out propertyEditor)) + { + // Cache missing property editors to avoid creating multiple instances + propertyEditor = new MissingPropertyEditor(property.PropertyType.PropertyEditorAlias, _dataValueEditorFactory); + missingPropertyEditors[property.PropertyType.PropertyEditorAlias] = propertyEditor; + } + + return new DocumentValueModel + { + Culture = propertyValue.Culture, + Segment = propertyValue.Segment, + Alias = property.Alias, + Value = propertyEditor.GetValueEditor().ToEditor(property, propertyValue.Culture, propertyValue.Segment), + }; + })) + .WhereNotNull() + .ToArray(); + } + + private DocumentVariantRequestModel[] MapVariantsToRequestModel(IContent content) + { + IPropertyValue[] propertyValues = content.Properties.SelectMany(propertyCollection => propertyCollection.Values).ToArray(); + var cultures = content.AvailableCultures.DefaultIfEmpty(null).ToArray(); + + // The default segment (null) must always be included + var segments = propertyValues.Select(property => property.Segment).Union([null]).Distinct().ToArray(); + + return cultures + .SelectMany(culture => segments.Select(segment => new DocumentVariantRequestModel + { + Culture = culture, + Segment = segment, + Name = content.GetCultureName(culture) ?? string.Empty, + })) + .ToArray(); + } + + private static PatchOperationType MapOperationType(string op) => + op.ToLowerInvariant() switch + { + "replace" => PatchOperationType.Replace, + "add" => PatchOperationType.Add, + "remove" => PatchOperationType.Remove, + _ => throw new ArgumentException($"Unsupported operation type: {op}", nameof(op)), + }; + private TUpdateModel MapUpdateContentModel(UpdateDocumentRequestModel requestModel) where TUpdateModel : ContentUpdateModel, new() { diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs index c9b7e5ff9a6e..a502352319e9 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs @@ -1,4 +1,6 @@ -using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; namespace Umbraco.Cms.Api.Management.Factories; @@ -16,16 +18,34 @@ public interface IDocumentEditingPresentationFactory ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel); /// - /// Maps the given to a . + /// Maps the given to a . /// /// The update document request model to map from. /// The mapped content update model. ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel); + // TODO (V19): Remove the default implementation. /// - /// Maps the given to a for validation purposes. + /// Creates an from the given , + /// mapping its properties, variants, and template into the request model representation. /// - /// The request model containing the data to validate. - /// A representing the validated content update. + /// The content item to create the update request model from. + /// An representing the content. + Task CreateUpdateRequestModelAsync(IContent content) => throw new NotImplementedException(); + + // TODO (V19): Remove the default implementation. + /// + /// Maps a to a , + /// extracting the affected cultures and segments from the patch operation paths. + /// + /// The patch document request model. + /// A containing the mapped operations and affected cultures/segments. + ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) => throw new NotImplementedException(); + + /// + /// Maps a to a for update validation. + /// + /// The validate update document request model. + /// A ready for validation. ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel); } diff --git a/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs b/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs new file mode 100644 index 000000000000..1979a7391472 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs @@ -0,0 +1,23 @@ +namespace Umbraco.Cms.Api.Management.OperationStatus; + +/// +/// Operation status for PATCH operations at the API layer. +/// This is distinct from ContentEditingOperationStatus which is for service layer operations. +/// +public enum ContentPatchingOperationStatus +{ + /// + /// The operation was successful. + /// + Success, + + /// + /// One or more PATCH operations were invalid (invalid path syntax, unsupported operation type, missing required value). + /// + InvalidOperation, + + /// + /// The target document could not be found. + /// + NotFound, +} diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs new file mode 100644 index 000000000000..dba24004431b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.OperationStatus; +using Umbraco.Cms.Api.Management.Patching; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Patchers; + +/// +/// Applies patch operations with Umbraco's custom path syntax to document update models. +/// +public class DocumentPatcher : IDocumentPatcher +{ + private readonly IContentEditingService _contentEditingService; + private readonly IJsonSerializer _jsonSerializer; + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + + public DocumentPatcher( + IContentEditingService contentEditingService, + IJsonSerializer jsonSerializer, + IDocumentEditingPresentationFactory documentEditingPresentationFactory) + { + _contentEditingService = contentEditingService; + _jsonSerializer = jsonSerializer; + _documentEditingPresentationFactory = documentEditingPresentationFactory; + } + + /// + public async Task> ApplyPatchAsync( + Guid documentKey, + ContentPatchModel patchModel) + { + // Validate operation structure + foreach (PatchOperationModel operation in patchModel.Operations) + { + if (!PatchPathParser.IsValid(operation.Path, out PatchPathSegment[]? pathSegments)) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); + } + + operation.PathSegments = pathSegments; + + // Validate that replace/add operations have a value + if (operation.Op is PatchOperationType.Replace or PatchOperationType.Add && + operation.Value is null) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); + } + } + + // Load the content + IContent? content = await _contentEditingService.GetAsync(documentKey); + if (content is null) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.NotFound, default(UpdateDocumentRequestModel)!); + } + + // Convert to JSON as if a client would have sent the full payload + UpdateDocumentRequestModel unModifiedUpdateModel = await _documentEditingPresentationFactory.CreateUpdateRequestModelAsync(content); + var currentJsonString = _jsonSerializer.Serialize(unModifiedUpdateModel); + + if (string.IsNullOrWhiteSpace(currentJsonString)) + { + // should not happen as the content exists. + throw new JsonException("Unexpected empty JSON string when building update model for patching."); + } + + // Should not fail parsing as the string is a result of JSON serialization. + JsonNode currentJsonNode = JsonNode.Parse(currentJsonString) + ?? throw new JsonException("Could not parse JSON string to JsonNode when building update model for patching."); + + // Apply each PATCH operation to the JSON + foreach (PatchOperationModel operation in patchModel.Operations) + { + try + { + currentJsonNode = PatchEngine.ApplyOperation( + currentJsonNode, + operation.Op, + operation.PathSegments!, // can't be null because of earlier validation. + operation.Value); + } + catch (InvalidOperationException) + { + // Path resolution failed or operation error + return Attempt.FailWithStatus( + ContentPatchingOperationStatus.InvalidOperation, + default(UpdateDocumentRequestModel)!); + } + catch (FormatException) + { + // Path syntax error + return Attempt.FailWithStatus( + ContentPatchingOperationStatus.InvalidOperation, + default(UpdateDocumentRequestModel)!); + } + } + + currentJsonString = currentJsonNode.ToJsonString(); + + // Deserialize the modified JSON back to UpdateDocumentRequestModel + UpdateDocumentRequestModel? modifiedUpdateModel = _jsonSerializer.Deserialize(currentJsonString); + + return modifiedUpdateModel is not null + ? Attempt.SucceedWithStatus(ContentPatchingOperationStatus.Success, modifiedUpdateModel) + : Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs new file mode 100644 index 000000000000..951dd4acf5fe --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs @@ -0,0 +1,20 @@ +using Umbraco.Cms.Api.Management.OperationStatus; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Api.Management.Patchers; + +public interface IDocumentPatcher +{ + /// + /// Applies PATCH operations to a document and returns an update model. + /// Validates operations and returns appropriate error status if validation fails. + /// + /// The document key. + /// The patch model containing operations and affected cultures/segments. + /// An attempt containing the update model or an error status. + Task> ApplyPatchAsync( + Guid documentKey, + ContentPatchModel patchModel); +} diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs new file mode 100644 index 000000000000..ce229e53976f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs @@ -0,0 +1,150 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Umbraco.Cms.Api.Management.ViewModels.Patching; + +namespace Umbraco.Cms.Api.Management.Patching; + +/// +/// Applies patch operations to JSON documents using Umbraco's custom path syntax based on Json pointer +/// augmented with array filtering to support culture/segment and keyed items. +/// +public static class PatchEngine +{ + /// + /// Applies a single patch operation to a JSON node and returns the modified JSON node. + /// + /// The JSON node to modify. + /// The operation type (Replace, Add, Remove). + /// The patch path expression. + /// The value to set (required for Replace and Add operations). + /// The modified JSON node. + /// Thrown when the operation cannot be applied. + /// Thrown when the path syntax is invalid. + public static JsonNode ApplyOperation(JsonNode jsonNode, PatchOperationType op, string path, object? value) + { + PatchPathSegment[] segments = PatchPathParser.Parse(path); + + return ApplyOperation(jsonNode, op, segments, value); + } + + /// + /// Applies a single patch operation to a JSON node and returns the modified JSON node. + /// + /// The JSON node to modify. + /// The operation type (Replace, Add, Remove). + /// The patch path segments. + /// The value to set (required for Replace and Add operations). + /// The modified JSON node. + /// Thrown when is null or whitespace. + /// Thrown when the operation cannot be applied. + /// Thrown when the path syntax is invalid. + public static JsonNode ApplyOperation(JsonNode jsonNode, PatchOperationType op, PatchPathSegment[] pathSegments, object? value) + { + if (jsonNode is null) + { + throw new InvalidOperationException("Failed to parse JSON string."); + } + + JsonNode? valueNode = value is not null + ? JsonSerializer.SerializeToNode(value) + : null; + + ResolvedTarget target = PatchPathResolver.Resolve(jsonNode, pathSegments); + ApplyMutation(target, op, valueNode); + + return jsonNode; + } + + private static void ApplyMutation(ResolvedTarget target, PatchOperationType op, JsonNode? valueNode) + { + switch (op) + { + case PatchOperationType.Replace: + ApplyReplace(target, valueNode); + break; + + case PatchOperationType.Add: + ApplyAdd(target, valueNode); + break; + + case PatchOperationType.Remove: + ApplyRemove(target); + break; + + default: + throw new InvalidOperationException($"Unsupported operation type: {op}"); + } + } + + private static void ApplyReplace(ResolvedTarget target, JsonNode? valueNode) + { + if (target.IsAppend) + { + throw new InvalidOperationException("Cannot use 'replace' with append target '/-'."); + } + + switch (target.Key) + { + case string propertyName when target.Parent is JsonObject parentObj: + parentObj[propertyName] = valueNode?.DeepClone(); + break; + + case int index when target.Parent is JsonArray parentArr: + parentArr[index] = valueNode?.DeepClone(); + break; + + default: + throw new InvalidOperationException("Cannot replace at the resolved target location."); + } + } + + private static void ApplyAdd(ResolvedTarget target, JsonNode? valueNode) + { + if (target.IsAppend) + { + if (target.Parent is JsonArray appendArray) + { + appendArray.Add(valueNode?.DeepClone()); + return; + } + + throw new InvalidOperationException("Append target '/-' requires parent to be an array."); + } + + switch (target.Key) + { + case string propertyName when target.Parent is JsonObject parentObj: + parentObj[propertyName] = valueNode?.DeepClone(); + break; + + case int index when target.Parent is JsonArray parentArr: + parentArr.Insert(index, valueNode?.DeepClone()); + break; + + default: + throw new InvalidOperationException("Cannot add at the resolved target location."); + } + } + + private static void ApplyRemove(ResolvedTarget target) + { + if (target.IsAppend) + { + throw new InvalidOperationException("Cannot use 'remove' with append target '/-'."); + } + + switch (target.Key) + { + case string propertyName when target.Parent is JsonObject parentObj: + parentObj.Remove(propertyName); + break; + + case int index when target.Parent is JsonArray parentArr: + parentArr.RemoveAt(index); + break; + + default: + throw new InvalidOperationException("Cannot remove at the resolved target location."); + } + } +} diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs new file mode 100644 index 000000000000..eedbce59288b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs @@ -0,0 +1,216 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Umbraco.Cms.Api.Management.Patching; + +/// +/// Parses Umbraco's patch path syntax into typed segments. +/// +/// The syntax is based on JSON Pointer (RFC 6901) +/// with a custom extension for array element filtering: +/// +/// /property — access object property (RFC 6901 reference token) +/// /0 — access array element by index (RFC 6901 numeric token) +/// /- — append to end of array (RFC 6901 past-the-end token, Add operations only) +/// [key=value,key2=null] — filter array element by matching properties (Umbraco extension, not part of RFC 6901) +/// +/// +/// +/// RFC 6901 escape sequences are supported in property name tokens: +/// ~1 decodes to / and ~0 decodes to ~. +/// +/// +/// /variants[culture=en-US,segment=null]/name +/// /values[alias=title,culture=en-US,segment=null]/value +/// +/// +public static class PatchPathParser +{ + /// + /// Parses a patch path string into an array of typed segments. + /// + /// The patch path expression. + /// An array of representing the parsed path. + /// Thrown when the path syntax is invalid. + public static PatchPathSegment[] Parse(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new FormatException("Path cannot be null or empty."); + } + + if (path.StartsWith('/') is false) + { + throw new FormatException("Path must start with '/'."); + } + + var segments = new List(); + var span = path.AsSpan(1); // Skip leading '/' + + while (span.Length > 0) + { + // Find the next '/' or '[' to determine segment boundary + var slashIndex = span.IndexOf('/'); + var bracketIndex = span.IndexOf('['); + + ReadOnlySpan token; + if (slashIndex < 0 && bracketIndex < 0) + { + // Last segment, no more '/' or '[' + token = span; + span = ReadOnlySpan.Empty; + } + else if (bracketIndex >= 0 && (slashIndex < 0 || bracketIndex < slashIndex)) + { + // '[' comes before '/' — property + filter + token = span[..bracketIndex]; + span = span[bracketIndex..]; + } + else + { + // '/' comes first — regular segment + token = span[..slashIndex]; + span = span[(slashIndex + 1)..]; + } + + // Parse the property/index token + if (token.Length > 0) + { + segments.Add(ParseToken(token)); + } + + // Parse filter if present + if (span.Length > 0 && span[0] == '[') + { + var closeBracket = span.IndexOf(']'); + if (closeBracket < 0) + { + throw new FormatException("Unclosed filter bracket '[' in path."); + } + + ReadOnlySpan filterContent = span[1..closeBracket]; + segments.Add(ParseFilter(filterContent)); + + span = span[(closeBracket + 1)..]; + + // Skip the '/' after the filter if present + if (span.Length > 0 && span[0] == '/') + { + span = span[1..]; + } + } + } + + if (segments.Count == 0) + { + throw new FormatException("Path must contain at least one segment."); + } + + return segments.ToArray(); + } + + /// + /// Validates that a path string is syntactically correct. + /// + /// The path expression to validate. + /// The parsedPath extracted from the path if the path is valid. + /// True if the path is valid, false otherwise. + public static bool IsValid(string path, [NotNullWhen(true)]out PatchPathSegment[]? parsedPath) + { + parsedPath = null; + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + parsedPath = Parse(path); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static PatchPathSegment ParseToken(ReadOnlySpan token) + { + if (token.Length == 1 && token[0] == '-') + { + return new AppendSegment(); + } + + if (int.TryParse(token, out var index)) + { + return new IndexSegment(index); + } + + var name = UnescapeRfc6901(token.ToString()); + return new PropertySegment(name); + } + + /// + /// Decodes RFC 6901 escape sequences in a reference token. + /// ~1 is decoded to / and ~0 is decoded to ~. + /// Per the RFC, ~1 must be decoded before ~0 to avoid double-decoding. + /// + private static string UnescapeRfc6901(string token) + { + if (!token.Contains('~')) + { + return token; + } + + return token.Replace("~1", "/").Replace("~0", "~"); + } + + private static FilterSegment ParseFilter(ReadOnlySpan filterContent) + { + if (filterContent.IsEmpty) + { + throw new FormatException("Empty filter expression."); + } + + var conditions = new List(); + + while (filterContent.Length > 0) + { + // Find comma separator (but not inside nested brackets) + var commaIndex = filterContent.IndexOf(','); + ReadOnlySpan pair; + + if (commaIndex < 0) + { + pair = filterContent; + filterContent = ReadOnlySpan.Empty; + } + else + { + pair = filterContent[..commaIndex]; + filterContent = filterContent[(commaIndex + 1)..]; + } + + var equalsIndex = pair.IndexOf('='); + if (equalsIndex < 0) + { + throw new FormatException($"Filter condition must contain '=': '{pair.ToString()}'"); + } + + var key = pair[..equalsIndex].Trim().ToString(); + var rawValue = pair[(equalsIndex + 1)..].Trim(); + + if (key.Length == 0) + { + throw new FormatException("Filter condition key cannot be empty."); + } + + string? value = rawValue.Equals("null", StringComparison.OrdinalIgnoreCase) + ? null + : rawValue.ToString(); + + conditions.Add(new FilterCondition(key, value)); + } + + return new FilterSegment(conditions.ToArray()); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs new file mode 100644 index 000000000000..f11f8a4637fc --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs @@ -0,0 +1,167 @@ +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Api.Management.Patching; + +/// +/// Resolves parsed patch path segments against a JSON document to find the target node. +/// +public static class PatchPathResolver +{ + /// + /// Resolves a parsed path against a JSON node tree and returns the target location. + /// + /// The root JSON node. + /// The parsed path segments. + /// The resolved target containing the parent node and key. + /// Thrown when the path cannot be resolved. + public static ResolvedTarget Resolve(JsonNode root, PatchPathSegment[] segments) + { + if (segments.Length == 0) + { + throw new InvalidOperationException("Path must contain at least one segment."); + } + + JsonNode? current = root; + JsonNode? parent = null; + object? lastKey = null; + + for (var i = 0; i < segments.Length; i++) + { + PatchPathSegment segment = segments[i]; + + switch (segment) + { + case PropertySegment property: + if (current is not JsonObject obj) + { + throw new InvalidOperationException( + $"Expected object at path segment '{property.Name}', but found {current?.GetType().Name ?? "null"}."); + } + + parent = current; + lastKey = property.Name; + current = obj[property.Name]; + break; + + case FilterSegment filter: + if (current is not JsonArray filterArray) + { + throw new InvalidOperationException( + $"Expected array for filter operation, but found {current?.GetType().Name ?? "null"}."); + } + + var (matchedNode, matchedIndex) = FindMatchingElement(filterArray, filter.Conditions); + if (matchedNode is null) + { + throw new InvalidOperationException( + $"No array element matched filter conditions: [{FormatConditions(filter.Conditions)}]."); + } + + parent = filterArray; + lastKey = matchedIndex; + current = matchedNode; + break; + + case IndexSegment index: + if (current is not JsonArray indexArray) + { + throw new InvalidOperationException( + $"Expected array for index access, but found {current?.GetType().Name ?? "null"}."); + } + + if (index.Index < 0 || index.Index >= indexArray.Count) + { + throw new InvalidOperationException( + $"Array index {index.Index} is out of bounds (array length: {indexArray.Count})."); + } + + parent = indexArray; + lastKey = index.Index; + current = indexArray[index.Index]; + break; + + case AppendSegment: + if (i != segments.Length - 1) + { + throw new InvalidOperationException("Append segment '-' must be the last segment in the path."); + } + + if (current is not JsonArray) + { + throw new InvalidOperationException( + $"Expected array for append operation, but found {current?.GetType().Name ?? "null"}."); + } + + return new ResolvedTarget + { + Parent = current, + Key = null, + Current = null, + IsAppend = true, + }; + } + } + + if (parent is null || lastKey is null) + { + throw new InvalidOperationException("Could not resolve path to a valid target."); + } + + return new ResolvedTarget + { + Parent = parent, + Key = lastKey, + Current = current, + }; + } + + private static (JsonNode? Node, int Index) FindMatchingElement(JsonArray array, FilterCondition[] conditions) + { + for (var i = 0; i < array.Count; i++) + { + JsonNode? element = array[i]; + if (element is JsonObject elementObj && MatchesAllConditions(elementObj, conditions)) + { + return (element, i); + } + } + + return (null, -1); + } + + private static bool MatchesAllConditions(JsonObject element, FilterCondition[] conditions) + { + foreach (FilterCondition condition in conditions) + { + JsonNode? propertyNode = element[condition.Key]; + + if (condition.Value is null) + { + // Condition expects null: property must be missing or explicitly null + if (propertyNode is not null) + { + return false; + } + } + else + { + // Condition expects a specific value: compare as string + if (propertyNode is null) + { + return false; + } + + var nodeValue = propertyNode.ToString(); + if (!string.Equals(nodeValue, condition.Value, StringComparison.Ordinal)) + { + return false; + } + } + } + + return true; + } + + private static string FormatConditions(FilterCondition[] conditions) => + string.Join(", ", conditions.Select(c => $"{c.Key}={c.Value ?? "null"}")); +} diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchPathSegment.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathSegment.cs new file mode 100644 index 000000000000..1a41a27b2293 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathSegment.cs @@ -0,0 +1,32 @@ +namespace Umbraco.Cms.Api.Management.Patching; + +/// +/// Represents a single segment in a patch path expression. +/// +public abstract record PatchPathSegment; + +/// +/// Accesses a named property on an object (e.g., /name, /variants). +/// +public sealed record PropertySegment(string Name) : PatchPathSegment; + +/// +/// Filters an array by matching element properties (e.g., [culture=en-US,segment=null]). +/// All conditions must match (AND logic). +/// +public sealed record FilterSegment(FilterCondition[] Conditions) : PatchPathSegment; + +/// +/// Accesses an array element by numeric index (e.g., /0, /1). +/// +public sealed record IndexSegment(int Index) : PatchPathSegment; + +/// +/// Marks the end-of-array position for Add operations (/-). +/// +public sealed record AppendSegment : PatchPathSegment; + +/// +/// A single filter condition: key=value where value can be null. +/// +public sealed record FilterCondition(string Key, string? Value); diff --git a/src/Umbraco.Cms.Api.Management/Patching/ResolvedTarget.cs b/src/Umbraco.Cms.Api.Management/Patching/ResolvedTarget.cs new file mode 100644 index 000000000000..e397a536f69b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Patching/ResolvedTarget.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Api.Management.Patching; + +/// +/// The result of resolving a patch path against a JSON document. +/// +public sealed class ResolvedTarget +{ + /// + /// The parent node of the target location. + /// + public required JsonNode Parent { get; init; } + + /// + /// The key identifying the target within the parent: + /// a for object properties, an for array indices, + /// or null for append operations. + /// + public required object? Key { get; init; } + + /// + /// The current value at the target location, or null if the location doesn't exist yet (e.g., for Add). + /// + public JsonNode? Current { get; init; } + + /// + /// Whether this target represents an append to the end of an array. + /// + public bool IsAppend { get; init; } +} diff --git a/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs b/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs index 6eee673ad1c5..cac5fead8166 100644 --- a/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs @@ -6,6 +6,7 @@ namespace Umbraco.Cms.Api.Management.Services; /// /// Represents a service that processes and applies JSON Patch operations to resources. /// +[Obsolete("Use the custom patch engine (IDocumentPatcher) instead. JsonPatch.Net dependency is being removed. Scheduled for removal in Umbraco 19.")] public interface IJsonPatchService { /// diff --git a/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs b/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs index 18848eb13ffa..045f0f58a838 100644 --- a/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs @@ -1,6 +1,5 @@ using System.Text.Json.Nodes; using Json.Patch; -using Umbraco.Cms.Api.Management.Serialization; using Umbraco.Cms.Api.Management.ViewModels.JsonPatch; using Umbraco.Cms.Core.Serialization; @@ -9,16 +8,18 @@ namespace Umbraco.Cms.Api.Management.Services; /// /// Provides functionality to apply and manage JSON Patch operations on resources. /// +[Obsolete("Use the custom patch engine (IDocumentPatcher) instead. JsonPatch.Net dependency is being removed. Scheduled for removal in Umbraco 19.")] public class JsonPatchService : IJsonPatchService { private readonly IJsonSerializer _jsonSerializer; /// - /// Initializes a new instance of the class with the specified JSON serializer. + /// Initializes a new instance of the class with the specified JSON serializer. /// /// The instance used for JSON serialization and deserialization. public JsonPatchService(IJsonSerializer jsonSerializer) => _jsonSerializer = jsonSerializer; + /// public PatchResult? Patch(JsonPatchViewModel[] patchViewModel, object objectToPatch) { var patchString = _jsonSerializer.Serialize(patchViewModel); diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs new file mode 100644 index 000000000000..1286d23f91ed --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +/// +/// Request model for operation-based PATCH on documents using Umbraco's extended JSON Pointer path syntax. +/// +public class PatchDocumentRequestModel +{ + /// + /// Collection of PATCH operations to apply to the document. + /// Operations are applied sequentially and atomically (all-or-nothing). + /// + [Required] + [MinLength(1)] + public PatchOperationRequestModel[] Operations { get; set; } = []; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs new file mode 100644 index 000000000000..bb491e490cb4 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +/// +/// Represents a single PATCH operation using Umbraco's path syntax. +/// +public class PatchOperationRequestModel +{ + /// + /// The operation type: "replace", "add", or "remove". + /// + [Required] + [AllowedValues("add", "replace", "remove", ErrorMessage = "Supported operations are: add, replace, remove.")] + public string Op { get; set; } = string.Empty; + + /// + /// Path expression identifying the target location using Umbraco's extended JSON Pointer syntax. + /// + /// Examples: + /// + /// /variants[culture=en-US,segment=null]/name + /// /values[alias=title,culture=en-US,segment=null]/value + /// /values[alias=blocks,culture=null,segment=null]/value/contentData/- (append to array) + /// + /// + /// + [Required] + public string Path { get; set; } = string.Empty; + + /// + /// The value to set. Required for "replace" and "add" operations, omitted for "remove". + /// + public object? Value { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Patching/ContentPatchModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/ContentPatchModel.cs new file mode 100644 index 000000000000..3b1063066090 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/ContentPatchModel.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Patching; + +/// +/// Model for operation-based partial content updates using Umbraco's extended JSON Pointer path syntax. +/// +public class ContentPatchModel +{ + /// + /// Gets or sets collection of PATCH operations to apply. + /// + public PatchOperationModel[] Operations { get; set; } = []; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationModel.cs new file mode 100644 index 000000000000..6c59e5b9e605 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationModel.cs @@ -0,0 +1,29 @@ +using Umbraco.Cms.Api.Management.Patching; + +namespace Umbraco.Cms.Api.Management.ViewModels.Patching; + +/// +/// Represents a single PATCH operation in the domain layer. +/// +public class PatchOperationModel +{ + /// + /// Gets or sets the operation type. + /// + public PatchOperationType Op { get; set; } + + /// + /// Gets or sets the patch path expression identifying the target location. + /// + public string Path { get; set; } = string.Empty; + + /// + /// Gets the PathSegments extracted from the Path expression. + /// + public PatchPathSegment[]? PathSegments { get; internal set; } + + /// + /// Gets or sets the value to set. Required for Replace and Add operations, null for Remove. + /// + public object? Value { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationType.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationType.cs new file mode 100644 index 000000000000..e6632d91d441 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationType.cs @@ -0,0 +1,22 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Patching; + +/// +/// Defines the supported PATCH operation types. +/// +public enum PatchOperationType +{ + /// + /// Replace an existing value at the target location. + /// + Replace, + + /// + /// Add a new value at the target location. + /// + Add, + + /// + /// Remove the value at the target location. + /// + Remove, +} diff --git a/src/Umbraco.Infrastructure/Serialization/JsonBlockValueConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonBlockValueConverter.cs index 9f66b9cf4cb7..a9b8c109791b 100644 --- a/src/Umbraco.Infrastructure/Serialization/JsonBlockValueConverter.cs +++ b/src/Umbraco.Infrastructure/Serialization/JsonBlockValueConverter.cs @@ -106,7 +106,7 @@ public override void Write(Utf8JsonWriter writer, BlockValue value, JsonSerializ Type layoutItemType = GetLayoutItemType(value.GetType()); - writer.WriteStartObject(nameof(BlockValue.Layout)); + writer.WriteStartObject(nameof(BlockValue.Layout).ToFirstLowerInvariant()); if (blockLayoutItems.Any()) { diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs new file mode 100644 index 000000000000..790317d1bd4b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs @@ -0,0 +1,1825 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Document; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using UmbracoDataType = Umbraco.Cms.Core.Models.DataType; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Document; + +public class PatchDocumentControllerTests : ManagementApiUserGroupTestBase +{ + private IContentEditingService ContentEditingService => GetRequiredService(); + + private ITemplateService TemplateService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private ILanguageService LanguageService => GetRequiredService(); + + private IShortStringHelper ShortStringHelper => GetRequiredService(); + + private IContentService ContentService => GetRequiredService(); + + private Guid _templateKey; + private Guid _documentKey; + + [SetUp] + public async Task Setup() + { + // Template + var template = TemplateBuilder.CreateTextPageTemplate(Guid.NewGuid().ToString()); + var templateResponse = await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + _templateKey = templateResponse.Result.Key; + + // Content Type + var contentType = ContentTypeBuilder.CreateTextPageContentType( + defaultTemplateId: template.Id, + name: Guid.NewGuid().ToString(), + alias: Guid.NewGuid().ToString()); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Content + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + TemplateKey = _templateKey, + ParentKey = Constants.System.RootKey, + Variants = new List { new() { Name = "Original Name" } }, + }; + var response = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (response.Result.Content is null) + { + throw new ArgumentNullException(nameof(response.Result.Content), "Setup failed: No content returned from CreateAsync"); + } + + _documentKey = response.Result.Content.Key; + } + + protected override Expression> MethodSelector => + x => x.Patch(CancellationToken.None, _documentKey, null); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.OK, + }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.OK + }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.OK + }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Unauthorized + }; + + protected override async Task ClientRequest() + { + PatchDocumentRequestModel patchModel = new() + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/variants[culture=null,segment=null]/name", + Value = "Updated Name" + } + } + }; + + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + return await Client.PatchAsync(Url, httpContent); + } + + [Test] + public async Task PatchDocument_SingleCulture_UpdatesOnlyThatCulture() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create languages + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + var langDaDk = new LanguageBuilder() + .WithCultureInfo("da-DK") + .Build(); + await LanguageService.CreateAsync(langDaDk, Constants.Security.SuperUserKey); + + // Arrange - Create content type with culture variation + var contentType = ContentTypeBuilder.CreateSimpleContentType("cultureTest", "Culture Test"); + contentType.Variations = ContentVariation.Culture; + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with multiple cultures + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = new List + { + new() { Name = "English Name", Culture = "en-US" }, + new() { Name = "Danish Name", Culture = "da-DK" }, + }, + }; + var createResponse = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createResponse.Result.Content is null) + { + throw new InvalidOperationException("Failed to create test content"); + } + + var documentKey = createResponse.Result.Content.Key; + + // Patch only en-US using authenticated admin client + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/variants[culture=en-US,segment=null]/name", + Value = "Updated English Name" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify only en-US was updated + var content = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(content); + Assert.AreEqual("Updated English Name", content.GetCultureName("en-US")); + Assert.AreEqual("Danish Name", content.GetCultureName("da-DK")); // Unchanged + } + + [Test] + public async Task PatchDocument_MultipleCultures_UpdatesBoth() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create languages + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + var langDaDk = new LanguageBuilder() + .WithCultureInfo("da-DK") + .Build(); + await LanguageService.CreateAsync(langDaDk, Constants.Security.SuperUserKey); + + var langDeDe = new LanguageBuilder() + .WithCultureInfo("de-DE") + .Build(); + await LanguageService.CreateAsync(langDeDe, Constants.Security.SuperUserKey); + + // Arrange - Create content type with culture variation + var contentType = ContentTypeBuilder.CreateSimpleContentType("multiCultureTest", "Multi Culture Test"); + contentType.Variations = ContentVariation.Culture; + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with multiple cultures + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = new List + { + new() { Name = "English Name", Culture = "en-US" }, + new() { Name = "Danish Name", Culture = "da-DK" }, + new() { Name = "German Name", Culture = "de-DE" }, + }, + }; + var createResponse = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createResponse.Result.Content is null) + { + throw new InvalidOperationException("Failed to create test content"); + } + + var documentKey = createResponse.Result.Content.Key; + + // Patch en-US and da-DK using authenticated admin client + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/variants[culture=en-US,segment=null]/name", + Value = "Updated English" + }, + new PatchOperationRequestModel + { + Op = "replace", + Path = "/variants[culture=da-DK,segment=null]/name", + Value = "Updated Danish" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify both cultures updated + var content = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(content); + Assert.AreEqual("Updated English", content.GetCultureName("en-US")); + Assert.AreEqual("Updated Danish", content.GetCultureName("da-DK")); + Assert.AreEqual("German Name", content.GetCultureName("de-DE")); // Unchanged + } + + [Test] + public async Task PatchDocument_NonExistentCulture_ReturnsInvalidCulture() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language (only en-US, not fr-FR) + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with culture variation + var contentType = ContentTypeBuilder.CreateSimpleContentType("invalidCultureTest", "Invalid Culture Test"); + contentType.Variations = ContentVariation.Culture; + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = new List + { + new() { Name = "English Name", Culture = "en-US" }, + }, + }; + var createResponse = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createResponse.Result.Content is null) + { + throw new InvalidOperationException("Failed to create test content"); + } + + var documentKey = createResponse.Result.Content.Key; + + // Try to patch non-existent culture using authenticated admin client + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/variants[culture=fr-FR,segment=null]/name", + Value = "French Name" // fr-FR not enabled + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Test] + public async Task PatchDocument_PropertyValue_UpdatesProperty() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with properties and culture variation + var contentType = new ContentTypeBuilder() + .WithAlias("propertyTest") + .WithName("Property Test") + .WithContentVariation(ContentVariation.Culture) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Culture) + .Done() + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US variant using ContentBuilder and ContentService + var content = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "English Name") + .Build(); + + // Set property value directly + content.SetValue("title", "Original Title", culture: "en-US"); + ContentService.Save(content); + + var documentKey = content.Key; + + // Patch title property for en-US + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/values[alias=title,culture=en-US,segment=null]/value", + Value = "Updated Title" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify property was updated + var updatedContent = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(updatedContent); + Assert.AreEqual("Updated Title", updatedContent.GetValue("title", "en-US")); + } + + [Test] + public async Task PatchDocument_MultipleProperties_UpdatesAll() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with multiple properties and culture variation + var contentType = new ContentTypeBuilder() + .WithAlias("multiPropertyTest") + .WithName("Multi Property Test") + .WithContentVariation(ContentVariation.Culture) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Culture) + .Done() + .AddPropertyType() + .WithAlias("description") + .WithName("Description") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Culture) + .Done() + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US variant using ContentBuilder and ContentService + var content = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "English Name") + .Build(); + + // Set property values directly + content.SetValue("title", "Original Title", culture: "en-US"); + content.SetValue("description", "Original Description", culture: "en-US"); + ContentService.Save(content); + + var documentKey = content.Key; + + // Patch both properties for en-US + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/values[alias=title,culture=en-US,segment=null]/value", + Value = "Updated Title" + }, + new PatchOperationRequestModel + { + Op = "replace", + Path = "/values[alias=description,culture=en-US,segment=null]/value", + Value = "Updated Description" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify both properties were updated + var updatedContent = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(updatedContent); + Assert.AreEqual("Updated Title", updatedContent.GetValue("title", "en-US")); + Assert.AreEqual("Updated Description", updatedContent.GetValue("description", "en-US")); + } + + [Test] + public async Task PatchDocument_PropertyAndVariant_UpdatesBoth() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with properties and culture variation + var contentType = new ContentTypeBuilder() + .WithAlias("propertyAndVariantTest") + .WithName("Property And Variant Test") + .WithContentVariation(ContentVariation.Culture) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Culture) + .Done() + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US variant using ContentBuilder and ContentService + var content = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "Original Name") + .Build(); + + // Set property value directly + content.SetValue("title", "Original Title", culture: "en-US"); + ContentService.Save(content); + + var documentKey = content.Key; + + // Patch both name and property for en-US + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/variants[culture=en-US,segment=null]/name", + Value = "Updated Name" + }, + new PatchOperationRequestModel + { + Op = "replace", + Path = "/values[alias=title,culture=en-US,segment=null]/value", + Value = "Updated Title" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify both name and property were updated + var updatedContent = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(updatedContent); + Assert.AreEqual("Updated Name", updatedContent.GetCultureName("en-US")); + Assert.AreEqual("Updated Title", updatedContent.GetValue("title", "en-US")); + } + + [Test] + public async Task PatchDocument_InvalidPropertyAlias_ReturnsBadRequest() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with culture variation (but no custom properties) + var contentType = ContentTypeBuilder.CreateSimpleContentType("invalidPropertyTest", "Invalid Property Test"); + contentType.Variations = ContentVariation.Culture; + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US variant + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = new List + { + new() { Name = "English Name", Culture = "en-US" }, + }, + }; + var createResponse = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createResponse.Result.Content is null) + { + throw new InvalidOperationException("Failed to create test content"); + } + + var documentKey = createResponse.Result.Content.Key; + + // Try to patch non-existent property + // When path filter matches no elements, it returns BadRequest (400) + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/values[alias=nonExistentProperty,culture=en-US,segment=null]/value", + Value = "Some Value" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert - Returns BadRequest (400) because path filter matches no elements + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Test] + public async Task PatchDocument_SegmentProperty_UpdatesCorrectSegment() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with segment variation property + var contentType = new ContentTypeBuilder() + .WithAlias("segmentTest") + .WithName("Segment Test") + .WithContentVariation(ContentVariation.CultureAndSegment) // Content type must support segments + .AddPropertyType() + .WithAlias("price") + .WithName("Price") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.CultureAndSegment) + .Done() + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US variant using ContentBuilder and ContentService + var content = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "English Name") + .Build(); + + // Set property values for different segments (including default segment) + content.SetValue("price", "75", culture: "en-US", segment: null); // Default segment + content.SetValue("price", "100", culture: "en-US", segment: "standard"); + content.SetValue("price", "150", culture: "en-US", segment: "premium"); + ContentService.Save(content); + + var documentKey = content.Key; + + // Patch only premium segment + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/values[alias=price,culture=en-US,segment=premium]/value", + Value = "200" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error Response: {errorContent}"); + } + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify only premium segment was updated + var updatedContent = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(updatedContent); + Assert.AreEqual("200", updatedContent.GetValue("price", "en-US", "premium")); + Assert.AreEqual("100", updatedContent.GetValue("price", "en-US", "standard")); // Unchanged + } + + [Test] + public async Task PatchDocument_DocumentInRecycleBin_AllowsPatch() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Create simple content type + var contentType = new ContentTypeBuilder() + .WithAlias("simpleDoc") + .WithName("Simple Document") + .WithContentVariation(ContentVariation.Nothing) + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create and immediately delete document (move to recycle bin) + var content = new ContentBuilder() + .WithContentType(contentType) + .Build(); + ContentService.Save(content); + ContentService.MoveToRecycleBin(content); + + var documentKey = content.Key; + + // Try to patch document in recycle bin + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/variants[culture=null,segment=null]/name", + Value = "Updated Name" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert - Umbraco allows patching documents in recycle bin (they can be edited before permanent deletion) + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + } + + [Test] + public async Task PatchDocument_NonExistentDocument_ReturnsNotFound() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + var nonExistentKey = Guid.NewGuid(); + + // Try to patch non-existent document + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "/variants[culture=null,segment=null]/name", + Value = "Updated Name" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{nonExistentKey}/patch", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + } + + [Test] + public async Task PatchDocument_BlockList_SingleBlock_UpdatesSingleProperty() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + var suffix = Guid.NewGuid().ToString("N")[..8]; + + // Create element type for blocks + var elementType = new ContentTypeBuilder() + .WithAlias($"heroBlock{suffix}") + .WithName("Hero Block") + .AddPropertyType() + .WithAlias("headline") + .WithName("Headline") + .WithDataTypeId(Constants.DataTypes.Textbox) + .Done() + .AddPropertyType() + .WithAlias("description") + .WithName("Description") + .WithDataTypeId(Constants.DataTypes.Textarea) + .Done() + .Build(); + elementType.IsElement = true; + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Create Block List data type + var propertyEditorCollection = GetRequiredService(); + var configurationEditorJsonSerializer = GetRequiredService(); + + var blockListDataType = new UmbracoDataType( + propertyEditorCollection[Constants.PropertyEditors.Aliases.BlockList], + configurationEditorJsonSerializer) + { + ConfigurationData = new Dictionary + { + { + "blocks", + new[] + { + new { contentElementTypeKey = elementType.Key } + } + } + }, + Name = "My Block List", + DatabaseType = ValueStorageType.Ntext, + ParentId = Constants.System.Root, + CreateDate = DateTime.UtcNow + }; + + var dataTypeService = GetRequiredService(); + await dataTypeService.CreateAsync(blockListDataType, Constants.Security.SuperUserKey); + + // Create content type with Block List property + var contentType = new ContentTypeBuilder() + .WithAlias($"blockListPage{suffix}") + .WithName("Block List Page") + .AddPropertyType() + .WithAlias("contentBlocks") + .WithName("Content Blocks") + .WithDataTypeId(blockListDataType.Id) + .Done() + .Build(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create three blocks + var block1Key = Guid.NewGuid(); + var block2Key = Guid.NewGuid(); + var block3Key = Guid.NewGuid(); + + var jsonSerializer = GetRequiredService(); + var blockListValue = new BlockListValue + { + Layout = new Dictionary> + { + { + Constants.PropertyEditors.Aliases.BlockList, + new IBlockLayoutItem[] + { + new BlockListLayoutItem { ContentKey = block1Key }, + new BlockListLayoutItem { ContentKey = block2Key }, + new BlockListLayoutItem { ContentKey = block3Key } + } + } + }, + ContentData = new List + { + new BlockItemData + { + Key = block1Key, + ContentTypeKey = elementType.Key, + ContentTypeAlias = elementType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "headline", Value = "Block 1 Headline" }, + new BlockPropertyValue { Alias = "description", Value = "Block 1 Description" } + } + }, + new BlockItemData + { + Key = block2Key, + ContentTypeKey = elementType.Key, + ContentTypeAlias = elementType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "headline", Value = "Block 2 Headline" }, + new BlockPropertyValue { Alias = "description", Value = "Block 2 Description" } + } + }, + new BlockItemData + { + Key = block3Key, + ContentTypeKey = elementType.Key, + ContentTypeAlias = elementType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "headline", Value = "Block 3 Headline" }, + new BlockPropertyValue { Alias = "description", Value = "Block 3 Description" } + } + } + }, + SettingsData = new List(), + Expose = new List() + }; + + var blockListJson = jsonSerializer.Serialize(blockListValue); + + // Create document with Block List + var content = new ContentBuilder() + .WithContentType(contentType) + .WithName("My Blocks Document") + .WithCreatorId(Constants.Security.SuperUserId) + .Build(); + + content.SetValue("contentBlocks", blockListJson); + ContentService.Save(content); + var documentKey = content.Key; + + // Act - Patch only the headline of block 2 using nested path + // The path expression navigates through the nested structure: + // 1. Find the property with alias 'contentBlocks' + // 2. Navigate into its .value (the BlockListValue object) + // 3. Navigate into .contentData array + // 4. Find the block with the specific key (block2Key) + // 5. Navigate into its .values array + // 6. Find the property with alias 'headline' + // 7. Update its .value + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = $"/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key={block2Key}]/values[alias=headline]/value", + Value = "Updated Block 2 Headline" + } + } + }; + + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error response: {response.StatusCode}"); + Console.WriteLine($"Error body: {errorContent}"); + } + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify only block 2's headline was updated + var updatedContent = ContentService.GetById(documentKey); + Assert.IsNotNull(updatedContent); + + var updatedBlockListJson = updatedContent.GetValue("contentBlocks"); + Assert.IsNotNull(updatedBlockListJson); + + var updatedBlockListValue = jsonSerializer.Deserialize(updatedBlockListJson); + Assert.IsNotNull(updatedBlockListValue); + + // Find block 2 in the content data + var block2Data = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == block2Key); + Assert.IsNotNull(block2Data); + + // Verify block 2 headline was updated + var headlineValue = block2Data.Values.FirstOrDefault(v => v.Alias == "headline"); + Assert.IsNotNull(headlineValue); + Assert.AreEqual("Updated Block 2 Headline", headlineValue.Value?.ToString()); + + // Verify block 2 description was NOT updated + var descriptionValue = block2Data.Values.FirstOrDefault(v => v.Alias == "description"); + Assert.IsNotNull(descriptionValue); + Assert.AreEqual("Block 2 Description", descriptionValue.Value?.ToString()); + + // Verify block 1 and block 3 were NOT updated + var block1Data = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == block1Key); + Assert.IsNotNull(block1Data); + Assert.AreEqual("Block 1 Headline", block1Data.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); + Assert.AreEqual("Block 1 Description", block1Data.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); + + var block3Data = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == block3Key); + Assert.IsNotNull(block3Data); + Assert.AreEqual("Block 3 Headline", block3Data.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); + Assert.AreEqual("Block 3 Description", block3Data.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); + } + + [Test] + public async Task PatchDocument_BlockList_AddBlock_AppendsToExistingBlockList() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + var suffix = Guid.NewGuid().ToString("N")[..8]; + + // Create element type for blocks + var elementType = new ContentTypeBuilder() + .WithAlias($"heroBlock{suffix}") + .WithName("Hero Block") + .AddPropertyType() + .WithAlias("headline") + .WithName("Headline") + .WithDataTypeId(Constants.DataTypes.Textbox) + .Done() + .AddPropertyType() + .WithAlias("description") + .WithName("Description") + .WithDataTypeId(Constants.DataTypes.Textarea) + .Done() + .Build(); + elementType.IsElement = true; + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Create Block List data type + var propertyEditorCollection = GetRequiredService(); + var configurationEditorJsonSerializer = GetRequiredService(); + + var blockListDataType = new UmbracoDataType( + propertyEditorCollection[Constants.PropertyEditors.Aliases.BlockList], + configurationEditorJsonSerializer) + { + ConfigurationData = new Dictionary + { + { + "blocks", + new[] + { + new { contentElementTypeKey = elementType.Key } + } + } + }, + Name = "My Block List", + DatabaseType = ValueStorageType.Ntext, + ParentId = Constants.System.Root, + CreateDate = DateTime.UtcNow + }; + + var dataTypeService = GetRequiredService(); + await dataTypeService.CreateAsync(blockListDataType, Constants.Security.SuperUserKey); + + // Create content type with Block List property + var contentType = new ContentTypeBuilder() + .WithAlias($"blockListPage{suffix}") + .WithName("Block List Page") + .AddPropertyType() + .WithAlias("contentBlocks") + .WithName("Content Blocks") + .WithDataTypeId(blockListDataType.Id) + .Done() + .Build(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create two existing blocks + var block1Key = Guid.NewGuid(); + var block2Key = Guid.NewGuid(); + + var jsonSerializer = GetRequiredService(); + var blockListValue = new BlockListValue + { + Layout = new Dictionary> + { + { + Constants.PropertyEditors.Aliases.BlockList, + new IBlockLayoutItem[] + { + new BlockListLayoutItem { ContentKey = block1Key }, + new BlockListLayoutItem { ContentKey = block2Key } + } + } + }, + ContentData = new List + { + new BlockItemData + { + Key = block1Key, + ContentTypeKey = elementType.Key, + ContentTypeAlias = elementType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "headline", Value = "Block 1 Headline" }, + new BlockPropertyValue { Alias = "description", Value = "Block 1 Description" } + } + }, + new BlockItemData + { + Key = block2Key, + ContentTypeKey = elementType.Key, + ContentTypeAlias = elementType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "headline", Value = "Block 2 Headline" }, + new BlockPropertyValue { Alias = "description", Value = "Block 2 Description" } + } + } + }, + SettingsData = new List(), + Expose = new List() + }; + + var blockListJson = jsonSerializer.Serialize(blockListValue); + + // Create document with Block List containing 2 blocks + var content = new ContentBuilder() + .WithContentType(contentType) + .WithName("My Blocks Document") + .WithCreatorId(Constants.Security.SuperUserId) + .Build(); + + content.SetValue("contentBlocks", blockListJson); + ContentService.Save(content); + var documentKey = content.Key; + + // Act - Add a new block to the block list using two add operations: + // 1. Append block data to contentData array + // 2. Append layout item to layout array (so the block is rendered) + var newBlockKey = Guid.NewGuid(); + + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "add", + Path = "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData/-", + Value = new + { + key = newBlockKey, + contentTypeKey = elementType.Key, + contentTypeAlias = elementType.Alias, + values = new[] + { + new { alias = "headline", value = "New Block Headline" }, + new { alias = "description", value = "New Block Description" } + } + } + }, + new PatchOperationRequestModel + { + Op = "add", + Path = $"/values[alias=contentBlocks,culture=null,segment=null]/value/layout/Umbraco.BlockList/-", + Value = new { contentKey = newBlockKey } + } + } + }; + + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error response: {response.StatusCode}"); + Console.WriteLine($"Error body: {errorContent}"); + } + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify the block list now has 3 blocks + var updatedContent = ContentService.GetById(documentKey); + Assert.IsNotNull(updatedContent); + + var updatedBlockListJson = updatedContent.GetValue("contentBlocks"); + Assert.IsNotNull(updatedBlockListJson); + + var updatedBlockListValue = jsonSerializer.Deserialize(updatedBlockListJson); + Assert.IsNotNull(updatedBlockListValue); + + // Verify contentData has 3 blocks + Assert.AreEqual(3, updatedBlockListValue.ContentData.Count); + + // Verify the new block was added with correct values + var newBlockData = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == newBlockKey); + Assert.IsNotNull(newBlockData); + Assert.AreEqual("New Block Headline", newBlockData.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); + Assert.AreEqual("New Block Description", newBlockData.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); + + // Verify original blocks were NOT changed + var block1Data = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == block1Key); + Assert.IsNotNull(block1Data); + Assert.AreEqual("Block 1 Headline", block1Data.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); + Assert.AreEqual("Block 1 Description", block1Data.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); + + var block2Data = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == block2Key); + Assert.IsNotNull(block2Data); + Assert.AreEqual("Block 2 Headline", block2Data.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); + Assert.AreEqual("Block 2 Description", block2Data.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); + + // Verify layout was also updated with the new block + var layoutItems = updatedBlockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].ToList(); + Assert.AreEqual(3, layoutItems.Count); + } + + // ── Deeply nested block editor tests ────────────────────────────────────── + // + // Structure: Document → RTE → BlockGrid (with areas) → BlockList → textBlock + // + // These tests prove that PatchEngine can navigate through multiple layers of + // recursively-expanded block editor values (RTE > Block Grid > Block List). + + /// + /// Builds the full deeply-nested block editor structure used by the deep nesting tests. + /// Returns all keys and identifiers needed to construct patch paths. + /// + private async Task CreateDeeplyNestedBlockDocument() + { + var jsonSerializer = GetRequiredService(); + var propertyEditorCollection = GetRequiredService(); + var configEditorJsonSerializer = GetRequiredService(); + var dataTypeService = GetRequiredService(); + + // ── Languages ── + var langEnUs = new LanguageBuilder().WithCultureInfo("en-US").WithIsDefault(true).Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + var langDaDk = new LanguageBuilder().WithCultureInfo("da-DK").Build(); + await LanguageService.CreateAsync(langDaDk, Constants.Security.SuperUserKey); + + // ── Element type: textBlock (culture-variant, has "text" property) ── + var suffix = Guid.NewGuid().ToString("N")[..8]; + var textBlockType = new ContentTypeBuilder() + .WithAlias($"textBlock{suffix}") + .WithName("Text Block") + .AddPropertyType() + .WithAlias("text") + .WithName("Text") + .WithDataTypeId(Constants.DataTypes.Textbox) + .Done() + .Build(); + textBlockType.IsElement = true; + textBlockType.Variations = ContentVariation.Culture; + foreach (var pt in textBlockType.PropertyTypes) + { + pt.Variations = ContentVariation.Culture; + } + + await ContentTypeService.CreateAsync(textBlockType, Constants.Security.SuperUserKey); + + // ── Element type: listContainerBlock (has "blockList" property) ── + // First create the Block List data type configured with textBlock + var blockListDataType = new UmbracoDataType( + propertyEditorCollection[Constants.PropertyEditors.Aliases.BlockList], + configEditorJsonSerializer) + { + ConfigurationData = new Dictionary + { + { "blocks", new[] { new { contentElementTypeKey = textBlockType.Key } } } + }, + Name = "Test Block List", + DatabaseType = ValueStorageType.Ntext, + ParentId = Constants.System.Root, + CreateDate = DateTime.UtcNow + }; + await dataTypeService.CreateAsync(blockListDataType, Constants.Security.SuperUserKey); + + var listContainerType = new ContentTypeBuilder() + .WithAlias($"listContainerBlock{suffix}") + .WithName("List Container Block") + .AddPropertyType() + .WithAlias("blockList") + .WithName("Block List") + .WithDataTypeId(blockListDataType.Id) + .Done() + .Build(); + listContainerType.IsElement = true; + await ContentTypeService.CreateAsync(listContainerType, Constants.Security.SuperUserKey); + + // ── Element type: areaBlock (empty container, holds areas) ── + var areaBlockType = new ContentTypeBuilder() + .WithAlias($"areaBlock{suffix}") + .WithName("Area Block") + .Build(); + areaBlockType.IsElement = true; + await ContentTypeService.CreateAsync(areaBlockType, Constants.Security.SuperUserKey); + + // ── Block Grid data type ── + var areaKey = Guid.NewGuid(); + var blockGridDataType = new UmbracoDataType( + propertyEditorCollection[Constants.PropertyEditors.Aliases.BlockGrid], + configEditorJsonSerializer) + { + ConfigurationData = new Dictionary + { + { + "blocks", new object[] + { + new + { + contentElementTypeKey = areaBlockType.Key, + allowAtRoot = true, + allowInAreas = false, + areaGridColumns = 12, + areas = new[] + { + new + { + key = areaKey, + alias = "content", + columnSpan = 12, + rowSpan = 1, + minAllowed = 0, + maxAllowed = 10 + } + } + }, + new + { + contentElementTypeKey = listContainerType.Key, + allowAtRoot = false, + allowInAreas = true, + areas = Array.Empty() + } + } + } + }, + Name = "Test Block Grid", + DatabaseType = ValueStorageType.Ntext, + ParentId = Constants.System.Root, + CreateDate = DateTime.UtcNow + }; + await dataTypeService.CreateAsync(blockGridDataType, Constants.Security.SuperUserKey); + + // ── Element type: gridContainerBlock (has "blockGrid" property) ── + var gridContainerType = new ContentTypeBuilder() + .WithAlias($"gridContainerBlock{suffix}") + .WithName("Grid Container Block") + .AddPropertyType() + .WithAlias("blockGrid") + .WithName("Block Grid") + .WithDataTypeId(blockGridDataType.Id) + .Done() + .Build(); + gridContainerType.IsElement = true; + await ContentTypeService.CreateAsync(gridContainerType, Constants.Security.SuperUserKey); + + // ── RTE data type ── + var rteDataType = new UmbracoDataType( + propertyEditorCollection[Constants.PropertyEditors.Aliases.RichText], + configEditorJsonSerializer) + { + ConfigurationData = new Dictionary + { + { "blocks", new[] { new { contentElementTypeKey = gridContainerType.Key } } } + }, + Name = "Test RTE", + DatabaseType = ValueStorageType.Ntext, + ParentId = Constants.System.Root, + CreateDate = DateTime.UtcNow + }; + await dataTypeService.CreateAsync(rteDataType, Constants.Security.SuperUserKey); + + // ── Document content type ── + var contentType = new ContentTypeBuilder() + .WithAlias($"deepNestedPage{suffix}") + .WithName("Deep Nested Page") + .AddPropertyType() + .WithAlias("rte") + .WithName("Rich Text") + .WithDataTypeId(rteDataType.Id) + .Done() + .Build(); + contentType.AllowedAsRoot = true; + contentType.Variations = ContentVariation.Culture; + foreach (var pt in contentType.PropertyTypes) + { + pt.Variations = ContentVariation.Culture; + } + + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // ── Build nested block values (inside-out) ── + var textBlock1Key = Guid.NewGuid(); + var textBlock2Key = Guid.NewGuid(); + var listContainerKey = Guid.NewGuid(); + var areaBlockKey = Guid.NewGuid(); + var gridContainerKey = Guid.NewGuid(); + + // Layer 1: Block List value with 2 text blocks (culture-variant) + var blockListValue = new BlockListValue + { + Layout = new Dictionary> + { + { + Constants.PropertyEditors.Aliases.BlockList, + new IBlockLayoutItem[] + { + new BlockListLayoutItem { ContentKey = textBlock1Key }, + new BlockListLayoutItem { ContentKey = textBlock2Key } + } + } + }, + ContentData = new List + { + new BlockItemData + { + Key = textBlock1Key, + ContentTypeKey = textBlockType.Key, + ContentTypeAlias = textBlockType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "text", Culture = "en-US", Value = "original en" }, + new BlockPropertyValue { Alias = "text", Culture = "da-DK", Value = "original da" } + } + }, + new BlockItemData + { + Key = textBlock2Key, + ContentTypeKey = textBlockType.Key, + ContentTypeAlias = textBlockType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "text", Culture = "en-US", Value = "second block en" }, + new BlockPropertyValue { Alias = "text", Culture = "da-DK", Value = "second block da" } + } + } + }, + SettingsData = new List(), + Expose = new List + { + new BlockItemVariation(textBlock1Key, "en-US", null), + new BlockItemVariation(textBlock1Key, "da-DK", null), + new BlockItemVariation(textBlock2Key, "en-US", null), + new BlockItemVariation(textBlock2Key, "da-DK", null) + } + }; + var blockListJson = jsonSerializer.Serialize(blockListValue); + + // Layer 2: Block Grid value with areaBlock (root) + listContainerBlock (in area) + var blockGridValue = new BlockGridValue(new[] + { + new BlockGridLayoutItem(areaBlockKey) + { + ColumnSpan = 12, + RowSpan = 1, + Areas = new[] + { + new BlockGridLayoutAreaItem(areaKey) + { + Items = new[] + { + new BlockGridLayoutItem(listContainerKey) + { + ColumnSpan = 12, + RowSpan = 1, + Areas = Array.Empty() + } + } + } + } + } + }) + { + ContentData = new List + { + new BlockItemData + { + Key = areaBlockKey, + ContentTypeKey = areaBlockType.Key, + ContentTypeAlias = areaBlockType.Alias, + Values = new List() + }, + new BlockItemData + { + Key = listContainerKey, + ContentTypeKey = listContainerType.Key, + ContentTypeAlias = listContainerType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "blockList", Value = blockListJson } + } + } + }, + SettingsData = new List(), + Expose = new List + { + new BlockItemVariation(areaBlockKey, null, null), + new BlockItemVariation(listContainerKey, null, null) + } + }; + var blockGridJson = jsonSerializer.Serialize(blockGridValue); + + // Layer 3: RTE value with gridContainerBlock + var rteBlockValue = new RichTextBlockValue(new[] + { + new RichTextBlockLayoutItem(gridContainerKey) + }) + { + ContentData = new List + { + new BlockItemData + { + Key = gridContainerKey, + ContentTypeKey = gridContainerType.Key, + ContentTypeAlias = gridContainerType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "blockGrid", Value = blockGridJson } + } + } + }, + SettingsData = new List(), + Expose = new List + { + new BlockItemVariation(gridContainerKey, null, null) + } + }; + + var rteEditorValue = new RichTextEditorValue + { + Markup = "

Hello

", + Blocks = rteBlockValue + }; + var rteJson = RichTextPropertyEditorHelper.SerializeRichTextEditorValue(rteEditorValue, jsonSerializer); + + // ── Create document ── + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = new List + { + new() { Name = "English Page", Culture = "en-US" }, + new() { Name = "Danish Page", Culture = "da-DK" }, + }, + Properties = new List + { + new() { Alias = "rte", Value = rteJson, Culture = "en-US" }, + new() { Alias = "rte", Value = rteJson, Culture = "da-DK" } + } + }; + var createResult = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + + return new DeepNestedSetup + { + DocumentKey = createResult.Result.Content!.Key, + GridContainerKey = gridContainerKey, + ListContainerKey = listContainerKey, + TextBlock1Key = textBlock1Key, + TextBlock2Key = textBlock2Key, + TextBlockTypeKey = textBlockType.Key, + JsonSerializer = jsonSerializer + }; + } + + private record DeepNestedSetup + { + public Guid DocumentKey { get; init; } + public Guid GridContainerKey { get; init; } + public Guid ListContainerKey { get; init; } + public Guid TextBlock1Key { get; init; } + public Guid TextBlock2Key { get; init; } + public Guid TextBlockTypeKey { get; init; } + public IJsonSerializer JsonSerializer { get; init; } = null!; + + /// + /// Builds the common path prefix to reach the nested block list value. + /// + public string BlockListPathPrefix(string culture) => + $"/values[alias=rte,culture={culture},segment=null]/value/blocks" + + $"/contentData[key={GridContainerKey}]/values[alias=blockGrid]/value" + + $"/contentData[key={ListContainerKey}]/values[alias=blockList]/value"; + } + + [Test] + public async Task PatchDocument_DeeplyNestedBlocks_ReplacesPropertyAtDeepestLevel() + { + // Arrange + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + var setup = await CreateDeeplyNestedBlockDocument(); + + // Path to the first text block's text property for en-US culture + var path = setup.BlockListPathPrefix("en-US") + + $"/contentData[key={setup.TextBlock1Key}]/values[alias=text,culture=en-US]/value"; + + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = path, + Value = "updated deep value" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{setup.DocumentKey}/patch", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error response: {response.StatusCode}"); + Console.WriteLine($"Error body: {errorContent}"); + } + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify the deeply nested value was updated for en-US + var updatedContent = await ContentEditingService.GetAsync(setup.DocumentKey); + Assert.IsNotNull(updatedContent); + + var rteValue = updatedContent.GetValue("rte", "en-US"); + Assert.IsNotNull(rteValue); + + // Parse through the nested structure to verify the patched value + RichTextPropertyEditorHelper.TryParseRichTextEditorValue(rteValue, setup.JsonSerializer, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, out var parsedRte); + Assert.IsNotNull(parsedRte?.Blocks); + + var gridContainerData = parsedRte!.Blocks!.ContentData.FirstOrDefault(b => b.Key == setup.GridContainerKey); + Assert.IsNotNull(gridContainerData); + + var blockGridRaw = gridContainerData!.Values.FirstOrDefault(v => v.Alias == "blockGrid")?.Value?.ToString(); + Assert.IsNotNull(blockGridRaw); + + var blockGridVal = setup.JsonSerializer.Deserialize(blockGridRaw!); + Assert.IsNotNull(blockGridVal); + + var listContainerData = blockGridVal!.ContentData.FirstOrDefault(b => b.Key == setup.ListContainerKey); + Assert.IsNotNull(listContainerData); + + var blockListRaw = listContainerData!.Values.FirstOrDefault(v => v.Alias == "blockList")?.Value?.ToString(); + Assert.IsNotNull(blockListRaw); + + var blockListVal = setup.JsonSerializer.Deserialize(blockListRaw!); + Assert.IsNotNull(blockListVal); + + // Verify the patched text block + var textBlock1 = blockListVal!.ContentData.FirstOrDefault(b => b.Key == setup.TextBlock1Key); + Assert.IsNotNull(textBlock1); + Assert.AreEqual("updated deep value", + textBlock1!.Values.FirstOrDefault(v => v.Alias == "text" && v.Culture == "en-US")?.Value?.ToString()); + + // Verify da-DK text was NOT changed + Assert.AreEqual("original da", + textBlock1.Values.FirstOrDefault(v => v.Alias == "text" && v.Culture == "da-DK")?.Value?.ToString()); + + // Verify the second text block was NOT changed + var textBlock2 = blockListVal.ContentData.FirstOrDefault(b => b.Key == setup.TextBlock2Key); + Assert.IsNotNull(textBlock2); + Assert.AreEqual("second block en", + textBlock2!.Values.FirstOrDefault(v => v.Alias == "text" && v.Culture == "en-US")?.Value?.ToString()); + } + + [Test] + public async Task PatchDocument_DeeplyNestedBlocks_AddsBlockToNestedBlockList() + { + // Arrange + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + var setup = await CreateDeeplyNestedBlockDocument(); + + var prefix = setup.BlockListPathPrefix("en-US"); + var newBlockKey = Guid.NewGuid(); + + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/contentData/-", + Value = new + { + key = newBlockKey, + contentTypeKey = setup.TextBlockTypeKey, + values = new[] + { + new { alias = "text", culture = "en-US", value = "new block en" }, + new { alias = "text", culture = "da-DK", value = "new block da" } + } + } + }, + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/layout/Umbraco.BlockList/-", + Value = new { contentKey = newBlockKey } + }, + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/expose/-", + Value = new { contentKey = newBlockKey, culture = "en-US" } + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{setup.DocumentKey}/patch", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error response: {response.StatusCode}"); + Console.WriteLine($"Error body: {errorContent}"); + } + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Parse through the nested structure to the block list + var updatedContent = await ContentEditingService.GetAsync(setup.DocumentKey); + Assert.IsNotNull(updatedContent); + var rteValue = updatedContent.GetValue("rte", "en-US"); + RichTextPropertyEditorHelper.TryParseRichTextEditorValue(rteValue, setup.JsonSerializer, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, out var parsedRte); + var gridContainerData = parsedRte!.Blocks!.ContentData.First(b => b.Key == setup.GridContainerKey); + var blockGridVal = setup.JsonSerializer.Deserialize(gridContainerData.Values.First(v => v.Alias == "blockGrid").Value!.ToString()!); + var listContainerData = blockGridVal!.ContentData.First(b => b.Key == setup.ListContainerKey); + var blockListVal = setup.JsonSerializer.Deserialize(listContainerData.Values.First(v => v.Alias == "blockList").Value!.ToString()!); + + // Verify 3 blocks in contentData + Assert.AreEqual(3, blockListVal!.ContentData.Count); + + // Verify new block was appended + var newBlock = blockListVal.ContentData.FirstOrDefault(b => b.Key == newBlockKey); + Assert.IsNotNull(newBlock); + Assert.AreEqual("new block en", newBlock!.Values.FirstOrDefault(v => v.Alias == "text" && v.Culture == "en-US")?.Value?.ToString()); + + // Verify originals unchanged + Assert.AreEqual("original en", blockListVal.ContentData.First(b => b.Key == setup.TextBlock1Key).Values.First(v => v.Alias == "text" && v.Culture == "en-US").Value?.ToString()); + Assert.AreEqual("second block en", blockListVal.ContentData.First(b => b.Key == setup.TextBlock2Key).Values.First(v => v.Alias == "text" && v.Culture == "en-US").Value?.ToString()); + + // Verify layout has 3 entries + var layoutItems = blockListVal.Layout[Constants.PropertyEditors.Aliases.BlockList].ToList(); + Assert.AreEqual(3, layoutItems.Count); + } + + [Test] + public async Task PatchDocument_DeeplyNestedBlocks_InsertsBlockAtSpecificPosition() + { + // Arrange + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + var setup = await CreateDeeplyNestedBlockDocument(); + + var prefix = setup.BlockListPathPrefix("en-US"); + var newBlockKey = Guid.NewGuid(); + + // Insert at index 1 (between the two existing blocks) + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/contentData/1", + Value = new + { + key = newBlockKey, + contentTypeKey = setup.TextBlockTypeKey, + values = new[] + { + new { alias = "text", culture = "en-US", value = "inserted block en" }, + new { alias = "text", culture = "da-DK", value = "inserted block da" } + } + } + }, + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/layout/Umbraco.BlockList/1", + Value = new { contentKey = newBlockKey } + }, + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/expose/1", + Value = new { contentKey = newBlockKey, culture = "en-US" } + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{setup.DocumentKey}/patch", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error response: {response.StatusCode}"); + Console.WriteLine($"Error body: {errorContent}"); + } + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Parse through the nested structure to the block list + var updatedContent = await ContentEditingService.GetAsync(setup.DocumentKey); + var rteValue = updatedContent.GetValue("rte", "en-US"); + RichTextPropertyEditorHelper.TryParseRichTextEditorValue(rteValue, setup.JsonSerializer, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, out var parsedRte); + var gridContainerData = parsedRte!.Blocks!.ContentData.First(b => b.Key == setup.GridContainerKey); + var blockGridVal = setup.JsonSerializer.Deserialize(gridContainerData.Values.First(v => v.Alias == "blockGrid").Value!.ToString()!); + var listContainerData = blockGridVal!.ContentData.First(b => b.Key == setup.ListContainerKey); + var blockListVal = setup.JsonSerializer.Deserialize(listContainerData.Values.First(v => v.Alias == "blockList").Value!.ToString()!); + + // Verify 3 blocks in contentData + Assert.AreEqual(3, blockListVal!.ContentData.Count); + + // Verify insertion order: original1, inserted, original2 + Assert.AreEqual(setup.TextBlock1Key, blockListVal.ContentData[0].Key); + Assert.AreEqual(newBlockKey, blockListVal.ContentData[1].Key); + Assert.AreEqual(setup.TextBlock2Key, blockListVal.ContentData[2].Key); + + // Verify inserted block values + Assert.AreEqual("inserted block en", blockListVal.ContentData[1].Values.First(v => v.Alias == "text" && v.Culture == "en-US").Value?.ToString()); + + // Verify layout order matches + var layoutItems = blockListVal.Layout[Constants.PropertyEditors.Aliases.BlockList].ToList(); + Assert.AreEqual(3, layoutItems.Count); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs b/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs index 598cfc015056..3756ddfaeb75 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs @@ -32,8 +32,7 @@ namespace Umbraco.Cms.Tests.Integration.ManagementApi; public abstract class ManagementApiTest : UmbracoTestServerTestBase where T : ManagementApiControllerBase { - - private static readonly Dictionary _tokenCache = new(); + private static readonly Dictionary _tokenCache = new(); private static readonly SHA256 _sha256 = SHA256.Create(); protected abstract Expression> MethodSelector { get; set; } @@ -126,8 +125,7 @@ protected async Task AuthenticateClientAsync(HttpClient client, Func(); + CookieContainer cookies = new CookieContainer(); + foreach (var cookieHeader in tokenResponse.Headers.GetValues("Set-Cookie")) + { + cookies.SetCookies(tokenResponse.RequestMessage!.RequestUri!, cookieHeader); + } + + string cookieTokenValue = cookies.GetCookies(tokenResponse.RequestMessage!.RequestUri!).FirstOrDefault(c => c.Name == "__Host-umbAccessToken")!.Value; - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenModel.AccessToken); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "[redacted]"); // Cache the token if cache key provided if (!string.IsNullOrEmpty(cacheKey)) { - _tokenCache[cacheKey] = tokenModel; + _tokenCache[cacheKey] = cookieTokenValue; } } - private class TokenModel + private void SetTokenCookie(HttpClient client, string token) { - [JsonPropertyName("access_token")] - public string AccessToken { get; set; } + if (client.DefaultRequestHeaders.Contains("Cookie")) + { + client.DefaultRequestHeaders.Remove("Cookie"); + } + + client.DefaultRequestHeaders.Add("Cookie", $"__Host-umbAccessToken={token}"); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "[redacted]"); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchEngineTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchEngineTests.cs new file mode 100644 index 000000000000..0248b0cbab70 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchEngineTests.cs @@ -0,0 +1,505 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.More; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Patching; +using Umbraco.Cms.Api.Management.ViewModels.Patching; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Patching; + +[TestFixture] +public class PatchEngineTests +{ + [Test] + public void Replace_SimpleProperty_UpdatesValue() + { + var json = """ + { + "name": "Original Name", + "title": "Original Title" + } + """; + + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, "/name", "Updated Name"); + + var doc = result.ToJsonDocument(); + Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Updated Name")); + Assert.That(doc.RootElement.GetProperty("title").GetString(), Is.EqualTo("Original Title")); + } + + [Test] + public void Replace_ArrayElementProperty_UpdatesValue() + { + var json = """ + { + "values": [ + { "alias": "title", "value": "Original Value" }, + { "alias": "description", "value": "Description Value" } + ] + } + """; + + var result = PatchEngine.ApplyOperation( + JsonNode.Parse(json)!, PatchOperationType.Replace, + "/values[alias=title]/value", + "Updated Value"); + + var doc = result.ToJsonDocument(); + var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); + Assert.That(values[0].GetProperty("value").GetString(), Is.EqualTo("Updated Value")); + Assert.That(values[1].GetProperty("value").GetString(), Is.EqualTo("Description Value")); + } + + [Test] + public void Replace_NestedProperty_UpdatesValue() + { + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "value": { + "contentData": [ + { "key": "block-1", "values": [{ "alias": "headline", "value": "Original" }] }, + { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2 Headline" }] } + ] + } + } + ] + } + """; + + var result = PatchEngine.ApplyOperation( + JsonNode.Parse(json)!, + PatchOperationType.Replace, + "/values[alias=contentBlocks]/value/contentData[key=block-2]/values[alias=headline]/value", + "Updated Block 2 Headline"); + + var doc = result.ToJsonDocument(); + var contentBlocks = doc.RootElement.GetProperty("values").EnumerateArray().First(); + var contentData = contentBlocks.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); + var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); + var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); + + // Verify block 1 wasn't changed + var block1 = contentData.First(b => b.GetProperty("key").GetString() == "block-1"); + var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Original")); + } + + [Test] + public void Replace_NullFilters_UpdatesValue() + { + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "culture": null, + "segment": null, + "value": { + "contentData": [ + { "key": "block-1", "values": [{ "alias": "headline", "value": "Block 1 Headline" }] }, + { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2 Headline" }] } + ] + } + } + ] + } + """; + + var result = PatchEngine.ApplyOperation( + JsonNode.Parse(json)!, + PatchOperationType.Replace, + "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key=block-2]/values[alias=headline]/value", + "Updated Block 2 Headline"); + + var doc = result.ToJsonDocument(); + var contentBlocks = doc.RootElement.GetProperty("values").EnumerateArray().First(); + var contentData = contentBlocks.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); + + var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); + var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); + + // Verify block 1 wasn't changed + var block1 = contentData.First(b => b.GetProperty("key").GetString() == "block-1"); + var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Block 1 Headline")); + } + + [Test] + public void Replace_NoMatches_ThrowsInvalidOperationException() + { + var json = """ + { + "values": [] + } + """; + + Assert.Throws(() => + PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "/values[alias=nonexistent]/value", "New Value")); + } + + [Test] + public void Replace_RealBlockListStructure_UpdatesValue() + { + var blockKey = "25acc650-ab47-40ba-b31b-c5dc0a9eb5b1"; + var json = $$""" + { + "template" : null, + "values" : [ { + "culture" : null, + "segment" : null, + "alias" : "contentBlocks", + "value" : { + "contentData" : [ { + "contentTypeKey" : "98b8b6da-d9c1-47af-abd2-f99cf8150356", + "udi" : null, + "key" : "629e4129-3128-4367-9dac-d90edbfa68df", + "values" : [ { + "editorAlias" : "Umbraco.TextBox", + "culture" : null, + "segment" : null, + "alias" : "headline", + "value" : "Block 1 Headline" + } ] + }, { + "contentTypeKey" : "98b8b6da-d9c1-47af-abd2-f99cf8150356", + "udi" : null, + "key" : "{{blockKey}}", + "values" : [ { + "editorAlias" : "Umbraco.TextBox", + "culture" : null, + "segment" : null, + "alias" : "headline", + "value" : "Block 2 Headline" + } ] + } ], + "settingsData" : [ ], + "expose" : [ ], + "Layout" : { + "Umbraco.BlockList" : [ ] + } + } + } ], + "variants" : [ { + "culture" : null, + "segment" : null, + "name" : "My Blocks Document" + } ] + } + """; + + var path = $"/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key={blockKey}]/values[alias=headline]/value"; + + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, path, "Updated Block 2 Headline"); + + var doc = result.ToJsonDocument(); + var values = doc.RootElement.GetProperty("values").EnumerateArray().First(); + var contentData = values.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); + + var block2 = contentData.First(b => b.GetProperty("key").GetString() == blockKey); + var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); + + // Verify block 1 wasn't changed + var block1 = contentData.First(b => b.GetProperty("key").GetString() == "629e4129-3128-4367-9dac-d90edbfa68df"); + var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Block 1 Headline")); + } + + [Test] + public void Add_AppendToArray_AddsElementAtEnd() + { + var json = """ + { + "contentData": [ + { "key": "block-1", "value": "first" } + ] + } + """; + + var newBlock = new { key = "block-2", value = "second" }; + + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Add, "/contentData/-", newBlock); + + var doc = result.ToJsonDocument(); + var contentData = doc.RootElement.GetProperty("contentData").EnumerateArray().ToList(); + Assert.That(contentData, Has.Count.EqualTo(2)); + Assert.That(contentData[0].GetProperty("key").GetString(), Is.EqualTo("block-1")); + Assert.That(contentData[1].GetProperty("key").GetString(), Is.EqualTo("block-2")); + } + + [Test] + public void Add_InsertAtIndex_InsertsElement() + { + var json = """ + { + "items": ["a", "b", "c"] + } + """; + + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Add, "/items/1", "inserted"); + + var doc = result.ToJsonDocument(); + var items = doc.RootElement.GetProperty("items").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.That(items, Is.EqualTo(new[] { "a", "inserted", "b", "c" })); + } + + [Test] + public void Add_NewObjectProperty_AddsProperty() + { + var json = """ + { + "name": "Test" + } + """; + + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Add, "/description", "New Description"); + + var doc = result.ToJsonDocument(); + Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Test")); + Assert.That(doc.RootElement.GetProperty("description").GetString(), Is.EqualTo("New Description")); + } + + [Test] + public void Add_AppendToNestedArray_AddsElement() + { + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "culture": null, + "segment": null, + "value": { + "contentData": [ + { "key": "block-1", "values": [] } + ] + } + } + ] + } + """; + + var newBlock = new { key = "block-2", values = Array.Empty() }; + + var result = PatchEngine.ApplyOperation( + JsonNode.Parse(json)!, + PatchOperationType.Add, + "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData/-", + newBlock); + + var doc = result.ToJsonDocument(); + var contentData = doc.RootElement + .GetProperty("values").EnumerateArray().First() + .GetProperty("value") + .GetProperty("contentData").EnumerateArray().ToList(); + Assert.That(contentData, Has.Count.EqualTo(2)); + Assert.That(contentData[1].GetProperty("key").GetString(), Is.EqualTo("block-2")); + } + + [Test] + public void Remove_ArrayElementByFilter_RemovesElement() + { + var json = """ + { + "values": [ + { "alias": "title", "value": "Title Value" }, + { "alias": "description", "value": "Description Value" } + ] + } + """; + + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Remove, "/values[alias=title]", null); + + var doc = result.ToJsonDocument(); + var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); + Assert.That(values, Has.Count.EqualTo(1)); + Assert.That(values[0].GetProperty("alias").GetString(), Is.EqualTo("description")); + } + + [Test] + public void Remove_ArrayElementByIndex_RemovesElement() + { + var json = """ + { + "items": ["a", "b", "c"] + } + """; + + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Remove, "/items/1", null); + + var doc = result.ToJsonDocument(); + var items = doc.RootElement.GetProperty("items").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.That(items, Is.EqualTo(new[] { "a", "c" })); + } + + [Test] + public void Remove_ObjectProperty_RemovesProperty() + { + var json = """ + { + "name": "Test", + "description": "To Remove" + } + """; + + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Remove, "/description", null); + + var doc = result.ToJsonDocument(); + Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Test")); + Assert.That(doc.RootElement.TryGetProperty("description", out _), Is.False); + } + + [Test] + public void Replace_WithAppendSegment_ThrowsInvalidOperationException() + { + var json = """ + { + "items": ["a", "b"] + } + """; + + Assert.Throws(() => + PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, "/items/-", "new")); + } + + [Test] + public void Remove_WithAppendSegment_ThrowsInvalidOperationException() + { + var json = """ + { + "items": ["a", "b"] + } + """; + + Assert.Throws(() => + PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Remove, "/items/-", null)); + } + + [Test] + public void ApplyOperation_InvalidPath_ThrowsFormatException() + { + var json = """{ "name": "test" }"""; + + Assert.Throws(() => + PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, "name", "new")); + } + + [Test] + public void ApplyOperation_NullJson_ThrowsArgumentNullException() + { + Assert.Throws(() => + PatchEngine.ApplyOperation(null!, PatchOperationType.Replace, "/name", "new")); + } + + [Test] + public void Replace_OutputCanBeDeserialized() + { + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "culture": null, + "segment": null, + "value": { + "contentData": [ + { "key": "block-1", "values": [{ "alias": "headline", "value": "Block 1" }] }, + { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2" }] } + ], + "settingsData": [], + "expose": [], + "Layout": { "Umbraco.BlockList": [] } + } + } + ] + } + """; + + var result = PatchEngine.ApplyOperation( + JsonNode.Parse(json)!, + PatchOperationType.Replace, + "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key=block-2]/values[alias=headline]/value", + "Updated Block 2"); + + var doc = result.ToJsonDocument(); + + // Extract the value property (simulates what JsonObjectConverter returns) + var valueElement = doc.RootElement.GetProperty("values").EnumerateArray().First().GetProperty("value"); + var valueJson = valueElement.GetRawText(); + + // Verify the modified value is in the JSON string + Assert.That(valueJson, Does.Contain("Updated Block 2")); + + // Parse and verify the structure is maintained + var reparsed = JsonDocument.Parse(valueJson); + var contentData = reparsed.RootElement.GetProperty("contentData").EnumerateArray().ToList(); + Assert.That(contentData, Has.Count.EqualTo(2)); + + var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); + var headline = block2.GetProperty("values").EnumerateArray().First().GetProperty("value").GetString(); + Assert.That(headline, Is.EqualTo("Updated Block 2")); + } + + [Test] + public void Replace_MultipleFilterConditions_MatchesCorrectElement() + { + var json = """ + { + "values": [ + { "alias": "price", "culture": "en-US", "segment": "standard", "value": "100" }, + { "alias": "price", "culture": "en-US", "segment": "premium", "value": "150" }, + { "alias": "price", "culture": "da-DK", "segment": "premium", "value": "1000" } + ] + } + """; + + var result = PatchEngine.ApplyOperation( + JsonNode.Parse(json)!, + PatchOperationType.Replace, + "/values[alias=price,culture=en-US,segment=premium]/value", + "200"); + + var doc = result.ToJsonDocument(); + var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); + Assert.That(values[0].GetProperty("value").GetString(), Is.EqualTo("100")); + Assert.That(values[1].GetProperty("value").GetString(), Is.EqualTo("200")); + Assert.That(values[2].GetProperty("value").GetString(), Is.EqualTo("1000")); + } + + // RFC 6901 escape sequence tests + + [Test] + public void Replace_PropertyNameContainingSlash_RequiresEscaping() + { + var json = """ + { + "a/b": "original" + } + """; + + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, "/a~1b", "updated"); + + var doc = result.ToJsonDocument(); + Assert.That(doc.RootElement.GetProperty("a/b").GetString(), Is.EqualTo("updated")); + } + + [Test] + public void Replace_PropertyNameContainingTilde_RequiresEscaping() + { + var json = """ + { + "a~b": "original" + } + """; + + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, "/a~0b", "updated"); + + var doc = result.ToJsonDocument(); + Assert.That(doc.RootElement.GetProperty("a~b").GetString(), Is.EqualTo("updated")); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs new file mode 100644 index 000000000000..7915e7d61cba --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs @@ -0,0 +1,229 @@ +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Patching; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Patching; + +[TestFixture] +public class PatchPathParserTests +{ + [Test] + public void Parse_SimpleProperty_ReturnsPropertySegment() + { + var segments = PatchPathParser.Parse("/name"); + + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("name")); + } + + [Test] + public void Parse_TwoProperties_ReturnsTwoPropertySegments() + { + var segments = PatchPathParser.Parse("/variants/name"); + + Assert.That(segments, Has.Length.EqualTo(2)); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("variants")); + Assert.That(((PropertySegment)segments[1]).Name, Is.EqualTo("name")); + } + + [Test] + public void Parse_PropertyWithFilter_ReturnsPropertyAndFilterSegments() + { + var segments = PatchPathParser.Parse("/variants[culture=en-US,segment=null]/name"); + + Assert.That(segments, Has.Length.EqualTo(3)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("variants")); + + Assert.That(segments[1], Is.InstanceOf()); + var filter = (FilterSegment)segments[1]; + Assert.That(filter.Conditions, Has.Length.EqualTo(2)); + Assert.That(filter.Conditions[0].Key, Is.EqualTo("culture")); + Assert.That(filter.Conditions[0].Value, Is.EqualTo("en-US")); + Assert.That(filter.Conditions[1].Key, Is.EqualTo("segment")); + Assert.That(filter.Conditions[1].Value, Is.Null); + + Assert.That(segments[2], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[2]).Name, Is.EqualTo("name")); + } + + [Test] + public void Parse_ValuesPath_ReturnsCorrectSegments() + { + var segments = PatchPathParser.Parse("/values[alias=title,culture=en-US,segment=null]/value"); + + Assert.That(segments, Has.Length.EqualTo(3)); + + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("values")); + + var filter = (FilterSegment)segments[1]; + Assert.That(filter.Conditions, Has.Length.EqualTo(3)); + Assert.That(filter.Conditions[0], Is.EqualTo(new FilterCondition("alias", "title"))); + Assert.That(filter.Conditions[1], Is.EqualTo(new FilterCondition("culture", "en-US"))); + Assert.That(filter.Conditions[2], Is.EqualTo(new FilterCondition("segment", null))); + + Assert.That(((PropertySegment)segments[2]).Name, Is.EqualTo("value")); + } + + [Test] + public void Parse_NestedBlockListPath_ReturnsCorrectSegments() + { + var path = "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key=block-2]/values[alias=headline]/value"; + var segments = PatchPathParser.Parse(path); + + Assert.That(segments, Has.Length.EqualTo(8)); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("values")); + Assert.That(segments[1], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[2]).Name, Is.EqualTo("value")); + Assert.That(((PropertySegment)segments[3]).Name, Is.EqualTo("contentData")); + Assert.That(segments[4], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[5]).Name, Is.EqualTo("values")); + Assert.That(segments[6], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[7]).Name, Is.EqualTo("value")); + } + + [Test] + public void Parse_IndexSegment_ReturnsIndexSegment() + { + var segments = PatchPathParser.Parse("/contentData/2/values"); + + Assert.That(segments, Has.Length.EqualTo(3)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(segments[1], Is.InstanceOf()); + Assert.That(((IndexSegment)segments[1]).Index, Is.EqualTo(2)); + Assert.That(segments[2], Is.InstanceOf()); + } + + [Test] + public void Parse_AppendSegment_ReturnsAppendSegment() + { + var segments = PatchPathParser.Parse("/contentData/-"); + + Assert.That(segments, Has.Length.EqualTo(2)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(segments[1], Is.InstanceOf()); + } + + [Test] + public void Parse_EmptyString_ThrowsFormatException() + { + Assert.Throws(() => PatchPathParser.Parse(string.Empty)); + } + + [Test] + public void Parse_NoLeadingSlash_ThrowsFormatException() + { + Assert.Throws(() => PatchPathParser.Parse("name")); + } + + [Test] + public void Parse_UnclosedBracket_ThrowsFormatException() + { + Assert.Throws(() => PatchPathParser.Parse("/values[alias=title")); + } + + [Test] + public void Parse_EmptyFilter_ThrowsFormatException() + { + Assert.Throws(() => PatchPathParser.Parse("/values[]")); + } + + [Test] + public void Parse_FilterWithoutEquals_ThrowsFormatException() + { + Assert.Throws(() => PatchPathParser.Parse("/values[alias]")); + } + + [Test] + public void IsValid_ValidSimplePath_ReturnsTrue() + { + Assert.That(PatchPathParser.IsValid("/name", out _), Is.True); + } + + [Test] + public void IsValid_ValidFilterPath_ReturnsTrue() + { + Assert.That(PatchPathParser.IsValid("/values[alias=title,culture=en-US,segment=null]/value", out _), Is.True); + } + + [Test] + public void IsValid_ValidAppendPath_ReturnsTrue() + { + Assert.That(PatchPathParser.IsValid("/contentData/-", out _), Is.True); + } + + [Test] + public void IsValid_EmptyString_ReturnsFalse() + { + Assert.That(PatchPathParser.IsValid(string.Empty, out _), Is.False); + } + + [Test] + public void IsValid_InvalidSyntax_ReturnsFalse() + { + Assert.That(PatchPathParser.IsValid("/values[alias=title", out _), Is.False); + } + + // RFC 6901 escape sequence tests + + [Test] + public void Parse_Tilde1EscapeSequence_DecodesToSlash() + { + var segments = PatchPathParser.Parse("/a~1b"); + + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("a/b")); + } + + [Test] + public void Parse_Tilde0EscapeSequence_DecodesToTilde() + { + var segments = PatchPathParser.Parse("/a~0b"); + + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("a~b")); + } + + [Test] + public void Parse_Tilde01Combined_DecodesCorrectly() + { + // ~01 should decode to ~1 (not /), because ~0 decodes to ~ and the trailing 1 stays. + // The implementation decodes ~1 first (to /), then ~0 (to ~). + // So ~01 → after ~1 pass: ~01 (no match) → after ~0 pass: ~1? No... + // Actually: "~01" → Replace("~1", "/") has no match → Replace("~0", "~") → "~1" + var segments = PatchPathParser.Parse("/~01"); + + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("~1")); + } + + [Test] + public void Parse_NoEscapeSequences_PropertyNameUnchanged() + { + var segments = PatchPathParser.Parse("/normalProperty"); + + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("normalProperty")); + } + + [Test] + public void Parse_EscapeSequenceInMultiSegmentPath_DecodesOnlyAffectedSegment() + { + var segments = PatchPathParser.Parse("/foo/bar~1baz/qux"); + + Assert.That(segments, Has.Length.EqualTo(3)); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("foo")); + Assert.That(((PropertySegment)segments[1]).Name, Is.EqualTo("bar/baz")); + Assert.That(((PropertySegment)segments[2]).Name, Is.EqualTo("qux")); + } + + [Test] + public void IsValid_PathWithEscapeSequence_ReturnsTrue() + { + Assert.That(PatchPathParser.IsValid("/foo~1bar", out _), Is.True); + Assert.That(PatchPathParser.IsValid("/foo~0bar", out _), Is.True); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathResolverTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathResolverTests.cs new file mode 100644 index 000000000000..c98939db58de --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathResolverTests.cs @@ -0,0 +1,153 @@ +using System.Text.Json.Nodes; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Patching; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Patching; + +[TestFixture] +public class PatchPathResolverTests +{ + [Test] + public void Resolve_FilterWithNumericValue_MatchesElement() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "id": 1, "name": "First" }, + { "id": 2, "name": "Second" }, + { "id": 3, "name": "Third" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("id", "2")]), + new PropertySegment("name"), + }; + + var result = PatchPathResolver.Resolve(root, segments); + + Assert.That(result.Current?.GetValue(), Is.EqualTo("Second")); + } + + [Test] + public void Resolve_FilterWithBooleanValue_MatchesElement() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "active": false, "name": "Inactive" }, + { "active": true, "name": "Active" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("active", "true")]), + new PropertySegment("name"), + }; + + var result = PatchPathResolver.Resolve(root, segments); + + Assert.That(result.Current?.GetValue(), Is.EqualTo("Active")); + } + + [Test] + public void Resolve_FilterWithBooleanFalseValue_MatchesElement() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "active": true, "name": "Active" }, + { "active": false, "name": "Inactive" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("active", "false")]), + new PropertySegment("name"), + }; + + var result = PatchPathResolver.Resolve(root, segments); + + Assert.That(result.Current?.GetValue(), Is.EqualTo("Inactive")); + } + + [Test] + public void Resolve_FilterWithDecimalValue_MatchesElement() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "score": 1.5, "name": "Low" }, + { "score": 9.9, "name": "High" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("score", "9.9")]), + new PropertySegment("name"), + }; + + var result = PatchPathResolver.Resolve(root, segments); + + Assert.That(result.Current?.GetValue(), Is.EqualTo("High")); + } + + [Test] + public void Resolve_FilterWithNumericValueNoMatch_Throws() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "id": 1, "name": "First" }, + { "id": 2, "name": "Second" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("id", "99")]), + new PropertySegment("name"), + }; + + Assert.Throws(() => PatchPathResolver.Resolve(root, segments)); + } + + [Test] + public void Resolve_FilterWithMixedStringAndNumericConditions_MatchesElement() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "type": "widget", "priority": 1, "name": "Low Widget" }, + { "type": "widget", "priority": 5, "name": "High Widget" }, + { "type": "gadget", "priority": 5, "name": "High Gadget" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("type", "widget"), new FilterCondition("priority", "5")]), + new PropertySegment("name"), + }; + + var result = PatchPathResolver.Resolve(root, segments); + + Assert.That(result.Current?.GetValue(), Is.EqualTo("High Widget")); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorPickerPropertyValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorPickerPropertyValueEditorTests.cs index 17b49f6b13cf..f17daf2675df 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorPickerPropertyValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorPickerPropertyValueEditorTests.cs @@ -1,5 +1,4 @@ using System.Globalization; -using Humanizer; using System.Text.Json.Nodes; using Moq; using NUnit.Framework;