From e0a1cea4907f9376587df64b263f09b330fb0840 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 16 Nov 2021 15:08:07 +0100 Subject: [PATCH 1/5] Basic plumbing of version through the pipeline --- .../Configuration/ResourceType.cs | 6 ++++ .../Resources/IVersionedIdentifiable.cs | 30 +++++++++++++++++++ .../TypeExtensions.cs | 2 +- .../Controllers/BaseJsonApiController.cs | 23 ++++++++++++-- .../Controllers/JsonApiController.cs | 12 ++++++++ .../Middleware/IJsonApiRequest.cs | 6 ++++ .../Middleware/JsonApiMiddleware.cs | 6 ++++ .../Middleware/JsonApiRequest.cs | 4 +++ .../Resources/IdentifiableExtensions.cs | 17 +++++++++++ .../JsonConverters/ResourceObjectConverter.cs | 11 +++++++ .../Serialization/Objects/ResourceIdentity.cs | 11 +++++-- .../Adapters/AtomicOperationObjectAdapter.cs | 1 + .../Adapters/RelationshipDataAdapter.cs | 6 ++-- .../ResourceDataInOperationsRequestAdapter.cs | 1 + .../Adapters/ResourceIdentityAdapter.cs | 2 ++ .../Serialization/Response/LinkBuilder.cs | 16 ++++++---- .../Response/ResourceObjectTreeNode.cs | 4 +-- .../Response/ResponseModelAdapter.cs | 6 ++-- 18 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs index 0263958b00..43a45d8a18 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Configuration; @@ -38,6 +39,11 @@ public sealed class ResourceType /// public IReadOnlySet DirectlyDerivedTypes { get; internal set; } = new HashSet(); + /// + /// When true, this resource type uses optimistic concurrency. + /// + public bool IsVersioned => ClrType.IsOrImplementsInterface(); + /// /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. When using resource inheritance, this /// includes the attributes and relationships from base types. diff --git a/src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs b/src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs new file mode 100644 index 0000000000..089ed83783 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs @@ -0,0 +1,30 @@ +namespace JsonApiDotNetCore.Resources; + +/// +/// Defines the basic contract for a JSON:API resource that uses optimistic concurrency. All resource classes must implement +/// . +/// +public interface IVersionedIdentifiable : IIdentifiable +{ + /// + /// The value for element 'version' in a JSON:API request or response. + /// + string? Version { get; set; } +} + +/// +/// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a JSON:API resource that uses optimistic concurrency. +/// +/// +/// The resource identifier type. +/// +/// +/// The database vendor-specific type that is used to store the concurrency token. +/// +public interface IVersionedIdentifiable : IIdentifiable, IVersionedIdentifiable +{ + /// + /// The concurrency token, which is used to detect if the resource was modified by another user since the moment this resource was last retrieved. + /// + TVersion ConcurrencyToken { get; set; } +} diff --git a/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs b/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs index b31f82d48e..c23c948740 100644 --- a/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs +++ b/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs @@ -13,7 +13,7 @@ public static bool IsOrImplementsInterface(this Type? source) /// /// Whether the specified source type implements or equals the specified interface. This overload enables to test for an open generic interface. /// - private static bool IsOrImplementsInterface(this Type? source, Type interfaceType) + public static bool IsOrImplementsInterface(this Type? source, Type interfaceType) { ArgumentGuard.NotNull(interfaceType); diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 22efab2840..60650ce2e6 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -206,8 +206,9 @@ public virtual async Task PostAsync([FromBody] TResource resource TResource? newResource = await _create.CreateAsync(resource, cancellationToken); - string resourceId = (newResource ?? resource).StringId!; - string locationUrl = $"{HttpContext.Request.Path}/{resourceId}"; + TResource resultResource = newResource ?? resource; + string? resourceVersion = resultResource.GetVersion(); + string locationUrl = $"{HttpContext.Request.Path}/{resultResource.StringId}{(resourceVersion != null ? $";v~{resourceVersion}" : null)}"; if (newResource == null) { @@ -221,6 +222,9 @@ public virtual async Task PostAsync([FromBody] TResource resource /// /// Adds resources to a to-many relationship. Example: Example: + /// /// /// @@ -262,6 +266,9 @@ public virtual async Task PostRelationshipAsync(TId id, string re /// Updates the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. And only the values of sent /// relationships are replaced. Example: Example: + /// /// public virtual async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) @@ -295,7 +302,13 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource /// PATCH /articles/1/relationships/author HTTP/1.1 /// ]]> Example: /// Example: + /// Example: + /// /// /// @@ -335,6 +348,9 @@ public virtual async Task PatchRelationshipAsync(TId id, string r /// /// Deletes an existing resource. Example: Example: + /// /// public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) @@ -357,6 +373,9 @@ public virtual async Task DeleteAsync(TId id, CancellationToken c /// /// Removes resources from a to-many relationship. Example: Example: + /// /// /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 091bbee47b..6a99dc911b 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -47,14 +47,18 @@ public override async Task GetAsync(CancellationToken cancellatio } /// + // The {version} parameter is allowed, but ignored. It occurs in rendered links, because POST/PATCH/DELETE use it. [HttpGet("{id}")] + [HttpGet("{id};v~{version}")] [HttpHead("{id}")] + [HttpHead("{id};v~{version}")] public override async Task GetAsync(TId id, CancellationToken cancellationToken) { return await base.GetAsync(id, cancellationToken); } /// + // No {version} parameter, because it does not occur in rendered links. [HttpGet("{id}/{relationshipName}")] [HttpHead("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) @@ -63,8 +67,11 @@ public override async Task GetSecondaryAsync(TId id, string relat } /// + // The {version} parameter is allowed, but ignored. It occurs in rendered links, because POST/PATCH/DELETE use it. [HttpGet("{id}/relationships/{relationshipName}")] + [HttpGet("{id};v~{version}/relationships/{relationshipName}")] [HttpHead("{id}/relationships/{relationshipName}")] + [HttpHead("{id};v~{version}/relationships/{relationshipName}")] public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); @@ -79,6 +86,7 @@ public override async Task PostAsync([FromBody] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] + [HttpPost("{id};v~{version}/relationships/{relationshipName}")] public override async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, CancellationToken cancellationToken) { @@ -87,6 +95,7 @@ public override async Task PostRelationshipAsync(TId id, string r /// [HttpPatch("{id}")] + [HttpPatch("{id};v~{version}")] public override async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) { return await base.PatchAsync(id, resource, cancellationToken); @@ -94,6 +103,7 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] + [HttpPatch("{id};v~{version}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, CancellationToken cancellationToken) { @@ -102,6 +112,7 @@ public override async Task PatchRelationshipAsync(TId id, string /// [HttpDelete("{id}")] + [HttpDelete("{id};v~{version}")] public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) { return await base.DeleteAsync(id, cancellationToken); @@ -109,6 +120,7 @@ public override async Task DeleteAsync(TId id, CancellationToken /// [HttpDelete("{id}/relationships/{relationshipName}")] + [HttpDelete("{id};v~{version}/relationships/{relationshipName}")] public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, CancellationToken cancellationToken) { diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 1d66bf517f..60e34dbd4a 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -19,6 +19,12 @@ public interface IJsonApiRequest /// string? PrimaryId { get; } + /// + /// The version of the primary resource for this request, when using optimistic concurrency. This would be "abc" in "/blogs/123;v~abc/author". This is + /// null when not using optimistic concurrency, and before and after processing operations in an atomic:operations request. + /// + string? PrimaryVersion { get; } + /// /// The primary resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". This is null before and /// after processing operations in an atomic:operations request. diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index b38ad986dd..aeafce4792 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -212,6 +212,7 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceType pr request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method || httpRequest.Method == HttpMethod.Head.Method; request.PrimaryResourceType = primaryResourceType; request.PrimaryId = GetPrimaryRequestId(routeValues); + request.PrimaryVersion = GetPrimaryRequestVersion(routeValues); string? relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); @@ -263,6 +264,11 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceType pr return routeValues.TryGetValue("id", out object? id) ? (string?)id : null; } + private static string? GetPrimaryRequestVersion(RouteValueDictionary routeValues) + { + return routeValues.TryGetValue("version", out object? id) ? (string?)id : null; + } + private static string? GetRelationshipNameForSecondaryRequest(RouteValueDictionary routeValues) { return routeValues.TryGetValue("relationshipName", out object? routeValue) ? (string?)routeValue : null; diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 98e42823a3..22364b8960 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -14,6 +14,9 @@ public sealed class JsonApiRequest : IJsonApiRequest /// public string? PrimaryId { get; set; } + /// + public string? PrimaryVersion { get; set; } + /// public ResourceType? PrimaryResourceType { get; set; } @@ -42,6 +45,7 @@ public void CopyFrom(IJsonApiRequest other) Kind = other.Kind; PrimaryId = other.PrimaryId; + PrimaryVersion = other.PrimaryVersion; PrimaryResourceType = other.PrimaryResourceType; SecondaryResourceType = other.SecondaryResourceType; Relationship = other.Relationship; diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 9a1c025214..4f30e8bd7d 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -41,4 +41,21 @@ public static Type GetClrType(this IIdentifiable identifiable) return identifiable is IAbstractResourceWrapper abstractResource ? abstractResource.AbstractType : identifiable.GetType(); } + + public static string? GetVersion(this IIdentifiable identifiable) + { + ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + + return identifiable is IVersionedIdentifiable versionedIdentifiable ? versionedIdentifiable.Version : null; + } + + public static void SetVersion(this IIdentifiable identifiable, string? version) + { + ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + + if (identifiable is IVersionedIdentifiable versionedIdentifiable) + { + versionedIdentifiable.Version = version; + } + } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs index 25218b1ba9..6258b02da5 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs @@ -18,6 +18,7 @@ public sealed class ResourceObjectConverter : JsonObjectConverter ToIdentifierData(Singl { Type = resourceObject.Type, Id = resourceObject.Id, - Lid = resourceObject.Lid + Lid = resourceObject.Lid, + Version = resourceObject.Version }); } else if (data.SingleValue != null) @@ -50,7 +51,8 @@ private static SingleOrManyData ToIdentifierData(Singl { Type = data.SingleValue.Type, Id = data.SingleValue.Id, - Lid = data.SingleValue.Lid + Lid = data.SingleValue.Lid, + Version = data.SingleValue.Version }; } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs index afccb303b5..69fb52c964 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs @@ -21,6 +21,7 @@ protected override (IIdentifiable resource, ResourceType resourceType) ConvertRe state.WritableRequest!.PrimaryResourceType = resourceType; state.WritableRequest.PrimaryId = resource.StringId; + state.WritableRequest.PrimaryVersion = resource.GetVersion(); return (resource, resourceType); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index 9df5215da9..f184123f93 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -117,6 +117,8 @@ private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentity IIdentifiable resource = _resourceFactory.CreateInstance(resourceClrType); AssignStringId(identity, resource, state); resource.LocalId = identity.Lid; + resource.SetVersion(identity.Version); + return resource; } diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index ea0eb197df..72f7b6dbc4 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -254,8 +254,10 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource private string? GetLinkForResourceSelf(ResourceType resourceType, IIdentifiable resource) { + string? version = resource.GetVersion(); + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); - IDictionary routeValues = GetRouteValues(resource.StringId!, null); + IDictionary routeValues = GetRouteValues(resource.StringId!, version, null); return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); } @@ -270,7 +272,8 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource if (ShouldIncludeRelationshipLink(LinkTypes.Self, relationship)) { - links.Self = GetLinkForRelationshipSelf(leftResource.StringId!, relationship); + string? leftVersion = leftResource.GetVersion(); + links.Self = GetLinkForRelationshipSelf(leftResource.StringId!, leftVersion, relationship); } if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship)) @@ -281,10 +284,10 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource return links.HasValue() ? links : null; } - private string? GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) + private string? GetLinkForRelationshipSelf(string leftId, string? leftVersion, RelationshipAttribute relationship) { string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + IDictionary routeValues = GetRouteValues(leftId, leftVersion, relationship.PublicName); return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); } @@ -292,12 +295,12 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource private string? GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) { string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + IDictionary routeValues = GetRouteValues(leftId, null, relationship.PublicName); return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); } - private IDictionary GetRouteValues(string primaryId, string? relationshipName) + private IDictionary GetRouteValues(string primaryId, string? primaryVersion, string? relationshipName) { // By default, we copy all route parameters from the *current* endpoint, which helps in case all endpoints have the same // set of non-standard parameters. There is no way we can know which non-standard parameters a *different* endpoint needs, @@ -305,6 +308,7 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource RouteValueDictionary routeValues = HttpContext.Request.RouteValues; routeValues["id"] = primaryId; + routeValues["version"] = primaryVersion; routeValues["relationshipName"] = relationshipName; return routeValues; diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index 01743168be..d480430a38 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -265,12 +265,12 @@ public bool Equals(ResourceObject? left, ResourceObject? right) return false; } - return left.Type == right.Type && left.Id == right.Id && left.Lid == right.Lid; + return left.Type == right.Type && left.Id == right.Id && left.Lid == right.Lid && left.Version == right.Version; } public int GetHashCode(ResourceObject obj) { - return HashCode.Combine(obj.Type, obj.Id, obj.Lid); + return HashCode.Combine(obj.Type, obj.Id, obj.Lid, obj.Version); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index 5dc97f8052..a882ffb1b1 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -216,7 +216,8 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc var resourceObject = new ResourceObject { Type = resourceType.PublicName, - Id = resource.StringId + Id = resource.StringId, + Version = resource.GetVersion() }; if (!isRelationship) @@ -349,7 +350,8 @@ private static SingleOrManyData GetRelationshipData(Re IEnumerable resourceIdentifierObjects = rightNodes.Select(rightNode => new ResourceIdentifierObject { Type = rightNode.ResourceType.PublicName, - Id = rightNode.ResourceObject.Id + Id = rightNode.ResourceObject.Id, + Version = rightNode.ResourceObject.Version }); return relationship is HasOneAttribute From 776357df375127dcc435619122e6d4fbdee4ecd5 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 19 Nov 2021 16:12:17 +0100 Subject: [PATCH 2/5] Fail at startup when resource implements IVersionedIdentifiable without IVersionedIdentifiable --- .../Configuration/ResourceGraphBuilder.cs | 6 +++++ .../ResourceGraphBuilderTests.cs | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 2af0e63caf..ed2606a8bb 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -207,6 +207,12 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st return this; } + if (resourceClrType.IsOrImplementsInterface() && !resourceClrType.IsOrImplementsInterface(typeof(IVersionedIdentifiable<,>))) + { + throw new InvalidConfigurationException( + $"Resource type '{resourceClrType}' implements 'IVersionedIdentifiable', but not 'IVersionedIdentifiable'."); + } + if (resourceClrType.IsOrImplementsInterface()) { string effectivePublicName = publicName ?? FormatResourceName(resourceClrType); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 3fc221c190..8a4957b502 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -163,6 +163,21 @@ public void Cannot_add_resource_that_implements_only_non_generic_IIdentifiable() .WithMessage($"Resource type '{typeof(ResourceWithoutId)}' implements 'IIdentifiable', but not 'IIdentifiable'."); } + [Fact] + public void Cannot_add_versioned_resource_that_implements_only_non_generic_IVersionedIdentifiable() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + Action action = () => builder.Add(typeof(VersionedResourceWithoutToken)); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Resource type '{typeof(VersionedResourceWithoutToken)}' implements 'IVersionedIdentifiable', but not 'IVersionedIdentifiable'."); + } + [Fact] public void Cannot_build_graph_with_missing_related_HasOne_resource() { @@ -428,6 +443,13 @@ private sealed class ResourceWithoutId : IIdentifiable public string? LocalId { get; set; } } + private sealed class VersionedResourceWithoutToken : IVersionedIdentifiable + { + public string? StringId { get; set; } + public string? LocalId { get; set; } + public string? Version { get; set; } + } + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] private sealed class NonResource { From 0f4274c9e9c04134c051ceb25ee1bce5e45f9f18 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 19 Nov 2021 16:54:55 +0100 Subject: [PATCH 3/5] Added ConcurrencyValue to ensure incoming left/right versions are both checked during update --- JsonApiDotNetCore.sln.DotSettings | 1 + .../Repositories/DbContextARepository.cs | 9 +- .../Repositories/DbContextBRepository.cs | 9 +- .../Resources/IVersionedIdentifiable.cs | 8 + .../QueryableBuilding/SelectClauseBuilder.cs | 19 + .../DataStoreConcurrencyException.cs | 16 + .../Repositories/DataStoreUpdateException.cs | 9 +- .../EntityFrameworkCoreRepository.cs | 43 +- .../Resources/IdentifiableExtensions.cs | 51 + .../Resources/ResourceChangeTracker.cs | 21 +- .../Services/JsonApiResourceService.cs | 38 +- .../PrivateResourceRepository.cs | 9 +- .../Transactions/LyricRepository.cs | 9 +- .../Transactions/MusicTrackRepository.cs | 9 +- .../CarCompositeKeyAwareRepository.cs | 9 +- .../EagerLoading/BuildingRepository.cs | 9 +- .../ConcurrencyDbContext.cs | 31 + .../ConcurrencyFakers.cs | 57 + .../OptimisticConcurrency/FriendlyUrl.cs | 15 + .../OptimisticConcurrencyResourceTests.cs | 3305 +++++++++++++++++ .../OptimisticConcurrency/PageFooter.cs | 18 + .../OptimisticConcurrency/Paragraph.cs | 21 + .../PostgresVersionedIdentifiable.cs | 25 + .../OptimisticConcurrency/TextBlock.cs | 18 + .../OptimisticConcurrency/WebImage.cs | 18 + .../OptimisticConcurrency/WebLink.cs | 21 + .../OptimisticConcurrency/WebPage.cs | 21 + .../ResultCapturingRepository.cs | 9 +- .../ObjectAssertionsExtensions.cs | 29 + test/TestBuildingBlocks/Unknown.cs | 1 + .../Middleware/JsonApiRequestTests.cs | 50 +- 31 files changed, 3830 insertions(+), 78 deletions(-) create mode 100644 src/JsonApiDotNetCore/Repositories/DataStoreConcurrencyException.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyFakers.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/FriendlyUrl.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyResourceTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/PageFooter.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/Paragraph.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/PostgresVersionedIdentifiable.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/TextBlock.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebImage.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebLink.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebPage.cs diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 1ffdf4a909..e1b5a9290a 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -660,5 +660,6 @@ $left$ = $right$; True True True + True True diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs index aadeb889cc..e1a5fb1cdc 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; @@ -11,10 +12,10 @@ namespace MultiDbContextExample.Repositories; public sealed class DbContextARepository : EntityFrameworkCoreRepository where TResource : class, IIdentifiable { - public DbContextARepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + public DbContextARepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver dbContextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory) { } } diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs index ac4ce8789c..dce27bb4fa 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; @@ -11,10 +12,10 @@ namespace MultiDbContextExample.Repositories; public sealed class DbContextBRepository : EntityFrameworkCoreRepository where TResource : class, IIdentifiable { - public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + public DbContextBRepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver dbContextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory) { } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs b/src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs index 089ed83783..9479cc20cc 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs @@ -1,3 +1,5 @@ +using JetBrains.Annotations; + namespace JsonApiDotNetCore.Resources; /// @@ -21,10 +23,16 @@ public interface IVersionedIdentifiable : IIdentifiable /// /// The database vendor-specific type that is used to store the concurrency token. /// +[PublicAPI] public interface IVersionedIdentifiable : IIdentifiable, IVersionedIdentifiable { /// /// The concurrency token, which is used to detect if the resource was modified by another user since the moment this resource was last retrieved. /// TVersion ConcurrencyToken { get; set; } + + /// + /// Represents a database column where random data is written to on updates, in order to force a concurrency check during relationship updates. + /// + Guid ConcurrencyValue { get; set; } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index 1f1c10301a..39754f023f 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -143,6 +143,10 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe } IncludeFields(fieldSelectors, propertySelectors); + + // Implicitly add concurrency tokens, which we need for rendering links, but may not be exposed as attributes. + IncludeConcurrencyTokens(resourceType, elementType, propertySelectors); + IncludeEagerLoads(resourceType, propertySelectors); return propertySelectors.Values; @@ -169,6 +173,21 @@ private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary propertySelectors) + { + if (resourceType.IsVersioned) + { + IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); + IEnumerable tokenProperties = entityModel.GetProperties().Where(property => property.IsConcurrencyToken).ToArray(); + + foreach (IProperty tokenProperty in tokenProperties) + { + var propertySelector = new PropertySelector(tokenProperty.PropertyInfo!); + IncludeWritableProperty(propertySelector, propertySelectors); + } + } + } + private static void IncludeWritableProperty(PropertySelector propertySelector, Dictionary propertySelectors) { if (propertySelector.Property.SetMethod != null) diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreConcurrencyException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreConcurrencyException.cs new file mode 100644 index 0000000000..4b8555c38d --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/DataStoreConcurrencyException.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Repositories; + +/// +/// The error that is thrown when the resource version from the request does not match the server version. +/// +[PublicAPI] +public sealed class DataStoreConcurrencyException : DataStoreUpdateException +{ + public DataStoreConcurrencyException(Exception? innerException) + : base("The resource version does not match the server version. This indicates that data has been modified since the resource was retrieved.", + innerException) + { + } +} diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs index e823b50077..59b1574c81 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -6,10 +6,15 @@ namespace JsonApiDotNetCore.Repositories; /// The error that is thrown when the underlying data store is unable to persist changes. /// [PublicAPI] -public sealed class DataStoreUpdateException : Exception +public class DataStoreUpdateException : Exception { public DataStoreUpdateException(Exception? innerException) - : base("Failed to persist changes in the underlying data store.", innerException) + : this("Failed to persist changes in the underlying data store.", innerException) + { + } + + protected DataStoreUpdateException(string message, Exception? innerException) + : base(message, innerException) { } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 653db6129a..9dad68712a 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -26,6 +26,7 @@ public class EntityFrameworkCoreRepository : IResourceRepository where TResource : class, IIdentifiable { private readonly CollectionConverter _collectionConverter = new(); + private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; private readonly DbContext _dbContext; private readonly IResourceGraph _resourceGraph; @@ -37,24 +38,26 @@ public class EntityFrameworkCoreRepository : IResourceRepository /// public virtual string? TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); - public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) + public EntityFrameworkCoreRepository(IJsonApiRequest request, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) { + ArgumentGuard.NotNull(request); ArgumentGuard.NotNull(targetedFields); ArgumentGuard.NotNull(dbContextResolver); ArgumentGuard.NotNull(resourceGraph); ArgumentGuard.NotNull(resourceFactory); + ArgumentGuard.NotNull(resourceDefinitionAccessor); ArgumentGuard.NotNull(constraintProviders); ArgumentGuard.NotNull(loggerFactory); - ArgumentGuard.NotNull(resourceDefinitionAccessor); + _request = request; _targetedFields = targetedFields; _dbContext = dbContextResolver.GetContext(); _resourceGraph = resourceGraph; _resourceFactory = resourceFactory; - _constraintProviders = constraintProviders; _resourceDefinitionAccessor = resourceDefinitionAccessor; + _constraintProviders = constraintProviders; _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -245,7 +248,11 @@ await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, has using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Get resource for update"); IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken); - return resources.FirstOrDefault(); + TResource? resource = resources.FirstOrDefault(); + + resource?.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion); + + return resource; } /// @@ -320,6 +327,7 @@ public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, C // If so, we'll reuse the tracked resource instead of this placeholder resource. TResource placeholderResource = resourceFromDatabase ?? _resourceFactory.CreateInstance(); placeholderResource.Id = id; + placeholderResource.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion); await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); @@ -529,6 +537,17 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored)) { + if (relationship.RightType.IsVersioned) + { + foreach (IIdentifiable rightResource in rightResourceIdsStored) + { + string? requestVersion = rightResourceIdsToRemove.Single(resource => resource.StringId == rightResource.StringId).GetVersion(); + + rightResource.RestoreConcurrencyToken(_dbContext, requestVersion); + rightResource.RefreshConcurrencyValue(); + } + } + AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); @@ -590,6 +609,9 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, await entityEntry.Reference(inversePropertyName).LoadAsync(cancellationToken); } + leftResource.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion); + leftResource.RefreshConcurrencyValue(); + relationship.SetValue(leftResource, trackedValueToAssign); } @@ -603,6 +625,13 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, IReadOnlyCollection rightResources = _collectionConverter.ExtractResources(rightValue); IIdentifiable[] rightResourcesTracked = rightResources.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)).ToArray(); + foreach (IIdentifiable rightResourceTracked in rightResourcesTracked) + { + string? rightVersion = rightResourceTracked.GetVersion(); + rightResourceTracked.RestoreConcurrencyToken(_dbContext, rightVersion); + rightResourceTracked.RefreshConcurrencyValue(); + } + return rightValue is IEnumerable ? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) : rightResourcesTracked.Single(); @@ -628,7 +657,7 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke { _dbContext.ResetChangeTracker(); - throw new DataStoreUpdateException(exception); + throw exception is DbUpdateConcurrencyException ? new DataStoreConcurrencyException(exception) : new DataStoreUpdateException(exception); } } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 4f30e8bd7d..921aa42566 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,11 +1,15 @@ using System.Reflection; using JsonApiDotNetCore.Resources.Internal; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; namespace JsonApiDotNetCore.Resources; internal static class IdentifiableExtensions { private const string IdPropertyName = nameof(Identifiable.Id); + private const string ConcurrencyTokenPropertyName = nameof(IVersionedIdentifiable.ConcurrencyToken); + private const string ConcurrencyValuePropertyName = nameof(IVersionedIdentifiable.ConcurrencyValue); public static object GetTypedId(this IIdentifiable identifiable) { @@ -58,4 +62,51 @@ public static void SetVersion(this IIdentifiable identifiable, string? version) versionedIdentifiable.Version = version; } } + + public static void RestoreConcurrencyToken(this IIdentifiable identifiable, DbContext dbContext, string? versionFromRequest) + { + ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + + if (identifiable is IVersionedIdentifiable versionedIdentifiable) + { + versionedIdentifiable.Version = versionFromRequest; + + PropertyInfo? property = identifiable.GetClrType().GetProperty(ConcurrencyTokenPropertyName); + + if (property == null) + { + throw new InvalidOperationException( + $"Resource of type '{identifiable.GetClrType()}' does not contain a property named '{ConcurrencyTokenPropertyName}'."); + } + + PropertyEntry propertyEntry = dbContext.Entry(identifiable).Property(ConcurrencyTokenPropertyName); + + if (!propertyEntry.Metadata.IsConcurrencyToken) + { + throw new InvalidOperationException($"Property '{identifiable.GetClrType()}.{ConcurrencyTokenPropertyName}' is not a concurrency token."); + } + + object? concurrencyTokenFromRequest = property.GetValue(identifiable); + propertyEntry.OriginalValue = concurrencyTokenFromRequest; + } + } + + public static void RefreshConcurrencyValue(this IIdentifiable identifiable) + { + ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + + if (identifiable is IVersionedIdentifiable) + { + PropertyInfo? property = identifiable.GetClrType().GetProperty(ConcurrencyValuePropertyName); + + if (property == null) + { + throw new InvalidOperationException( + $"Resource of type '{identifiable.GetClrType()}' does not contain a property named '{ConcurrencyValuePropertyName}'."); + } + + property.SetValue(identifiable, Guid.NewGuid()); + } + } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 89ba115a64..accb5a2f1a 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -13,9 +13,9 @@ public sealed class ResourceChangeTracker : IResourceChangeTracker? _initiallyStoredAttributeValues; - private IDictionary? _requestAttributeValues; - private IDictionary? _finallyStoredAttributeValues; + private IDictionary? _initiallyStoredAttributeValues; + private IDictionary? _requestAttributeValues; + private IDictionary? _finallyStoredAttributeValues; public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields) { @@ -50,9 +50,9 @@ public void SetFinallyStoredAttributeValues(TResource resource) _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); } - private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) + private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) { - var result = new Dictionary(); + var result = new Dictionary(); foreach (AttrAttribute attribute in attributes) { @@ -61,6 +61,11 @@ private IDictionary CreateAttributeDictionary(TResource resource result.Add(attribute.PublicName, json); } + if (resource is IVersionedIdentifiable versionedIdentifiable) + { + result.Add(nameof(versionedIdentifiable.Version), versionedIdentifiable.Version); + } + return result; } @@ -73,7 +78,7 @@ public bool HasImplicitChanges() { if (_requestAttributeValues.TryGetValue(key, out string? requestValue)) { - string actualValue = _finallyStoredAttributeValues[key]; + string? actualValue = _finallyStoredAttributeValues[key]; if (requestValue != actualValue) { @@ -82,8 +87,8 @@ public bool HasImplicitChanges() } else { - string initiallyStoredValue = _initiallyStoredAttributeValues[key]; - string finallyStoredValue = _finallyStoredAttributeValues[key]; + string? initiallyStoredValue = _initiallyStoredAttributeValues[key]; + string? finallyStoredValue = _finallyStoredAttributeValues[key]; if (initiallyStoredValue != finallyStoredValue) { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 0d7280e1b4..5c51b39f83 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Net; using System.Runtime.CompilerServices; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -10,6 +11,7 @@ using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.Logging; using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; @@ -216,10 +218,11 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa { await _repositoryAccessor.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); } - catch (DataStoreUpdateException) + catch (DataStoreUpdateException exception) { await AssertPrimaryResourceDoesNotExistAsync(resourceFromRequest, cancellationToken); await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); + AssertIsNotResourceVersionMismatch(exception); throw; } @@ -377,10 +380,11 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relati { await _repositoryAccessor.AddToToManyRelationshipAsync(resourceFromDatabase, leftId, effectiveRightResourceIds, cancellationToken); } - catch (DataStoreUpdateException) + catch (DataStoreUpdateException exception) { await GetPrimaryResourceByIdAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); await AssertRightResourcesExistAsync(effectiveRightResourceIds, cancellationToken); + AssertIsNotResourceVersionMismatch(exception); throw; } } @@ -466,9 +470,10 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR { await _repositoryAccessor.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); } - catch (DataStoreUpdateException) + catch (DataStoreUpdateException exception) { await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); + AssertIsNotResourceVersionMismatch(exception); throw; } @@ -511,9 +516,10 @@ public virtual async Task SetRelationshipAsync(TId leftId, string relationshipNa { await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, effectiveRightValue, cancellationToken); } - catch (DataStoreUpdateException) + catch (DataStoreUpdateException exception) { await AssertRightResourcesExistAsync(effectiveRightValue, cancellationToken); + AssertIsNotResourceVersionMismatch(exception); throw; } } @@ -544,9 +550,10 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke { await _repositoryAccessor.DeleteAsync(resourceFromDatabase, id, cancellationToken); } - catch (DataStoreUpdateException) + catch (DataStoreUpdateException exception) { await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + AssertIsNotResourceVersionMismatch(exception); throw; } } @@ -578,7 +585,15 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string r await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); - await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, effectiveRightResourceIds, cancellationToken); + try + { + await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, effectiveRightResourceIds, cancellationToken); + } + catch (DataStoreUpdateException exception) + { + AssertIsNotResourceVersionMismatch(exception); + throw; + } } protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) @@ -610,6 +625,17 @@ protected async Task GetPrimaryResourceForUpdateAsync(TId id, Cancell return resource; } + protected void AssertIsNotResourceVersionMismatch(DataStoreUpdateException exception) + { + if (exception is DataStoreConcurrencyException) + { + throw new JsonApiException(new ErrorObject(HttpStatusCode.Conflict) + { + Title = exception.Message + }, exception); + } + } + private void AccurizeJsonApiRequest(TResource resourceFromDatabase) { // When using resource inheritance, the stored left-side resource may be more derived than what this endpoint assumes. diff --git a/test/DiscoveryTests/PrivateResourceRepository.cs b/test/DiscoveryTests/PrivateResourceRepository.cs index cb654ea724..4dea155a9b 100644 --- a/test/DiscoveryTests/PrivateResourceRepository.cs +++ b/test/DiscoveryTests/PrivateResourceRepository.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; @@ -10,10 +11,10 @@ namespace DiscoveryTests; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class PrivateResourceRepository : EntityFrameworkCoreRepository { - public PrivateResourceRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + public PrivateResourceRepository(IJsonApiRequest request, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs index 98be0da662..f67ef695d8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; @@ -14,10 +15,10 @@ public sealed class LyricRepository : EntityFrameworkCoreRepository public override string? TransactionId => _extraDbContext.Database.CurrentTransaction?.TransactionId.ToString(); - public LyricRepository(ExtraDbContext extraDbContext, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + public LyricRepository(ExtraDbContext extraDbContext, IJsonApiRequest request, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory) { _extraDbContext = extraDbContext; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs index 6512b3fb27..c3ad431689 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; @@ -12,10 +13,10 @@ public sealed class MusicTrackRepository : EntityFrameworkCoreRepository null; - public MusicTrackRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + public MusicTrackRepository(IJsonApiRequest request, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor, IEnumerable constraintProviders, + ILoggerFactory loggerFactory) + : base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs index 989967bc10..0b7273e2b1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; @@ -14,10 +15,10 @@ public class CarCompositeKeyAwareRepository : EntityFrameworkCor { private readonly CarExpressionRewriter _writer; - public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + public CarCompositeKeyAwareRepository(IJsonApiRequest request, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory) { _writer = new CarExpressionRewriter(resourceGraph); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs index 888f060ca1..2cf97da1b2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; @@ -10,10 +11,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class BuildingRepository : EntityFrameworkCoreRepository { - public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + public BuildingRepository(IJsonApiRequest request, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor, IEnumerable constraintProviders, + ILoggerFactory loggerFactory) + : base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyDbContext.cs new file mode 100644 index 0000000000..15335dcbd4 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyDbContext.cs @@ -0,0 +1,31 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ConcurrencyDbContext : DbContext +{ + public DbSet WebPages => Set(); + public DbSet FriendlyUrls => Set(); + public DbSet TextBlocks => Set(); + public DbSet Paragraphs => Set(); + public DbSet WebImages => Set(); + public DbSet PageFooters => Set(); + public DbSet WebLinks => Set(); + + public ConcurrencyDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(webPage => webPage.Url) + .WithOne(friendlyUrl => friendlyUrl.Page!) + .HasForeignKey("FriendlyUrlId"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyFakers.cs new file mode 100644 index 0000000000..b13772d710 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyFakers.cs @@ -0,0 +1,57 @@ +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +internal sealed class ConcurrencyFakers : FakerContainer +{ + private readonly Lazy> _lazyWebPageFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webPage => webPage.Title, faker => faker.Lorem.Sentence())); + + private readonly Lazy> _lazyFriendlyUrlFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(friendlyUrl => friendlyUrl.Uri, faker => faker.Internet.Url())); + + private readonly Lazy> _lazyTextBlockFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(textBlock => textBlock.ColumnCount, faker => faker.Random.Int(1, 3))); + + private readonly Lazy> _lazyParagraphFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(paragraph => paragraph.Heading, faker => faker.Lorem.Sentence()) + .RuleFor(paragraph => paragraph.Text, faker => faker.Lorem.Paragraph())); + + private readonly Lazy> _lazyWebImageFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webImage => webImage.Description, faker => faker.Lorem.Sentence()) + .RuleFor(webImage => webImage.Path, faker => faker.Image.PicsumUrl())); + + private readonly Lazy> _lazyPageFooterFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(pageFooter => pageFooter.Copyright, faker => faker.Lorem.Sentence())); + + private readonly Lazy> _lazyWebLinkFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webLink => webLink.Text, faker => faker.Lorem.Word()) + .RuleFor(webLink => webLink.Url, faker => faker.Internet.Url()) + .RuleFor(webLink => webLink.OpensInNewTab, faker => faker.Random.Bool())); + + public Faker WebPage => _lazyWebPageFaker.Value; + public Faker FriendlyUrl => _lazyFriendlyUrlFaker.Value; + public Faker TextBlock => _lazyTextBlockFaker.Value; + public Faker Paragraph => _lazyParagraphFaker.Value; + public Faker WebImage => _lazyWebImageFaker.Value; + public Faker PageFooter => _lazyPageFooterFaker.Value; + public Faker WebLink => _lazyWebLinkFaker.Value; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/FriendlyUrl.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/FriendlyUrl.cs new file mode 100644 index 0000000000..e27ad5a59b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/FriendlyUrl.cs @@ -0,0 +1,15 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency")] +public sealed class FriendlyUrl : PostgresVersionedIdentifiable +{ + [Attr] + public string Uri { get; set; } = null!; + + [HasOne] + public WebPage? Page { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyResourceTests.cs new file mode 100644 index 0000000000..38ad2d4abd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyResourceTests.cs @@ -0,0 +1,3305 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +public sealed class OptimisticConcurrencyResourceTests : IClassFixture, ConcurrencyDbContext>> +{ + private readonly IntegrationTestContext, ConcurrencyDbContext> _testContext; + private readonly ConcurrencyFakers _fakers = new(); + + public OptimisticConcurrencyResourceTests(IntegrationTestContext, ConcurrencyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Includes_version_in_get_resources_response() + { + // Arrange + Paragraph paragraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Paragraphs.Add(paragraph); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Type.Should().Be("paragraphs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(paragraph.StringId); + responseDocument.Data.ManyValue[0].Version.Should().Be(paragraph.Version); + responseDocument.Data.ManyValue[0].Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + } + + [Fact] + public async Task Includes_version_in_get_resource_response() + { + // Arrange + Paragraph paragraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(paragraph); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/paragraphs/{paragraph.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("paragraphs"); + responseDocument.Data.SingleValue.Id.Should().Be(paragraph.StringId); + responseDocument.Data.SingleValue.Version.Should().Be(paragraph.Version); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + } + + [Fact] + public async Task Includes_version_in_get_resource_response_with_sparse_fieldset() + { + // Arrange + Paragraph paragraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(paragraph); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/paragraphs/{paragraph.StringId}?fields[paragraphs]=text"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("paragraphs"); + responseDocument.Data.SingleValue.Id.Should().Be(paragraph.StringId); + responseDocument.Data.SingleValue.Version.Should().Be(paragraph.Version); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + } + + [Fact] + public async Task Includes_version_in_get_secondary_resources_response() + { + // Arrange + TextBlock block = _fakers.TextBlock.Generate(); + block.Paragraphs = _fakers.Paragraph.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextBlocks.Add(block); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/textBlocks/{block.StringId}/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Type.Should().Be("paragraphs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(block.Paragraphs[0].StringId); + responseDocument.Data.ManyValue[0].Version.Should().Be(block.Paragraphs[0].Version); + responseDocument.Data.ManyValue[0].Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + } + + [Fact] + public async Task Includes_version_in_get_secondary_resource_response() + { + // Arrange + WebPage page = _fakers.WebPage.Generate(); + page.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(page); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webPages/{page.StringId}/url"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("friendlyUrls"); + responseDocument.Data.SingleValue.Id.Should().Be(page.Url.StringId); + responseDocument.Data.SingleValue.Version.Should().Be(page.Url.Version); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + } + + [Fact] + public async Task Includes_version_in_get_ToMany_relationship_response() + { + // Arrange + TextBlock block = _fakers.TextBlock.Generate(); + block.Paragraphs = _fakers.Paragraph.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextBlocks.Add(block); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/textBlocks/{block.StringId}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Type.Should().Be("paragraphs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(block.Paragraphs[0].StringId); + responseDocument.Data.ManyValue[0].Version.Should().Be(block.Paragraphs[0].Version); + + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().NotBeVersioned(); + responseDocument.Links.Related.Should().NotBeVersioned(); + } + + [Fact] + public async Task Includes_version_in_get_ToOne_relationship_response() + { + // Arrange + WebPage page = _fakers.WebPage.Generate(); + page.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(page); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webPages/{page.StringId}/relationships/url"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("friendlyUrls"); + responseDocument.Data.SingleValue.Id.Should().Be(page.Url.StringId); + responseDocument.Data.SingleValue.Version.Should().Be(page.Url.Version); + + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().NotBeVersioned(); + responseDocument.Links.Related.Should().NotBeVersioned(); + } + + [Fact] + public async Task Ignores_incoming_version_in_get_resource_request() + { + // Arrange + Paragraph paragraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(paragraph); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/paragraphs/{paragraph.StringId};v~{Unknown.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("paragraphs"); + responseDocument.Data.SingleValue.Id.Should().Be(paragraph.StringId); + responseDocument.Data.SingleValue.Version.Should().Be(paragraph.Version); + } + + [Fact] + public async Task Fails_on_incoming_version_in_get_secondary_request() + { + // Arrange + TextBlock block = _fakers.TextBlock.Generate(); + block.Paragraphs = _fakers.Paragraph.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextBlocks.Add(block); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/textBlocks/{block.StringId};v~{Unknown.Version}/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'textBlocks' with ID '{block.StringId};v~{Unknown.Version}' does not exist."); + } + + [Fact] + public async Task Ignores_incoming_version_in_get_relationship_request() + { + // Arrange + TextBlock block = _fakers.TextBlock.Generate(); + block.Paragraphs = _fakers.Paragraph.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextBlocks.Add(block); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/textBlocks/{block.StringId};v~{Unknown.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Type.Should().Be("paragraphs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(block.Paragraphs[0].StringId); + responseDocument.Data.ManyValue[0].Version.Should().Be(block.Paragraphs[0].Version); + } + + [Fact] + public async Task Can_create_versioned_resource() + { + // Arrange + string newText = _fakers.Paragraph.Generate().Text; + + var requestBody = new + { + data = new + { + type = "paragraphs", + attributes = new + { + text = newText + } + } + }; + + const string route = "/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Headers.Location.ShouldNotBeNull(); + httpResponse.Headers.Location.ToString().Should().BeVersioned(); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("paragraphs"); + responseDocument.Data.SingleValue.Version.Should().BeGreaterThan(0); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + + long newParagraphId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Paragraph paragraphInDatabase = await dbContext.Paragraphs.FirstWithIdAsync(newParagraphId); + + paragraphInDatabase.Text.Should().Be(newText); + paragraphInDatabase.Version.Should().Be(responseDocument.Data.SingleValue.Version); + }); + } + + [Fact] + public async Task Can_create_versioned_resource_with_OneToOne_relationship_at_principal_side() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + string newUri = _fakers.FriendlyUrl.Generate().Uri; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "friendlyUrls", + attributes = new + { + uri = newUri + }, + relationships = new + { + page = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + } + } + }; + + const string route = "/friendlyUrls?include=page"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("friendlyUrls"); + responseDocument.Data.SingleValue.Version.Should().BeGreaterThan(0); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("page").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeVersioned(); + value.Links.Related.Should().NotBeVersioned(); + + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("webPages"); + value.Data.SingleValue.Id.Should().Be(existingPage.StringId); + value.Data.SingleValue.Version.Should().BeGreaterThan(existingPage.ConcurrencyToken); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].Type.Should().Be("webPages"); + responseDocument.Included[0].Id.Should().Be(existingPage.StringId); + responseDocument.Included[0].Version.Should().BeGreaterThan(existingPage.ConcurrencyToken); + + responseDocument.Included[0].Relationships.ShouldContainKey("footer").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeVersioned(); + value.Links.Related.Should().NotBeVersioned(); + }); + + long newUrlId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + FriendlyUrl urlInDatabase = await dbContext.FriendlyUrls.Include(url => url.Page).FirstWithIdAsync(newUrlId); + + urlInDatabase.Uri.Should().Be(newUri); + urlInDatabase.Version.Should().Be(responseDocument.Data.SingleValue.Version); + urlInDatabase.Page.ShouldNotBeNull(); + urlInDatabase.Page.Id.Should().Be(existingPage.Id); + urlInDatabase.Page.ConcurrencyToken.Should().BeGreaterThan(existingPage.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_create_versioned_resource_with_OneToOne_relationship_at_principal_side_having_stale_token() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + string newUri = _fakers.FriendlyUrl.Generate().Uri; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebPages\" set \"Title\" = 'other' where \"Id\" = {existingPage.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "friendlyUrls", + attributes = new + { + uri = newUri + }, + relationships = new + { + page = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + } + } + }; + + const string route = "/friendlyUrls"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_create_versioned_resource_with_OneToOne_relationship_at_dependent_side() + { + // Arrange + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + string newTitle = _fakers.WebPage.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FriendlyUrls.Add(existingUrl); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webPages", + attributes = new + { + title = newTitle + }, + relationships = new + { + url = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version + } + } + } + } + }; + + const string route = "/webPages?include=url"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("webPages"); + responseDocument.Data.SingleValue.Version.Should().BeGreaterThan(0); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].Type.Should().Be("friendlyUrls"); + responseDocument.Included[0].Id.Should().Be(existingUrl.StringId); + responseDocument.Included[0].Version.Should().BeGreaterThan(existingUrl.ConcurrencyToken); + + long newPageId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebPage pageInDatabase = await dbContext.WebPages.Include(page => page.Url).FirstWithIdAsync(newPageId); + + pageInDatabase.Title.Should().Be(newTitle); + pageInDatabase.Version.Should().Be(responseDocument.Data.SingleValue.Version); + pageInDatabase.Url.ShouldNotBeNull(); + pageInDatabase.Url.Id.Should().Be(existingUrl.Id); + pageInDatabase.Url.ConcurrencyToken.Should().BeGreaterThan(existingUrl.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_create_versioned_resource_with_OneToOne_relationship_at_dependent_side_having_stale_token() + { + // Arrange + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + string newTitle = _fakers.WebPage.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FriendlyUrls.Add(existingUrl); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"FriendlyUrls\" set \"Uri\" = 'other' where \"Id\" = {existingUrl.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "webPages", + attributes = new + { + title = newTitle + }, + relationships = new + { + url = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version + } + } + } + } + }; + + const string route = "/webPages"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_create_versioned_resource_with_OneToMany_relationship() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + string newCopyright = _fakers.PageFooter.Generate().Copyright!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "pageFooters", + attributes = new + { + copyright = newCopyright + }, + relationships = new + { + usedAt = new + { + data = new[] + { + new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + } + } + } + }; + + const string route = "/pageFooters?include=usedAt"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("pageFooters"); + responseDocument.Data.SingleValue.Version.Should().BeGreaterThan(0); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("usedAt").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeVersioned(); + value.Links.Related.Should().NotBeVersioned(); + + value.Data.ManyValue.ShouldHaveCount(1); + value.Data.ManyValue[0].Type.Should().Be("webPages"); + value.Data.ManyValue[0].Id.Should().Be(existingPage.StringId); + value.Data.ManyValue[0].Version.Should().BeGreaterThan(existingPage.ConcurrencyToken); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].Type.Should().Be("webPages"); + responseDocument.Included[0].Id.Should().Be(existingPage.StringId); + responseDocument.Included[0].Version.Should().BeGreaterThan(existingPage.ConcurrencyToken); + + responseDocument.Included[0].Relationships.ShouldContainKey("content").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeVersioned(); + value.Links.Related.Should().NotBeVersioned(); + }); + + long newFooterId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + PageFooter footerInDatabase = await dbContext.PageFooters.Include(footer => footer.UsedAt).FirstWithIdAsync(newFooterId); + + footerInDatabase.Copyright.Should().Be(newCopyright); + footerInDatabase.Version.Should().Be(responseDocument.Data.SingleValue.Version); + footerInDatabase.UsedAt.ShouldHaveCount(1); + footerInDatabase.UsedAt[0].Id.Should().Be(existingPage.Id); + footerInDatabase.UsedAt[0].ConcurrencyToken.Should().BeGreaterThan(existingPage.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_create_versioned_resource_with_OneToMany_relationship_having_stale_token() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + string newCopyright = _fakers.PageFooter.Generate().Copyright!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebPages\" set \"Title\" = 'other' where \"Id\" = {existingPage.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "pageFooters", + attributes = new + { + copyright = newCopyright + }, + relationships = new + { + usedAt = new + { + data = new[] + { + new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + } + } + } + }; + + const string route = "/pageFooters"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_create_versioned_resource_with_ManyToMany_relationship() + { + // Arrange + WebLink existingLink = _fakers.WebLink.Generate(); + + string newCopyright = _fakers.PageFooter.Generate().Copyright!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebLinks.Add(existingLink); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "pageFooters", + attributes = new + { + copyright = newCopyright + }, + relationships = new + { + links = new + { + data = new[] + { + new + { + type = "webLinks", + id = existingLink.StringId, + version = existingLink.Version + } + } + } + } + } + }; + + const string route = "/pageFooters?include=links"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("pageFooters"); + responseDocument.Data.SingleValue.Version.Should().BeGreaterThan(0); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].Type.Should().Be("webLinks"); + responseDocument.Included[0].Id.Should().Be(existingLink.StringId); + responseDocument.Included[0].Version.Should().BeGreaterThan(existingLink.ConcurrencyToken); + + long newFooterId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + PageFooter footerInDatabase = await dbContext.PageFooters.Include(footer => footer.Links).FirstWithIdAsync(newFooterId); + + footerInDatabase.Copyright.Should().Be(newCopyright); + footerInDatabase.Version.Should().Be(responseDocument.Data.SingleValue.Version); + footerInDatabase.Links.ShouldHaveCount(1); + footerInDatabase.Links[0].Id.Should().Be(existingLink.Id); + footerInDatabase.Links[0].ConcurrencyToken.Should().BeGreaterThan(existingLink.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_create_versioned_resource_with_ManyToMany_relationship_having_stale_token() + { + // Arrange + WebLink existingLink = _fakers.WebLink.Generate(); + + string newCopyright = _fakers.PageFooter.Generate().Copyright!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebLinks.Add(existingLink); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebLinks\" set \"Text\" = 'other' where \"Id\" = {existingLink.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "pageFooters", + attributes = new + { + copyright = newCopyright + }, + relationships = new + { + links = new + { + data = new[] + { + new + { + type = "webLinks", + id = existingLink.StringId, + version = existingLink.Version + } + } + } + } + } + }; + + const string route = "/pageFooters"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_update_versioned_resource() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + string newText = _fakers.Paragraph.Generate().Text; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(existingParagraph); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version, + attributes = new + { + text = newText + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("paragraphs"); + responseDocument.Data.SingleValue.Id.Should().Be(existingParagraph.StringId); + responseDocument.Data.SingleValue.Version.Should().BeGreaterThan(existingParagraph.ConcurrencyToken); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Paragraph paragraphInDatabase = await dbContext.Paragraphs.FirstWithIdAsync(existingParagraph.Id); + + paragraphInDatabase.Text.Should().Be(newText); + paragraphInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingParagraph.ConcurrencyToken); + }); + } + + [Fact] + public async Task Can_update_versioned_resource_without_changes() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(existingParagraph); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Paragraph paragraphInDatabase = await dbContext.Paragraphs.FirstWithIdAsync(existingParagraph.Id); + + paragraphInDatabase.ConcurrencyToken.Should().Be(existingParagraph.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_update_versioned_resource_having_stale_token() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + string newText = _fakers.Paragraph.Generate().Text; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(existingParagraph); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"Paragraphs\" set \"Text\" = 'other' where \"Id\" = {existingParagraph.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version, + attributes = new + { + text = newText + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_unknown_versioned_resource() + { + // Arrange + string paragraphId = Unknown.StringId.For(); + const string paragraphVersion = Unknown.Version; + + var requestBody = new + { + data = new + { + type = "paragraphs", + id = paragraphId, + version = paragraphVersion + } + }; + + string route = $"/paragraphs/{paragraphId};v~{paragraphVersion}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'paragraphs' with ID '{paragraphId}' does not exist."); + } + + [Fact] + public async Task Can_update_versioned_resource_with_OneToOne_relationship_at_principal_side() + { + // Arrange + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // Ensure assigned versions are different by saving in separate transactions. + dbContext.FriendlyUrls.Add(existingUrl); + await dbContext.SaveChangesAsync(); + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version, + relationships = new + { + page = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + } + } + }; + + string route = $"/friendlyUrls/{existingUrl.StringId};v~{existingUrl.Version}?include=page"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(existingUrl.StringId); + responseDocument.Data.SingleValue.Type.Should().Be("friendlyUrls"); + responseDocument.Data.SingleValue.Version.Should().BeGreaterThan(existingUrl.ConcurrencyToken); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("page").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeVersioned(); + value.Links.Related.Should().NotBeVersioned(); + + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("webPages"); + value.Data.SingleValue.Id.Should().Be(existingPage.StringId); + value.Data.SingleValue.Version.Should().BeGreaterThan(existingPage.ConcurrencyToken); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].Type.Should().Be("webPages"); + responseDocument.Included[0].Id.Should().Be(existingPage.StringId); + responseDocument.Included[0].Version.Should().BeGreaterThan(existingPage.ConcurrencyToken); + + responseDocument.Included[0].Relationships.ShouldContainKey("footer").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeVersioned(); + value.Links.Related.Should().NotBeVersioned(); + }); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + FriendlyUrl urlInDatabase = await dbContext.FriendlyUrls.Include(url => url.Page).FirstWithIdAsync(existingUrl.Id); + + urlInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingUrl.ConcurrencyToken); + urlInDatabase.Page.ShouldNotBeNull(); + urlInDatabase.Page.Id.Should().Be(existingPage.Id); + urlInDatabase.Page.ConcurrencyToken.Should().BeGreaterThan(existingPage.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_update_versioned_resource_with_OneToOne_relationship_at_principal_side_having_stale_left_token() + { + // Arrange + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FriendlyUrls.Add(existingUrl); + await dbContext.SaveChangesAsync(); + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"FriendlyUrls\" set \"Uri\" = 'other' where \"Id\" = {existingUrl.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version, + relationships = new + { + page = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + } + } + }; + + string route = $"/friendlyUrls/{existingUrl.StringId};v~{existingUrl.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_versioned_resource_with_OneToOne_relationship_at_principal_side_having_stale_right_token() + { + // Arrange + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FriendlyUrls.Add(existingUrl); + await dbContext.SaveChangesAsync(); + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebPages\" set \"Title\" = 'other' where \"Id\" = {existingPage.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version, + relationships = new + { + page = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + } + } + }; + + string route = $"/friendlyUrls/{existingUrl.StringId};v~{existingUrl.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_update_versioned_resource_with_OneToOne_relationship_at_dependent_side() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + dbContext.FriendlyUrls.Add(existingUrl); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version, + relationships = new + { + url = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version + } + } + } + } + }; + + string route = $"/webPages/{existingPage.StringId};v~{existingPage.Version}?include=url"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(existingPage.StringId); + responseDocument.Data.SingleValue.Type.Should().Be("webPages"); + responseDocument.Data.SingleValue.Version.Should().BeGreaterThan(existingPage.ConcurrencyToken); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].Type.Should().Be("friendlyUrls"); + responseDocument.Included[0].Id.Should().Be(existingUrl.StringId); + responseDocument.Included[0].Version.Should().BeGreaterThan(existingUrl.ConcurrencyToken); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebPage pageInDatabase = await dbContext.WebPages.Include(page => page.Url).FirstWithIdAsync(existingPage.Id); + + pageInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingPage.ConcurrencyToken); + pageInDatabase.Url.ShouldNotBeNull(); + pageInDatabase.Url.Id.Should().Be(existingUrl.Id); + pageInDatabase.Url.ConcurrencyToken.Should().BeGreaterThan(existingUrl.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_update_versioned_resource_with_OneToOne_relationship_at_dependent_side_having_stale_left_token() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + dbContext.FriendlyUrls.Add(existingUrl); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebPages\" set \"Title\" = 'other' where \"Id\" = {existingPage.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version, + relationships = new + { + url = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version + } + } + } + } + }; + + string route = $"/webPages/{existingPage.StringId};v~{existingPage.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_versioned_resource_with_OneToOne_relationship_at_dependent_side_having_stale_right_token() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + dbContext.FriendlyUrls.Add(existingUrl); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"FriendlyUrls\" set \"Uri\" = 'other' where \"Id\" = {existingUrl.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version, + relationships = new + { + url = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version + } + } + } + } + }; + + string route = $"/webPages/{existingPage.StringId};v~{existingPage.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_update_versioned_resource_with_OneToMany_relationship() + { + // Arrange + PageFooter existingFooter = _fakers.PageFooter.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PageFooters.Add(existingFooter); + await dbContext.SaveChangesAsync(); + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "pageFooters", + id = existingFooter.StringId, + version = existingFooter.Version, + relationships = new + { + usedAt = new + { + data = new[] + { + new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + } + } + } + }; + + string route = $"/pageFooters/{existingFooter.StringId};v~{existingFooter.Version}?include=usedAt"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(existingFooter.StringId); + responseDocument.Data.SingleValue.Type.Should().Be("pageFooters"); + responseDocument.Data.SingleValue.Version.Should().BeGreaterThan(existingFooter.ConcurrencyToken); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("usedAt").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeVersioned(); + value.Links.Related.Should().NotBeVersioned(); + + value.Data.ManyValue.ShouldHaveCount(1); + value.Data.ManyValue[0].Type.Should().Be("webPages"); + value.Data.ManyValue[0].Id.Should().Be(existingPage.StringId); + value.Data.ManyValue[0].Version.Should().BeGreaterThan(existingPage.ConcurrencyToken); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].Type.Should().Be("webPages"); + responseDocument.Included[0].Id.Should().Be(existingPage.StringId); + responseDocument.Included[0].Version.Should().BeGreaterThan(existingPage.ConcurrencyToken); + + responseDocument.Included[0].Relationships.ShouldContainKey("content").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeVersioned(); + value.Links.Related.Should().NotBeVersioned(); + }); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + PageFooter footerInDatabase = await dbContext.PageFooters.Include(footer => footer.UsedAt).FirstWithIdAsync(existingFooter.Id); + + footerInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingFooter.ConcurrencyToken); + footerInDatabase.UsedAt.ShouldHaveCount(1); + footerInDatabase.UsedAt[0].Id.Should().Be(existingPage.Id); + footerInDatabase.UsedAt[0].ConcurrencyToken.Should().BeGreaterThan(existingPage.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_update_versioned_resource_with_OneToMany_relationship_having_stale_left_token() + { + // Arrange + PageFooter existingFooter = _fakers.PageFooter.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PageFooters.Add(existingFooter); + await dbContext.SaveChangesAsync(); + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"PageFooters\" set \"Copyright\" = 'other' where \"Id\" = {existingFooter.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "pageFooters", + id = existingFooter.StringId, + version = existingFooter.Version, + relationships = new + { + usedAt = new + { + data = new[] + { + new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + } + } + } + }; + + string route = $"/pageFooters/{existingFooter.StringId};v~{existingFooter.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_versioned_resource_with_OneToMany_relationship_having_stale_right_token() + { + // Arrange + PageFooter existingFooter = _fakers.PageFooter.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PageFooters.Add(existingFooter); + await dbContext.SaveChangesAsync(); + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebPages\" set \"Title\" = 'other' where \"Id\" = {existingPage.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "pageFooters", + id = existingFooter.StringId, + version = existingFooter.Version, + relationships = new + { + usedAt = new + { + data = new[] + { + new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + } + } + } + }; + + string route = $"/pageFooters/{existingFooter.StringId};v~{existingFooter.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_update_versioned_resource_with_ManyToMany_relationship() + { + // Arrange + PageFooter existingFooter = _fakers.PageFooter.Generate(); + + WebLink existingLink = _fakers.WebLink.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PageFooters.Add(existingFooter); + await dbContext.SaveChangesAsync(); + dbContext.WebLinks.Add(existingLink); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "pageFooters", + id = existingFooter.StringId, + version = existingFooter.Version, + relationships = new + { + links = new + { + data = new[] + { + new + { + type = "webLinks", + id = existingLink.StringId, + version = existingLink.Version + } + } + } + } + } + }; + + string route = $"/pageFooters/{existingFooter.StringId};v~{existingFooter.Version}?include=links"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(existingFooter.StringId); + responseDocument.Data.SingleValue.Type.Should().Be("pageFooters"); + responseDocument.Data.SingleValue.Version.Should().BeGreaterThan(existingFooter.ConcurrencyToken); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull().With(value => value.Self.Should().BeVersioned()); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].Type.Should().Be("webLinks"); + responseDocument.Included[0].Id.Should().Be(existingLink.StringId); + responseDocument.Included[0].Version.Should().BeGreaterThan(existingLink.ConcurrencyToken); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + PageFooter footerInDatabase = await dbContext.PageFooters.Include(footer => footer.Links).FirstWithIdAsync(existingFooter.Id); + + footerInDatabase.ConcurrencyToken.Should().BeGreaterOrEqualTo(existingFooter.ConcurrencyToken); + footerInDatabase.Links.ShouldHaveCount(1); + footerInDatabase.Links[0].Id.Should().Be(existingLink.Id); + footerInDatabase.Links[0].ConcurrencyToken.Should().BeGreaterThan(existingLink.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_update_versioned_resource_with_ManyToMany_relationship_having_stale_left_token() + { + // Arrange + PageFooter existingFooter = _fakers.PageFooter.Generate(); + + WebLink existingLink = _fakers.WebLink.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PageFooters.Add(existingFooter); + await dbContext.SaveChangesAsync(); + dbContext.WebLinks.Add(existingLink); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"PageFooters\" set \"Copyright\" = 'other' where \"Id\" = {existingFooter.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "pageFooters", + id = existingFooter.StringId, + version = existingFooter.Version, + relationships = new + { + links = new + { + data = new[] + { + new + { + type = "webLinks", + id = existingLink.StringId, + version = existingLink.Version + } + } + } + } + } + }; + + string route = $"/pageFooters/{existingFooter.StringId};v~{existingFooter.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_versioned_resource_with_ManyToMany_relationship_having_stale_right_token() + { + // Arrange + PageFooter existingFooter = _fakers.PageFooter.Generate(); + + WebLink existingLink = _fakers.WebLink.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PageFooters.Add(existingFooter); + await dbContext.SaveChangesAsync(); + dbContext.WebLinks.Add(existingLink); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebLinks\" set \"Text\" = 'other' where \"Id\" = {existingLink.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "pageFooters", + id = existingFooter.StringId, + version = existingFooter.Version, + relationships = new + { + links = new + { + data = new[] + { + new + { + type = "webLinks", + id = existingLink.StringId, + version = existingLink.Version + } + } + } + } + } + }; + + string route = $"/pageFooters/{existingFooter.StringId};v~{existingFooter.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_delete_versioned_resource() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(existingParagraph); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Paragraph? paragraphInDatabase = await dbContext.Paragraphs.FirstWithIdOrDefaultAsync(existingParagraph.Id); + + paragraphInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_versioned_resource_having_stale_token() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(existingParagraph); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"Paragraphs\" set \"Text\" = 'other' where \"Id\" = {existingParagraph.Id}"); + }); + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_delete_unknown_versioned_resource() + { + // Arrange + string paragraphId = Unknown.StringId.For(); + const string paragraphVersion = Unknown.Version; + + var requestBody = new + { + data = new + { + type = "paragraphs", + id = paragraphId, + version = paragraphVersion + } + }; + + string route = $"/paragraphs/{paragraphId};v~{paragraphVersion}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'paragraphs' with ID '{paragraphId}' does not exist."); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_at_principal_side_on_versioned_resource() + { + // Arrange + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingUrl, existingPage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + }; + + string route = $"/friendlyUrls/{existingUrl.StringId};v~{existingUrl.Version}/relationships/page"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + FriendlyUrl urlInDatabase = await dbContext.FriendlyUrls.Include(url => url.Page).FirstWithIdAsync(existingUrl.Id); + + urlInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingUrl.ConcurrencyToken); + urlInDatabase.Page.ShouldNotBeNull(); + urlInDatabase.Page.Id.Should().Be(existingPage.Id); + urlInDatabase.Page.ConcurrencyToken.Should().BeGreaterThan(existingPage.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_replace_OneToOne_relationship_at_principal_side_on_versioned_resource_having_stale_left_token() + { + // Arrange + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingUrl, existingPage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"FriendlyUrls\" set \"Uri\" = 'other' where \"Id\" = {existingUrl.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + }; + + string route = $"/friendlyUrls/{existingUrl.StringId};v~{existingUrl.Version}/relationships/page"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_replace_OneToOne_relationship_at_principal_side_on_versioned_resource_having_stale_right_token() + { + // Arrange + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingUrl, existingPage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebPages\" set \"Title\" = 'other' where \"Id\" = {existingPage.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + }; + + string route = $"/friendlyUrls/{existingUrl.StringId};v~{existingUrl.Version}/relationships/page"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_at_dependent_side_on_versioned_resource() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPage, existingUrl); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version + } + }; + + string route = $"/webPages/{existingPage.StringId};v~{existingPage.Version}/relationships/url"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebPage pageInDatabase = await dbContext.WebPages.Include(page => page.Url).FirstWithIdAsync(existingPage.Id); + + pageInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingPage.ConcurrencyToken); + pageInDatabase.Url.ShouldNotBeNull(); + pageInDatabase.Url.Id.Should().Be(existingUrl.Id); + pageInDatabase.Url.ConcurrencyToken.Should().BeGreaterThan(existingUrl.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_replace_OneToOne_relationship_at_dependent_side_on_versioned_resource_having_stale_left_token() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPage, existingUrl); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebPages\" set \"Title\" = 'other' where \"Id\" = {existingPage.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version + } + }; + + string route = $"/webPages/{existingPage.StringId};v~{existingPage.Version}/relationships/url"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_replace_OneToOne_relationship_at_dependent_side_on_versioned_resource_having_stale_right_token() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPage, existingUrl); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"FriendlyUrls\" set \"Uri\" = 'other' where \"Id\" = {existingUrl.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version + } + }; + + string route = $"/webPages/{existingPage.StringId};v~{existingPage.Version}/relationships/url"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_replace_OneToMany_relationship_on_versioned_resource() + { + // Arrange + PageFooter existingFooter = _fakers.PageFooter.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingFooter, existingPage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + }; + + string route = $"/pageFooters/{existingFooter.StringId};v~{existingFooter.Version}/relationships/usedAt"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + PageFooter footerInDatabase = await dbContext.PageFooters.Include(footer => footer.UsedAt).FirstWithIdAsync(existingFooter.Id); + + footerInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingFooter.ConcurrencyToken); + footerInDatabase.UsedAt.ShouldHaveCount(1); + footerInDatabase.UsedAt[0].Id.Should().Be(existingPage.Id); + footerInDatabase.UsedAt[0].ConcurrencyToken.Should().BeGreaterThan(existingPage.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_replace_OneToMany_relationship_on_versioned_resource_having_stale_left_token() + { + // Arrange + PageFooter existingFooter = _fakers.PageFooter.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingFooter, existingPage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"PageFooters\" set \"Copyright\" = 'other' where \"Id\" = {existingFooter.Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + }; + + string route = $"/pageFooters/{existingFooter.StringId};v~{existingFooter.Version}/relationships/usedAt"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_replace_OneToMany_relationship_on_versioned_resource_having_stale_right_token() + { + // Arrange + PageFooter existingFooter = _fakers.PageFooter.Generate(); + + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingFooter, existingPage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebPages\" set \"Title\" = 'other' where \"Id\" = {existingPage.Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version + } + } + }; + + string route = $"/pageFooters/{existingFooter.StringId};v~{existingFooter.Version}/relationships/usedAt"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_replace_ManyToMany_relationship_on_versioned_resource() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingBlock, existingParagraph); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + } + }; + + string route = $"/textBlocks/{existingBlock.StringId};v~{existingBlock.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TextBlock blockInDatabase = await dbContext.TextBlocks.Include(block => block.Paragraphs).FirstWithIdAsync(existingBlock.Id); + + blockInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingBlock.ConcurrencyToken); + blockInDatabase.Paragraphs.ShouldHaveCount(1); + blockInDatabase.Paragraphs[0].Id.Should().Be(existingParagraph.Id); + blockInDatabase.Paragraphs[0].ConcurrencyToken.Should().BeGreaterThan(existingParagraph.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_replace_ManyToMany_relationship_on_versioned_resource_having_stale_left_token() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingBlock, existingParagraph); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"TextBlocks\" set \"ColumnCount\" = 0 where \"Id\" = {existingBlock.Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + } + }; + + string route = $"/textBlocks/{existingBlock.StringId};v~{existingBlock.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_replace_ManyToMany_relationship_on_versioned_resource_having_stale_right_token() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingBlock, existingParagraph); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"Paragraphs\" set \"Text\" = 'other' where \"Id\" = {existingParagraph.Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + } + }; + + string route = $"/textBlocks/{existingBlock.StringId};v~{existingBlock.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_replace_relationship_for_unknown_versioned_resource() + { + // Arrange + string unknownFooterId = Unknown.StringId.For(); + + var requestBody = new + { + data = new[] + { + new + { + type = "webPages", + id = unknownFooterId, + version = Unknown.Version + } + } + }; + + string route = $"/pageFooters/{unknownFooterId};v~{Unknown.Version}/relationships/usedAt"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().StartWith("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'pageFooters' with ID '{unknownFooterId}' does not exist."); + } + + [Fact] + public async Task Can_add_to_OneToMany_relationship_on_versioned_resource() + { + // Arrange + WebImage existingImage = _fakers.WebImage.Generate(); + existingImage.UsedIn = _fakers.Paragraph.Generate(1); + + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingImage, existingParagraph); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + } + }; + + string route = $"/webImages/{existingImage.StringId};v~{existingImage.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebImage imageInDatabase = await dbContext.WebImages.Include(image => image.UsedIn).FirstWithIdAsync(existingImage.Id); + + imageInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingImage.ConcurrencyToken); + imageInDatabase.UsedIn.ShouldHaveCount(2); + + Paragraph paragraphInDatabase = imageInDatabase.UsedIn.Single(paragraph => paragraph.Id == existingParagraph.Id); + paragraphInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingParagraph.ConcurrencyToken); + }); + } + + [Fact] + public async Task Can_add_to_OneToMany_relationship_on_versioned_resource_without_changes() + { + // Arrange + WebImage existingImage = _fakers.WebImage.Generate(); + existingImage.UsedIn = _fakers.Paragraph.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebImages.Add(existingImage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingImage.UsedIn[0].StringId, + version = existingImage.UsedIn[0].Version + } + } + }; + + string route = $"/webImages/{existingImage.StringId};v~{existingImage.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebImage imageInDatabase = await dbContext.WebImages.Include(image => image.UsedIn).FirstWithIdAsync(existingImage.Id); + + imageInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingImage.ConcurrencyToken); + imageInDatabase.UsedIn.ShouldHaveCount(1); + imageInDatabase.UsedIn[0].Id.Should().Be(existingImage.UsedIn[0].Id); + imageInDatabase.UsedIn[0].ConcurrencyToken.Should().BeGreaterThan(existingImage.UsedIn[0].ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_add_to_OneToMany_relationship_on_versioned_resource_having_stale_left_token() + { + // Arrange + WebImage existingImage = _fakers.WebImage.Generate(); + existingImage.UsedIn = _fakers.Paragraph.Generate(1); + + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingImage, existingParagraph); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebImages\" set \"Description\" = 'other' where \"Id\" = {existingImage.Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + } + }; + + string route = $"/webImages/{existingImage.StringId};v~{existingImage.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_add_to_OneToMany_relationship_on_versioned_resource_having_stale_right_token() + { + // Arrange + WebImage existingImage = _fakers.WebImage.Generate(); + existingImage.UsedIn = _fakers.Paragraph.Generate(1); + + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingImage, existingParagraph); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"Paragraphs\" set \"Text\" = 'other' where \"Id\" = {existingParagraph.Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + } + }; + + string route = $"/webImages/{existingImage.StringId};v~{existingImage.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_add_to_ManyToMany_relationship_on_versioned_resource() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + existingBlock.Paragraphs = _fakers.Paragraph.Generate(1); + + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingBlock, existingParagraph); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + } + }; + + string route = $"/textBlocks/{existingBlock.StringId};v~{existingBlock.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TextBlock blockInDatabase = await dbContext.TextBlocks.Include(block => block.Paragraphs).FirstWithIdAsync(existingBlock.Id); + + blockInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingBlock.ConcurrencyToken); + blockInDatabase.Paragraphs.ShouldHaveCount(2); + + Paragraph paragraphInDatabase = blockInDatabase.Paragraphs.Single(paragraph => paragraph.Id == existingParagraph.Id); + paragraphInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingParagraph.ConcurrencyToken); + }); + } + + [Fact] + public async Task Can_add_to_ManyToMany_relationship_on_versioned_resource_without_changes() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + existingBlock.Paragraphs = _fakers.Paragraph.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextBlocks.Add(existingBlock); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingBlock.Paragraphs[0].StringId, + version = existingBlock.Paragraphs[0].Version + } + } + }; + + string route = $"/textBlocks/{existingBlock.StringId};v~{existingBlock.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TextBlock blockInDatabase = await dbContext.TextBlocks.Include(block => block.Paragraphs).FirstWithIdAsync(existingBlock.Id); + + blockInDatabase.ConcurrencyToken.Should().Be(existingBlock.ConcurrencyToken); + blockInDatabase.Paragraphs.ShouldHaveCount(1); + blockInDatabase.Paragraphs[0].Id.Should().Be(existingBlock.Paragraphs[0].Id); + blockInDatabase.Paragraphs[0].ConcurrencyToken.Should().Be(existingBlock.Paragraphs[0].ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_add_to_ManyToMany_relationship_on_versioned_resource_having_stale_left_token() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + existingBlock.Paragraphs = _fakers.Paragraph.Generate(1); + + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingBlock, existingParagraph); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"TextBlocks\" set \"ColumnCount\" = 0 where \"Id\" = {existingBlock.Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + } + }; + + string route = $"/textBlocks/{existingBlock.StringId};v~{existingBlock.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_add_to_ManyToMany_relationship_on_versioned_resource_having_stale_right_token() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + existingBlock.Paragraphs = _fakers.Paragraph.Generate(1); + + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingBlock, existingParagraph); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"Paragraphs\" set \"Text\" = 'other' where \"Id\" = {existingParagraph.Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + } + }; + + string route = $"/textBlocks/{existingBlock.StringId};v~{existingBlock.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_add_to_relationship_for_unknown_versioned_resource() + { + // Arrange + string unknownImageId = Unknown.StringId.For(); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = Unknown.StringId.For(), + version = Unknown.Version + } + } + }; + + string route = $"/webImages/{unknownImageId};v~{Unknown.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().StartWith("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webImages' with ID '{unknownImageId}' does not exist."); + } + + [Fact] + public async Task Can_remove_from_OneToMany_relationship_on_versioned_resource() + { + // Arrange + WebImage existingImage = _fakers.WebImage.Generate(); + existingImage.UsedIn = _fakers.Paragraph.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebImages.Add(existingImage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingImage.UsedIn[0].StringId, + version = existingImage.UsedIn[0].Version + } + } + }; + + string route = $"/webImages/{existingImage.StringId};v~{existingImage.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebImage imageInDatabase = await dbContext.WebImages.Include(image => image.UsedIn).FirstWithIdAsync(existingImage.Id); + + imageInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingImage.ConcurrencyToken); + imageInDatabase.UsedIn.ShouldHaveCount(1); + imageInDatabase.UsedIn[0].Id.Should().Be(existingImage.UsedIn[1].Id); + imageInDatabase.UsedIn[0].ConcurrencyToken.Should().Be(existingImage.UsedIn[1].ConcurrencyToken); + + Paragraph paragraphInDatabase = await dbContext.Paragraphs.FirstWithIdAsync(existingImage.UsedIn[0].Id); + paragraphInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingImage.UsedIn[0].ConcurrencyToken); + }); + } + + [Fact] + public async Task Can_remove_from_OneToMany_relationship_on_versioned_resource_without_changes() + { + // Arrange + WebImage existingImage = _fakers.WebImage.Generate(); + existingImage.UsedIn = _fakers.Paragraph.Generate(1); + + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingImage, existingParagraph); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version + } + } + }; + + string route = $"/webImages/{existingImage.StringId};v~{existingImage.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebImage imageInDatabase = await dbContext.WebImages.Include(image => image.UsedIn).FirstWithIdAsync(existingImage.Id); + + imageInDatabase.ConcurrencyToken.Should().Be(existingImage.ConcurrencyToken); + imageInDatabase.UsedIn.ShouldHaveCount(1); + imageInDatabase.UsedIn[0].Id.Should().Be(existingImage.UsedIn[0].Id); + imageInDatabase.UsedIn[0].ConcurrencyToken.Should().Be(existingImage.UsedIn[0].ConcurrencyToken); + + Paragraph paragraphInDatabase = await dbContext.Paragraphs.FirstWithIdAsync(existingParagraph.Id); + paragraphInDatabase.ConcurrencyToken.Should().Be(existingParagraph.ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_remove_from_OneToMany_relationship_on_versioned_resource_having_stale_left_token() + { + // Arrange + WebImage existingImage = _fakers.WebImage.Generate(); + existingImage.UsedIn = _fakers.Paragraph.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebImages.Add(existingImage); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"WebImages\" set \"Description\" = 'other' where \"Id\" = {existingImage.Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingImage.UsedIn[0].StringId, + version = existingImage.UsedIn[0].Version + } + } + }; + + string route = $"/webImages/{existingImage.StringId};v~{existingImage.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_remove_from_OneToMany_relationship_on_versioned_resource_having_stale_right_token() + { + // Arrange + WebImage existingImage = _fakers.WebImage.Generate(); + existingImage.UsedIn = _fakers.Paragraph.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebImages.Add(existingImage); + await dbContext.SaveChangesAsync(); + + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"Paragraphs\" set \"Text\" = 'other' where \"Id\" = {existingImage.UsedIn[0].Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingImage.UsedIn[0].StringId, + version = existingImage.UsedIn[0].Version + } + } + }; + + string route = $"/webImages/{existingImage.StringId};v~{existingImage.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_remove_from_ManyToMany_relationship_on_versioned_resource() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + existingBlock.Paragraphs = _fakers.Paragraph.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextBlocks.Add(existingBlock); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingBlock.Paragraphs[0].StringId, + version = existingBlock.Paragraphs[0].Version + } + } + }; + + string route = $"/textBlocks/{existingBlock.StringId};v~{existingBlock.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TextBlock blockInDatabase = await dbContext.TextBlocks.Include(block => block.Paragraphs).FirstWithIdAsync(existingBlock.Id); + + blockInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingBlock.ConcurrencyToken); + blockInDatabase.Paragraphs.ShouldHaveCount(1); + blockInDatabase.Paragraphs[0].Id.Should().Be(existingBlock.Paragraphs[1].Id); + blockInDatabase.Paragraphs[0].ConcurrencyToken.Should().Be(existingBlock.Paragraphs[1].ConcurrencyToken); + + Paragraph paragraphInDatabase = await dbContext.Paragraphs.FirstWithIdAsync(existingBlock.Paragraphs[0].Id); + paragraphInDatabase.ConcurrencyToken.Should().BeGreaterThan(existingBlock.Paragraphs[0].ConcurrencyToken); + }); + } + + [Fact] + public async Task Cannot_remove_from_ManyToMany_relationship_on_versioned_resource_having_stale_left_token() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + existingBlock.Paragraphs = _fakers.Paragraph.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextBlocks.Add(existingBlock); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"TextBlocks\" set \"ColumnCount\" = 0 where \"Id\" = {existingBlock.Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingBlock.Paragraphs[0].StringId, + version = existingBlock.Paragraphs[0].Version + } + } + }; + + string route = $"/textBlocks/{existingBlock.StringId};v~{existingBlock.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_remove_from_ManyToMany_relationship_on_versioned_resource_having_stale_right_token() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + existingBlock.Paragraphs = _fakers.Paragraph.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextBlocks.Add(existingBlock); + await dbContext.SaveChangesAsync(); + + await dbContext.Database.ExecuteSqlInterpolatedAsync( + $"update \"Paragraphs\" set \"Text\" = 'other' where \"Id\" = {existingBlock.Paragraphs[0].Id}"); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = existingBlock.Paragraphs[0].StringId, + version = existingBlock.Paragraphs[0].Version + } + } + }; + + string route = $"/textBlocks/{existingBlock.StringId};v~{existingBlock.Version}/relationships/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The resource version does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_remove_from_relationship_for_unknown_versioned_resource() + { + // Arrange + string unknownImageId = Unknown.StringId.For(); + + var requestBody = new + { + data = new[] + { + new + { + type = "paragraphs", + id = Unknown.StringId.For(), + version = Unknown.Version + } + } + }; + + string route = $"/webImages/{unknownImageId};v~{Unknown.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().StartWith("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webImages' with ID '{unknownImageId}' does not exist."); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/PageFooter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/PageFooter.cs new file mode 100644 index 0000000000..c0889ce195 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/PageFooter.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency")] +public sealed class PageFooter : PostgresVersionedIdentifiable +{ + [Attr] + public string? Copyright { get; set; } + + [HasMany] + public IList Links { get; set; } = new List(); + + [HasMany] + public IList UsedAt { get; set; } = new List(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/Paragraph.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/Paragraph.cs new file mode 100644 index 0000000000..6c278915f9 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/Paragraph.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency")] +public sealed class Paragraph : PostgresVersionedIdentifiable +{ + [Attr] + public string? Heading { get; set; } + + [Attr] + public string Text { get; set; } = null!; + + [HasOne] + public WebImage? TopImage { get; set; } + + [HasMany] + public IList UsedIn { get; set; } = new List(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/PostgresVersionedIdentifiable.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/PostgresVersionedIdentifiable.cs new file mode 100644 index 0000000000..5e92e9307a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/PostgresVersionedIdentifiable.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public abstract class PostgresVersionedIdentifiable : Identifiable, IVersionedIdentifiable +{ + [NotMapped] + public string? Version + { + get => ConcurrencyToken == default ? null : ConcurrencyToken.ToString(); + set => ConcurrencyToken = value == null ? default : uint.Parse(value); + } + + // https://www.npgsql.org/efcore/modeling/concurrency.html + [Column("xmin", TypeName = "xid")] + [ConcurrencyCheck] + [DatabaseGenerated(DatabaseGeneratedOption.Computed)] + public uint ConcurrencyToken { get; set; } + + public Guid ConcurrencyValue { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/TextBlock.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/TextBlock.cs new file mode 100644 index 0000000000..c7019e6b24 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/TextBlock.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency")] +public sealed class TextBlock : PostgresVersionedIdentifiable +{ + [Attr] + public int ColumnCount { get; set; } + + [HasMany] + public IList Paragraphs { get; set; } = new List(); + + [HasMany] + public IList UsedAt { get; set; } = new List(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebImage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebImage.cs new file mode 100644 index 0000000000..352e13c793 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebImage.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency")] +public sealed class WebImage : PostgresVersionedIdentifiable +{ + [Attr] + public string? Description { get; set; } + + [Attr] + public string Path { get; set; } = null!; + + [HasMany] + public IList UsedIn { get; set; } = new List(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebLink.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebLink.cs new file mode 100644 index 0000000000..c06c56218d --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebLink.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency")] +public sealed class WebLink : PostgresVersionedIdentifiable +{ + [Attr] + public string? Text { get; set; } + + [Attr] + public string Url { get; set; } = null!; + + [Attr] + public bool OpensInNewTab { get; set; } + + [HasMany] + public IList UsedIn { get; set; } = new List(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebPage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebPage.cs new file mode 100644 index 0000000000..6250cd7857 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/WebPage.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency")] +public sealed class WebPage : PostgresVersionedIdentifiable +{ + [Attr] + public string Title { get; set; } = null!; + + [HasOne] + public FriendlyUrl Url { get; set; } = null!; + + [HasMany] + public IList Content { get; set; } = new List(); + + [HasOne] + public PageFooter? Footer { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs index d900e5809d..1cf307eb8e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; @@ -16,10 +17,10 @@ public sealed class ResultCapturingRepository : EntityFrameworkC { private readonly ResourceCaptureStore _captureStore; - public ResultCapturingRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor, ResourceCaptureStore captureStore) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + public ResultCapturingRepository(ResourceCaptureStore captureStore, IJsonApiRequest request, ITargetedFields targetedFields, + IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor, IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory) { _captureStore = captureStore; } diff --git a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs index a295e1eaf9..51e431fb56 100644 --- a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs +++ b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs @@ -65,4 +65,33 @@ private static string ToJsonString(JsonDocument document) writer.Flush(); return Encoding.UTF8.GetString(stream.ToArray()); } + + /// + /// Asserts that a string contains a resource version separator. + /// + [CustomAssertion] + public static void BeVersioned(this StringAssertions source, string because = "", params object[] becauseArgs) + { + source.Contain(";v~", because, becauseArgs); + } + + /// + /// Asserts that a string does not contain a resource version separator. + /// + [CustomAssertion] + public static void NotBeVersioned(this StringAssertions source, string because = "", params object[] becauseArgs) + { + source.NotBeNull(); + source.NotContain(";v~", because, becauseArgs); + } + + /// + /// Asserts that a version string contains a numeric value that is greater than the specified expected value. + /// + [CustomAssertion] + public static void BeGreaterThan(this StringAssertions source, uint expected) + { + uint.TryParse(source.Subject, out uint number).Should().BeTrue(); + number.Should().BeGreaterThan(expected); + } } diff --git a/test/TestBuildingBlocks/Unknown.cs b/test/TestBuildingBlocks/Unknown.cs index a777435228..a65a61c7a9 100644 --- a/test/TestBuildingBlocks/Unknown.cs +++ b/test/TestBuildingBlocks/Unknown.cs @@ -11,6 +11,7 @@ public static class Unknown public const string ResourceType = "doesNotExist1"; public const string Relationship = "doesNotExist2"; public const string LocalId = "doesNotExist3"; + public const string Version = "99999999"; public static class TypedId { diff --git a/test/UnitTests/Middleware/JsonApiRequestTests.cs b/test/UnitTests/Middleware/JsonApiRequestTests.cs index bee1b18bf9..75b2124385 100644 --- a/test/UnitTests/Middleware/JsonApiRequestTests.cs +++ b/test/UnitTests/Middleware/JsonApiRequestTests.cs @@ -21,30 +21,30 @@ public sealed class JsonApiRequestTests { // @formatter:wrap_lines false [Theory] - [InlineData("HEAD", "/todoItems", EndpointKind.Primary, null, "todoItems", null, null, IsCollection.Yes, IsReadOnly.Yes, null)] - [InlineData("HEAD", "/people/1", EndpointKind.Primary, "1", "people", null, null, IsCollection.No, IsReadOnly.Yes, null)] - [InlineData("HEAD", "/todoItems/2/owner", EndpointKind.Secondary, "2", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] - [InlineData("HEAD", "/todoItems/3/tags", EndpointKind.Secondary, "3", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] - [InlineData("HEAD", "/todoItems/ABC/relationships/owner", EndpointKind.Relationship, "ABC", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] - [InlineData("HEAD", "/todoItems/ABC/relationships/tags", EndpointKind.Relationship, "ABC", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] - [InlineData("GET", "/todoItems", EndpointKind.Primary, null, "todoItems", null, null, IsCollection.Yes, IsReadOnly.Yes, null)] - [InlineData("GET", "/todoItems/-1", EndpointKind.Primary, "-1", "todoItems", null, null, IsCollection.No, IsReadOnly.Yes, null)] - [InlineData("GET", "/todoItems/1/owner", EndpointKind.Secondary, "1", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] - [InlineData("GET", "/todoItems/1/tags", EndpointKind.Secondary, "1", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] - [InlineData("GET", "/todoItems/1/relationships/owner", EndpointKind.Relationship, "1", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] - [InlineData("GET", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] - [InlineData("POST", "/todoItems", EndpointKind.Primary, null, "todoItems", null, null, IsCollection.No, IsReadOnly.No, WriteOperationKind.CreateResource)] - [InlineData("POST", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.No, WriteOperationKind.AddToRelationship)] - [InlineData("PATCH", "/itemTags/1", EndpointKind.Primary, "1", "itemTags", null, null, IsCollection.No, IsReadOnly.No, WriteOperationKind.UpdateResource)] - [InlineData("PATCH", "/todoItems/1/relationships/owner", EndpointKind.Relationship, "1", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.No, WriteOperationKind.SetRelationship)] - [InlineData("PATCH", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.No, WriteOperationKind.SetRelationship)] - [InlineData("DELETE", "/todoItems/1", EndpointKind.Primary, "1", "todoItems", null, null, IsCollection.No, IsReadOnly.No, WriteOperationKind.DeleteResource)] - [InlineData("DELETE", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.No, WriteOperationKind.RemoveFromRelationship)] - [InlineData("POST", "/operations", EndpointKind.AtomicOperations, null, null, null, null, IsCollection.No, IsReadOnly.No, null)] + [InlineData("HEAD", "/todoItems", EndpointKind.Primary, null, null, "todoItems", null, null, IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("HEAD", "/people/1", EndpointKind.Primary, "1", null, "people", null, null, IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("HEAD", "/todoItems/2/owner", EndpointKind.Secondary, "2", null, "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("HEAD", "/todoItems/3/tags", EndpointKind.Secondary, "3", null, "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("HEAD", "/todoItems/ABC/relationships/owner", EndpointKind.Relationship, "ABC", null, "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("HEAD", "/todoItems/ABC/relationships/tags", EndpointKind.Relationship, "ABC", null, "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems", EndpointKind.Primary, null, null, "todoItems", null, null, IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems/-1", EndpointKind.Primary, "-1", null, "todoItems", null, null, IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems/1;v~25/owner", EndpointKind.Secondary, "1", "25", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems/1/tags", EndpointKind.Secondary, "1", null, "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems/1/relationships/owner", EndpointKind.Relationship, "1", null, "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", null, "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("POST", "/todoItems", EndpointKind.Primary, null, null, "todoItems", null, null, IsCollection.No, IsReadOnly.No, WriteOperationKind.CreateResource)] + [InlineData("POST", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", null, "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.No, WriteOperationKind.AddToRelationship)] + [InlineData("PATCH", "/itemTags/1", EndpointKind.Primary, "1", null, "itemTags", null, null, IsCollection.No, IsReadOnly.No, WriteOperationKind.UpdateResource)] + [InlineData("PATCH", "/todoItems/1/relationships/owner", EndpointKind.Relationship, "1", null, "todoItems", "people", "owner", IsCollection.No, IsReadOnly.No, WriteOperationKind.SetRelationship)] + [InlineData("PATCH", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", null, "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.No, WriteOperationKind.SetRelationship)] + [InlineData("DELETE", "/todoItems/1;v~18", EndpointKind.Primary, "1", "18", "todoItems", null, null, IsCollection.No, IsReadOnly.No, WriteOperationKind.DeleteResource)] + [InlineData("DELETE", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", null, "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.No, WriteOperationKind.RemoveFromRelationship)] + [InlineData("POST", "/operations", EndpointKind.AtomicOperations, null, null, null, null, null, IsCollection.No, IsReadOnly.No, null)] // @formatter:wrap_lines restore public async Task Sets_request_properties_correctly(string requestMethod, string requestPath, EndpointKind expectKind, string? expectPrimaryId, - string? expectPrimaryResourceType, string? expectSecondaryResourceType, string? expectRelationshipName, IsCollection expectIsCollection, - IsReadOnly expectIsReadOnly, WriteOperationKind? expectWriteOperation) + string? expectPrimaryVersion, string? expectPrimaryResourceType, string? expectSecondaryResourceType, string? expectRelationshipName, + IsCollection expectIsCollection, IsReadOnly expectIsReadOnly, WriteOperationKind? expectWriteOperation) { // Arrange var options = new JsonApiOptions(); @@ -74,6 +74,7 @@ public async Task Sets_request_properties_correctly(string requestMethod, string // Assert request.Kind.Should().Be(expectKind); request.PrimaryId.Should().Be(expectPrimaryId); + request.PrimaryVersion.Should().Be(expectPrimaryVersion); if (expectPrimaryResourceType == null) { @@ -127,7 +128,10 @@ private static IControllerResourceMapping SetupRoutes(HttpContext httpContext, I if (pathSegments.Length > 1) { - feature.RouteValues["id"] = pathSegments[1]; + string[] idVersionSegments = pathSegments[1].Split(";v~"); + + feature.RouteValues["id"] = idVersionSegments[0]; + feature.RouteValues["version"] = idVersionSegments.Length > 1 ? idVersionSegments[1] : null; if (pathSegments.Length >= 3) { From 2001becabc7af19c0a3edb772485ae8d9e258d5f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sat, 20 Nov 2021 11:31:37 +0100 Subject: [PATCH 4/5] Added input validation for version in URL and request body --- .../Middleware/JsonApiMiddleware.cs | 35 + .../Adapters/AtomicOperationObjectAdapter.cs | 2 + ...tInResourceOrRelationshipRequestAdapter.cs | 6 +- .../Adapters/RelationshipDataAdapter.cs | 3 + .../Adapters/ResourceIdentityAdapter.cs | 45 +- .../Adapters/ResourceIdentityRequirements.cs | 10 + .../ConcurrencyDbContext.cs | 1 + .../ConcurrencyFakers.cs | 6 + .../OptimisticConcurrency/DeploymentJob.cs | 21 + ...alidationForNonVersionedOperationsTests.cs | 414 ++++++++++ ...tValidationForNonVersionedResourceTests.cs | 616 +++++++++++++++ ...nputValidationForVersionedResourceTests.cs | 715 ++++++++++++++++++ .../OperationsController.cs | 17 + 13 files changed, 1887 insertions(+), 4 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/DeploymentJob.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForNonVersionedOperationsTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForNonVersionedResourceTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForVersionedResourceTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OperationsController.cs diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index aeafce4792..76d3392cd0 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -62,6 +62,11 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin SetupResourceRequest((JsonApiRequest)request, primaryResourceType, routeValues, httpContext.Request); + if (!await ValidateVersionAsync(request, httpContext, options.SerializerWriteOptions)) + { + return; + } + httpContext.RegisterJsonApiRequest(); } else if (IsRouteForOperations(routeValues)) @@ -192,6 +197,36 @@ private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue a return true; } + private static async Task ValidateVersionAsync(IJsonApiRequest request, HttpContext httpContext, JsonSerializerOptions serializerOptions) + { + if (!request.IsReadOnly) + { + if (request.PrimaryResourceType!.IsVersioned && request.WriteOperation != WriteOperationKind.CreateResource && request.PrimaryVersion == null) + { + await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "The 'version' parameter is required at this endpoint.", + Detail = $"Resources of type '{request.PrimaryResourceType.PublicName}' require the version to be specified." + }); + + return false; + } + + if (!request.PrimaryResourceType.IsVersioned && request.PrimaryVersion != null) + { + await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "The 'version' parameter is not supported at this endpoint.", + Detail = $"Resources of type '{request.PrimaryResourceType.PublicName}' are not versioned." + }); + + return false; + } + } + + return true; + } + private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, ErrorObject error) { httpResponse.ContentType = HeaderConstants.MediaType; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs index 80bae1ee28..80827364f0 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs @@ -141,6 +141,8 @@ private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferen IdConstraint = refRequirements.IdConstraint, IdValue = refResult.Resource.StringId, LidValue = refResult.Resource.LocalId, + VersionConstraint = !refResult.ResourceType.IsVersioned ? JsonElementConstraint.Forbidden : null, + VersionValue = refResult.Resource.GetVersion(), RelationshipName = refResult.Relationship?.PublicName }; } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs index f5d4cb088c..1b57927c06 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs @@ -67,7 +67,11 @@ private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterSt { ResourceType = state.Request.PrimaryResourceType, IdConstraint = idConstraint, - IdValue = state.Request.PrimaryId + IdValue = state.Request.PrimaryId, + VersionConstraint = state.Request.PrimaryResourceType!.IsVersioned && state.Request.WriteOperation != WriteOperationKind.CreateResource + ? JsonElementConstraint.Required + : JsonElementConstraint.Forbidden, + VersionValue = state.Request.PrimaryVersion }; return requirements; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs index 1116633a5e..d96fe6005d 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -1,4 +1,5 @@ using System.Collections; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -73,6 +74,8 @@ private static SingleOrManyData ToIdentifierData(Singl { ResourceType = relationship.RightType, IdConstraint = JsonElementConstraint.Required, + VersionConstraint = !relationship.RightType.IsVersioned ? JsonElementConstraint.Forbidden : + state.Request.Kind == EndpointKind.AtomicOperations ? null : JsonElementConstraint.Required, RelationshipName = relationship.PublicName }; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index f184123f93..ebdd973425 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -33,7 +33,7 @@ protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory ArgumentGuard.NotNull(state); ResourceType resourceType = ResolveType(identity, requirements, state); - IIdentifiable resource = CreateResource(identity, requirements, resourceType.ClrType, state); + IIdentifiable resource = CreateResource(identity, requirements, resourceType, state); return (resource, resourceType); } @@ -93,7 +93,8 @@ private static void AssertIsCompatibleResourceType(ResourceType actual, Resource } } - private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, Type resourceClrType, RequestAdapterState state) + private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, ResourceType resourceType, + RequestAdapterState state) { if (state.Request.Kind != EndpointKind.AtomicOperations) { @@ -111,10 +112,20 @@ private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentity AssertHasNoId(identity, state); } + if (requirements.VersionConstraint == JsonElementConstraint.Required) + { + AssertHasVersion(identity, state); + } + else if (!resourceType.IsVersioned || requirements.VersionConstraint == JsonElementConstraint.Forbidden) + { + AssertHasNoVersion(identity, state); + } + AssertSameIdValue(identity, requirements.IdValue, state); AssertSameLidValue(identity, requirements.LidValue, state); + AssertSameVersionValue(identity, requirements.VersionValue, state); - IIdentifiable resource = _resourceFactory.CreateInstance(resourceClrType); + IIdentifiable resource = _resourceFactory.CreateInstance(resourceType.ClrType); AssignStringId(identity, resource, state); resource.LocalId = identity.Lid; resource.SetVersion(identity.Version); @@ -171,6 +182,23 @@ private static void AssertHasNoId(ResourceIdentity identity, RequestAdapterState } } + private static void AssertHasVersion(ResourceIdentity identity, RequestAdapterState state) + { + if (identity.Version == null) + { + throw new ModelConversionException(state.Position, "The 'version' element is required.", null); + } + } + + private static void AssertHasNoVersion(ResourceIdentity identity, RequestAdapterState state) + { + if (identity.Version != null) + { + using IDisposable _ = state.Position.PushElement("version"); + throw new ModelConversionException(state.Position, "Unexpected 'version' element.", null); + } + } + private static void AssertSameIdValue(ResourceIdentity identity, string? expected, RequestAdapterState state) { if (expected != null && identity.Id != expected) @@ -193,6 +221,17 @@ private static void AssertSameLidValue(ResourceIdentity identity, string? expect } } + private static void AssertSameVersionValue(ResourceIdentity identity, string? expected, RequestAdapterState state) + { + if (expected != null && identity.Version != expected) + { + using IDisposable _ = state.Position.PushElement("version"); + + throw new ModelConversionException(state.Position, "Conflicting 'version' values found.", $"Expected '{expected}' instead of '{identity.Version}'.", + HttpStatusCode.Conflict); + } + } + private void AssignStringId(ResourceIdentity identity, IIdentifiable resource, RequestAdapterState state) { if (identity.Id != null) diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs index d5498397bf..441f1703c2 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs @@ -30,6 +30,16 @@ public sealed class ResourceIdentityRequirements /// public string? LidValue { get; init; } + /// + /// When not null, indicates the presence or absence of the "version" element. + /// + public JsonElementConstraint? VersionConstraint { get; init; } + + /// + /// When not null, indicates what the value of the "version" element must be. + /// + public string? VersionValue { get; init; } + /// /// When not null, indicates the name of the relationship to use in error messages. /// diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyDbContext.cs index 15335dcbd4..8fdbbd2d2d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyDbContext.cs @@ -15,6 +15,7 @@ public sealed class ConcurrencyDbContext : DbContext public DbSet WebImages => Set(); public DbSet PageFooters => Set(); public DbSet WebLinks => Set(); + public DbSet DeploymentJobs => Set(); public ConcurrencyDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyFakers.cs index b13772d710..7113a5dd8d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyFakers.cs @@ -47,6 +47,11 @@ internal sealed class ConcurrencyFakers : FakerContainer .RuleFor(webLink => webLink.Url, faker => faker.Internet.Url()) .RuleFor(webLink => webLink.OpensInNewTab, faker => faker.Random.Bool())); + private readonly Lazy> _lazyDeploymentJobFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(deploymentJob => deploymentJob.StartedAt, faker => faker.Date.PastOffset())); + public Faker WebPage => _lazyWebPageFaker.Value; public Faker FriendlyUrl => _lazyFriendlyUrlFaker.Value; public Faker TextBlock => _lazyTextBlockFaker.Value; @@ -54,4 +59,5 @@ internal sealed class ConcurrencyFakers : FakerContainer public Faker WebImage => _lazyWebImageFaker.Value; public Faker PageFooter => _lazyPageFooterFaker.Value; public Faker WebLink => _lazyWebLinkFaker.Value; + public Faker DeploymentJob => _lazyDeploymentJobFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/DeploymentJob.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/DeploymentJob.cs new file mode 100644 index 0000000000..5aa0aaf8c6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/DeploymentJob.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency")] +public sealed class DeploymentJob : Identifiable +{ + [Attr] + [Required] + public DateTimeOffset? StartedAt { get; set; } + + [HasOne] + public DeploymentJob? ParentJob { get; set; } + + [HasMany] + public IList ChildJobs { get; set; } = new List(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForNonVersionedOperationsTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForNonVersionedOperationsTests.cs new file mode 100644 index 0000000000..f8119f117c --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForNonVersionedOperationsTests.cs @@ -0,0 +1,414 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +public sealed class InputValidationForNonVersionedOperationsTests + : IClassFixture, ConcurrencyDbContext>> +{ + private readonly IntegrationTestContext, ConcurrencyDbContext> _testContext; + private readonly ConcurrencyFakers _fakers = new(); + + public InputValidationForNonVersionedOperationsTests(IntegrationTestContext, ConcurrencyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Cannot_create_resource_with_version_in_ToOne_relationship() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + DateTimeOffset? newJobStartedAt = _fakers.DeploymentJob.Generate().StartedAt; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "deploymentJobs", + attributes = new + { + startedAt = newJobStartedAt + }, + relationships = new + { + parentJob = new + { + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/parentJob/data/version"); + } + + [Fact] + public async Task Cannot_create_resource_with_version_in_ToMany_relationship() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + DateTimeOffset? newJobStartedAt = _fakers.DeploymentJob.Generate().StartedAt; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "deploymentJobs", + attributes = new + { + startedAt = newJobStartedAt + }, + relationships = new + { + childJobs = new + { + data = new[] + { + new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/childJobs/data[0]/version"); + } + + [Fact] + public async Task Cannot_update_resource_with_version_in_ref() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + }, + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + attributes = new + { + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/version"); + } + + [Fact] + public async Task Cannot_update_resource_with_version_in_data() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "deploymentJobs", + id = existingJob.StringId + }, + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version, + attributes = new + { + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/version"); + } + + [Fact] + public async Task Cannot_delete_resource_with_version() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/version"); + } + + [Fact] + public async Task Cannot_update_relationship_with_version_in_ref() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version, + relationship = "parentJob" + }, + data = new + { + type = "deploymentJobs", + id = existingJob.StringId + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/version"); + } + + [Fact] + public async Task Cannot_update_relationship_with_version_in_data() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "deploymentJobs", + id = existingJob.StringId, + relationship = "parentJob" + }, + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/version"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForNonVersionedResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForNonVersionedResourceTests.cs new file mode 100644 index 0000000000..bd3b1fe770 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForNonVersionedResourceTests.cs @@ -0,0 +1,616 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +public sealed class InputValidationForNonVersionedResourceTests + : IClassFixture, ConcurrencyDbContext>> +{ + private readonly IntegrationTestContext, ConcurrencyDbContext> _testContext; + private readonly ConcurrencyFakers _fakers = new(); + + public InputValidationForNonVersionedResourceTests(IntegrationTestContext, ConcurrencyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Cannot_create_resource_with_version_in_ToOne_relationship() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + DateTimeOffset? newJobStartedAt = _fakers.DeploymentJob.Generate().StartedAt; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "deploymentJobs", + attributes = new + { + startedAt = newJobStartedAt + }, + relationships = new + { + parentJob = new + { + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + } + } + } + }; + + const string route = "/deploymentJobs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/parentJob/data/version"); + } + + [Fact] + public async Task Cannot_create_resource_with_version_in_ToMany_relationship() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + DateTimeOffset? newJobStartedAt = _fakers.DeploymentJob.Generate().StartedAt; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "deploymentJobs", + attributes = new + { + startedAt = newJobStartedAt + }, + relationships = new + { + childJobs = new + { + data = new[] + { + new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + } + } + } + } + }; + + const string route = "/deploymentJobs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/childJobs/data[0]/version"); + } + + [Fact] + public async Task Cannot_update_resource_with_version_in_url() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + attributes = new + { + } + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId};v~{Unknown.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The 'version' parameter is not supported at this endpoint."); + error.Detail.Should().Be("Resources of type 'deploymentJobs' are not versioned."); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_resource_with_version_in_body() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version, + attributes = new + { + } + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/version"); + } + + [Fact] + public async Task Cannot_update_resource_with_version_in_ToOne_relationship() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + relationships = new + { + parentJob = new + { + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + } + } + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/parentJob/data/version"); + } + + [Fact] + public async Task Cannot_update_resource_with_version_in_ToMany_relationship() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + relationships = new + { + childJobs = new + { + data = new[] + { + new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + } + } + } + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/childJobs/data[0]/version"); + } + + [Fact] + public async Task Cannot_update_relationship_with_version_in_url() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "deploymentJobs", + id = existingJob.StringId + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId};v~{Unknown.Version}/relationships/parentJob"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The 'version' parameter is not supported at this endpoint."); + error.Detail.Should().Be("Resources of type 'deploymentJobs' are not versioned."); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_ToOne_relationship_with_version_in_body() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId}/relationships/parentJob"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/version"); + } + + [Fact] + public async Task Cannot_update_ToMany_relationship_with_version_in_body() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId}/relationships/childJobs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data[0]/version"); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_with_version_in_url() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "deploymentJobs", + id = existingJob.StringId + } + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId};v~{Unknown.Version}/relationships/childJobs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The 'version' parameter is not supported at this endpoint."); + error.Detail.Should().Be("Resources of type 'deploymentJobs' are not versioned."); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_with_version_in_body() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "deploymentJobs", + id = existingJob.StringId, + version = Unknown.Version + } + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId}/relationships/childJobs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data[0]/version"); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_with_version_in_url() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + existingJob.ChildJobs = _fakers.DeploymentJob.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "deploymentJobs", + id = existingJob.ChildJobs[0].StringId + } + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId};v~{Unknown.Version}/relationships/childJobs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The 'version' parameter is not supported at this endpoint."); + error.Detail.Should().Be("Resources of type 'deploymentJobs' are not versioned."); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_with_version_in_body() + { + // Arrange + DeploymentJob existingJob = _fakers.DeploymentJob.Generate(); + existingJob.ChildJobs = _fakers.DeploymentJob.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DeploymentJobs.Add(existingJob); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "deploymentJobs", + id = existingJob.ChildJobs[0].StringId, + version = Unknown.Version + } + } + }; + + string route = $"/deploymentJobs/{existingJob.StringId}/relationships/childJobs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data[0]/version"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForVersionedResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForVersionedResourceTests.cs new file mode 100644 index 0000000000..b662b83d0e --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/InputValidationForVersionedResourceTests.cs @@ -0,0 +1,715 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +public sealed class InputValidationForVersionedResourceTests + : IClassFixture, ConcurrencyDbContext>> +{ + private readonly IntegrationTestContext, ConcurrencyDbContext> _testContext; + private readonly ConcurrencyFakers _fakers = new(); + + public InputValidationForVersionedResourceTests(IntegrationTestContext, ConcurrencyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Cannot_create_resource_with_version() + { + // Arrange + string newParagraphText = _fakers.Paragraph.Generate().Text; + + var requestBody = new + { + data = new + { + type = "paragraphs", + version = Unknown.Version, + attributes = new + { + text = newParagraphText + } + } + }; + + const string route = "/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unexpected 'version' element."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/version"); + } + + [Fact] + public async Task Cannot_create_resource_without_version_in_ToOne_relationship() + { + // Arrange + WebImage existingImage = _fakers.WebImage.Generate(); + + string newParagraphText = _fakers.Paragraph.Generate().Text; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebImages.Add(existingImage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "paragraphs", + attributes = new + { + text = newParagraphText + }, + relationships = new + { + topImage = new + { + data = new + { + type = "webImages", + id = existingImage.StringId + } + } + } + } + }; + + const string route = "/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'version' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/topImage/data"); + } + + [Fact] + public async Task Cannot_create_resource_without_version_in_ToMany_relationship() + { + // Arrange + TextBlock existingBlock = _fakers.TextBlock.Generate(); + + string newParagraphText = _fakers.Paragraph.Generate().Text; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextBlocks.Add(existingBlock); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "paragraphs", + attributes = new + { + text = newParagraphText + }, + relationships = new + { + usedIn = new + { + data = new[] + { + new + { + type = "textBlocks", + id = existingBlock.StringId + } + } + } + } + } + }; + + const string route = "/paragraphs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'version' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/usedIn/data[0]"); + } + + [Fact] + public async Task Cannot_update_resource_without_version_in_url() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(existingParagraph); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version, + attributes = new + { + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The 'version' parameter is required at this endpoint."); + error.Detail.Should().Be("Resources of type 'paragraphs' require the version to be specified."); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_resource_without_version_in_body() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(existingParagraph); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "paragraphs", + id = existingParagraph.StringId, + attributes = new + { + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'version' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data"); + } + + [Fact] + public async Task Cannot_update_resource_with_version_mismatch_between_url_and_body() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Paragraphs.Add(existingParagraph); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = Unknown.Version, + attributes = new + { + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Conflicting 'version' values found."); + error.Detail.Should().Be($"Expected '{existingParagraph.Version}' instead of '{Unknown.Version}'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/version"); + } + + [Fact] + public async Task Cannot_update_resource_without_version_in_ToOne_relationship() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + WebImage existingImage = _fakers.WebImage.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingParagraph, existingImage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version, + relationships = new + { + topImage = new + { + data = new + { + type = "webImages", + id = existingImage.StringId + } + } + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'version' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/topImage/data"); + } + + [Fact] + public async Task Cannot_update_resource_without_version_in_ToMany_relationship() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + TextBlock existingBlock = _fakers.TextBlock.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingParagraph, existingBlock); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "paragraphs", + id = existingParagraph.StringId, + version = existingParagraph.Version, + relationships = new + { + usedIn = new + { + data = new[] + { + new + { + type = "textBlocks", + id = existingBlock.StringId + } + } + } + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'version' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/usedIn/data[0]"); + } + + [Fact] + public async Task Cannot_update_relationship_without_version_in_url() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + WebImage existingImage = _fakers.WebImage.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingParagraph, existingImage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webImages", + id = existingImage.StringId, + version = existingImage.Version + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId}/relationships/topImage"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The 'version' parameter is required at this endpoint."); + error.Detail.Should().Be("Resources of type 'paragraphs' require the version to be specified."); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_ToOne_relationship_without_version_in_body() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + WebImage existingImage = _fakers.WebImage.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingParagraph, existingImage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webImages", + id = existingImage.StringId + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}/relationships/topImage"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'version' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data"); + } + + [Fact] + public async Task Cannot_update_ToMany_relationship_without_version_in_body() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + TextBlock existingBlock = _fakers.TextBlock.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingParagraph, existingBlock); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "textBlocks", + id = existingBlock.StringId + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'version' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data[0]"); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_without_version_in_url() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + TextBlock existingBlock = _fakers.TextBlock.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingParagraph, existingBlock); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "textBlocks", + id = existingBlock.StringId, + version = existingBlock.Version + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The 'version' parameter is required at this endpoint."); + error.Detail.Should().Be("Resources of type 'paragraphs' require the version to be specified."); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_without_version_in_body() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + TextBlock existingBlock = _fakers.TextBlock.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingParagraph, existingBlock); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "textBlocks", + id = existingBlock.StringId + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'version' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data[0]"); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_without_version_in_url() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + TextBlock existingBlock = _fakers.TextBlock.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingParagraph, existingBlock); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "textBlocks", + id = existingBlock.StringId, + version = existingBlock.Version + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The 'version' parameter is required at this endpoint."); + error.Detail.Should().Be("Resources of type 'paragraphs' require the version to be specified."); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_without_version_in_body() + { + // Arrange + Paragraph existingParagraph = _fakers.Paragraph.Generate(); + + TextBlock existingBlock = _fakers.TextBlock.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingParagraph, existingBlock); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "textBlocks", + id = existingBlock.StringId + } + } + }; + + string route = $"/paragraphs/{existingParagraph.StringId};v~{existingParagraph.Version}/relationships/usedIn"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'version' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data[0]"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OperationsController.cs new file mode 100644 index 0000000000..86344fd856 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OperationsController.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +public sealed class OperationsController : JsonApiOperationsController +{ + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) + { + } +} From c33677ad2ecd6eef5eca1ad46e057cf1b90d6232 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 30 Nov 2021 19:22:21 +0100 Subject: [PATCH 5/5] Tryout: tracking versions in atomic:operations --- .../AtomicOperations/IVersionTracker.cs | 13 + .../AtomicOperations/OperationsProcessor.cs | 41 ++- .../AtomicOperations/VersionTracker.cs | 91 ++++++ .../JsonApiApplicationBuilder.cs | 1 + .../Queries/IQueryLayerComposer.cs | 5 + .../Queries/Internal/QueryLayerComposer.cs | 40 +++ .../Services/JsonApiResourceService.cs | 34 ++- test/DiscoveryTests/PrivateResourceService.cs | 6 +- .../ServiceDiscoveryFacadeTests.cs | 2 + .../ConsumerArticleService.cs | 6 +- .../MultiTenantResourceService.cs | 6 +- .../OptimisticConcurrencyOperationsTests.cs | 271 ++++++++++++++++++ .../SoftDeletionAwareResourceService.cs | 7 +- 13 files changed, 511 insertions(+), 12 deletions(-) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyOperationsTests.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs new file mode 100644 index 0000000000..25c7b4e2c5 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs @@ -0,0 +1,13 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations; + +public interface IVersionTracker +{ + bool RequiresVersionTracking(); + + void CaptureVersions(ResourceType resourceType, IIdentifiable resource); + + string? GetVersion(ResourceType resourceType, string stringId); +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 6ecdfd6077..4b9f209bce 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -15,6 +15,7 @@ public class OperationsProcessor : IOperationsProcessor private readonly IOperationProcessorAccessor _operationProcessorAccessor; private readonly IOperationsTransactionFactory _operationsTransactionFactory; private readonly ILocalIdTracker _localIdTracker; + private readonly IVersionTracker _versionTracker; private readonly IResourceGraph _resourceGraph; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; @@ -22,12 +23,13 @@ public class OperationsProcessor : IOperationsProcessor private readonly LocalIdValidator _localIdValidator; public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory, - ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields, + ILocalIdTracker localIdTracker, IVersionTracker versionTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields, ISparseFieldSetCache sparseFieldSetCache) { ArgumentGuard.NotNull(operationProcessorAccessor); ArgumentGuard.NotNull(operationsTransactionFactory); ArgumentGuard.NotNull(localIdTracker); + ArgumentGuard.NotNull(versionTracker); ArgumentGuard.NotNull(resourceGraph); ArgumentGuard.NotNull(request); ArgumentGuard.NotNull(targetedFields); @@ -36,6 +38,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso _operationProcessorAccessor = operationProcessorAccessor; _operationsTransactionFactory = operationsTransactionFactory; _localIdTracker = localIdTracker; + _versionTracker = versionTracker; _resourceGraph = resourceGraph; _request = request; _targetedFields = targetedFields; @@ -104,11 +107,15 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso cancellationToken.ThrowIfCancellationRequested(); TrackLocalIdsForOperation(operation); + RefreshVersionsForOperation(operation); _targetedFields.CopyFrom(operation.TargetedFields); _request.CopyFrom(operation.Request); return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); + + // Ideally we'd take the versions from response here and update the version cache, but currently + // not all resource service methods return data. Therefore this is handled elsewhere. } protected void TrackLocalIdsForOperation(OperationContainer operation) @@ -144,4 +151,36 @@ private void AssignStringId(IIdentifiable resource) resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType); } } + + private void RefreshVersionsForOperation(OperationContainer operation) + { + if (operation.Request.PrimaryResourceType!.IsVersioned) + { + string? requestVersion = operation.Resource.GetVersion(); + + if (requestVersion == null) + { + string? trackedVersion = _versionTracker.GetVersion(operation.Request.PrimaryResourceType, operation.Resource.StringId!); + operation.Resource.SetVersion(trackedVersion); + + ((JsonApiRequest)operation.Request).PrimaryVersion = trackedVersion; + } + } + + foreach (IIdentifiable rightResource in operation.GetSecondaryResources()) + { + ResourceType rightResourceType = _resourceGraph.GetResourceType(rightResource.GetClrType()); + + if (rightResourceType.IsVersioned) + { + string? requestVersion = rightResource.GetVersion(); + + if (requestVersion == null) + { + string? trackedVersion = _versionTracker.GetVersion(rightResourceType, rightResource.StringId!); + rightResource.SetVersion(trackedVersion); + } + } + } + } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs new file mode 100644 index 0000000000..4230f8a515 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs @@ -0,0 +1,91 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.AtomicOperations; + +public sealed class VersionTracker : IVersionTracker +{ + private static readonly CollectionConverter CollectionConverter = new(); + + private readonly ITargetedFields _targetedFields; + private readonly IJsonApiRequest _request; + private readonly Dictionary _versionPerResource = new(); + + public VersionTracker(ITargetedFields targetedFields, IJsonApiRequest request) + { + ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + ArgumentGuard.NotNull(request, nameof(request)); + + _targetedFields = targetedFields; + _request = request; + } + + public bool RequiresVersionTracking() + { + if (_request.Kind != EndpointKind.AtomicOperations) + { + return false; + } + + return _request.PrimaryResourceType!.IsVersioned || _targetedFields.Relationships.Any(relationship => relationship.RightType.IsVersioned); + } + + public void CaptureVersions(ResourceType resourceType, IIdentifiable resource) + { + if (_request.Kind == EndpointKind.AtomicOperations) + { + if (resourceType.IsVersioned) + { + string? leftVersion = resource.GetVersion(); + SetVersion(resourceType, resource.StringId!, leftVersion); + } + + foreach (RelationshipAttribute relationship in _targetedFields.Relationships) + { + if (relationship.RightType.IsVersioned) + { + CaptureVersionsInRelationship(resource, relationship); + } + } + } + } + + private void CaptureVersionsInRelationship(IIdentifiable resource, RelationshipAttribute relationship) + { + object? afterRightValue = relationship.GetValue(resource); + IReadOnlyCollection afterRightResources = CollectionConverter.ExtractResources(afterRightValue); + + foreach (IIdentifiable rightResource in afterRightResources) + { + string? rightVersion = rightResource.GetVersion(); + SetVersion(relationship.RightType, rightResource.StringId!, rightVersion); + } + } + + private void SetVersion(ResourceType resourceType, string stringId, string? version) + { + string key = GetKey(resourceType, stringId); + + if (version == null) + { + _versionPerResource.Remove(key); + } + else + { + _versionPerResource[key] = version; + } + } + + public string? GetVersion(ResourceType resourceType, string stringId) + { + string key = GetKey(resourceType, stringId); + return _versionPerResource.TryGetValue(key, out string? version) ? version : null; + } + + private string GetKey(ResourceType resourceType, string stringId) + { + return $"{resourceType.PublicName}::{stringId}"; + } +} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 82e0ff52e1..e94d0f3ce7 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -275,6 +275,7 @@ private void AddOperationsLayer() _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); + _services.AddScoped(); } public void Dispose() diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index a9d99c3b13..1d00c42207 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -47,6 +47,11 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc /// QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType); + /// + /// Builds a query that retrieves the primary resource, along with the subset of versioned targeted relationships, after a create/update/delete request. + /// + QueryLayer ComposeForGetVersionsAfterWrite(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection); + /// /// Builds a query for each targeted relationship with a filter to match on its right resource IDs. /// diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 29e0935954..7e27c1ca52 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -394,6 +394,46 @@ public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType return primaryLayer; } + public QueryLayer ComposeForGetVersionsAfterWrite(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection) + { + ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + IImmutableSet includeElements = _targetedFields.Relationships + .Where(relationship => relationship.RightType.IsVersioned) + .Select(relationship => new IncludeElementExpression(relationship)) + .ToImmutableHashSet(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + + QueryLayer primaryLayer = new(primaryResourceType) + { + Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty, + Filter = CreateFilterByIds(id.AsArray(), primaryIdAttribute, null) + }; + + if (fieldSelection == TopFieldSelection.OnlyIdAttribute) + { + var primarySelection = new FieldSelection(); + FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryLayer.ResourceType); + primarySelectors.IncludeAttribute(primaryIdAttribute); + + foreach (IncludeElementExpression include in includeElements) + { + primarySelectors.IncludeRelationship(include.Relationship, null); + } + + primaryLayer.Selection = primarySelection; + } + + return primaryLayer; + } + /// public IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource) { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 5c51b39f83..3d81d7a064 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -2,6 +2,7 @@ using System.Net; using System.Runtime.CompilerServices; using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; using JsonApiDotNetCore.Errors; @@ -30,11 +31,12 @@ public class JsonApiResourceService : IResourceService> _traceWriter; private readonly IJsonApiRequest _request; private readonly IResourceChangeTracker _resourceChangeTracker; + private readonly IVersionTracker _versionTracker; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) + IVersionTracker versionTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) { ArgumentGuard.NotNull(repositoryAccessor); ArgumentGuard.NotNull(queryLayerComposer); @@ -43,6 +45,7 @@ public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQ ArgumentGuard.NotNull(loggerFactory); ArgumentGuard.NotNull(request); ArgumentGuard.NotNull(resourceChangeTracker); + ArgumentGuard.NotNull(versionTracker); ArgumentGuard.NotNull(resourceDefinitionAccessor); _repositoryAccessor = repositoryAccessor; @@ -51,6 +54,7 @@ public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQ _options = options; _request = request; _resourceChangeTracker = resourceChangeTracker; + _versionTracker = versionTracker; _resourceDefinitionAccessor = resourceDefinitionAccessor; _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -226,7 +230,8 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa throw; } - TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); + TResource resourceFromDatabase = + await GetPrimaryResourceAfterWriteAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); @@ -477,7 +482,7 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR throw; } - TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); + TResource afterResourceFromDatabase = await GetPrimaryResourceAfterWriteAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); @@ -522,6 +527,11 @@ public virtual async Task SetRelationshipAsync(TId leftId, string relationshipNa AssertIsNotResourceVersionMismatch(exception); throw; } + + if (_versionTracker.RequiresVersionTracking()) + { + await GetPrimaryResourceAfterWriteAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + } } /// @@ -614,6 +624,24 @@ protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSele return primaryResources.SingleOrDefault(); } + private async Task GetPrimaryResourceAfterWriteAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + if (_versionTracker.RequiresVersionTracking()) + { + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetVersionsAfterWrite(id, _request.PrimaryResourceType, fieldSelection); + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); + TResource? primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); + + _versionTracker.CaptureVersions(_request.PrimaryResourceType, primaryResource); + return primaryResource; + } + + return await GetPrimaryResourceByIdAsync(id, fieldSelection, cancellationToken); + } + protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) { AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); diff --git a/test/DiscoveryTests/PrivateResourceService.cs b/test/DiscoveryTests/PrivateResourceService.cs index 6d289eafb1..9e68e4bd67 100644 --- a/test/DiscoveryTests/PrivateResourceService.cs +++ b/test/DiscoveryTests/PrivateResourceService.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -14,8 +15,9 @@ public sealed class PrivateResourceService : JsonApiResourceService resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) + IVersionTracker versionTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, versionTracker, + resourceDefinitionAccessor) { } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 3396aed54b..70a4ac881d 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -36,6 +37,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); + _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs index 335bf7e9fb..bb0fa8e34a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -17,8 +18,9 @@ public sealed class ConsumerArticleService : JsonApiResourceService resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) + IVersionTracker versionTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, versionTracker, + resourceDefinitionAccessor) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs index b46a8133d0..27b55020f9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -19,8 +20,9 @@ public class MultiTenantResourceService : JsonApiResourceService public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) + IResourceChangeTracker resourceChangeTracker, IVersionTracker versionTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, versionTracker, + resourceDefinitionAccessor) { _tenantProvider = tenantProvider; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyOperationsTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyOperationsTests.cs new file mode 100644 index 0000000000..62a169d66b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/OptimisticConcurrencyOperationsTests.cs @@ -0,0 +1,271 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency; + +public sealed class OptimisticConcurrencyOperationsTests : IClassFixture, ConcurrencyDbContext>> +{ + private readonly IntegrationTestContext, ConcurrencyDbContext> _testContext; + private readonly ConcurrencyFakers _fakers = new(); + + public OptimisticConcurrencyOperationsTests(IntegrationTestContext, ConcurrencyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact(Skip = "Does not work, requires investigation.")] + public async Task Tracks_versions_over_various_operations() + { + // Arrange + WebPage existingPage = _fakers.WebPage.Generate(); + existingPage.Url = _fakers.FriendlyUrl.Generate(); + existingPage.Footer = _fakers.PageFooter.Generate(); + + FriendlyUrl existingUrl = _fakers.FriendlyUrl.Generate(); + + string newImagePath1 = _fakers.WebImage.Generate().Path; + string newImagePath2 = _fakers.WebImage.Generate().Path; + string newImageDescription = _fakers.WebImage.Generate().Description!; + string newParagraphText = _fakers.Paragraph.Generate().Text; + int newBlockColumnCount = _fakers.TextBlock.Generate().ColumnCount; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebPages.Add(existingPage); + await dbContext.SaveChangesAsync(); + dbContext.FriendlyUrls.Add(existingUrl); + await dbContext.SaveChangesAsync(); + }); + + const string imageLid1 = "image-1"; + const string imageLid2 = "image-2"; + const string paragraphLid = "para-1"; + const string blockLid = "block-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + // create resource + new + { + op = "add", + data = new + { + type = "webImages", + lid = imageLid1, + attributes = new + { + path = newImagePath1 + } + } + }, + new + { + op = "add", + data = new + { + type = "webImages", + lid = imageLid2, + attributes = new + { + path = newImagePath2 + } + } + }, + new + { + op = "add", + data = new + { + type = "paragraphs", + lid = paragraphLid, + attributes = new + { + text = newParagraphText + }, + relationships = new + { + topImage = new + { + data = new + { + type = "webImages", + lid = imageLid1 + } + } + } + } + }, + new + { + op = "add", + data = new + { + type = "textBlocks", + lid = blockLid, + attributes = new + { + columnCount = newBlockColumnCount + }, + relationships = new + { + paragraphs = new + { + data = new[] + { + new + { + type = "paragraphs", + lid = paragraphLid + } + } + } + } + } + }, + // update resource + new + { + op = "update", + data = new + { + type = "webImages", + lid = imageLid1, + attributes = new + { + description = newImageDescription + } + } + }, + new + { + op = "update", + data = new + { + type = "webPages", + id = existingPage.StringId, + version = existingPage.Version, + relationships = new + { + url = new + { + data = new + { + type = "friendlyUrls", + id = existingUrl.StringId, + version = existingUrl.Version + } + }, + content = new + { + data = new[] + { + new + { + type = "textBlocks", + lid = blockLid + } + } + } + } + } + }, + // delete resource + new + { + op = "remove", + @ref = new + { + type = "webImages", + lid = imageLid1 + } + }, + // set relationship + // Fix: the next operation fails, because the previous delete operation updated Paragraph, which we didn't track. + new + { + op = "update", + @ref = new + { + type = "paragraphs", + lid = paragraphLid, + relationship = "topImage" + }, + data = new + { + type = "webImages", + lid = imageLid2 + } + } + /* + new + { + op = "update", + @ref = new + { + type = "paragraphs", + lid = paragraphLid, + relationship = "usedIn" + }, + data = new[] + { + new + { + type = "textBlocks", + lid = blockLid + } + } + }, + new + { + op = "update", + data = new + { + type = "paragraphs", + lid = paragraphLid, + attributes = new + { + text = newParagraphText + }, + relationships = new + { + usedIn = new + { + data = Array.Empty() + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "textBlocks", + lid = blockLid + } + }*/ + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs index 299a40bad8..7436836b5b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -21,8 +22,10 @@ public class SoftDeletionAwareResourceService : JsonApiResourceS public SoftDeletionAwareResourceService(ISystemClock systemClock, ITargetedFields targetedFields, IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IVersionTracker versionTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, versionTracker, + resourceDefinitionAccessor) { _systemClock = systemClock; _targetedFields = targetedFields;