From e3d53cafbbb7157d8439c23745d6b23cbbaeea17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20J=C3=B8rgen=20Skogstad?= Date: Fri, 25 Oct 2024 12:56:22 +0200 Subject: [PATCH] feat: Add restrictions to Transmissions reference hierarchy (#1310) ## Description This restricts transmissions reference hierarchies to: * only have a width of 1 (two separate transmissions cannot have the same relativeTransmissionId) * have a chain length/depth of max 100 * not allow cyclical references (A => B => C => A) Added tests for the command handlers making sure things are ready before using the new validator. Added tests for general rule violations, varying depth/width etc., ## Related Issue(s) - #1225 ## Verification - [x] **Your** code builds clean without any errors or warnings - [x] Manual testing done (required) - [x] Relevant automated test added (if you find this hard, leave it and we'll help out) ## Documentation - [ ] Documentation is updated (either in `docs`-directory, Altinnpedia or a separate linked PR in [altinn-studio-docs.](https://github.com/Altinn/altinn-studio-docs), if applicable) ## Summary by CodeRabbit - **New Features** - Introduced a method for validating reference hierarchies in collections, enhancing error handling for hierarchical structures. - Enhanced dialog creation and update processes to ensure proper transmission ID assignment and validation. - **Bug Fixes** - Improved validation logic to prevent depth, width, and cyclic reference violations in hierarchical data. - **Tests** - Added new unit tests to validate reference hierarchy functionality, including scenarios for depth, width, and circular reference violations. - Introduced integration tests for creating and updating transmissions within dialogs, ensuring robust error handling and validation. - Added tests for handling related transmissions with null IDs and ensuring valid updates. --------- Co-authored-by: Magnus Sandgren <5285192+MagnusSandgren@users.noreply.github.com> --- .../ReadOnlyCollectionExtensions.cs | 162 ++++++++++++++ .../Commands/Create/CreateDialogCommand.cs | 15 ++ .../Commands/Update/UpdateDialogCommand.cs | 41 ++-- .../Commands/CreateTransmissionTests.cs | 24 ++- .../Commands/UpdateTransmissionTests.cs | 134 ++++++++++++ .../Common/HierarchyTestNode.cs | 63 ++++++ .../Common/IHierarchyTestNodeBuilder.cs | 83 ++++++++ ...Dialogporten.Application.Unit.Tests.csproj | 1 + .../ValidateReferenceHierarchyTests.cs | 197 ++++++++++++++++++ 9 files changed, 692 insertions(+), 28 deletions(-) create mode 100644 src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ReadOnlyCollectionExtensions.cs create mode 100644 tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/UpdateTransmissionTests.cs create mode 100644 tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Common/HierarchyTestNode.cs create mode 100644 tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Common/IHierarchyTestNodeBuilder.cs create mode 100644 tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Validators/ValidateReferenceHierarchyTests.cs diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ReadOnlyCollectionExtensions.cs b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ReadOnlyCollectionExtensions.cs new file mode 100644 index 000000000..6b027751b --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ReadOnlyCollectionExtensions.cs @@ -0,0 +1,162 @@ +using Digdir.Domain.Dialogporten.Domain.Common; + +namespace Digdir.Domain.Dialogporten.Application.Common.Extensions; + +internal static class ReadOnlyCollectionExtensions +{ + private const int Cycle = int.MaxValue; + + /// + /// Validates the reference hierarchy in a collection of entities, checking for depth, cyclic references, and width violations. + /// + /// The type of the entities in the collection. + /// The type of the key used to identify entities. + /// The collection of entities to validate. + /// A function to select the key for each entity. + /// A function to select the parent key for each entity. + /// The name of the property being validated. + /// The maximum allowed depth of the hierarchy. Default is 100. + /// The maximum allowed width of the hierarchy. Default is 1. + /// A list of objects representing any validation errors found. + /// Thrown if an entity's parent key is not found in the collection. + /// Thrown if an entity's returns default . + public static List ValidateReferenceHierarchy( + this IReadOnlyCollection entities, + Func keySelector, + Func parentKeySelector, + string propertyName, + int maxDepth = 100, + int maxWidth = 1) + where TKey : struct + { + entities.Select(keySelector).EnsureNonDefaultTKey(); + + var maxDepthViolation = maxDepth + 1; + var type = typeof(TEntity); + var errors = new List(); + + var invalidReferences = GetInvalidReferences(entities, keySelector, parentKeySelector); + if (invalidReferences.Count > 0) + { + var ids = $"[{string.Join(",", invalidReferences)}]"; + errors.Add(new DomainFailure(propertyName, + $"Hierarchy reference violation found. " + + $"{type.Name} with the following referenced ids does not exist: {ids}.")); + + return errors; + } + + var depthByKey = entities + .ToDictionary(keySelector) + .ToDepthByKey(keySelector, parentKeySelector); + + var depthErrors = depthByKey + .Where(x => x.Value == maxDepthViolation) + .ToList(); + + var cycleErrors = depthByKey + .Where(x => x.Value == Cycle) + .ToList(); + + var widthErrors = entities + .Where(x => parentKeySelector(x) is not null) + .GroupBy(parentKeySelector) + .Where(x => x.Count() > maxWidth) + .ToList(); + + if (depthErrors.Count > 0) + { + var ids = $"[{string.Join(",", depthErrors.Select(x => x.Key))}]"; + errors.Add(new DomainFailure(propertyName, + $"Hierarchy depth violation found. {type.Name} with the following " + + $"ids is at depth {maxDepthViolation}, exceeding the max allowed depth of {maxDepth}. " + + $"It, and all its referencing children is in violation of the depth constraint. {ids}.")); + } + + if (cycleErrors.Count > 0) + { + var firstTenFailedIds = cycleErrors.Take(10).Select(x => x.Key).ToList(); + var cycleCutOffInfo = cycleErrors.Count > 10 ? " (showing first 10)" : string.Empty; + + var joinedIds = $"[{string.Join(",", firstTenFailedIds)}]"; + errors.Add(new DomainFailure(propertyName, + $"Hierarchy cyclic reference violation found. {type.Name} with the " + + $"following ids is part of a cyclic reference chain{cycleCutOffInfo}: {joinedIds}.")); + } + + if (widthErrors.Count > 0) + { + var ids = $"[{string.Join(",", widthErrors.Select(x => x.Key))}]"; + errors.Add(new DomainFailure(propertyName, + $"Hierarchy width violation found. '{type.Name}' with the following " + + $"ids has to many referring {type.Name}, exceeding the max " + + $"allowed width of {maxWidth}: {ids}.")); + } + + return errors; + } + + private static List GetInvalidReferences(IReadOnlyCollection entities, + Func keySelector, + Func parentKeySelector) where TKey : struct => entities + .Where(x => parentKeySelector(x).HasValue) + .Select(x => parentKeySelector(x)!.Value) + .Except(entities.Select(keySelector)) + .ToList(); + + private static Dictionary ToDepthByKey( + this Dictionary transmissionById, + Func keySelector, + Func parentKeySelector) + where TKey : struct + { + var depthByKey = new Dictionary(); + var breadCrumbs = new HashSet(); + foreach (var (_, current) in transmissionById) + { + GetDepth(current, transmissionById, keySelector, parentKeySelector, depthByKey, breadCrumbs); + } + + return depthByKey; + } + + private static int GetDepth(TEntity current, + Dictionary entitiesById, + Func keySelector, + Func parentKeySelector, + Dictionary cachedDepthByVisited, + HashSet breadCrumbs) + where TKey : struct + { + var key = keySelector(current); + if (breadCrumbs.Contains(key)) + { + return Cycle; + } + + if (cachedDepthByVisited.TryGetValue(key, out var cachedDepth)) + { + return cachedDepth; + } + + breadCrumbs.Add(key); + var parentKey = parentKeySelector(current); + var parentDepth = !parentKey.HasValue ? 0 + : entitiesById.TryGetValue(parentKey.Value, out var parent) + ? GetDepth(parent, entitiesById, keySelector, parentKeySelector, cachedDepthByVisited, breadCrumbs) + : throw new InvalidOperationException( + $"{nameof(entitiesById)} does not contain expected " + + $"key '{parentKey.Value}'."); + + breadCrumbs.Remove(key); + return cachedDepthByVisited[key] = parentDepth == Cycle ? Cycle : ++parentDepth; + } + + private static void EnsureNonDefaultTKey(this IEnumerable keys) where TKey : struct + { + if (keys.Any(key => EqualityComparer.Default.Equals(key, default))) + { + throw new InvalidOperationException("All keys must be non-default."); + } + } +} diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommand.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommand.cs index e8dd6fc0e..f16b28f29 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommand.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommand.cs @@ -12,6 +12,7 @@ using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Transmissions; using Digdir.Domain.Dialogporten.Domain.Parties; +using Digdir.Library.Entity.Abstractions.Features.Identifiable; using MediatR; using OneOf; using OneOf.Types; @@ -70,6 +71,20 @@ public async Task Handle(CreateDialogCommand request, Cancel } CreateDialogEndUserContext(request, dialog); await EnsureNoExistingUserDefinedIds(dialog, cancellationToken); + + // Ensure transmissions have a UUIDv7 ID, needed for the transmission hierarchy validation. + foreach (var transmission in dialog.Transmissions) + { + transmission.Id = transmission.Id.CreateVersion7IfDefault(); + } + + _domainContext.AddErrors(dialog.Transmissions.ValidateReferenceHierarchy( + keySelector: x => x.Id, + parentKeySelector: x => x.RelatedTransmissionId, + propertyName: nameof(CreateDialogCommand.Transmissions), + maxDepth: 100, + maxWidth: 1)); + await _db.Dialogs.AddAsync(dialog, cancellationToken); var saveResult = await _unitOfWork.SaveChangesAsync(cancellationToken); return saveResult.Match( diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommand.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommand.cs index cc53c5df0..f247a3f81 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommand.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommand.cs @@ -15,6 +15,7 @@ using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Transmissions; using Digdir.Domain.Dialogporten.Domain.Parties; +using Digdir.Library.Entity.Abstractions.Features.Identifiable; using MediatR; using Microsoft.EntityFrameworkCore; using OneOf; @@ -97,6 +98,12 @@ public async Task Handle(UpdateDialogCommand request, Cancel return new BadRequest($"Entity '{nameof(DialogEntity)}' with key '{request.Id}' is removed, and cannot be updated."); } + // Ensure transmissions have a UUIDv7 ID, needed for the transmission hierarchy validation. + foreach (var transmission in request.Dto.Transmissions) + { + transmission.Id = transmission.Id.CreateVersion7IfDefault(); + } + // Update primitive properties _mapper.Map(request.Dto, dialog); ValidateTimeFields(dialog); @@ -104,7 +111,13 @@ public async Task Handle(UpdateDialogCommand request, Cancel await AppendActivity(dialog, request.Dto, cancellationToken); await AppendTransmission(dialog, request.Dto, cancellationToken); - VerifyTransmissionRelations(dialog); + + _domainContext.AddErrors(dialog.Transmissions.ValidateReferenceHierarchy( + keySelector: x => x.Id, + parentKeySelector: x => x.RelatedTransmissionId, + propertyName: nameof(UpdateDialogDto.Transmissions), + maxDepth: 100, + maxWidth: 1)); VerifyActivityTransmissionRelations(dialog); @@ -270,32 +283,6 @@ private async Task AppendTransmission(DialogEntity dialog, UpdateDialogDto dto, _db.DialogTransmissions.AddRange(newDialogTransmissions); } - private void VerifyTransmissionRelations(DialogEntity dialog) - { - var relatedTransmissionIds = dialog.Transmissions - .Where(x => x.RelatedTransmissionId is not null) - .Select(x => x.RelatedTransmissionId) - .ToList(); - - if (relatedTransmissionIds.Count == 0) - { - return; - } - - var transmissionIds = dialog.Transmissions.Select(x => x.Id).ToList(); - - var invalidRelatedTransmissionIds = relatedTransmissionIds - .Where(id => !transmissionIds.Contains(id!.Value)) - .ToList(); - - if (invalidRelatedTransmissionIds.Count != 0) - { - _domainContext.AddError( - nameof(UpdateDialogDto.Transmissions), - $"Invalid '{nameof(DialogTransmission.RelatedTransmissionId)}, entity '{nameof(DialogTransmission)}' with the following key(s) does not exist: ({string.Join(", ", invalidRelatedTransmissionIds)})."); - } - } - private IEnumerable CreateApiActions(IEnumerable creatables) { return creatables.Select(x => diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/CreateTransmissionTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/CreateTransmissionTests.cs index fb93e5b9d..27cfeb4a9 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/CreateTransmissionTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/CreateTransmissionTests.cs @@ -1,6 +1,5 @@ using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Content; using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations; -using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Create; using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; using Digdir.Domain.Dialogporten.Domain; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Transmissions; @@ -101,4 +100,27 @@ public async Task Cannot_Create_Transmission_Embeddable_Content_With_Http_Url() validationError.Errors.First().ErrorMessage.Should().Contain("HTTPS"); } + + [Fact] + public async Task Can_Create_Related_Transmission_With_Null_Id() + { + // Arrange + var createCommand = DialogGenerator.GenerateSimpleFakeDialog(); + var transmissions = DialogGenerator.GenerateFakeDialogTransmissions(2); + + transmissions[0].RelatedTransmissionId = transmissions[1].Id; + + // This test assures that the Create-handler will use CreateVersion7IfDefault + // on all transmissions before validating the hierarchy. + transmissions[0].Id = null; + + createCommand.Transmissions = transmissions; + + // Act + var response = await Application.Send(createCommand); + + // Assert + response.TryPickT0(out var success, out _).Should().BeTrue(); + success.Should().NotBeNull(); + } } diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/UpdateTransmissionTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/UpdateTransmissionTests.cs new file mode 100644 index 000000000..95371beca --- /dev/null +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/UpdateTransmissionTests.cs @@ -0,0 +1,134 @@ +using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Update; +using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Get; +using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; +using Digdir.Domain.Dialogporten.Domain.Actors; +using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Transmissions; +using Digdir.Tool.Dialogporten.GenerateFakeData; +using FluentAssertions; +using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; + +namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.ServiceOwner.Transmissions.Commands; + +[Collection(nameof(DialogCqrsCollectionFixture))] +public class UpdateTransmissionTests : ApplicationCollectionFixture +{ + public UpdateTransmissionTests(DialogApplication application) : base(application) { } + + [Fact] + public async Task Can_Create_Simple_Transmission_In_Update() + { + // Arrange + var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(); + var existingTransmission = DialogGenerator.GenerateFakeDialogTransmissions(1).First(); + + createDialogCommand.Transmissions.Add(existingTransmission); + var createCommandResponse = await Application.Send(createDialogCommand); + + var getDialogQuery = new GetDialogQuery { DialogId = createCommandResponse.AsT0.Value }; + var getDialogDto = await Application.Send(getDialogQuery); + + var mapper = Application.GetMapper(); + var updateDialogDto = mapper.Map(getDialogDto.AsT0); + + var newTransmission = UpdateDialogDialogTransmissionDto(); + updateDialogDto.Transmissions.Add(newTransmission); + + // Act + var updateResponse = await Application.Send(new UpdateDialogCommand + { + Id = createCommandResponse.AsT0.Value, + Dto = updateDialogDto + }); + + // Assert + updateResponse.TryPickT0(out var success, out _).Should().BeTrue(); + success.Should().NotBeNull(); + + var transmissionEntities = await Application.GetDbEntities(); + transmissionEntities.Should().HaveCount(2); + transmissionEntities.Single(x => x.Id == newTransmission.Id).Should().NotBeNull(); + } + + [Fact] + public async Task Can_Update_Related_Transmission_With_Null_Id() + { + // Arrange + var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(); + var existingTransmission = DialogGenerator.GenerateFakeDialogTransmissions(1).First(); + createDialogCommand.Transmissions.Add(existingTransmission); + var createCommandResponse = await Application.Send(createDialogCommand); + + var getDialogQuery = new GetDialogQuery { DialogId = createCommandResponse.AsT0.Value }; + var getDialogDto = await Application.Send(getDialogQuery); + + var mapper = Application.GetMapper(); + var updateDialogDto = mapper.Map(getDialogDto.AsT0); + + // Add new transmission with null Id + // This test assures that the Update-handler will use CreateVersion7IfDefault + // on all transmissions before validating the hierarchy. + var newTransmission = UpdateDialogDialogTransmissionDto(); + newTransmission.RelatedTransmissionId = existingTransmission.Id; + newTransmission.Id = null; + + updateDialogDto.Transmissions.Add(newTransmission); + + // Act + var updateResponse = await Application.Send(new UpdateDialogCommand + { + Id = createCommandResponse.AsT0.Value, + Dto = updateDialogDto + }); + + // Assert + updateResponse.TryPickT0(out var success, out _).Should().BeTrue(); + success.Should().NotBeNull(); + var transmissionEntities = await Application.GetDbEntities(); + transmissionEntities.Should().HaveCount(2); + transmissionEntities.Single(x => x.Id == newTransmission.Id).Should().NotBeNull(); + } + + [Fact] + public async Task Cannot_Include_Old_Transmissions_In_UpdateCommand() + { + // Arrange + var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(); + var existingTransmission = DialogGenerator.GenerateFakeDialogTransmissions(count: 1).First(); + createDialogCommand.Transmissions.Add(existingTransmission); + var createCommandResponse = await Application.Send(createDialogCommand); + + var getDialogQuery = new GetDialogQuery { DialogId = createCommandResponse.AsT0.Value }; + var getDialogDto = await Application.Send(getDialogQuery); + + var mapper = Application.GetMapper(); + var updateDialogDto = mapper.Map(getDialogDto.AsT0); + + var newTransmission = UpdateDialogDialogTransmissionDto(); + newTransmission.Id = existingTransmission.Id; + updateDialogDto.Transmissions.Add(newTransmission); + + // Act + var updateResponse = await Application.Send(new UpdateDialogCommand + { + Id = createCommandResponse.AsT0.Value, + Dto = updateDialogDto + }); + + // Assert + updateResponse.TryPickT5(out var domainError, out _).Should().BeTrue(); + domainError.Should().NotBeNull(); + domainError.Errors.Should().Contain(e => e.ErrorMessage.Contains(existingTransmission.Id.ToString()!)); + } + + private static UpdateDialogDialogTransmissionDto UpdateDialogDialogTransmissionDto() => new() + { + Id = GenerateBigEndianUuidV7(), + Type = DialogTransmissionType.Values.Information, + Sender = new() { ActorType = ActorType.Values.ServiceOwner }, + Content = new() + { + Title = new() { Value = DialogGenerator.GenerateFakeLocalizations(1) }, + Summary = new() { Value = DialogGenerator.GenerateFakeLocalizations(1) } + } + }; +} diff --git a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Common/HierarchyTestNode.cs b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Common/HierarchyTestNode.cs new file mode 100644 index 000000000..89876275c --- /dev/null +++ b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Common/HierarchyTestNode.cs @@ -0,0 +1,63 @@ +namespace Digdir.Domain.Dialogporten.Application.Unit.Tests.Common; + +internal sealed class HierarchyTestNode +{ + private readonly List _children = []; + + public Guid Id { get; } + public Guid? ParentId => Parent?.Id; + public HierarchyTestNode? Parent { get; private set; } + public IReadOnlyCollection Children => _children; + + private HierarchyTestNode(Guid? id = null, HierarchyTestNode? parent = null) + { + Id = id ?? Guid.NewGuid(); + Parent = parent; + } + + public IEnumerable CreateChildrenWidth(int width, params Guid?[] ids) => CreateWidth(width, this, ids); + + public IEnumerable CreateChildrenDepth(int depth, params Guid?[] ids) => CreateDepth(depth, this, ids); + + public static HierarchyTestNode Create(Guid? id = null, HierarchyTestNode? parent = null) + { + var node = new HierarchyTestNode(id, parent); + parent?._children.Add(node); + return node; + } + + public static IEnumerable CreateDepth(int depth, HierarchyTestNode? from = null, params Guid?[] ids) + { + for (var i = 0; i < depth; i++) + { + yield return from = Create(ids.ElementAtOrDefault(i), from); + } + } + + public static IEnumerable CreateWidth(int width, HierarchyTestNode from, params Guid?[] ids) + { + for (var i = 0; i < width; i++) + { + yield return Create(ids.ElementAtOrDefault(i), from); + } + } + + public static IEnumerable CreateCyclicDepth(int depth, params Guid?[] ids) + { + if (depth < 1) + { + yield break; + } + + var last = Create(ids.ElementAtOrDefault(depth - 1)); + var current = last; + foreach (var element in CreateDepth(depth - 1, from: current, ids)) + { + yield return current = element; + } + + last.Parent = current; + current._children.Add(last); + yield return last; + } +} diff --git a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Common/IHierarchyTestNodeBuilder.cs b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Common/IHierarchyTestNodeBuilder.cs new file mode 100644 index 000000000..c3d97e41d --- /dev/null +++ b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Common/IHierarchyTestNodeBuilder.cs @@ -0,0 +1,83 @@ +namespace Digdir.Domain.Dialogporten.Application.Unit.Tests.Common; + +internal interface IHierarchyTestNodeBuilder +{ + IHierarchyTestNodeBuilder CreateNewCyclicHierarchy(int depth, params Guid?[] ids); + IHierarchyTestNodeBuilder CreateNewHierarchy(int depth, params Guid?[] ids); + IHierarchyTestNodeBuilder FromNode(Guid id); + IHierarchyTestNodeBuilder AddWidth(int width, params Guid?[] ids); + IHierarchyTestNodeBuilder AddDepth(int depth, params Guid?[] ids); + IReadOnlyCollection Build(); +} + +internal sealed class HierarchyTestNodeBuilder : IHierarchyTestNodeBuilder +{ + private readonly Dictionary _nodes = []; + private HierarchyTestNode _current; + + private HierarchyTestNodeBuilder(IEnumerable nodes) + { + _current = AddRangeReturnFirst(nodes); + } + + public static IHierarchyTestNodeBuilder CreateNewHierarchy(int depth, params Guid?[] ids) + { + return new HierarchyTestNodeBuilder(HierarchyTestNode.CreateDepth(depth, ids: ids)); + } + + public static IHierarchyTestNodeBuilder CreateNewCyclicHierarchy(int depth, params Guid?[] ids) + { + return new HierarchyTestNodeBuilder(HierarchyTestNode.CreateCyclicDepth(depth, ids)); + } + + IHierarchyTestNodeBuilder IHierarchyTestNodeBuilder.CreateNewHierarchy(int depth, params Guid?[] ids) + { + _current = AddRangeReturnFirst(HierarchyTestNode.CreateDepth(depth, ids: ids)); + return this; + } + + IHierarchyTestNodeBuilder IHierarchyTestNodeBuilder.CreateNewCyclicHierarchy(int depth, params Guid?[] ids) + { + _current = AddRangeReturnFirst(HierarchyTestNode.CreateCyclicDepth(depth, ids)); + return this; + } + + IHierarchyTestNodeBuilder IHierarchyTestNodeBuilder.FromNode(Guid id) + { + _current = _nodes[id]; + return this; + } + + IHierarchyTestNodeBuilder IHierarchyTestNodeBuilder.AddWidth(int width, params Guid?[] ids) + { + if (width == 0) return this; + AddRangeReturnFirst(_current.CreateChildrenWidth(width, ids)); + return this; + } + + IHierarchyTestNodeBuilder IHierarchyTestNodeBuilder.AddDepth(int depth, params Guid?[] ids) + { + AddRangeReturnFirst(_current.CreateChildrenDepth(depth, ids)); + return this; + } + + IReadOnlyCollection IHierarchyTestNodeBuilder.Build() => _nodes.Values; + + private HierarchyTestNode AddRangeReturnFirst(IEnumerable nodes) + { + using var nodeEnumerator = nodes.GetEnumerator(); + if (!nodeEnumerator.MoveNext()) + { + throw new InvalidOperationException("Expected at least one node."); + } + + var first = nodeEnumerator.Current; + _nodes.Add(first.Id, first); + while (nodeEnumerator.MoveNext()) + { + _nodes.Add(nodeEnumerator.Current.Id, nodeEnumerator.Current); + } + + return first; + } +} diff --git a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Digdir.Domain.Dialogporten.Application.Unit.Tests.csproj b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Digdir.Domain.Dialogporten.Application.Unit.Tests.csproj index 2e6b3b18d..3f62f42de 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Digdir.Domain.Dialogporten.Application.Unit.Tests.csproj +++ b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Digdir.Domain.Dialogporten.Application.Unit.Tests.csproj @@ -10,6 +10,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Validators/ValidateReferenceHierarchyTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Validators/ValidateReferenceHierarchyTests.cs new file mode 100644 index 000000000..d2d5bd9cf --- /dev/null +++ b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Features/V1/Common/Validators/ValidateReferenceHierarchyTests.cs @@ -0,0 +1,197 @@ +using Digdir.Domain.Dialogporten.Application.Common.Extensions; +using Digdir.Domain.Dialogporten.Application.Unit.Tests.Common; +using Digdir.Domain.Dialogporten.Domain.Common; +using FluentAssertions; + +namespace Digdir.Domain.Dialogporten.Application.Unit.Tests.Features.V1.Common.Validators; + +public class ValidateReferenceHierarchyTests +{ + [Theory] + [InlineData(1, 1)] + [InlineData(10, 10)] + [InlineData(100, 100)] + public void Cannot_Create_Hierarchy_With_Depth_Violations(int maxDepth, int numberOfViolations) + { + // Arrange + var violatingDepth = maxDepth + 1; + var elements = Enumerable + .Range(1, numberOfViolations) + .Aggregate( + HierarchyTestNodeBuilder.CreateNewHierarchy(violatingDepth), + (current, _) => current.CreateNewHierarchy(violatingDepth)) + .Build(); + + // Act + var domainFailures = Sut(elements, maxDepth: maxDepth, maxWidth: 1); + + // Assert + domainFailures.Should().HaveCount(1); + var domainFailure = domainFailures.First(); + domainFailure.ErrorMessage.Should().Contain("depth violation"); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(10, 10)] + [InlineData(100, 100)] + public void Cannot_Create_Hierarchy_With_Width_Violations(int maxWidth, int numberOfViolations) + { + // Arrange + var violatingWidth = maxWidth + 1; + var elements = Enumerable + .Range(1, numberOfViolations) + .Aggregate( + HierarchyTestNodeBuilder.CreateNewHierarchy(1).AddWidth(violatingWidth), + (current, _) => current.CreateNewHierarchy(1).AddWidth(violatingWidth)) + .Build(); + + // Act + var domainFailures = Sut(elements, maxDepth: 2, maxWidth: maxWidth); + + // Assert + domainFailures.Should().HaveCount(1); + var domainFailure = domainFailures.First(); + domainFailure.ErrorMessage.Should().Contain("width violation"); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(10, 10)] + [InlineData(100, 100)] + public void Cannot_Create_Hierarchy_With_Circular_References(int cycleLength, int numberOfViolations) + { + // Arrange + var elements = Enumerable + .Range(1, numberOfViolations) + .Aggregate(HierarchyTestNodeBuilder.CreateNewCyclicHierarchy(cycleLength), + (current, _) => current.CreateNewCyclicHierarchy(cycleLength)) + .Build(); + + // Act + var domainFailures = Sut(elements, maxDepth: cycleLength, maxWidth: 1); + + // Assert + domainFailures.Should().HaveCount(1); + var domainFailure = domainFailures.First(); + domainFailure.ErrorMessage.Should().Contain("cyclic reference"); + } + + [Theory] + [InlineData(1, 1, 1)] + [InlineData(10, 10, 10)] + [InlineData(100, 100, 100)] + public void Cannot_Create_Hierarchy_With_Multiple_Violations(int maxDepth, int maxWidth, int cycleLength) + { + // Arrange + var violatingDepth = maxDepth + 1; + var violatingWidth = maxWidth + 1; + + var elements = Enumerable + .Range(1, maxDepth) + .Aggregate( + HierarchyTestNodeBuilder.CreateNewHierarchy(violatingDepth), + (current, _) => current.CreateNewHierarchy(violatingDepth)) + .AddWidth(violatingWidth) + .CreateNewCyclicHierarchy(cycleLength) + .Build(); + + // Act + var domainFailures = Sut(elements, maxDepth: maxDepth, maxWidth: maxWidth); + + // Assert + domainFailures.Should().HaveCount(3); + domainFailures.Should().ContainSingle(x => x.ErrorMessage.Contains("depth violation")); + domainFailures.Should().ContainSingle(x => x.ErrorMessage.Contains("width violation")); + domainFailures.Should().ContainSingle(x => x.ErrorMessage.Contains("cyclic reference")); + + } + + [Theory] + [InlineData(1, 1, 1)] + [InlineData(10, 10, 10)] + [InlineData(100, 100, 100)] + public void Can_Create_Valid_Complex_Hierarchy(int numberOfSegments, int maxDepth, int maxWidth) + { + // Arrange + var elements = Enumerable + .Range(1, numberOfSegments) + .Aggregate( + HierarchyTestNodeBuilder.CreateNewHierarchy(maxDepth).AddWidth(maxWidth - 1), + (current, _) => current.CreateNewHierarchy(maxDepth)) + .Build(); + + // Act + var domainFailures = Sut(elements, maxDepth: maxDepth, maxWidth: maxWidth); + + // Assert + domainFailures.Should().BeEmpty(); + } + + [Fact] + public void Cannot_Create_Node_Referencing_Non_Existent_Parent() + { + // Arrange + var unknownParent = HierarchyTestNode.Create(); + var node = HierarchyTestNode.Create(parent: unknownParent); + + // Act + var domainFailures = Sut([node], maxDepth: 1, maxWidth: 1); + + // Assert + domainFailures.Should().HaveCount(1); + var domainFailure = domainFailures.First(); + domainFailure.ErrorMessage.Should().Contain(node.ParentId.ToString()); + domainFailure.ErrorMessage.Should().Contain("reference violation"); + } + + [Fact] + public void Cannot_Create_Node_With_Self_Reference() + { + // Arrange + var id = Guid.NewGuid(); + var nodes = HierarchyTestNodeBuilder + .CreateNewCyclicHierarchy(depth: 1, id) + .Build(); + + // Act + var domainFailures = Sut(nodes, maxDepth: 1, maxWidth: 1); + + // Assert + domainFailures.Should().HaveCount(1); + var domainFailure = domainFailures.First(); + domainFailure.ErrorMessage.Should().Contain(id.ToString()); + domainFailure.ErrorMessage.Should().Contain("cyclic reference"); + } + + [Fact] + public void Sut_Should_Throw_Exception_For_Node_With_Default_Id() + { + // Arrange + var node = HierarchyTestNode.Create(Guid.Empty); + + // Act + var exception = Assert.Throws(() => Sut([node], maxDepth: 1, maxWidth: 1)); + + // Assert + exception.Message.Should().Contain("non-default"); + } + + [Fact] + public void Empty_Hierarchy_Should_Not_Fail() + { + // Arrange/Act + var domainFailures = Sut([], maxDepth: 1, maxWidth: 1); + + // Assert + domainFailures.Should().BeEmpty(); + } + + private static List Sut(IReadOnlyCollection nodes, int maxDepth, int maxWidth) + => nodes.ValidateReferenceHierarchy( + keySelector: x => x.Id, + parentKeySelector: x => x.ParentId, + propertyName: "Reference", + maxDepth: maxDepth, + maxWidth: maxWidth); +}