diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index af8add84f..1b70331f4 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -367,6 +367,9 @@ public async Task Put( /// A response object with the new full model and validation issues from all the groups that run [Authorize(Policy = AuthzConstants.POLICY_INSTANCE_WRITE)] [HttpPatch("{dataGuid:guid}")] + [ProducesResponseType(typeof(DataPatchResponse), 200)] + [ProducesResponseType(typeof(ProblemDetails), 412)] + [ProducesResponseType(typeof(ProblemDetails), 422)] public async Task> PatchFormData( [FromRoute] string org, [FromRoute] string app, @@ -409,9 +412,14 @@ public async Task> PatchFormData( var oldModel = await _dataClient.GetFormData(instanceGuid, modelType, org, app, instanceOwnerPartyId, dataGuid); - var response = + var (response, problemDetails) = await PatchFormDataImplementation(dataType, dataElement, dataPatchRequest, oldModel, instance); + if (problemDetails is not null) + { + return StatusCode(problemDetails.Status ?? 500, problemDetails); + } + await UpdatePresentationTextsOnInstance(instance, dataType.Id, response.NewDataModel); await UpdateDataValuesOnInstance(instance, dataType.Id, response.NewDataModel); @@ -442,13 +450,20 @@ await _dataClient.UpdateData( /// The old state of the form data /// The instance /// DataPatchResponse after this patch operation - internal async Task PatchFormDataImplementation(DataType dataType, DataElement dataElement, DataPatchRequest dataPatchRequest, object oldModel, Instance instance) + internal async Task<(DataPatchResponse, ProblemDetails?)> PatchFormDataImplementation(DataType dataType, DataElement dataElement, DataPatchRequest dataPatchRequest, object oldModel, Instance instance) { var oldModelNode = JsonSerializer.SerializeToNode(oldModel, oldModel.GetType()); var patchResult = dataPatchRequest.Patch.Apply(oldModelNode); if (!patchResult.IsSuccess) { - throw new Exception(patchResult.Error); // TODO: Let DataPatchResponse have an error state + bool testOperationFailed = dataPatchRequest.Patch.Operations[patchResult.Operation].Op == OperationType.Test; + return (null!, new ProblemDetails() + { + Title = testOperationFailed ? "Precondition in patch failed": "PatchOperationFailed", + Detail = patchResult.Error, + Type = "https://datatracker.ietf.org/doc/html/rfc6902/", + Status = testOperationFailed ? (int)HttpStatusCode.PreconditionFailed : (int)HttpStatusCode.UnprocessableContent, + }); } var model = patchResult.Result.Deserialize(oldModel.GetType())!; @@ -466,7 +481,7 @@ internal async Task PatchFormDataImplementation(DataType data NewDataModel = model, ValidationIssues = validationIssues }; - return response; + return (response, null); } /// diff --git a/src/Altinn.App.Api/Controllers/ValidateController.cs b/src/Altinn.App.Api/Controllers/ValidateController.cs index 4da14bbde..28f08183c 100644 --- a/src/Altinn.App.Api/Controllers/ValidateController.cs +++ b/src/Altinn.App.Api/Controllers/ValidateController.cs @@ -141,7 +141,6 @@ public async Task ValidateData( ValidationIssue message = new ValidationIssue { Code = ValidationIssueCodes.DataElementCodes.DataElementValidatedAtWrongTask, - InstanceId = instance.Id, Severity = ValidationIssueSeverity.Warning, DataElementId = element.Id, Description = AppTextHelper.GetAppText( diff --git a/src/Altinn.App.Core/Features/IDataElementValidator.cs b/src/Altinn.App.Core/Features/IDataElementValidator.cs index 95e5a1c37..800dd9d97 100644 --- a/src/Altinn.App.Core/Features/IDataElementValidator.cs +++ b/src/Altinn.App.Core/Features/IDataElementValidator.cs @@ -20,6 +20,11 @@ public interface IDataElementValidator /// string DataType { get; } + /// + /// Override this if you want a different name for the validation source + /// + string ValidationSource => $"{this.GetType().FullName}-{DataType}"; + /// /// Run validations for a data element. This is supposed to run quickly /// diff --git a/src/Altinn.App.Core/Features/IFormDataValidator.cs b/src/Altinn.App.Core/Features/IFormDataValidator.cs index 91a2e2594..070b5315a 100644 --- a/src/Altinn.App.Core/Features/IFormDataValidator.cs +++ b/src/Altinn.App.Core/Features/IFormDataValidator.cs @@ -22,7 +22,7 @@ public interface IFormDataValidator /// Used for partial validation to ensure that the validator only runs when relevant fields have changed. /// /// List of the json path to all changed fields for incremental validation - bool ShouldRunForIncrementalValidation(List changedFields); + bool ShouldRun(List changedFields); /// /// Returns the group id of the validator. This is used to run partial validations on the backend. @@ -30,7 +30,7 @@ public interface IFormDataValidator /// /// The default implementation should work for most cases. /// - public string Code => $"{this.GetType().FullName}-{DataType}"; + public string ValidationSource => $"{this.GetType().FullName}-{DataType}"; /// /// The actual validation function diff --git a/src/Altinn.App.Core/Features/ITaskValidator.cs b/src/Altinn.App.Core/Features/ITaskValidator.cs index f2137fe34..2bcb02d3b 100644 --- a/src/Altinn.App.Core/Features/ITaskValidator.cs +++ b/src/Altinn.App.Core/Features/ITaskValidator.cs @@ -25,6 +25,11 @@ public interface ITaskValidator /// string TaskId { get; } + /// + /// Override this if you want a different name for the validation source + /// + string ValidationSource => $"{this.GetType().FullName}-{TaskId}"; + /// /// Actual validation logic for the task /// diff --git a/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs index 1c4bd5c9e..5b8948e64 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs @@ -38,12 +38,12 @@ public DataAnnotationValidator(IHttpContextAccessor httpContextAccessor, IObject /// /// This validator has the code "dataannotations" and this is known by the frontend, who requests this validator to not run for incremental validation. /// - public string Code => "dataannotations"; + public string ValidationSource => "dataannotations"; /// /// We don't know which fields are relevant for data annotation validation, so we always run it. /// - public bool ShouldRunForIncrementalValidation(List changedFields) => true; + public bool ShouldRun(List changedFields) => true; /// public Task> ValidateFormData(Instance instance, DataElement dataElement, object data) diff --git a/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs index 841a2c16b..28a94a041 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs @@ -22,7 +22,6 @@ public Task> ValidateDataElement(Instance instance, DataEl { issues.Add( new ValidationIssue { - InstanceId = instance.Id, Code = ValidationIssueCodes.DataElementCodes.MissingContentType, DataElementId = dataElement.Id, Severity = ValidationIssueSeverity.Error, @@ -39,7 +38,6 @@ public Task> ValidateDataElement(Instance instance, DataEl { issues.Add( new ValidationIssue { - InstanceId = instance.Id, DataElementId = dataElement.Id, Code = ValidationIssueCodes.DataElementCodes.ContentTypeNotAllowed, Severity = ValidationIssueSeverity.Error, @@ -54,7 +52,6 @@ public Task> ValidateDataElement(Instance instance, DataEl { issues.Add( new ValidationIssue { - InstanceId = instance.Id, DataElementId = dataElement.Id, Code = ValidationIssueCodes.DataElementCodes.DataElementTooLarge, Severity = ValidationIssueSeverity.Error, @@ -67,7 +64,6 @@ public Task> ValidateDataElement(Instance instance, DataEl { issues.Add( new ValidationIssue { - InstanceId = instance.Id, DataElementId = dataElement.Id, Code = ValidationIssueCodes.DataElementCodes.DataElementFileInfected, Severity = ValidationIssueSeverity.Error, @@ -81,7 +77,6 @@ public Task> ValidateDataElement(Instance instance, DataEl { issues.Add( new ValidationIssue { - InstanceId = instance.Id, DataElementId = dataElement.Id, Code = ValidationIssueCodes.DataElementCodes.DataElementFileScanPending, Severity = ValidationIssueSeverity.Error, diff --git a/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs index 629c9f4db..a01b98bd6 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs @@ -38,7 +38,6 @@ public async Task> ValidateTask(Instance instance, string { var message = new ValidationIssue { - InstanceId = instance.Id, Code = ValidationIssueCodes.InstanceCodes.TooManyDataElementsOfType, Severity = ValidationIssueSeverity.Error, Description = ValidationIssueCodes.InstanceCodes.TooManyDataElementsOfType, @@ -51,7 +50,6 @@ public async Task> ValidateTask(Instance instance, string { var message = new ValidationIssue { - InstanceId = instance.Id, Code = ValidationIssueCodes.InstanceCodes.TooFewDataElementsOfType, Severity = ValidationIssueSeverity.Error, Description = ValidationIssueCodes.InstanceCodes.TooFewDataElementsOfType, diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 714e5c78b..3e76654f9 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -38,12 +38,12 @@ public ExpressionValidator(ILogger logger, IAppResources ap /// /// This validator has the code "expression" and this is known by the frontend, who requests this validator to not run for incremental validation. /// - public string Code => "expression"; + public string ValidationSource => "expression"; /// /// Expression validations should always run (it is way to complex to figure out if it should run or not) /// - public bool ShouldRunForIncrementalValidation(List changedFields) => true; + public bool ShouldRun(List changedFields) => true; /// public async Task> ValidateFormData(Instance instance, DataElement dataElement, object data) diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs index 503dd34cb..1b210bfbf 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs @@ -33,7 +33,7 @@ public LegacyIInstanceValidatorFormDataValidator(IInstanceValidator? instanceVal /// /// Always run for incremental validation /// - public bool ShouldRunForIncrementalValidation(List? changedFields = null) => _instanceValidator is not null; + public bool ShouldRun(List? changedFields = null) => _instanceValidator is not null; /// diff --git a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs index 0188b7309..f083248de 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs @@ -32,12 +32,12 @@ public RequiredLayoutValidator(LayoutEvaluatorStateInitializer layoutEvaluatorSt /// /// This validator has the code "required" and this is known by the frontend, who requests this validator to not run for incremental validation. /// - public string Code => "required"; + public string ValidationSource => "required"; /// /// Always run for incremental validation /// - public bool ShouldRunForIncrementalValidation(List changedFields) => true; + public bool ShouldRun(List changedFields) => true; /// /// Validate the form data against the required rules in the layout diff --git a/src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs b/src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs index 8b019bb1a..8a4a3912a 100644 --- a/src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs @@ -30,7 +30,7 @@ protected GenericFormDataValidator(string dataType) /// /// Default implementation that respects the runFor prefixes. /// - public bool ShouldRunForIncrementalValidation(List? changedFields = null) + public bool ShouldRun(List? changedFields = null) { if (changedFields == null) { @@ -62,7 +62,7 @@ private static bool IsMatch(string changedField, string prefix) } /// - /// Easy way to configure to only run for fields that start with the given prefix. + /// Easy way to configure to only run for fields that start with the given prefix. /// /// A selector that will be translated into a prefix /// The type of the selected element (only for making the compiler happy) @@ -80,6 +80,8 @@ protected void CreateValidationIssue(Expression> selector, str AddValidationIssue(new ValidationIssue { Field = LinqExpressionHelpers.GetJsonPath(selector), + Description = textKey, + Code = textKey, CustomTextKey = textKey, Severity = severity }); diff --git a/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs b/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs index 6bad70a2b..13e7a7c74 100644 --- a/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs +++ b/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs @@ -39,7 +39,6 @@ public static List ModelStateToIssueList(ModelStateDictionary m var severityAndMessage = GetSeverityFromMessage(error.ErrorMessage, generalSettings); validationIssues.Add(new ValidationIssue { - InstanceId = instance.Id, DataElementId = dataElement.Id, Source = source, Code = severityAndMessage.Message, @@ -159,7 +158,6 @@ public static List MapModelStateToIssueList(ModelStateDictionar var severityAndMessage = GetSeverityFromMessage(error.ErrorMessage, generalSettings); validationIssues.Add(new ValidationIssue { - InstanceId = instance.Id, Code = severityAndMessage.Message, Severity = severityAndMessage.Severity, Description = severityAndMessage.Message diff --git a/src/Altinn.App.Core/Features/Validation/ValidationService.cs b/src/Altinn.App.Core/Features/Validation/ValidationService.cs index 0d5f9fcd1..9911705e1 100644 --- a/src/Altinn.App.Core/Features/Validation/ValidationService.cs +++ b/src/Altinn.App.Core/Features/Validation/ValidationService.cs @@ -43,12 +43,14 @@ public async Task> ValidateInstanceAtTask(Instance instanc // .Concat(_serviceProvider.GetKeyedServices(taskId)) .ToArray(); - var taskIssuesTask = Task.WhenAll(taskValidators.Select(tv => + var taskIssuesTask = Task.WhenAll(taskValidators.Select(async tv => { try { _logger.LogDebug("Start running validator {validatorName} on task {taskId} in instance {instanceId}", tv.GetType().Name, taskId, instance.Id); - return tv.ValidateTask(instance, taskId); + var issues = await tv.ValidateTask(instance, taskId); + issues.ForEach(i => i.Source = tv.ValidationSource); // Ensure that the source is set to the validator source + return issues; } catch (Exception e) { @@ -85,7 +87,9 @@ public async Task> ValidateDataElement(Instance instance, try { _logger.LogDebug("Start running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", v.GetType().Name, dataElement.DataType, dataElement.Id, instance.Id); - return await v.ValidateDataElement(instance, dataElement, dataType); + var issues = await v.ValidateDataElement(instance, dataElement, dataType); + issues.ForEach(i => i.Source = v.ValidationSource); // Ensure that the source is set to the validator source + return issues; } catch (Exception e) { @@ -126,8 +130,8 @@ public async Task>> ValidateFormData(In var dataValidators = _serviceProvider.GetServices() .Where(dv => dv.DataType == "*" || dv.DataType == dataType.Id) // .Concat(_serviceProvider.GetKeyedServices(dataElement.DataType)) - .Where(dv => ignoredValidators?.Contains(dv.Code) != true) - .Where(dv => changedFields is null ? true : dv.ShouldRunForIncrementalValidation(changedFields)) + .Where(dv => ignoredValidators?.Contains(dv.ValidationSource) != true) + .Where(dv => changedFields is null ? true : dv.ShouldRun(changedFields)) .ToArray(); var issuesLists = await Task.WhenAll(dataValidators.Select(async (v) => @@ -136,7 +140,7 @@ public async Task>> ValidateFormData(In { _logger.LogDebug("Start running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", v.GetType().Name, dataElement.DataType, dataElement.Id, instance.Id); var issues = await v.ValidateFormData(instance, dataElement, data); - issues.ForEach(i => i.Code = v.Code);// Ensure that the code is set to the validator code + issues.ForEach(i => i.Source = v.ValidationSource);// Ensure that the code is set to the validator code return issues; } catch (Exception e) @@ -146,7 +150,7 @@ public async Task>> ValidateFormData(In } })); - return dataValidators.Zip(issuesLists).ToDictionary(kv => kv.First.Code, kv => kv.Second); + return dataValidators.Zip(issuesLists).ToDictionary(kv => kv.First.ValidationSource, kv => kv.Second); } } \ No newline at end of file diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index 8d191936d..a736adb55 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -134,7 +134,6 @@ private static void RunLayoutValidationsForRequiredRecurs(List validationIssues.Add(new ValidationIssue() { Severity = ValidationIssueSeverity.Error, - InstanceId = state.GetInstanceContext("instanceId").ToString(), DataElementId = dataElementId, Field = field, Description = $"{field} is required in component with id {context.Component.Id}", diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs index 2d516a073..76d6ed308 100644 --- a/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Altinn.App.Core.Features.Validation; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -16,24 +17,25 @@ public class ValidationIssue [Newtonsoft.Json.JsonConverter(typeof(StringEnumConverter))] [JsonPropertyName("severity")] [System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] - public ValidationIssueSeverity Severity { get; set; } + public required ValidationIssueSeverity Severity { get; set; } /// /// The unique id of the specific element with the identified issue. /// - [JsonProperty(PropertyName = "instanceId")] - [JsonPropertyName("instanceId")] + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [Obsolete("Not in use", error: true)] public string? InstanceId { get; set; } /// - /// The uniqe id of the data element of a given instance with the identified issue. + /// The unique id of the data element of a given instance with the identified issue. /// [JsonProperty(PropertyName = "dataElementId")] [JsonPropertyName("dataElementId")] public string? DataElementId { get; set; } /// - /// A reference to a property the issue is a bout. + /// A reference to a property the issue is about. /// [JsonProperty(PropertyName = "field")] [JsonPropertyName("field")] @@ -54,11 +56,14 @@ public class ValidationIssue public string? Description { get; set; } /// - /// The validation source of the issue eg. File, Schema, Component + /// The short name of the class that crated the message (set automatically after return of list) /// + /// + /// Intentionally not marked as "required", because it is set in + /// [JsonProperty(PropertyName = "source")] [JsonPropertyName("source")] - public string? Source { get; set; } + public string Source { get; set; } = default!; /// /// The custom text key to use for the localized text in the frontend. @@ -66,5 +71,12 @@ public class ValidationIssue [JsonProperty(PropertyName = "customTextKey")] [JsonPropertyName("customTextKey")] public string? CustomTextKey { get; set; } + + /// + /// The custom text key to use for the localized text in the frontend. + /// + [JsonProperty(PropertyName = "customTextParams")] + [JsonPropertyName("customTextParams")] + public List? CustomTextParams { get; set; } } } diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssueSeverity.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssueSeverity.cs index 5661998d7..2b2264492 100644 --- a/src/Altinn.App.Core/Models/Validation/ValidationIssueSeverity.cs +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssueSeverity.cs @@ -28,6 +28,7 @@ public enum ValidationIssueSeverity /// /// The issue has been corrected. /// + [Obsolete("We run all validations from frontend version 4, so we don't need info about fixed issues")] Fixed = 4, /// diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchFormDataImplementation.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchFormDataImplementation.cs index af7bf6d3d..2c73a3345 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchFormDataImplementation.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchFormDataImplementation.cs @@ -60,8 +60,8 @@ public class DataController_PatchFormDataImplementation : IAsyncDisposable public DataController_PatchFormDataImplementation() { _formDataValidator.Setup(fdv => fdv.DataType).Returns(_dataType.Id); - _formDataValidator.Setup(fdv => fdv.Code).Returns("formDataValidator"); - _formDataValidator.Setup(fdv => fdv.ShouldRunForIncrementalValidation(It.IsAny>())).Returns(true); + _formDataValidator.Setup(fdv => fdv.ValidationSource).Returns("formDataValidator"); + _formDataValidator.Setup(fdv => fdv.ShouldRun(It.IsAny>())).Returns(true); // _dataElementValidator.Setup(ev => ev.DataType).Returns(_dataType.Id); _serviceCollection.AddSingleton(_formDataValidator.Object); _serviceCollection.AddSingleton(_dataElementValidator); @@ -129,6 +129,7 @@ public async Task Test() { new () { + Severity = ValidationIssueSeverity.Error, Description = "First error", } }; @@ -141,7 +142,7 @@ public async Task Test() .ReturnsAsync(validationIssues); // Act - var response = await _dataController.PatchFormDataImplementation(_dataType, _dataElement , request, oldModel, _instance); + var (response, problemDetails) = await _dataController.PatchFormDataImplementation(_dataType, _dataElement , request, oldModel, _instance); // Assert response.Should().NotBeNull(); diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index 8f47f6e9c..bd7fc1fa5 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -3,94 +3,150 @@ using System.Net.Http.Headers; using System.Net; using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Altinn.App.Api.Models; +using Altinn.App.Api.Tests.Data; using Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models; using Altinn.App.Core.Features; using Xunit; using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; +using Json.Patch; +using Json.Pointer; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Moq; +using Xunit.Abstractions; namespace Altinn.App.Api.Tests.Controllers; -public class DataController_PatchTests : ApiTestBase, IClassFixture> +public class DataControllerPatchTests : ApiTestBase, IClassFixture> { - private readonly Mock _dataProcessor = new(); + private const string Org = "tdd"; + private const string App = "contributer-restriction"; + private const int InstanceOwnerPartyId = 500600; + private static readonly Guid InstanceGuid = new("0fc98a23-fe31-4ef5-8fb9-dd3f479354cd"); + private static readonly string InstanceId = $"{InstanceOwnerPartyId}/{InstanceGuid}"; + private static readonly Guid DataGuid = new("fc121812-0336-45fb-a75c-490df3ad5109"); + + private readonly Mock _dataProcessorMock = new(); + private readonly Mock _formDataValidatorMock = new(); private static readonly JsonSerializerOptions JsonSerializerOptions = new () { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + UnknownTypeHandling = JsonUnknownTypeHandling.JsonElement, }; - public DataController_PatchTests(WebApplicationFactory factory) : base(factory) + private readonly ITestOutputHelper _outputHelper; + + public DataControllerPatchTests(WebApplicationFactory factory, ITestOutputHelper outputHelper) : base(factory) { + _formDataValidatorMock.Setup(v => v.DataType).Returns("Not a valid data type"); + _outputHelper = outputHelper; OverrideServicesForAllTests = (services) => { - services.AddSingleton(_dataProcessor.Object); + services.AddSingleton(_dataProcessorMock.Object); + services.AddSingleton(_formDataValidatorMock.Object); }; + TestData.DeleteInstanceAndData(Org, App, InstanceOwnerPartyId, InstanceGuid); + TestData.PrepareInstance(Org, App, InstanceOwnerPartyId, InstanceGuid); } - [Fact] - public async Task PutDataElement_TestSinglePartUpdate_ReturnsOk() + private async Task CallPatchApi(JsonPatch patch, List? ignoredValidators) { - // Setup test data - string org = "tdd"; - string app = "contributer-restriction"; - int instanceOwnerPartyId = 501337; - HttpClient client = GetRootedClient(org, app); + using var httpClient = GetRootedClient(Org, App); string token = PrincipalUtil.GetToken(1337, null); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - - // Create instance - var createResponse = await client.PostAsync($"{org}/{app}/instances/?instanceOwnerPartyId={instanceOwnerPartyId}", null); - var createResponseContent = await createResponse.Content.ReadAsStringAsync(); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - var createResponseParsed = JsonSerializer.Deserialize(createResponseContent, JsonSerializerOptions)!; - var instanceId = createResponseParsed.Id; - - // Create data element (not sure why it isn't created when the instance is created, autoCreate is true) - using var createDataElementContent = - new StringContent("""{"melding":{"name": "Ivar"}}""", System.Text.Encoding.UTF8, "application/json"); - var createDataElementResponse = - await client.PostAsync($"/{org}/{app}/instances/{instanceId}/data?dataType=default", createDataElementContent); - var createDataElementResponseContent = await createDataElementResponse.Content.ReadAsStringAsync(); - createDataElementResponse.StatusCode.Should().Be(HttpStatusCode.Created); - var createDataElementResponseParsed = - JsonSerializer.Deserialize(createDataElementResponseContent, JsonSerializerOptions)!; - var dataGuid = createDataElementResponseParsed.Id; - - // Update data element + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); using var updateDataElementContent = new StringContent( - """ + JsonSerializer.Serialize(new DataPatchRequest() { - "patch": [ - { - "op": "replace", - "path": "/melding/name", - "value": "Ivar Nesje" - } - ], - "ignoredValidators": [ - "required" - ] - } - """, System.Text.Encoding.UTF8, "application/json"); - var response = await client.PatchAsync($"/{org}/{app}/instances/{instanceId}/data/{dataGuid}", updateDataElementContent); - response.StatusCode.Should().Be(HttpStatusCode.OK); + Patch = patch, + IgnoredValidators = ignoredValidators, + }, JsonSerializerOptions), System.Text.Encoding.UTF8, "application/json"); + return await httpClient.PatchAsync($"/{Org}/{App}/instances/{InstanceId}/data/{DataGuid}", updateDataElementContent); + } + + private async Task LogResponse(HttpResponseMessage response) + { var responseString = await response.Content.ReadAsStringAsync(); - responseString.Should().Be("""{"validationIssues":{},"newDataModel":{"melding":{"name":"Ivar Nesje","random":null,"tags":null,"simple_list":null,"nested_list":[],"toggle":false}}}"""); - - // Verify stored data - var readDataElementResponse = await client.GetAsync($"/{org}/{app}/instances/{instanceId}/data/{dataGuid}"); - readDataElementResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var readDataElementResponseContent = await readDataElementResponse.Content.ReadAsStringAsync(); - var readDataElementResponseParsed = - JsonSerializer.Deserialize(readDataElementResponseContent)!; - readDataElementResponseParsed.Melding.Name.Should().Be("Ivar Nesje"); - - _dataProcessor.Verify(p => p.ProcessDataRead(It.IsAny(), It.Is(dataId => dataId == Guid.Parse(dataGuid)), It.IsAny()), Times.Exactly(1)); - _dataProcessor.Verify(p => p.ProcessDataWrite(It.IsAny(), It.Is(dataId => dataId == Guid.Parse(dataGuid)), It.IsAny(), It.IsAny()), Times.Exactly(1)); // TODO: Shouldn't this be 2 because of the first write? - _dataProcessor.VerifyNoOtherCalls(); + using var responseParsedRaw = JsonDocument.Parse(responseString); + _outputHelper.WriteLine(JsonSerializer.Serialize(responseParsedRaw, JsonSerializerOptions)); + return responseString; + + } + private TResponse ParseResponse(string responseString) + { + return JsonSerializer.Deserialize(responseString, JsonSerializerOptions)!; + } + + [Fact] + public async Task PatchDataElement_ValidName_ReturnsOk() + { + // Update data element + var patch = new JsonPatch( + PatchOperation.Replace(JsonPointer.Create("melding", "name"), JsonNode.Parse("\"Ivar Nesje\""))); + + var response = await CallPatchApi(patch, null); + var responseString = await LogResponse(response); + + response.Should().HaveStatusCode(HttpStatusCode.OK); + var parsedResponse = ParseResponse(responseString); + parsedResponse.ValidationIssues.Should().ContainKey("required").WhoseValue.Should().BeEmpty(); + + var newModelElement = parsedResponse.NewDataModel.Should().BeOfType().Which; + var newModel = newModelElement.Deserialize()!; + newModel.Melding.Name.Should().Be("Ivar Nesje"); + + _dataProcessorMock.Verify(p => p.ProcessDataWrite(It.IsAny(), It.Is(dataId => dataId == DataGuid), It.IsAny(), It.IsAny()), Times.Exactly(1)); + _dataProcessorMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task PatchDataElement_NullName_ReturnsOkAndValidationError() + { + // Update data element + var patch = new JsonPatch( + PatchOperation.Test(JsonPointer.Create("melding", "name"), JsonNode.Parse("null")), + PatchOperation.Replace(JsonPointer.Create("melding", "name"), JsonNode.Parse("null"))); + + var response = await CallPatchApi(patch, null); + var responseString = await LogResponse(response); + + response.Should().HaveStatusCode(HttpStatusCode.OK); + var parsedResponse = ParseResponse(responseString); + var requiredList = parsedResponse.ValidationIssues.Should().ContainKey("required").WhoseValue; + var requiredName = requiredList.Should().ContainSingle().Which; + requiredName.Field.Should().Be("melding.name"); + requiredName.Description.Should().Be("melding.name is required in component with id name"); + + var newModelElement = parsedResponse.NewDataModel.Should().BeOfType().Which; + var newModel = newModelElement.Deserialize()!; + newModel.Melding.Name.Should().BeNull(); + + _dataProcessorMock.Verify(p => p.ProcessDataWrite(It.IsAny(), It.Is(dataId => dataId == DataGuid), It.IsAny(), It.IsAny()), Times.Exactly(1)); + _dataProcessorMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task PatchDataElement_InvalidTest_ReturnsPreconditionFailed() + { + // Update data element + var patch = new JsonPatch( + PatchOperation.Test(JsonPointer.Create("melding", "name"), JsonNode.Parse("\"Not correct previous value\"")), + PatchOperation.Replace(JsonPointer.Create("melding", "name"), JsonNode.Parse("null"))); + + var response = await CallPatchApi(patch, null); + + var responseString = await LogResponse(response); + response.Should().HaveStatusCode(HttpStatusCode.PreconditionFailed); + + var parsedResponse = ParseResponse(responseString); + parsedResponse.Detail.Should().Be("Path `/melding/name` is not equal to the indicated value."); + + _dataProcessorMock.VerifyNoOtherCalls(); } } \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs index 16a50bd7e..b09672457 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs @@ -125,7 +125,6 @@ public class TestScenariosData : IEnumerable new ValidationIssue { Code = ValidationIssueCodes.DataElementCodes.DataElementValidatedAtWrongTask, - InstanceId = "0fc98a23-fe31-4ef5-8fb9-dd3f479354ef", Severity = ValidationIssueSeverity.Warning, DataElementId = "0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", Description = AppTextHelper.GetAppText( diff --git a/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/.gitignore b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/.gitignore index 9c7e6c4af..dcf6ac329 100644 --- a/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/.gitignore +++ b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/.gitignore @@ -1,6 +1,4 @@ -#Ignore all blob files named with a guid for the dataElementId -????????-????-????-????-???????????? -# Ignore json files -*.json -# Except those that ends in .pretest.json -!*.pretest.json \ No newline at end of file +# Ignore guid.json files +????????-????-????-????-????????????.json +# ignore copied blobs +*/*/blob/????????-????-????-????-???????????? \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/0fc98a23-fe31-4ef5-8fb9-dd3f479354cd.pretest.json b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/0fc98a23-fe31-4ef5-8fb9-dd3f479354cd.pretest.json new file mode 100644 index 000000000..1cc0c5045 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/0fc98a23-fe31-4ef5-8fb9-dd3f479354cd.pretest.json @@ -0,0 +1,30 @@ +{ + "id": "500600/0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", + "instanceOwner": { + "partyId": "500600", + "organisationNumber": "897069631" + }, + "appId": "tdd/contributer-restriction", + "org": "tdd", + "process": { + "started": "2019-12-05T13:24:34.8412179Z", + "startEvent": "StartEvent_1", + "currentTask": { + "flow": 2, + "started": "2019-12-05T13:24:34.9196661Z", + "elementId": "Task_1", + "name": "Utfylling", + "altinnTaskType": "data", + "validated": { + "timestamp": "2020-02-07T10:46:36.985894Z", + "canCompleteTask": false + } + } + }, + "status": { + "isArchived": false, + "isSoftDeleted": false, + "isHardDeleted": false, + "readStatus": "Read" + } +} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/0fc98a23-fe31-4ef5-8fb9-dd3f479354cd/blob/fc121812-0336-45fb-a75c-490df3ad5109.pretest b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/0fc98a23-fe31-4ef5-8fb9-dd3f479354cd/blob/fc121812-0336-45fb-a75c-490df3ad5109.pretest new file mode 100644 index 000000000..903c3e28a --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/0fc98a23-fe31-4ef5-8fb9-dd3f479354cd/blob/fc121812-0336-45fb-a75c-490df3ad5109.pretest @@ -0,0 +1,6 @@ + + + + false + + \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/0fc98a23-fe31-4ef5-8fb9-dd3f479354cd/fc121812-0336-45fb-a75c-490df3ad5109.pretest.json b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/0fc98a23-fe31-4ef5-8fb9-dd3f479354cd/fc121812-0336-45fb-a75c-490df3ad5109.pretest.json new file mode 100644 index 000000000..bc6bc73e5 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/0fc98a23-fe31-4ef5-8fb9-dd3f479354cd/fc121812-0336-45fb-a75c-490df3ad5109.pretest.json @@ -0,0 +1,22 @@ +{ + "id": "fc121812-0336-45fb-a75c-490df3ad5109", + "instanceGuid": "0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", + "dataType": "default", + "filename": null, + "contentType": "application/xml", + "blobStoragePath": null, + "selfLinks": null, + "size": 0, + "contentHash": null, + "locked": false, + "refs": null, + "isRead": true, + "tags": [], + "deleteStatus": null, + "fileScanResult": "NotApplicable", + "references": null, + "created": null, + "createdBy": null, + "lastChanged": "2024-01-10T22:04:31.511965Z", + "lastChangedBy": null +} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layouts/page.json b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layouts/page.json index 8a940965e..ca66ac17f 100644 --- a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layouts/page.json +++ b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layouts/page.json @@ -9,6 +9,14 @@ "textResourceBindings": { "title": "Brukeropp-side-overskrift" } + }, + { + "id": "name", + "type": "Input", + "required": true, + "dataModelBindings": { + "simpleBinding": "melding.name" + } } ] } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs index 1be67b4e3..e0f8f5d0f 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs @@ -33,7 +33,7 @@ public async Task ValidateFormData_NoErrors() var changedFields = new List(); var validator = new LegacyIInstanceValidatorFormDataValidator(null, Options.Create(new GeneralSettings())); - validator.ShouldRunForIncrementalValidation(changedFields).Should().BeFalse(); + validator.ShouldRun(changedFields).Should().BeFalse(); // Act var result = await validator.ValidateFormData(new Instance(), new DataElement(), data); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/GenericValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/GenericValidatorTests.cs index c222148e9..b817e4dce 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/GenericValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/GenericValidatorTests.cs @@ -47,9 +47,9 @@ public void TestShouldRun() { var testValidator = new TestValidator(); testValidator.RunForExternal(m => m.Name); - testValidator.ShouldRunForIncrementalValidation().Should().BeTrue(); - testValidator.ShouldRunForIncrementalValidation(new List() { "name" }).Should().BeTrue(); - testValidator.ShouldRunForIncrementalValidation(new List() { "age" }).Should().BeFalse(); + testValidator.ShouldRun().Should().BeTrue(); + testValidator.ShouldRun(new List() { "name" }).Should().BeTrue(); + testValidator.ShouldRun(new List() { "age" }).Should().BeFalse(); } [Theory] @@ -63,7 +63,7 @@ public void TestShouldRunWithIndexedRow(string changedField, bool shouldBe) { var testValidator = new TestValidator(); testValidator.RunForExternal(m => m.Children![0].Name); - testValidator.ShouldRunForIncrementalValidation(new List() { changedField }).Should().Be(shouldBe); + testValidator.ShouldRun(new List() { changedField }).Should().Be(shouldBe); } [Theory] @@ -78,6 +78,6 @@ public void TestShouldRunWithSelectAllRow(string changedField, bool shouldBe) { var testValidator = new TestValidator(); testValidator.RunForExternal(m => m.Children!.Select(c => c.Name)); - testValidator.ShouldRunForIncrementalValidation(new List() { changedField }).Should().Be(shouldBe); + testValidator.ShouldRun(new List() { changedField }).Should().Be(shouldBe); } } \ No newline at end of file