diff --git a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs index 3ca960ef87..5de48d8604 100644 --- a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs @@ -39,7 +39,7 @@ public JsonApiDeserializerBenchmarks() IResourceGraph resourceGraph = DependencyFactory.CreateResourceGraph(options); var targetedFields = new TargetedFields(); var request = new JsonApiRequest(); - _jsonApiDeserializer = new RequestDeserializer(resourceGraph, new ResourceFactory(new ServiceContainer()), targetedFields, new HttpContextAccessor(), request); + _jsonApiDeserializer = new RequestDeserializer(resourceGraph, new ResourceFactory(new ServiceContainer()), targetedFields, new HttpContextAccessor(), request, options); } [Benchmark] diff --git a/docs/usage/toc.md b/docs/usage/toc.md index 0dc75882c4..82ba96a42f 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -14,6 +14,7 @@ ## [Creating](writing/creating.md) ## [Updating](writing/updating.md) ## [Deleting](writing/deleting.md) +## [Bulk/batch](writing/bulk-batch-operations.md) # [Resource Graph](resource-graph.md) # [Options](options.md) diff --git a/docs/usage/writing/bulk-batch-operations.md b/docs/usage/writing/bulk-batch-operations.md new file mode 100644 index 0000000000..41e8ed2f52 --- /dev/null +++ b/docs/usage/writing/bulk-batch-operations.md @@ -0,0 +1,116 @@ +# Bulk/batch + +_since v4.1_ + +The [Atomic Operations](https://jsonapi.org/ext/atomic/) JSON:API extension defines +how to perform multiple write operations in a linear and atomic manner. + +Clients can send an array of operations in a single request. JsonApiDotNetCore guarantees that those +operations will be processed in order and will either completely succeed or fail together. + +On failure, the zero-based index of the failing operation is returned in the `error.source.pointer` field of the error response. + +## Usage + +To enable operations, add a controller to your project that inherits from `JsonApiOperationsController` or `BaseJsonApiOperationsController`: +```c# +public sealed class OperationsController : JsonApiOperationsController +{ + public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, + ITargetedFields targetedFields) + : base(options, loggerFactory, processor, request, targetedFields) + { + } +} +``` + +You'll need to send the next Content-Type in a POST request for operations: +``` +application/vnd.api+json; ext="https://jsonapi.org/ext/atomic" +``` + +### Local IDs + +Local IDs (lid) can be used to associate resources that have not yet been assigned an ID. +The next example creates two resources and sets a relationship between them: + +```json +POST http://localhost/api/operations HTTP/1.1 +Content-Type: application/vnd.api+json;ext="https://jsonapi.org/ext/atomic" + +{ + "atomic:operations": [ + { + "op": "add", + "data": { + "type": "musicTracks", + "lid": "id-for-i-will-survive", + "attributes": { + "title": "I will survive" + } + } + }, + { + "op": "add", + "data": { + "type": "performers", + "lid": "id-for-gloria-gaynor", + "attributes": { + "artistName": "Gloria Gaynor" + } + } + }, + { + "op": "update", + "ref": { + "type": "musicTracks", + "lid": "id-for-i-will-survive", + "relationship": "performers" + }, + "data": [ + { + "type": "performers", + "lid": "id-for-gloria-gaynor" + } + ] + } + ] +} +``` + +For example requests, see our suite of tests in JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations. + +## Configuration + +The maximum number of operations per request defaults to 10, which you can change from Startup.cs: +```c# +services.AddJsonApi(options => options.MaximumOperationsPerRequest = 250); +``` +Or, if you want to allow unconstrained, set it to `null` instead. + +### Multiple controllers + +You can register multiple operations controllers using custom routes, for example: +```c# +[DisableRoutingConvention, Route("/operations/musicTracks/create")] +public sealed class CreateMusicTrackOperationsController : JsonApiOperationsController +{ + public override async Task PostOperationsAsync( + IList operations, CancellationToken cancellationToken) + { + AssertOnlyCreatingMusicTracks(operations); + + return await base.PostOperationsAsync(operations, cancellationToken); + } +} +``` + +## Limitations + +For our atomic:operations implementation, the next limitations apply: + +- The `ref.href` field cannot be used. Use type/id or type/lid instead. +- You cannot both assign and reference the same local ID in a single operation. +- All repositories used in an operations request must implement `IRepositorySupportsTransaction` and participate in the same transaction. +- If you're not using Entity Framework Core, you'll need to implement and register `IOperationsTransactionFactory` yourself. diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs new file mode 100644 index 0000000000..247bd52d78 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + public sealed class OperationsController : JsonApiOperationsController + { + public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, loggerFactory, processor, request, targetedFields) + { + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs new file mode 100644 index 0000000000..f863a03d1e --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Represents an Entity Framework Core transaction in an atomic:operations request. + /// + public sealed class EntityFrameworkCoreTransaction : IOperationsTransaction + { + private readonly IDbContextTransaction _transaction; + private readonly DbContext _dbContext; + + /// + public Guid TransactionId => _transaction.TransactionId; + + public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext) + { + _transaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + } + + /// + /// Detaches all entities from the Entity Framework Core change tracker. + /// + public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) + { + _dbContext.ResetChangeTracker(); + return Task.CompletedTask; + } + + /// + /// Does nothing. + /// + public Task AfterProcessOperationAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task CommitAsync(CancellationToken cancellationToken) + { + return _transaction.CommitAsync(cancellationToken); + } + + /// + public ValueTask DisposeAsync() + { + return _transaction.DisposeAsync(); + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs new file mode 100644 index 0000000000..afde631c33 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Provides transaction support for atomic:operation requests using Entity Framework Core. + /// + public sealed class EntityFrameworkCoreTransactionFactory : IOperationsTransactionFactory + { + private readonly IDbContextResolver _dbContextResolver; + private readonly IJsonApiOptions _options; + + public EntityFrameworkCoreTransactionFactory(IDbContextResolver dbContextResolver, IJsonApiOptions options) + { + _dbContextResolver = dbContextResolver ?? throw new ArgumentNullException(nameof(dbContextResolver)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public async Task BeginTransactionAsync(CancellationToken cancellationToken) + { + var dbContext = _dbContextResolver.GetContext(); + + var transaction = _options.TransactionIsolationLevel != null + ? await dbContext.Database.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, + cancellationToken) + : await dbContext.Database.BeginTransactionAsync(cancellationToken); + + return new EntityFrameworkCoreTransaction(transaction, dbContext); + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs new file mode 100644 index 0000000000..eb61e41371 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs @@ -0,0 +1,28 @@ +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Used to track declarations, assignments and references to local IDs an in atomic:operations request. + /// + public interface ILocalIdTracker + { + /// + /// Removes all declared and assigned values. + /// + void Reset(); + + /// + /// Declares a local ID without assigning a server-generated value. + /// + void Declare(string localId, string resourceType); + + /// + /// Assigns a server-generated ID value to a previously declared local ID. + /// + void Assign(string localId, string resourceType, string stringId); + + /// + /// Gets the server-assigned ID for the specified local ID. + /// + string GetValue(string localId, string resourceType); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs new file mode 100644 index 0000000000..ca4aa424cf --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.AtomicOperations.Processors; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Retrieves a instance from the D/I container and invokes a method on it. + /// + public interface IOperationProcessorAccessor + { + /// + /// Invokes on a processor compatible with the operation kind. + /// + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs new file mode 100644 index 0000000000..839d0d6cb0 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Atomically processes a request that contains a list of operations. + /// + public interface IOperationsProcessor + { + /// + /// Processes the list of specified operations. + /// + Task> ProcessAsync(IList operations, CancellationToken cancellationToken); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs new file mode 100644 index 0000000000..5e36b5ff03 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Represents the overarching transaction in an atomic:operations request. + /// + public interface IOperationsTransaction : IAsyncDisposable + { + /// + /// Identifies the active transaction. + /// + Guid TransactionId { get; } + + /// + /// Enables to execute custom logic before processing of an operation starts. + /// + Task BeforeProcessOperationAsync(CancellationToken cancellationToken); + + /// + /// Enables to execute custom logic after processing of an operation succeeds. + /// + Task AfterProcessOperationAsync(CancellationToken cancellationToken); + + /// + /// Commits all changes made to the underlying data store. + /// + Task CommitAsync(CancellationToken cancellationToken); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs new file mode 100644 index 0000000000..f9b752381b --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Provides a method to start the overarching transaction for an atomic:operations request. + /// + public interface IOperationsTransactionFactory + { + /// + /// Starts a new transaction. + /// + Task BeginTransactionAsync(CancellationToken cancellationToken); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs new file mode 100644 index 0000000000..ae475027c5 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Net; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + public sealed class LocalIdTracker : ILocalIdTracker + { + private readonly IDictionary _idsTracked = new Dictionary(); + + /// + public void Reset() + { + _idsTracked.Clear(); + } + + /// + public void Declare(string localId, string resourceType) + { + if (localId == null) throw new ArgumentNullException(nameof(localId)); + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + + AssertIsNotDeclared(localId); + + _idsTracked[localId] = new LocalIdState(resourceType); + } + + private void AssertIsNotDeclared(string localId) + { + if (_idsTracked.ContainsKey(localId)) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Another local ID with the same name is already defined at this point.", + Detail = $"Another local ID with name '{localId}' is already defined at this point." + }); + } + } + + /// + public void Assign(string localId, string resourceType, string stringId) + { + if (localId == null) throw new ArgumentNullException(nameof(localId)); + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + if (stringId == null) throw new ArgumentNullException(nameof(stringId)); + + AssertIsDeclared(localId); + + var item = _idsTracked[localId]; + + AssertSameResourceType(resourceType, item.ResourceType, localId); + + if (item.ServerId != null) + { + throw new InvalidOperationException($"Cannot reassign to existing local ID '{localId}'."); + } + + item.ServerId = stringId; + } + + /// + public string GetValue(string localId, string resourceType) + { + if (localId == null) throw new ArgumentNullException(nameof(localId)); + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + + AssertIsDeclared(localId); + + var item = _idsTracked[localId]; + + AssertSameResourceType(resourceType, item.ResourceType, localId); + + if (item.ServerId == null) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Local ID cannot be both defined and used within the same operation.", + Detail = $"Local ID '{localId}' cannot be both defined and used within the same operation." + }); + } + + return item.ServerId; + } + + private void AssertIsDeclared(string localId) + { + if (!_idsTracked.ContainsKey(localId)) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Server-generated value for local ID is not available at this point.", + Detail = $"Server-generated value for local ID '{localId}' is not available at this point." + }); + } + } + + private static void AssertSameResourceType(string currentType, string declaredType, string localId) + { + if (declaredType != currentType) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Type mismatch in local ID usage.", + Detail = $"Local ID '{localId}' belongs to resource type '{declaredType}' instead of '{currentType}'." + }); + } + } + + private sealed class LocalIdState + { + public string ResourceType { get; } + public string ServerId { get; set; } + + public LocalIdState(string resourceType) + { + ResourceType = resourceType; + } + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs new file mode 100644 index 0000000000..c5e2a09f03 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Validates declaration, assignment and reference of local IDs within a list of operations. + /// + public sealed class LocalIdValidator + { + private readonly ILocalIdTracker _localIdTracker; + private readonly IResourceContextProvider _resourceContextProvider; + + public LocalIdValidator(ILocalIdTracker localIdTracker, IResourceContextProvider resourceContextProvider) + { + _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + } + + public void Validate(IEnumerable operations) + { + _localIdTracker.Reset(); + + int operationIndex = 0; + + try + { + foreach (var operation in operations) + { + if (operation.Kind == OperationKind.CreateResource) + { + DeclareLocalId(operation.Resource); + } + else + { + AssertLocalIdIsAssigned(operation.Resource); + } + + foreach (var secondaryResource in operation.GetSecondaryResources()) + { + AssertLocalIdIsAssigned(secondaryResource); + } + + if (operation.Kind == OperationKind.CreateResource) + { + AssignLocalId(operation); + } + + operationIndex++; + } + + } + catch (JsonApiException exception) + { + foreach (var error in exception.Errors) + { + error.Source.Pointer = $"/atomic:operations[{operationIndex}]" + error.Source.Pointer; + } + + throw; + } + } + + private void DeclareLocalId(IIdentifiable resource) + { + if (resource.LocalId != null) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); + _localIdTracker.Declare(resource.LocalId, resourceContext.PublicName); + } + } + + private void AssignLocalId(OperationContainer operation) + { + if (operation.Resource.LocalId != null) + { + var resourceContext = + _resourceContextProvider.GetResourceContext(operation.Resource.GetType()); + + _localIdTracker.Assign(operation.Resource.LocalId, resourceContext.PublicName, string.Empty); + } + } + + private void AssertLocalIdIsAssigned(IIdentifiable resource) + { + if (resource.LocalId != null) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); + _localIdTracker.GetValue(resource.LocalId, resourceContext.PublicName); + } + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs new file mode 100644 index 0000000000..75c327c0f2 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// A transaction factory that throws when used in an atomic:operations request, because no transaction support is available. + /// + public sealed class MissingTransactionFactory : IOperationsTransactionFactory + { + /// + public Task BeginTransactionAsync(CancellationToken cancellationToken) + { + // When using a data store other than Entity Framework Core, replace this type with your custom implementation + // by overwriting the IoC container registration. + throw new NotImplementedException("No transaction support is available."); + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs new file mode 100644 index 0000000000..31dea19969 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.AtomicOperations.Processors; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + public class OperationProcessorAccessor : IOperationProcessorAccessor + { + private readonly IResourceContextProvider _resourceContextProvider; + private readonly IServiceProvider _serviceProvider; + + public OperationProcessorAccessor(IResourceContextProvider resourceContextProvider, + IServiceProvider serviceProvider) + { + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + /// + public Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + var processor = ResolveProcessor(operation); + return processor.ProcessAsync(operation, cancellationToken); + } + + protected virtual IOperationProcessor ResolveProcessor(OperationContainer operation) + { + var processorInterface = GetProcessorInterface(operation.Kind); + var resourceContext = _resourceContextProvider.GetResourceContext(operation.Resource.GetType()); + + var processorType = processorInterface.MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); + return (IOperationProcessor) _serviceProvider.GetRequiredService(processorType); + } + + private static Type GetProcessorInterface(OperationKind kind) + { + switch (kind) + { + case OperationKind.CreateResource: + return typeof(ICreateProcessor<,>); + case OperationKind.UpdateResource: + return typeof(IUpdateProcessor<,>); + case OperationKind.DeleteResource: + return typeof(IDeleteProcessor<,>); + case OperationKind.SetRelationship: + return typeof(ISetRelationshipProcessor<,>); + case OperationKind.AddToRelationship: + return typeof(IAddToRelationshipProcessor<,>); + case OperationKind.RemoveFromRelationship: + return typeof(IRemoveFromRelationshipProcessor<,>); + default: + throw new NotSupportedException($"Unknown operation kind '{kind}'."); + } + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs new file mode 100644 index 0000000000..80f2a2a454 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + public class OperationsProcessor : IOperationsProcessor + { + private readonly IOperationProcessorAccessor _operationProcessorAccessor; + private readonly IOperationsTransactionFactory _operationsTransactionFactory; + private readonly ILocalIdTracker _localIdTracker; + private readonly IResourceContextProvider _resourceContextProvider; + private readonly IJsonApiRequest _request; + private readonly ITargetedFields _targetedFields; + private readonly LocalIdValidator _localIdValidator; + + public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, + IOperationsTransactionFactory operationsTransactionFactory, ILocalIdTracker localIdTracker, + IResourceContextProvider resourceContextProvider, IJsonApiRequest request, ITargetedFields targetedFields) + { + _operationProcessorAccessor = operationProcessorAccessor ?? throw new ArgumentNullException(nameof(operationProcessorAccessor)); + _operationsTransactionFactory = operationsTransactionFactory ?? throw new ArgumentNullException(nameof(operationsTransactionFactory)); + _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + _localIdValidator = new LocalIdValidator(_localIdTracker, _resourceContextProvider); + } + + /// + public virtual async Task> ProcessAsync(IList operations, + CancellationToken cancellationToken) + { + if (operations == null) throw new ArgumentNullException(nameof(operations)); + + _localIdValidator.Validate(operations); + _localIdTracker.Reset(); + + var results = new List(); + + await using var transaction = await _operationsTransactionFactory.BeginTransactionAsync(cancellationToken); + try + { + foreach (var operation in operations) + { + operation.SetTransactionId(transaction.TransactionId); + + await transaction.BeforeProcessOperationAsync(cancellationToken); + + var result = await ProcessOperation(operation, cancellationToken); + results.Add(result); + + await transaction.AfterProcessOperationAsync(cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (JsonApiException exception) + { + foreach (var error in exception.Errors) + { + error.Source.Pointer = $"/atomic:operations[{results.Count}]" + error.Source.Pointer; + } + + throw; + } + catch (Exception exception) + { + throw new JsonApiException(new Error(HttpStatusCode.InternalServerError) + { + Title = "An unhandled error occurred while processing an operation in this request.", + Detail = exception.Message, + Source = + { + Pointer = $"/atomic:operations[{results.Count}]" + } + + }, exception); + } + + return results; + } + + protected virtual async Task ProcessOperation(OperationContainer operation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + TrackLocalIdsForOperation(operation); + + _targetedFields.Attributes = operation.TargetedFields.Attributes; + _targetedFields.Relationships = operation.TargetedFields.Relationships; + + _request.CopyFrom(operation.Request); + + return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); + } + + protected void TrackLocalIdsForOperation(OperationContainer operation) + { + if (operation.Kind == OperationKind.CreateResource) + { + DeclareLocalId(operation.Resource); + } + else + { + AssignStringId(operation.Resource); + } + + foreach (var secondaryResource in operation.GetSecondaryResources()) + { + AssignStringId(secondaryResource); + } + } + + private void DeclareLocalId(IIdentifiable resource) + { + if (resource.LocalId != null) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); + _localIdTracker.Declare(resource.LocalId, resourceContext.PublicName); + } + } + + private void AssignStringId(IIdentifiable resource) + { + if (resource.LocalId != null) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); + resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceContext.PublicName); + } + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs new file mode 100644 index 0000000000..8aeba25d8f --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public class AddToRelationshipProcessor : IAddToRelationshipProcessor + where TResource : class, IIdentifiable + { + private readonly IAddToRelationshipService _service; + + public AddToRelationshipProcessor(IAddToRelationshipService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + public virtual async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + var primaryId = (TId) operation.Resource.GetTypedId(); + var secondaryResourceIds = operation.GetSecondaryResources(); + + await _service.AddToToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, + secondaryResourceIds, cancellationToken); + + return null; + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs new file mode 100644 index 0000000000..062e096910 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public class CreateProcessor : ICreateProcessor + where TResource : class, IIdentifiable + { + private readonly ICreateService _service; + private readonly ILocalIdTracker _localIdTracker; + private readonly IResourceContextProvider _resourceContextProvider; + + public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, + IResourceContextProvider resourceContextProvider) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + } + + /// + public virtual async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + var newResource = await _service.CreateAsync((TResource) operation.Resource, cancellationToken); + + if (operation.Resource.LocalId != null) + { + var serverId = newResource != null ? newResource.StringId : operation.Resource.StringId; + var resourceContext = _resourceContextProvider.GetResourceContext(); + + _localIdTracker.Assign(operation.Resource.LocalId, resourceContext.PublicName, serverId); + } + + return newResource == null ? null : operation.WithResource(newResource); + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs new file mode 100644 index 0000000000..dea9ba72da --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public class DeleteProcessor : IDeleteProcessor + where TResource : class, IIdentifiable + { + private readonly IDeleteService _service; + + public DeleteProcessor(IDeleteService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + public virtual async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + var id = (TId) operation.Resource.GetTypedId(); + await _service.DeleteAsync(id, cancellationToken); + + return null; + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs new file mode 100644 index 0000000000..965c704e0a --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + /// Processes a single operation to add resources to a to-many relationship. + /// + /// The resource type. + /// The resource identifier type. + public interface IAddToRelationshipProcessor : IOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs new file mode 100644 index 0000000000..846d6fc39a --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + /// Processes a single operation to create a new resource with attributes, relationships or both. + /// + /// The resource type. + /// The resource identifier type. + public interface ICreateProcessor : IOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs new file mode 100644 index 0000000000..bb3efc30b0 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + /// Processes a single operation to delete an existing resource. + /// + /// The resource type. + /// The resource identifier type. + public interface IDeleteProcessor : IOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs new file mode 100644 index 0000000000..6b51694260 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + /// Processes a single entry in a list of operations. + /// + public interface IOperationProcessor + { + /// + /// Processes the specified operation. + /// + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs new file mode 100644 index 0000000000..ae5d80c55a --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + /// Processes a single operation to remove resources from a to-many relationship. + /// + /// + /// + public interface IRemoveFromRelationshipProcessor : IOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs new file mode 100644 index 0000000000..f99c91d672 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + /// Processes a single operation to perform a complete replacement of a relationship on an existing resource. + /// + /// The resource type. + /// The resource identifier type. + public interface ISetRelationshipProcessor : IOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs new file mode 100644 index 0000000000..f4f403c073 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + /// Processes a single operation to update 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. + /// + /// The resource type. + /// The resource identifier type. + public interface IUpdateProcessor : IOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs new file mode 100644 index 0000000000..2ed9152d93 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public class RemoveFromRelationshipProcessor : IRemoveFromRelationshipProcessor + where TResource : class, IIdentifiable + { + private readonly IRemoveFromRelationshipService _service; + + public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + public virtual async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + var primaryId = (TId) operation.Resource.GetTypedId(); + var secondaryResourceIds = operation.GetSecondaryResources(); + + await _service.RemoveFromToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, + secondaryResourceIds, cancellationToken); + + return null; + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs new file mode 100644 index 0000000000..979f44237b --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public class SetRelationshipProcessor : ISetRelationshipProcessor + where TResource : class, IIdentifiable + { + private readonly ISetRelationshipService _service; + + public SetRelationshipProcessor(ISetRelationshipService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + public virtual async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + var primaryId = (TId) operation.Resource.GetTypedId(); + object rightValue = GetRelationshipRightValue(operation); + + await _service.SetRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, rightValue, + cancellationToken); + + return null; + } + + private static object GetRelationshipRightValue(OperationContainer operation) + { + var relationship = operation.Request.Relationship; + var rightValue = relationship.GetValue(operation.Resource); + + if (relationship is HasManyAttribute) + { + var rightResources = TypeHelper.ExtractResources(rightValue); + return rightResources.ToHashSet(IdentifiableComparer.Instance); + } + + return rightValue; + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs new file mode 100644 index 0000000000..25f3232ffc --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public class UpdateProcessor : IUpdateProcessor + where TResource : class, IIdentifiable + { + private readonly IUpdateService _service; + + public UpdateProcessor(IUpdateService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + public virtual async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + var resource = (TResource) operation.Resource; + var updated = await _service.UpdateAsync(resource.Id, resource, cancellationToken); + + return updated == null ? null : operation.WithResource(updated); + } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs b/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs index 70ac627218..52acdc14f2 100644 --- a/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs +++ b/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Configuration { /// /// Represents the Service Locator design pattern. Used to obtain object instances for types are not known until runtime. - /// The typical use case would be for accessing relationship data or resolving operations processors. + /// This is only used by resource hooks and subject to be removed in a future version. /// public interface IGenericServiceFactory { diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 96717fe796..a54e24b1f9 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -1,4 +1,5 @@ using System; +using System.Data; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; @@ -172,6 +173,18 @@ public interface IJsonApiOptions /// int? MaximumIncludeDepth { get; } + /// + /// Limits the maximum number of operations allowed per atomic:operations request. Defaults to 10. + /// Set to null for unlimited. + /// + int? MaximumOperationsPerRequest { get; } + + /// + /// Enables to override the default isolation level for database transactions, enabling to balance between consistency and performance. + /// Defaults to null, which leaves this up to Entity Framework Core to choose (and then it varies per database provider). + /// + IsolationLevel? TransactionIsolationLevel { get; } + /// /// Specifies the settings that are used by the . /// Note that at some places a few settings are ignored, to ensure JSON:API spec compliance. diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 74bc6cf4a5..25fe41bbd7 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCore.Hooks.Internal; using JsonApiDotNetCore.Hooks.Internal.Discovery; @@ -130,6 +132,12 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) var contextResolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); _services.AddScoped(typeof(IDbContextResolver), contextResolverType); } + + _services.AddScoped(); + } + else + { + _services.AddScoped(); } AddResourceLayer(); @@ -138,6 +146,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) AddMiddlewareLayer(); AddSerializationLayer(); AddQueryStringLayer(); + AddOperationsLayer(); AddResourceHooks(); @@ -263,10 +272,25 @@ private void AddSerializationLayer() _services.AddScoped(); _services.AddScoped(); _services.AddScoped(typeof(ResponseSerializer<>)); + _services.AddScoped(typeof(AtomicOperationsResponseSerializer)); _services.AddScoped(sp => sp.GetRequiredService().GetSerializer()); _services.AddScoped(); } + private void AddOperationsLayer() + { + _services.AddScoped(typeof(ICreateProcessor<,>), typeof(CreateProcessor<,>)); + _services.AddScoped(typeof(IUpdateProcessor<,>), typeof(UpdateProcessor<,>)); + _services.AddScoped(typeof(IDeleteProcessor<,>), typeof(DeleteProcessor<,>)); + _services.AddScoped(typeof(IAddToRelationshipProcessor<,>), typeof(AddToRelationshipProcessor<,>)); + _services.AddScoped(typeof(ISetRelationshipProcessor<,>), typeof(SetRelationshipProcessor<,>)); + _services.AddScoped(typeof(IRemoveFromRelationshipProcessor<,>), typeof(RemoveFromRelationshipProcessor<,>)); + + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + } + private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder builder) { foreach (var entityType in dbContext.Model.GetEntityTypes()) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index c999508574..fe409cef5d 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -1,3 +1,4 @@ +using System.Data; using JsonApiDotNetCore.Resources.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -67,6 +68,12 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public int? MaximumIncludeDepth { get; set; } + /// + public int? MaximumOperationsPerRequest { get; set; } = 10; + + /// + public IsolationLevel? TransactionIsolationLevel { get; } + /// public JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings { diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs index d6a3459aa9..c866a831f0 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -30,14 +30,14 @@ public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEnt return true; } - var isTopResourceInPrimaryRequest = string.IsNullOrEmpty(parentEntry.Key) && request.Kind == EndpointKind.Primary; + var isTopResourceInPrimaryRequest = string.IsNullOrEmpty(parentEntry.Key) && IsAtPrimaryEndpoint(request); if (!isTopResourceInPrimaryRequest) { return false; } var httpContextAccessor = _serviceProvider.GetRequiredService(); - if (httpContextAccessor.HttpContext.Request.Method == HttpMethods.Patch) + if (httpContextAccessor.HttpContext.Request.Method == HttpMethods.Patch || request.OperationKind == OperationKind.UpdateResource) { var targetedFields = _serviceProvider.GetRequiredService(); return IsFieldTargeted(entry, targetedFields); @@ -51,6 +51,11 @@ private static bool IsId(string key) return key == nameof(Identifiable.Id) || key.EndsWith("." + nameof(Identifiable.Id), StringComparison.Ordinal); } + private static bool IsAtPrimaryEndpoint(IJsonApiRequest request) + { + return request.Kind == EndpointKind.Primary || request.Kind == EndpointKind.AtomicOperations; + } + private static bool IsFieldTargeted(ValidationEntry entry, ITargetedFields targetedFields) { return targetedFields.Attributes.Any(attribute => attribute.Property.Name == entry.Key); diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index c3c966b4ce..29ce0b6b8a 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -162,7 +162,7 @@ public virtual async Task PostAsync([FromBody] TResource resource throw new RequestMethodNotAllowedException(HttpMethod.Post); if (!_options.AllowClientGeneratedIds && resource.StringId != null) - throw new ResourceIdInPostRequestNotAllowedException(); + throw new ResourceIdInCreateResourceNotAllowedException(); if (_options.ValidateModelState && !ModelState.IsValid) { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs new file mode 100644 index 0000000000..f118d20e1f --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.Controllers +{ + /// + /// Implements the foundational ASP.NET Core controller layer in the JsonApiDotNetCore architecture for handling atomic:operations requests. + /// See https://jsonapi.org/ext/atomic/ for details. Delegates work to . + /// + public abstract class BaseJsonApiOperationsController : CoreJsonApiController + { + private readonly IJsonApiOptions _options; + private readonly IOperationsProcessor _processor; + private readonly IJsonApiRequest _request; + private readonly ITargetedFields _targetedFields; + private readonly TraceLogWriter _traceWriter; + + protected BaseJsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + { + if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); + + _options = options ?? throw new ArgumentNullException(nameof(options)); + _processor = processor ?? throw new ArgumentNullException(nameof(processor)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + _traceWriter = new TraceLogWriter(loggerFactory); + } + + /// + /// Atomically processes a list of operations and returns a list of results. + /// All changes are reverted if processing fails. + /// If processing succeeds but none of the operations returns any data, then HTTP 201 is returned instead of 200. + /// + /// + /// The next example creates a new resource. + /// + /// + /// The next example updates an existing resource. + /// + /// + /// The next example deletes an existing resource. + /// + public virtual async Task PostOperationsAsync([FromBody] IList operations, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new {operations}); + if (operations == null) throw new ArgumentNullException(nameof(operations)); + + ValidateClientGeneratedIds(operations); + + if (_options.ValidateModelState) + { + ValidateModelState(operations); + } + + var results = await _processor.ProcessAsync(operations, cancellationToken); + return results.Any(result => result != null) ? (IActionResult) Ok(results) : NoContent(); + } + + protected virtual void ValidateClientGeneratedIds(IEnumerable operations) + { + if (!_options.AllowClientGeneratedIds) + { + int index = 0; + foreach (var operation in operations) + { + if (operation.Kind == OperationKind.CreateResource && operation.Resource.StringId != null) + { + throw new ResourceIdInCreateResourceNotAllowedException(index); + } + + index++; + } + } + } + + protected virtual void ValidateModelState(IEnumerable operations) + { + // We must validate the resource inside each operation manually, because they are typed as IIdentifiable. + // Instead of validating IIdentifiable we need to validate the resource runtime-type. + + var violations = new List(); + + int index = 0; + foreach (var operation in operations) + { + if (operation.Kind == OperationKind.CreateResource || operation.Kind == OperationKind.UpdateResource) + { + _targetedFields.Attributes = operation.TargetedFields.Attributes; + _targetedFields.Relationships = operation.TargetedFields.Relationships; + + _request.CopyFrom(operation.Request); + + var validationContext = new ActionContext(); + ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource); + + if (!validationContext.ModelState.IsValid) + { + foreach (var (key, entry) in validationContext.ModelState) + { + foreach (var error in entry.Errors) + { + var violation = new ModelStateViolation($"/atomic:operations[{index}]/data/attributes/", key, operation.Resource.GetType(), error); + violations.Add(violation); + } + } + } + } + + index++; + } + + if (violations.Any()) + { + var namingStrategy = _options.SerializerContractResolver.NamingStrategy; + throw new InvalidModelStateException(violations, _options.IncludeExceptionStackTraceInErrors, namingStrategy); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs new file mode 100644 index 0000000000..a2aed59e8f --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.Controllers +{ + /// + /// The base class to derive atomic:operations controllers from. + /// This class delegates all work to but adds attributes for routing templates. + /// If you want to provide routing templates yourself, you should derive from BaseJsonApiOperationsController directly. + /// + public abstract class JsonApiOperationsController : BaseJsonApiOperationsController + { + protected JsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, loggerFactory, processor, request, targetedFields) + { + } + + /// + [HttpPost] + public override async Task PostOperationsAsync([FromBody] IList operations, + CancellationToken cancellationToken) + { + return await base.PostOperationsAsync(operations, cancellationToken); + } + } +} diff --git a/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs b/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs new file mode 100644 index 0000000000..b83453e919 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs @@ -0,0 +1,24 @@ +using System; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace JsonApiDotNetCore.Controllers +{ + /// + /// Represents the violation of a model state validation rule. + /// + public sealed class ModelStateViolation + { + public string Prefix { get; } + public string PropertyName { get; } + public Type ResourceType { get; set; } + public ModelError Error { get; } + + public ModelStateViolation(string prefix, string propertyName, Type resourceType, ModelError error) + { + Prefix = prefix ?? throw new ArgumentNullException(nameof(prefix)); + PropertyName = propertyName ?? throw new ArgumentNullException(nameof(propertyName)); + ResourceType = resourceType ?? throw new ArgumentNullException(nameof(resourceType)); + Error = error ?? throw new ArgumentNullException(nameof(error)); + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index 9cba6d0467..7393a5596d 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Net; using System.Reflection; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -18,37 +18,50 @@ public class InvalidModelStateException : JsonApiException { public InvalidModelStateException(ModelStateDictionary modelState, Type resourceType, bool includeExceptionStackTraceInErrors, NamingStrategy namingStrategy) - : base(FromModelState(modelState, resourceType, includeExceptionStackTraceInErrors, namingStrategy)) + : this(FromModelStateDictionary(modelState, resourceType), includeExceptionStackTraceInErrors, namingStrategy) { } - private static IReadOnlyCollection FromModelState(ModelStateDictionary modelState, Type resourceType, + private static IEnumerable FromModelStateDictionary(ModelStateDictionary modelState, Type resourceType) + { + foreach (var (propertyName, entry) in modelState) + { + foreach (ModelError error in entry.Errors) + { + yield return new ModelStateViolation("/data/attributes/", propertyName, resourceType, error); + } + } + } + + public InvalidModelStateException(IEnumerable violations, bool includeExceptionStackTraceInErrors, NamingStrategy namingStrategy) + : base(FromModelStateViolations(violations, includeExceptionStackTraceInErrors, namingStrategy)) { - if (modelState == null) throw new ArgumentNullException(nameof(modelState)); - if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); - if (namingStrategy == null) throw new ArgumentNullException(nameof(namingStrategy)); + } - List errors = new List(); + private static IEnumerable FromModelStateViolations(IEnumerable violations, + bool includeExceptionStackTraceInErrors, NamingStrategy namingStrategy) + { + if (violations == null) throw new ArgumentNullException(nameof(violations)); + if (namingStrategy == null) throw new ArgumentNullException(nameof(namingStrategy)); - foreach (var (propertyName, entry) in modelState.Where(x => x.Value.Errors.Any())) + foreach (var violation in violations) { - string attributeName = GetDisplayNameForProperty(propertyName, resourceType, namingStrategy); - - foreach (var modelError in entry.Errors) + if (violation.Error.Exception is JsonApiException jsonApiException) { - if (modelError.Exception is JsonApiException jsonApiException) - { - errors.AddRange(jsonApiException.Errors); - } - else + foreach (var error in jsonApiException.Errors) { - errors.Add(FromModelError(modelError, attributeName, includeExceptionStackTraceInErrors)); + yield return error; } } - } + else + { + string attributeName = GetDisplayNameForProperty(violation.PropertyName, violation.ResourceType, namingStrategy); + var attributePath = violation.Prefix + attributeName; - return errors; + yield return FromModelError(violation.Error, attributePath, includeExceptionStackTraceInErrors); + } + } } private static string GetDisplayNameForProperty(string propertyName, Type resourceType, @@ -64,18 +77,18 @@ private static string GetDisplayNameForProperty(string propertyName, Type resour return propertyName; } - private static Error FromModelError(ModelError modelError, string attributeName, + private static Error FromModelError(ModelError modelError, string attributePath, bool includeExceptionStackTraceInErrors) { var error = new Error(HttpStatusCode.UnprocessableEntity) { Title = "Input validation failed.", Detail = modelError.ErrorMessage, - Source = attributeName == null + Source = attributePath == null ? null : new ErrorSource { - Pointer = $"/data/attributes/{attributeName}" + Pointer = attributePath } }; diff --git a/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs b/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs new file mode 100644 index 0000000000..088db9300d --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs @@ -0,0 +1,21 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when accessing a repository that does not support transactions + /// during an atomic:operations request. + /// + public sealed class MissingTransactionSupportException : JsonApiException + { + public MissingTransactionSupportException(string resourceType) + : base(new Error(HttpStatusCode.UnprocessableEntity) + { + Title = "Unsupported resource type in atomic:operations request.", + Detail = $"Operations on resources of type '{resourceType}' cannot be used because transaction support is unavailable." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/NonSharedTransactionException.cs b/src/JsonApiDotNetCore/Errors/NonSharedTransactionException.cs new file mode 100644 index 0000000000..d0bcd69505 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/NonSharedTransactionException.cs @@ -0,0 +1,21 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when a repository does not participate in the overarching transaction + /// during an atomic:operations request. + /// + public sealed class NonSharedTransactionException : JsonApiException + { + public NonSharedTransactionException() + : base(new Error(HttpStatusCode.UnprocessableEntity) + { + Title = "Unsupported combination of resource types in atomic:operations request.", + Detail = "All operations need to participate in a single shared transaction, which is not the case for this request." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs new file mode 100644 index 0000000000..fff90a6fb9 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs @@ -0,0 +1,27 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when a resource creation request or operation is received that contains a client-generated ID. + /// + public sealed class ResourceIdInCreateResourceNotAllowedException : JsonApiException + { + public ResourceIdInCreateResourceNotAllowedException(int? atomicOperationIndex = null) + : base(new Error(HttpStatusCode.Forbidden) + { + Title = atomicOperationIndex == null + ? "Specifying the resource ID in POST requests is not allowed." + : "Specifying the resource ID in operations that create a resource is not allowed.", + Source = + { + Pointer = atomicOperationIndex != null + ? $"/atomic:operations[{atomicOperationIndex}]/data/id" + : "/data/id" + } + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs deleted file mode 100644 index 6b621faa6f..0000000000 --- a/src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Net; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when a POST request is received that contains a client-generated ID. - /// - public sealed class ResourceIdInPostRequestNotAllowedException : JsonApiException - { - public ResourceIdInPostRequestNotAllowedException() - : base(new Error(HttpStatusCode.Forbidden) - { - Title = "Specifying the resource ID in POST requests is not allowed.", - Source = - { - Pointer = "/data/id" - } - }) - { - } - } -} diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 8897cdfcaf..1a13f38425 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -24,6 +24,7 @@ + diff --git a/src/JsonApiDotNetCore/Middleware/EndpointKind.cs b/src/JsonApiDotNetCore/Middleware/EndpointKind.cs index ea5b9339c9..6123aa09f9 100644 --- a/src/JsonApiDotNetCore/Middleware/EndpointKind.cs +++ b/src/JsonApiDotNetCore/Middleware/EndpointKind.cs @@ -15,6 +15,11 @@ public enum EndpointKind /// /// A relationship request, for example: "/blogs/123/relationships/author" or "/author/123/relationships/articles" /// - Relationship + Relationship, + + /// + /// A request to an atomic:operations endpoint. + /// + AtomicOperations } } diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index db270ec56d..b85125a821 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -1,7 +1,6 @@ using System; using System.Diagnostics; using System.Net; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Serialization.Objects; @@ -72,7 +71,7 @@ protected virtual ErrorDocument CreateErrorDocument(Exception exception) var errors = exception is JsonApiException jsonApiException ? jsonApiException.Errors - : exception is TaskCanceledException + : exception is OperationCanceledException ? new[] { new Error((HttpStatusCode) 499) diff --git a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs index 910cb1c17e..e60fbc31a3 100644 --- a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs +++ b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs @@ -3,5 +3,6 @@ namespace JsonApiDotNetCore.Middleware public static class HeaderConstants { public const string MediaType = "application/vnd.api+json"; + public const string AtomicOperationsMediaType = MediaType + "; ext=\"https://jsonapi.org/ext/atomic\""; } } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 3ea888e4ff..ecabf6d8eb 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -1,3 +1,4 @@ +using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; @@ -57,5 +58,20 @@ public interface IJsonApiRequest /// Indicates whether this request targets only fetching of data (such as resources and relationships). /// bool IsReadOnly { get; } + + /// + /// In case of an atomic:operations request, this indicates the kind of operation currently being processed. + /// + OperationKind? OperationKind { get; } + + /// + /// In case of an atomic:operations request, identifies the overarching transaction. + /// + Guid? TransactionId { get; } + + /// + /// Performs a shallow copy. + /// + void CopyFrom(IJsonApiRequest other); } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 026e50682a..838d234c09 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -24,6 +24,7 @@ namespace JsonApiDotNetCore.Middleware public sealed class JsonApiMiddleware { private static readonly MediaTypeHeaderValue _mediaType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType); + private static readonly MediaTypeHeaderValue _atomicOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType); private readonly RequestDelegate _next; @@ -49,13 +50,25 @@ public async Task Invoke(HttpContext httpContext, var primaryResourceContext = CreatePrimaryResourceContext(routeValues, controllerResourceMapping, resourceContextProvider); if (primaryResourceContext != null) { - if (!await ValidateContentTypeHeaderAsync(httpContext, options.SerializerSettings) || - !await ValidateAcceptHeaderAsync(httpContext, options.SerializerSettings)) + if (!await ValidateContentTypeHeaderAsync(HeaderConstants.MediaType, httpContext, options.SerializerSettings) || + !await ValidateAcceptHeaderAsync(_mediaType, httpContext, options.SerializerSettings)) { return; } - SetupRequest((JsonApiRequest)request, primaryResourceContext, routeValues, options, resourceContextProvider, httpContext.Request); + SetupResourceRequest((JsonApiRequest)request, primaryResourceContext, routeValues, options, resourceContextProvider, httpContext.Request); + + httpContext.RegisterJsonApiRequest(); + } + else if (IsOperationsRequest(routeValues)) + { + if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerSettings) || + !await ValidateAcceptHeaderAsync(_atomicOperationsMediaType, httpContext, options.SerializerSettings)) + { + return; + } + + SetupOperationsRequest((JsonApiRequest)request, options, httpContext.Request); httpContext.RegisterJsonApiRequest(); } @@ -79,15 +92,15 @@ private static ResourceContext CreatePrimaryResourceContext(RouteValueDictionary return null; } - private static async Task ValidateContentTypeHeaderAsync(HttpContext httpContext, JsonSerializerSettings serializerSettings) + private static async Task ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, JsonSerializerSettings serializerSettings) { var contentType = httpContext.Request.ContentType; - if (contentType != null && contentType != HeaderConstants.MediaType) + if (contentType != null && contentType != allowedContentType) { await FlushResponseAsync(httpContext.Response, serializerSettings, new Error(HttpStatusCode.UnsupportedMediaType) { Title = "The specified Content-Type header value is not supported.", - Detail = $"Please specify '{HeaderConstants.MediaType}' instead of '{contentType}' for the Content-Type header value." + Detail = $"Please specify '{allowedContentType}' instead of '{contentType}' for the Content-Type header value." }); return false; } @@ -95,7 +108,7 @@ private static async Task ValidateContentTypeHeaderAsync(HttpContext httpC return true; } - private static async Task ValidateAcceptHeaderAsync(HttpContext httpContext, JsonSerializerSettings serializerSettings) + private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue allowedMediaTypeValue, HttpContext httpContext, JsonSerializerSettings serializerSettings) { StringValues acceptHeaders = httpContext.Request.Headers["Accept"]; if (!acceptHeaders.Any()) @@ -117,7 +130,7 @@ private static async Task ValidateAcceptHeaderAsync(HttpContext httpContex break; } - if (_mediaType.Equals(headerValue)) + if (allowedMediaTypeValue.Equals(headerValue)) { seenCompatibleMediaType = true; break; @@ -130,7 +143,7 @@ private static async Task ValidateAcceptHeaderAsync(HttpContext httpContex await FlushResponseAsync(httpContext.Response, serializerSettings, new Error(HttpStatusCode.NotAcceptable) { Title = "The specified Accept header value does not contain any supported media types.", - Detail = $"Please include '{_mediaType}' in the Accept header values." + Detail = $"Please include '{allowedMediaTypeValue}' in the Accept header values." }); return false; } @@ -162,7 +175,7 @@ private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSeri await httpResponse.Body.FlushAsync(); } - private static void SetupRequest(JsonApiRequest request, ResourceContext primaryResourceContext, + private static void SetupResourceRequest(JsonApiRequest request, ResourceContext primaryResourceContext, RouteValueDictionary routeValues, IJsonApiOptions options, IResourceContextProvider resourceContextProvider, HttpRequest httpRequest) { @@ -225,15 +238,18 @@ private static string GetBasePath(string resourceName, IJsonApiOptions options, private static string GetCustomRoute(string resourceName, string apiNamespace, HttpContext httpContext) { - var endpoint = httpContext.GetEndpoint(); - var routeAttribute = endpoint.Metadata.GetMetadata(); - if (routeAttribute != null) + if (resourceName != null) { - var trimmedComponents = httpContext.Request.Path.Value.Trim('/').Split('/').ToList(); - var resourceNameIndex = trimmedComponents.FindIndex(c => c == resourceName); - var newComponents = trimmedComponents.Take(resourceNameIndex).ToArray(); - var customRoute = string.Join('/', newComponents); - return customRoute == apiNamespace ? null : customRoute; + var endpoint = httpContext.GetEndpoint(); + var routeAttribute = endpoint.Metadata.GetMetadata(); + if (routeAttribute != null) + { + var trimmedComponents = httpContext.Request.Path.Value.Trim('/').Split('/').ToList(); + var resourceNameIndex = trimmedComponents.FindIndex(c => c == resourceName); + var newComponents = trimmedComponents.Take(resourceNameIndex).ToArray(); + var customRoute = string.Join('/', newComponents); + return customRoute == apiNamespace ? null : customRoute; + } } return null; @@ -249,5 +265,18 @@ private static bool IsRouteForRelationship(RouteValueDictionary routeValues) var actionName = (string)routeValues["action"]; return actionName.EndsWith("Relationship", StringComparison.Ordinal); } + + private static bool IsOperationsRequest(RouteValueDictionary routeValues) + { + var actionName = (string)routeValues["action"]; + return actionName == "PostOperations"; + } + + private static void SetupOperationsRequest(JsonApiRequest request, IJsonApiOptions options, HttpRequest httpRequest) + { + request.IsReadOnly = false; + request.Kind = EndpointKind.AtomicOperations; + request.BasePath = GetBasePath(null, options, httpRequest); + } } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 5080c5f9e2..b4776419e3 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -1,3 +1,4 @@ +using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; @@ -29,5 +30,28 @@ public sealed class JsonApiRequest : IJsonApiRequest /// public bool IsReadOnly { get; set; } + + /// + public OperationKind? OperationKind { get; set; } + + /// + public Guid? TransactionId { get; set; } + + /// + public void CopyFrom(IJsonApiRequest other) + { + if (other == null) throw new ArgumentNullException(nameof(other)); + + Kind = other.Kind; + BasePath = other.BasePath; + PrimaryId = other.PrimaryId; + PrimaryResource = other.PrimaryResource; + SecondaryResource = other.SecondaryResource; + Relationship = other.Relationship; + IsCollection = other.IsCollection; + IsReadOnly = other.IsReadOnly; + OperationKind = other.OperationKind; + TransactionId = other.TransactionId; + } } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 9a4fde58aa..f751e2a6ec 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -63,15 +63,19 @@ public void Apply(ApplicationModel application) foreach (var controller in application.Controllers) { - var resourceType = ExtractResourceTypeFromController(controller.ControllerType); - - if (resourceType != null) + bool isOperationsController = IsOperationsController(controller.ControllerType); + if (!isOperationsController) { - var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - - if (resourceContext != null) + var resourceType = ExtractResourceTypeFromController(controller.ControllerType); + + if (resourceType != null) { - _registeredResources.Add(controller.ControllerName, resourceContext); + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + + if (resourceContext != null) + { + _registeredResources.Add(controller.ControllerName, resourceContext); + } } } @@ -169,5 +173,11 @@ private Type ExtractResourceTypeFromController(Type type) return currentType?.GetGenericArguments().First(); } + + private static bool IsOperationsController(Type type) + { + var baseControllerType = typeof(BaseJsonApiOperationsController); + return baseControllerType.IsAssignableFrom(type); + } } } diff --git a/src/JsonApiDotNetCore/Middleware/OperationKind.cs b/src/JsonApiDotNetCore/Middleware/OperationKind.cs new file mode 100644 index 0000000000..7cfbe0f892 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/OperationKind.cs @@ -0,0 +1,15 @@ +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Lists the functional operation kinds from an atomic:operations request. + /// + public enum OperationKind + { + CreateResource, + UpdateResource, + DeleteResource, + SetRelationship, + AddToRelationship, + RemoveFromRelationship + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs index e3eb52a664..19333c149c 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs @@ -143,5 +143,10 @@ private HashSet GetResourceFields(ResourceContext resour return fieldSet; } + + public void Reset() + { + _visitedTable.Clear(); + } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs index 0af5114144..e7e2711548 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs @@ -49,7 +49,8 @@ public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttr { if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); - return !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Filter); + return !IsAtomicOperationsRequest && + !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Filter); } /// diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs index d79b24b223..5b193140ff 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs @@ -44,7 +44,8 @@ public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttr { if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); - return !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Include); + return !IsAtomicOperationsRequest && + !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Include); } /// diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs index b7b8148971..7196e9aca1 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs @@ -35,7 +35,8 @@ public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttr { if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); - return !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Page); + return !IsAtomicOperationsRequest && + !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Page); } /// diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs index 453565eab5..2ba5387f34 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs @@ -14,6 +14,7 @@ public abstract class QueryStringParameterReader private readonly bool _isCollectionRequest; protected ResourceContext RequestResource { get; } + protected bool IsAtomicOperationsRequest { get; } protected QueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider) { @@ -25,6 +26,7 @@ protected QueryStringParameterReader(IJsonApiRequest request, IResourceContextPr _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _isCollectionRequest = request.IsCollection; RequestResource = request.SecondaryResource ?? request.PrimaryResource; + IsAtomicOperationsRequest = request.Kind == EndpointKind.AtomicOperations; } protected ResourceContext GetResourceContextForScope(ResourceFieldChainExpression scope) diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs index a9ef383e49..3fd55aabb1 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs @@ -32,6 +32,11 @@ public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttr /// public virtual bool CanRead(string parameterName) { + if (_request.Kind == EndpointKind.AtomicOperations) + { + return false; + } + var queryableHandler = GetQueryableHandler(parameterName); return queryableHandler != null; } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs index 514f2ba235..8fa2cba15d 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs @@ -40,7 +40,8 @@ public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttr { if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); - return !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Sort); + return !IsAtomicOperationsRequest && + !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Sort); } /// diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs index 973cb409f2..d39fd5c774 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs @@ -41,7 +41,8 @@ public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttr { if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); - return !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Fields); + return !IsAtomicOperationsRequest && + !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Fields); } /// diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index c12fd6eaeb..83a3f77bf3 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; namespace JsonApiDotNetCore.Repositories { @@ -42,5 +44,20 @@ public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifia return entityEntry?.Entity; } + + /// + /// Detaches all entities from the change tracker. + /// + public static void ResetChangeTracker(this DbContext dbContext) + { + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); + + List entriesWithChanges = dbContext.ChangeTracker.Entries().ToList(); + + foreach (EntityEntry entry in entriesWithChanges) + { + entry.State = EntityState.Detached; + } + } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 9738f4795b..53c672f0c7 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -22,7 +22,7 @@ namespace JsonApiDotNetCore.Repositories /// /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. /// - public class EntityFrameworkCoreRepository : IResourceRepository + public class EntityFrameworkCoreRepository : IResourceRepository, IRepositorySupportsTransaction where TResource : class, IIdentifiable { private readonly ITargetedFields _targetedFields; @@ -32,6 +32,9 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly IEnumerable _constraintProviders; private readonly TraceLogWriter> _traceWriter; + /// + public virtual Guid? TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId; + public EntityFrameworkCoreRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, @@ -399,6 +402,13 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke } catch (DbUpdateException exception) { + if (_dbContext.Database.CurrentTransaction != null) + { + // The ResourceService calling us needs to run additional SQL queries after an aborted transaction, + // to determine error cause. This fails when a failed transaction is still in progress. + await _dbContext.Database.CurrentTransaction.RollbackAsync(cancellationToken); + } + throw new DataStoreUpdateException(exception); } } diff --git a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs new file mode 100644 index 0000000000..d134e39bd9 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs @@ -0,0 +1,15 @@ +using System; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// Used to indicate that a supports execution inside a transaction. + /// + public interface IRepositorySupportsTransaction + { + /// + /// Identifies the currently active transaction. + /// + Guid? TransactionId { get; } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index 2eafaa2f39..824bfe0423 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Repositories { /// - /// Retrieves a instance from the D/I container and invokes a callback on it. + /// Retrieves a instance from the D/I container and invokes a method on it. /// public interface IResourceRepositoryAccessor { diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 61c64633a3..cc7af76e18 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -3,6 +3,8 @@ using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; @@ -15,11 +17,13 @@ public class ResourceRepositoryAccessor : IResourceRepositoryAccessor { private readonly IServiceProvider _serviceProvider; private readonly IResourceContextProvider _resourceContextProvider; + private readonly IJsonApiRequest _request; - public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceContextProvider resourceContextProvider) + public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceContextProvider resourceContextProvider, IJsonApiRequest request) { _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentException(nameof(serviceProvider)); + _request = request ?? throw new ArgumentNullException(nameof(request)); } /// @@ -51,7 +55,7 @@ public async Task CountAsync(FilterExpression topFilter, Cancell public async Task GetForCreateAsync(TId id, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - dynamic repository = ResolveWriteRepository(typeof(TResource)); + dynamic repository = GetWriteRepository(typeof(TResource)); return await repository.GetForCreateAsync(id, cancellationToken); } @@ -59,7 +63,7 @@ public async Task GetForCreateAsync(TId id, Cancellat public async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - dynamic repository = ResolveWriteRepository(typeof(TResource)); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); } @@ -67,7 +71,7 @@ public async Task CreateAsync(TResource resourceFromRequest, TResourc public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - dynamic repository = ResolveWriteRepository(typeof(TResource)); + dynamic repository = GetWriteRepository(typeof(TResource)); return await repository.GetForUpdateAsync(queryLayer, cancellationToken); } @@ -75,7 +79,7 @@ public async Task GetForUpdateAsync(QueryLayer queryLayer, public async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - dynamic repository = ResolveWriteRepository(typeof(TResource)); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); } @@ -83,7 +87,7 @@ public async Task UpdateAsync(TResource resourceFromRequest, TResourc public async Task DeleteAsync(TId id, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - dynamic repository = ResolveWriteRepository(typeof(TResource)); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.DeleteAsync(id, cancellationToken); } @@ -91,7 +95,7 @@ public async Task DeleteAsync(TId id, CancellationToken cancella public async Task SetRelationshipAsync(TResource primaryResource, object secondaryResourceIds, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - dynamic repository = ResolveWriteRepository(typeof(TResource)); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.SetRelationshipAsync(primaryResource, secondaryResourceIds, cancellationToken); } @@ -99,7 +103,7 @@ public async Task SetRelationshipAsync(TResource primaryResource, obj public async Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - dynamic repository = ResolveWriteRepository(typeof(TResource)); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds, cancellationToken); } @@ -107,7 +111,7 @@ public async Task AddToToManyRelationshipAsync(TId primaryId, IS public async Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - dynamic repository = ResolveWriteRepository(typeof(TResource)); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.RemoveFromToManyRelationshipAsync(primaryResource, secondaryResourceIds, cancellationToken); } @@ -130,6 +134,27 @@ protected virtual object ResolveReadRepository(Type resourceType) return _serviceProvider.GetRequiredService(resourceDefinitionType); } + private object GetWriteRepository(Type resourceType) + { + var writeRepository = ResolveWriteRepository(resourceType); + + if (_request.TransactionId != null) + { + if (!(writeRepository is IRepositorySupportsTransaction repository)) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + throw new MissingTransactionSupportException(resourceContext.PublicName); + } + + if (repository.TransactionId != _request.TransactionId) + { + throw new NonSharedTransactionException(); + } + } + + return writeRepository; + } + protected virtual object ResolveWriteRepository(Type resourceType) { var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); diff --git a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs index 2d059278bd..6fa523c617 100644 --- a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs +++ b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs @@ -10,6 +10,11 @@ public interface IIdentifiable /// The value for element 'id' in a JSON:API request or response. /// string StringId { get; set; } + + /// + /// The value for element 'lid' in a JSON:API request. + /// + string LocalId { get; set; } } /// diff --git a/src/JsonApiDotNetCore/Resources/Identifiable.cs b/src/JsonApiDotNetCore/Resources/Identifiable.cs index 8d54806085..6b95285da4 100644 --- a/src/JsonApiDotNetCore/Resources/Identifiable.cs +++ b/src/JsonApiDotNetCore/Resources/Identifiable.cs @@ -24,6 +24,10 @@ public string StringId set => Id = GetTypedId(value); } + /// + [NotMapped] + public string LocalId { get; set; } + /// /// Converts an outgoing typed resource identifier to string format for use in a JSON:API response. /// diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs index 849fbc807a..99885c951f 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs @@ -4,7 +4,8 @@ namespace JsonApiDotNetCore.Resources { /// - /// Compares `IIdentifiable` instances with each other based on their type and . + /// Compares `IIdentifiable` instances with each other based on their type and , + /// falling back to when both StringIds are null. /// public sealed class IdentifiableComparer : IEqualityComparer { @@ -26,12 +27,18 @@ public bool Equals(IIdentifiable x, IIdentifiable y) return false; } + if (x.StringId == null && y.StringId == null) + { + return x.LocalId == y.LocalId; + } + return x.StringId == y.StringId; } public int GetHashCode(IIdentifiable obj) { - return obj.StringId != null ? HashCode.Combine(obj.GetType(), obj.StringId) : 0; + // LocalId is intentionally omitted here, it is okay for hashes to collide. + return HashCode.Combine(obj.GetType(), obj.StringId); } } } diff --git a/src/JsonApiDotNetCore/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs new file mode 100644 index 0000000000..ff37241666 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Middleware; + +namespace JsonApiDotNetCore.Resources +{ + /// + /// Represents a write operation on a JSON:API resource. + /// + public sealed class OperationContainer + { + public OperationKind Kind { get; } + public IIdentifiable Resource { get; } + public ITargetedFields TargetedFields { get; } + public IJsonApiRequest Request { get; } + + public OperationContainer(OperationKind kind, IIdentifiable resource, ITargetedFields targetedFields, + IJsonApiRequest request) + { + Kind = kind; + Resource = resource ?? throw new ArgumentNullException(nameof(resource)); + TargetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + Request = request ?? throw new ArgumentNullException(nameof(request)); + } + + public void SetTransactionId(Guid transactionId) + { + ((JsonApiRequest) Request).TransactionId = transactionId; + } + + public OperationContainer WithResource(IIdentifiable resource) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + return new OperationContainer(Kind, resource, TargetedFields, Request); + } + + public ISet GetSecondaryResources() + { + var secondaryResources = new HashSet(IdentifiableComparer.Instance); + + foreach (var relationship in TargetedFields.Relationships) + { + var rightValue = relationship.GetValue(Resource); + foreach (var rightResource in TypeHelper.ExtractResources(rightValue)) + { + secondaryResources.Add(rightResource); + } + } + + return secondaryResources; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs new file mode 100644 index 0000000000..ba69bc0be6 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// Server serializer implementation of for atomic:operations responses. + /// + public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonApiSerializer + { + private readonly IMetaBuilder _metaBuilder; + private readonly ILinkBuilder _linkBuilder; + private readonly IFieldsToSerialize _fieldsToSerialize; + private readonly IJsonApiRequest _request; + private readonly IJsonApiOptions _options; + + /// + public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; + + public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, + IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, IFieldsToSerialize fieldsToSerialize, + IJsonApiRequest request, IJsonApiOptions options) + : base(resourceObjectBuilder) + { + _metaBuilder = metaBuilder ?? throw new ArgumentNullException(nameof(metaBuilder)); + _linkBuilder = linkBuilder ?? throw new ArgumentNullException(nameof(linkBuilder)); + _fieldsToSerialize = fieldsToSerialize ?? throw new ArgumentNullException(nameof(fieldsToSerialize)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public string Serialize(object content) + { + if (content is IList operations) + { + return SerializeOperationsDocument(operations); + } + + if (content is ErrorDocument errorDocument) + { + return SerializeErrorDocument(errorDocument); + } + + throw new InvalidOperationException("Data being returned must be errors or operations."); + } + + private string SerializeOperationsDocument(IEnumerable operations) + { + var document = new AtomicOperationsDocument + { + Results = operations.Select(SerializeOperation).ToList(), + Meta = _metaBuilder.Build() + }; + + return SerializeObject(document, _options.SerializerSettings); + } + + private AtomicResultObject SerializeOperation(OperationContainer operation) + { + ResourceObject resourceObject = null; + + if (operation != null) + { + _request.CopyFrom(operation.Request); + _fieldsToSerialize.ResetCache(); + + var resourceType = operation.Resource.GetType(); + var attributes = _fieldsToSerialize.GetAttributes(resourceType); + var relationships = _fieldsToSerialize.GetRelationships(resourceType); + + resourceObject = ResourceObjectBuilder.Build(operation.Resource, attributes, relationships); + } + + if (resourceObject != null) + { + resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); + } + + return new AtomicResultObject + { + Data = resourceObject + }; + } + + private string SerializeErrorDocument(ErrorDocument errorDocument) + { + return SerializeObject(errorDocument, _options.SerializerSettings, serializer => { serializer.ApplyErrorSettings(); }); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 67e4f41c71..0b9626ffc1 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -22,6 +22,8 @@ public abstract class BaseDeserializer protected IResourceFactory ResourceFactory { get; } protected Document Document { get; set; } + protected int? AtomicOperationIndex { get; set; } + protected BaseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) { ResourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); @@ -84,7 +86,8 @@ protected IIdentifiable SetAttributes(IIdentifiable resource, IDictionary /// The parsed resource. - private IIdentifiable ParseResourceObject(ResourceObject data) + protected IIdentifiable ParseResourceObject(ResourceObject data) { AssertHasType(data, null); + if (AtomicOperationIndex == null) + { + AssertHasNoLid(data); + } + var resourceContext = GetExistingResourceContext(data.Type); var resource = ResourceFactory.CreateInstance(resourceContext.ResourceType); @@ -161,18 +169,22 @@ private IIdentifiable ParseResourceObject(ResourceObject data) resource = SetRelationships(resource, data.Relationships, resourceContext.Relationships); if (data.Id != null) + { resource.StringId = data.Id; + } + + resource.LocalId = data.Lid; return resource; } - private ResourceContext GetExistingResourceContext(string publicName) + protected ResourceContext GetExistingResourceContext(string publicName) { var resourceContext = ResourceContextProvider.GetResourceContext(publicName); if (resourceContext == null) { throw new JsonApiSerializationException("Request body includes unknown resource type.", - $"Resource type '{publicName}' does not exist."); + $"Resource type '{publicName}' does not exist.", atomicOperationIndex: AtomicOperationIndex); } return resourceContext; @@ -186,7 +198,8 @@ private void SetHasOneRelationship(IIdentifiable resource, HasOneAttribute hasOn if (relationshipData.ManyData != null) { throw new JsonApiSerializationException("Expected single data element for to-one relationship.", - $"Expected single data element for '{hasOneRelationship.PublicName}' relationship."); + $"Expected single data element for '{hasOneRelationship.PublicName}' relationship.", + atomicOperationIndex: AtomicOperationIndex); } var rightResource = CreateRightResource(hasOneRelationship, relationshipData.SingleData); @@ -208,7 +221,8 @@ private void SetHasManyRelationship( if (relationshipData.ManyData == null) { throw new JsonApiSerializationException("Expected data[] element for to-many relationship.", - $"Expected data[] element for '{hasManyRelationship.PublicName}' relationship."); + $"Expected data[] element for '{hasManyRelationship.PublicName}' relationship.", + atomicOperationIndex: AtomicOperationIndex); } var rightResources = relationshipData.ManyData @@ -227,13 +241,14 @@ private IIdentifiable CreateRightResource(RelationshipAttribute relationship, if (resourceIdentifierObject != null) { AssertHasType(resourceIdentifierObject, relationship); - AssertHasId(resourceIdentifierObject, relationship); + AssertHasIdOrLid(resourceIdentifierObject, relationship); var rightResourceContext = GetExistingResourceContext(resourceIdentifierObject.Type); AssertRightTypeIsCompatible(rightResourceContext, relationship); var rightInstance = ResourceFactory.CreateInstance(rightResourceContext.ResourceType); rightInstance.StringId = resourceIdentifierObject.Id; + rightInstance.LocalId = resourceIdentifierObject.Lid; return rightInstance; } @@ -249,16 +264,44 @@ private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, Re ? $"Expected 'type' element in '{relationship.PublicName}' relationship." : "Expected 'type' element in 'data' element."; - throw new JsonApiSerializationException("Request body must include 'type' element.", details); + throw new JsonApiSerializationException("Request body must include 'type' element.", details, + atomicOperationIndex: AtomicOperationIndex); + } + } + + private void AssertHasIdOrLid(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) + { + if (AtomicOperationIndex != null) + { + bool hasNone = resourceIdentifierObject.Id == null && resourceIdentifierObject.Lid == null; + bool hasBoth = resourceIdentifierObject.Id != null && resourceIdentifierObject.Lid != null; + + if (hasNone || hasBoth) + { + throw new JsonApiSerializationException("Request body must include 'id' or 'lid' element.", + $"Expected 'id' or 'lid' element in '{relationship.PublicName}' relationship.", + atomicOperationIndex: AtomicOperationIndex); + } + } + else + { + if (resourceIdentifierObject.Id == null) + { + throw new JsonApiSerializationException("Request body must include 'id' element.", + $"Expected 'id' element in '{relationship.PublicName}' relationship.", + atomicOperationIndex: AtomicOperationIndex); + } + + AssertHasNoLid(resourceIdentifierObject); } } - private void AssertHasId(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) + private void AssertHasNoLid(ResourceIdentifierObject resourceIdentifierObject) { - if (resourceIdentifierObject.Id == null) + if (resourceIdentifierObject.Lid != null) { - throw new JsonApiSerializationException("Request body must include 'id' element.", - $"Expected 'id' element in '{relationship.PublicName}' relationship."); + throw new JsonApiSerializationException("Local IDs cannot be used at this endpoint.", null, + atomicOperationIndex: AtomicOperationIndex); } } @@ -267,7 +310,8 @@ private void AssertRightTypeIsCompatible(ResourceContext rightResourceContext, R if (!relationship.RightType.IsAssignableFrom(rightResourceContext.ResourceType)) { throw new JsonApiSerializationException("Relationship contains incompatible resource type.", - $"Relationship '{relationship.PublicName}' contains incompatible resource type '{rightResourceContext.PublicName}'."); + $"Relationship '{relationship.PublicName}' contains incompatible resource type '{rightResourceContext.PublicName}'.", + atomicOperationIndex: AtomicOperationIndex); } } diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs index 7820f874f0..b23828581d 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using JsonApiDotNetCore.Serialization.Objects; @@ -23,12 +24,12 @@ public bool Equals(ResourceIdentifierObject x, ResourceIdentifierObject y) return false; } - return x.Id == y.Id && x.Type == y.Type; + return x.Type == y.Type && x.Id == y.Id && x.Lid == y.Lid; } public int GetHashCode(ResourceIdentifierObject obj) { - return obj.GetHashCode(); + return HashCode.Combine(obj.Type, obj.Id, obj.Lid); } } } diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs index 5d51eb24a6..59abbab7fb 100644 --- a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs @@ -63,5 +63,11 @@ public IReadOnlyCollection GetRelationships(Type resource var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); return resourceContext.Relationships; } + + /// + public void ResetCache() + { + _sparseFieldSetCache.Reset(); + } } } diff --git a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs index 4f687f920a..aca991a49c 100644 --- a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs @@ -20,5 +20,10 @@ public interface IFieldsToSerialize /// Gets the collection of relationships that are to be serialized for resources of type . /// IReadOnlyCollection GetRelationships(Type resourceType); + + /// + /// Clears internal caches. + /// + void ResetCache(); } } diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs index eeebb47d95..b8dea76109 100644 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs @@ -8,11 +8,10 @@ namespace JsonApiDotNetCore.Serialization public interface IJsonApiDeserializer { /// - /// Deserializes JSON into a and constructs resources + /// Deserializes JSON into a or and constructs resources /// from . /// /// The JSON to be deserialized. - /// The resources constructed from the content. object Deserialize(string body); } } diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs index 29d09a2c36..ebdf401213 100644 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs @@ -9,5 +9,10 @@ public interface IJsonApiSerializer /// Serializes a single resource or a collection of resources. /// string Serialize(object content); + + /// + /// Gets the Content-Type HTTP header value. + /// + string ContentType { get; } } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index b63b7da0ad..83208e4941 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -36,7 +36,7 @@ public JsonApiReader(IJsonApiDeserializer deserializer, _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); _request = request ?? throw new ArgumentNullException(nameof(request)); - _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _traceWriter = new TraceLogWriter(loggerFactory); } @@ -59,7 +59,7 @@ public async Task ReadAsync(InputFormatterContext context) } catch (JsonApiSerializationException exception) { - throw new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, body, exception); + throw ToInvalidRequestBodyException(exception, body); } catch (Exception exception) { @@ -67,7 +67,11 @@ public async Task ReadAsync(InputFormatterContext context) } } - if (RequiresRequestBody(context.HttpContext.Request.Method)) + if (_request.Kind == EndpointKind.AtomicOperations) + { + AssertHasRequestBody(model, body); + } + else if (RequiresRequestBody(context.HttpContext.Request.Method)) { ValidateRequestBody(model, body, context.HttpContext.Request); } @@ -81,6 +85,29 @@ private async Task GetRequestBodyAsync(Stream bodyStream) return await reader.ReadToEndAsync(); } + private InvalidRequestBodyException ToInvalidRequestBodyException(JsonApiSerializationException exception, string body) + { + if (_request.Kind != EndpointKind.AtomicOperations) + { + return new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, body, + exception); + } + + // In contrast to resource endpoints, we don't include the request body for operations because they are usually very long. + var requestException = + new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, null, exception.InnerException); + + if (exception.AtomicOperationIndex != null) + { + foreach (var error in requestException.Errors) + { + error.Source.Pointer = $"/atomic:operations[{exception.AtomicOperationIndex}]"; + } + } + + return requestException; + } + private bool RequiresRequestBody(string requestMethod) { if (requestMethod == HttpMethods.Post || requestMethod == HttpMethods.Patch) @@ -93,13 +120,7 @@ private bool RequiresRequestBody(string requestMethod) private void ValidateRequestBody(object model, string body, HttpRequest httpRequest) { - if (model == null && string.IsNullOrWhiteSpace(body)) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Missing request body." - }); - } + AssertHasRequestBody(model, body); ValidateIncomingResourceType(model, httpRequest); @@ -115,6 +136,17 @@ private void ValidateRequestBody(object model, string body, HttpRequest httpRequ } } + private static void AssertHasRequestBody(object model, string body) + { + if (model == null && string.IsNullOrWhiteSpace(body)) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Missing request body." + }); + } + } + private void ValidateIncomingResourceType(object model, HttpRequest httpRequest) { var endpointResourceType = GetResourceTypeFromEndpoint(); diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs b/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs index 15b64d815f..92848c3eca 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs @@ -9,12 +9,15 @@ public class JsonApiSerializationException : Exception { public string GenericMessage { get; } public string SpecificMessage { get; } + public int? AtomicOperationIndex { get; } - public JsonApiSerializationException(string genericMessage, string specificMessage, Exception innerException = null) + public JsonApiSerializationException(string genericMessage, string specificMessage, + Exception innerException = null, int? atomicOperationIndex = null) : base(genericMessage, innerException) { GenericMessage = genericMessage; SpecificMessage = specificMessage; + AtomicOperationIndex = atomicOperationIndex; } } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs index a540776c9a..72ecb569ee 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs @@ -38,7 +38,7 @@ public async Task WriteAsync(OutputFormatterWriteContext context) if (context == null) throw new ArgumentNullException(nameof(context)); var response = context.HttpContext.Response; - response.ContentType = HeaderConstants.MediaType; + response.ContentType = _serializer.ContentType; await using var writer = context.WriterFactory(response.Body, Encoding.UTF8); string responseContent; diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs new file mode 100644 index 0000000000..f7852773b3 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + /// + /// See https://jsonapi.org/ext/atomic/#operation-objects. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum AtomicOperationCode + { + Add, + Update, + Remove + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs new file mode 100644 index 0000000000..baa787aed0 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + /// + /// See https://jsonapi.org/ext/atomic/#operation-objects. + /// + public sealed class AtomicOperationObject : ExposableData + { + [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary Meta { get; set; } + + [JsonProperty("op"), JsonConverter(typeof(StringEnumConverter))] + public AtomicOperationCode Code { get; set; } + + [JsonProperty("ref", NullValueHandling = NullValueHandling.Ignore)] + public AtomicReference Ref { get; set; } + + [JsonProperty("href", NullValueHandling = NullValueHandling.Ignore)] + public string Href { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs new file mode 100644 index 0000000000..8d08f5fb31 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + /// + /// See https://jsonapi.org/ext/atomic/#document-structure. + /// + public sealed class AtomicOperationsDocument + { + /// + /// See "meta" in https://jsonapi.org/format/#document-top-level. + /// + [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] + public IDictionary Meta { get; set; } + + /// + /// See "jsonapi" in https://jsonapi.org/format/#document-top-level. + /// + [JsonProperty("jsonapi", NullValueHandling = NullValueHandling.Ignore)] + public IDictionary JsonApi { get; set; } + + /// + /// See "links" in https://jsonapi.org/format/#document-top-level. + /// + [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] + public TopLevelLinks Links { get; set; } + + /// + /// See https://jsonapi.org/ext/atomic/#operation-objects. + /// + [JsonProperty("atomic:operations", NullValueHandling = NullValueHandling.Ignore)] + public IList Operations { get; set; } + + /// + /// See https://jsonapi.org/ext/atomic/#result-objects. + /// + [JsonProperty("atomic:results", NullValueHandling = NullValueHandling.Ignore)] + public IList Results { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs new file mode 100644 index 0000000000..5847d33fe9 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs @@ -0,0 +1,20 @@ +using System.Text; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + /// + /// See 'ref' in https://jsonapi.org/ext/atomic/#operation-objects. + /// + public sealed class AtomicReference : ResourceIdentifierObject + { + [JsonProperty("relationship", NullValueHandling = NullValueHandling.Ignore)] + public string Relationship { get; set; } + + protected override void WriteMembers(StringBuilder builder) + { + base.WriteMembers(builder); + WriteMember(builder, "relationship", Relationship); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs new file mode 100644 index 0000000000..44b5f691d7 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + /// + /// See https://jsonapi.org/ext/atomic/#result-objects. + /// + public sealed class AtomicResultObject : ExposableData + { + [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary Meta { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index f3e0268a81..b216011bf7 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -1,15 +1,50 @@ +using System.Text; using Newtonsoft.Json; namespace JsonApiDotNetCore.Serialization.Objects { public class ResourceIdentifierObject { - [JsonProperty("type", Order = -3)] + [JsonProperty("type", Order = -4)] public string Type { get; set; } - [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore, Order = -2)] + [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore, Order = -3)] public string Id { get; set; } - public override string ToString() => $"(type: {Type}, id: {Id})"; + [JsonProperty("lid", NullValueHandling = NullValueHandling.Ignore, Order = -2)] + public string Lid { get; set; } + + public override string ToString() + { + var builder = new StringBuilder(); + + WriteMembers(builder); + builder.Insert(0, GetType().Name + ": "); + + return builder.ToString(); + } + + protected virtual void WriteMembers(StringBuilder builder) + { + WriteMember(builder, "type", Type); + WriteMember(builder, "id", Id); + WriteMember(builder, "lid", Lid); + } + + protected static void WriteMember(StringBuilder builder, string memberName, string memberValue) + { + if (memberValue != null) + { + if (builder.Length > 0) + { + builder.Append(", "); + } + + builder.Append(memberName); + builder.Append("=\""); + builder.Append(memberValue); + builder.Append("\""); + } + } } } diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index d4f420f412..e942081d2c 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -1,12 +1,15 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net.Http; +using Humanizer; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; +using Newtonsoft.Json.Linq; namespace JsonApiDotNetCore.Serialization { @@ -15,21 +18,24 @@ namespace JsonApiDotNetCore.Serialization /// public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer { - private readonly ITargetedFields _targetedFields; + private readonly ITargetedFields _targetedFields; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IJsonApiRequest _request; + private readonly IJsonApiOptions _options; public RequestDeserializer( IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, ITargetedFields targetedFields, IHttpContextAccessor httpContextAccessor, - IJsonApiRequest request) + IJsonApiRequest request, + IJsonApiOptions options) : base(resourceContextProvider, resourceFactory) { _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); _request = request ?? throw new ArgumentNullException(nameof(request)); + _options = options ?? throw new ArgumentNullException(nameof(options)); } /// @@ -41,19 +47,446 @@ public object Deserialize(string body) { _targetedFields.Relationships.Add(_request.Relationship); } - + + if (_request.Kind == EndpointKind.AtomicOperations) + { + return DeserializeOperationsDocument(body); + } + var instance = DeserializeBody(body); - AssertResourceIdIsNotTargeted(); - + AssertResourceIdIsNotTargeted(_targetedFields); + return instance; } - private void AssertResourceIdIsNotTargeted() + private object DeserializeOperationsDocument(string body) + { + JToken bodyToken = LoadJToken(body); + var document = bodyToken.ToObject(); + + if (document?.Operations == null || !document.Operations.Any()) + { + throw new JsonApiSerializationException("No operations found.", null); + } + + if (document.Operations.Count > _options.MaximumOperationsPerRequest) + { + throw new JsonApiSerializationException("Request exceeds the maximum number of operations.", + $"The number of operations in this request ({document.Operations.Count}) is higher than {_options.MaximumOperationsPerRequest}."); + } + + var operations = new List(); + AtomicOperationIndex = 0; + + foreach (var operation in document.Operations) + { + var container = DeserializeOperation(operation); + operations.Add(container); + + AtomicOperationIndex++; + } + + return operations; + } + + private OperationContainer DeserializeOperation(AtomicOperationObject operation) + { + _targetedFields.Attributes.Clear(); + _targetedFields.Relationships.Clear(); + + AssertHasNoHref(operation); + + var kind = GetOperationKind(operation); + switch (kind) + { + case OperationKind.CreateResource: + case OperationKind.UpdateResource: + { + return ParseForCreateOrUpdateResourceOperation(operation, kind); + } + case OperationKind.DeleteResource: + { + return ParseForDeleteResourceOperation(operation, kind); + } + } + + bool requireToManyRelationship = + kind == OperationKind.AddToRelationship || kind == OperationKind.RemoveFromRelationship; + + return ParseForRelationshipOperation(operation, kind, requireToManyRelationship); + } + + private void AssertHasNoHref(AtomicOperationObject operation) + { + if (operation.Href != null) + { + throw new JsonApiSerializationException("Usage of the 'href' element is not supported.", null, + atomicOperationIndex: AtomicOperationIndex); + } + } + + private OperationKind GetOperationKind(AtomicOperationObject operation) + { + switch (operation.Code) + { + case AtomicOperationCode.Add: + { + if (operation.Ref != null && operation.Ref.Relationship == null) + { + throw new JsonApiSerializationException("The 'ref.relationship' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } + + return operation.Ref == null ? OperationKind.CreateResource : OperationKind.AddToRelationship; + } + case AtomicOperationCode.Update: + { + return operation.Ref?.Relationship != null + ? OperationKind.SetRelationship + : OperationKind.UpdateResource; + } + case AtomicOperationCode.Remove: + { + if (operation.Ref == null) + { + throw new JsonApiSerializationException("The 'ref' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } + + return operation.Ref.Relationship != null + ? OperationKind.RemoveFromRelationship + : OperationKind.DeleteResource; + } + } + + throw new NotSupportedException($"Unknown operation code '{operation.Code}'."); + } + + private OperationContainer ParseForCreateOrUpdateResourceOperation(AtomicOperationObject operation, + OperationKind kind) { - if (!_request.IsReadOnly && _targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) + var resourceObject = GetRequiredSingleDataForResourceOperation(operation); + + AssertElementHasType(resourceObject, "data"); + AssertElementHasIdOrLid(resourceObject, "data", kind != OperationKind.CreateResource); + + var primaryResourceContext = GetExistingResourceContext(resourceObject.Type); + + AssertCompatibleId(resourceObject, primaryResourceContext.IdentityType); + + if (operation.Ref != null) { - throw new JsonApiSerializationException("Resource ID is read-only.", null); + // For resource update, 'ref' is optional. But when specified, it must match with 'data'. + + AssertElementHasType(operation.Ref, "ref"); + AssertElementHasIdOrLid(operation.Ref, "ref", true); + + var resourceContextInRef = GetExistingResourceContext(operation.Ref.Type); + + if (resourceContextInRef != primaryResourceContext) + { + throw new JsonApiSerializationException( + "Resource type mismatch between 'ref.type' and 'data.type' element.", + $"Expected resource of type '{resourceContextInRef.PublicName}' in 'data.type', instead of '{primaryResourceContext.PublicName}'.", + atomicOperationIndex: AtomicOperationIndex); + } + + AssertSameIdentityInRefData(operation, resourceObject); + } + + var request = new JsonApiRequest + { + Kind = EndpointKind.AtomicOperations, + BasePath = _request.BasePath, + PrimaryResource = primaryResourceContext, + OperationKind = kind + }; + _request.CopyFrom(request); + + var primaryResource = ParseResourceObject(operation.SingleData); + + request.PrimaryId = primaryResource.StringId; + _request.CopyFrom(request); + + var targetedFields = new TargetedFields + { + Attributes = _targetedFields.Attributes.ToHashSet(), + Relationships = _targetedFields.Relationships.ToHashSet() + }; + + AssertResourceIdIsNotTargeted(targetedFields); + + return new OperationContainer(kind, primaryResource, targetedFields, request); + } + + private ResourceObject GetRequiredSingleDataForResourceOperation(AtomicOperationObject operation) + { + if (operation.Data == null) + { + throw new JsonApiSerializationException("The 'data' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } + + if (operation.SingleData == null) + { + throw new JsonApiSerializationException( + "Expected single data element for create/update resource operation.", + null, atomicOperationIndex: AtomicOperationIndex); + } + + return operation.SingleData; + } + + private void AssertElementHasType(ResourceIdentifierObject resourceIdentifierObject, string elementPath) + { + if (resourceIdentifierObject.Type == null) + { + throw new JsonApiSerializationException($"The '{elementPath}.type' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } + } + + private void AssertElementHasIdOrLid(ResourceIdentifierObject resourceIdentifierObject, string elementPath, + bool isRequired) + { + bool hasNone = resourceIdentifierObject.Id == null && resourceIdentifierObject.Lid == null; + bool hasBoth = resourceIdentifierObject.Id != null && resourceIdentifierObject.Lid != null; + + if (isRequired ? hasNone || hasBoth : hasBoth) + { + throw new JsonApiSerializationException( + $"The '{elementPath}.id' or '{elementPath}.lid' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } + } + + private void AssertCompatibleId(ResourceIdentifierObject resourceIdentifierObject, Type idType) + { + if (resourceIdentifierObject.Id != null) + { + try + { + TypeHelper.ConvertType(resourceIdentifierObject.Id, idType); + } + catch (FormatException exception) + { + throw new JsonApiSerializationException(null, exception.Message, null, AtomicOperationIndex); + } + } + } + + private void AssertSameIdentityInRefData(AtomicOperationObject operation, + ResourceIdentifierObject resourceIdentifierObject) + { + if (operation.Ref.Id != null && resourceIdentifierObject.Id != null && + resourceIdentifierObject.Id != operation.Ref.Id) + { + throw new JsonApiSerializationException( + "Resource ID mismatch between 'ref.id' and 'data.id' element.", + $"Expected resource with ID '{operation.Ref.Id}' in 'data.id', instead of '{resourceIdentifierObject.Id}'.", + atomicOperationIndex: AtomicOperationIndex); + } + + if (operation.Ref.Lid != null && resourceIdentifierObject.Lid != null && + resourceIdentifierObject.Lid != operation.Ref.Lid) + { + throw new JsonApiSerializationException( + "Resource local ID mismatch between 'ref.lid' and 'data.lid' element.", + $"Expected resource with local ID '{operation.Ref.Lid}' in 'data.lid', instead of '{resourceIdentifierObject.Lid}'.", + atomicOperationIndex: AtomicOperationIndex); + } + + if (operation.Ref.Id != null && resourceIdentifierObject.Lid != null) + { + throw new JsonApiSerializationException( + "Resource identity mismatch between 'ref.id' and 'data.lid' element.", + $"Expected resource with ID '{operation.Ref.Id}' in 'data.id', instead of '{resourceIdentifierObject.Lid}' in 'data.lid'.", + atomicOperationIndex: AtomicOperationIndex); + } + + if (operation.Ref.Lid != null && resourceIdentifierObject.Id != null) + { + throw new JsonApiSerializationException( + "Resource identity mismatch between 'ref.lid' and 'data.id' element.", + $"Expected resource with local ID '{operation.Ref.Lid}' in 'data.lid', instead of '{resourceIdentifierObject.Id}' in 'data.id'.", + atomicOperationIndex: AtomicOperationIndex); + } + } + + private OperationContainer ParseForDeleteResourceOperation(AtomicOperationObject operation, OperationKind kind) + { + AssertElementHasType(operation.Ref, "ref"); + AssertElementHasIdOrLid(operation.Ref, "ref", true); + + var primaryResourceContext = GetExistingResourceContext(operation.Ref.Type); + + AssertCompatibleId(operation.Ref, primaryResourceContext.IdentityType); + + var primaryResource = ResourceFactory.CreateInstance(primaryResourceContext.ResourceType); + primaryResource.StringId = operation.Ref.Id; + primaryResource.LocalId = operation.Ref.Lid; + + var request = new JsonApiRequest + { + Kind = EndpointKind.AtomicOperations, + BasePath = _request.BasePath, + PrimaryId = primaryResource.StringId, + PrimaryResource = primaryResourceContext, + OperationKind = kind + }; + + return new OperationContainer(kind, primaryResource, new TargetedFields(), request); + } + + private OperationContainer ParseForRelationshipOperation(AtomicOperationObject operation, OperationKind kind, + bool requireToMany) + { + AssertElementHasType(operation.Ref, "ref"); + AssertElementHasIdOrLid(operation.Ref, "ref", true); + + var primaryResourceContext = GetExistingResourceContext(operation.Ref.Type); + + AssertCompatibleId(operation.Ref, primaryResourceContext.IdentityType); + + var primaryResource = ResourceFactory.CreateInstance(primaryResourceContext.ResourceType); + primaryResource.StringId = operation.Ref.Id; + primaryResource.LocalId = operation.Ref.Lid; + + var relationship = GetExistingRelationship(operation.Ref, primaryResourceContext); + + if (requireToMany && relationship is HasOneAttribute) + { + throw new JsonApiSerializationException( + $"Only to-many relationships can be targeted in '{operation.Code.ToString().Camelize()}' operations.", + $"Relationship '{operation.Ref.Relationship}' must be a to-many relationship.", + atomicOperationIndex: AtomicOperationIndex); + } + + var secondaryResourceContext = ResourceContextProvider.GetResourceContext(relationship.RightType); + + var request = new JsonApiRequest + { + Kind = EndpointKind.AtomicOperations, + BasePath = _request.BasePath, + PrimaryId = primaryResource.StringId, + PrimaryResource = primaryResourceContext, + SecondaryResource = secondaryResourceContext, + Relationship = relationship, + IsCollection = relationship is HasManyAttribute, + OperationKind = kind + }; + _request.CopyFrom(request); + + _targetedFields.Relationships.Add(relationship); + + ParseDataForRelationship(relationship, secondaryResourceContext, operation, primaryResource); + + var targetedFields = new TargetedFields + { + Attributes = _targetedFields.Attributes.ToHashSet(), + Relationships = _targetedFields.Relationships.ToHashSet() + }; + + return new OperationContainer(kind, primaryResource, targetedFields, request); + } + + private RelationshipAttribute GetExistingRelationship(AtomicReference reference, + ResourceContext resourceContext) + { + var relationship = resourceContext.Relationships.FirstOrDefault(attribute => + attribute.PublicName == reference.Relationship); + + if (relationship == null) + { + throw new JsonApiSerializationException( + "The referenced relationship does not exist.", + $"Resource of type '{reference.Type}' does not contain a relationship named '{reference.Relationship}'.", + atomicOperationIndex: AtomicOperationIndex); + } + + return relationship; + } + + private void ParseDataForRelationship(RelationshipAttribute relationship, + ResourceContext secondaryResourceContext, + AtomicOperationObject operation, IIdentifiable primaryResource) + { + if (relationship is HasOneAttribute) + { + if (operation.ManyData != null) + { + throw new JsonApiSerializationException( + "Expected single data element for to-one relationship.", + $"Expected single data element for '{relationship.PublicName}' relationship.", + atomicOperationIndex: AtomicOperationIndex); + } + + if (operation.SingleData != null) + { + ValidateSingleDataForRelationship(operation.SingleData, secondaryResourceContext, "data"); + + var secondaryResource = ParseResourceObject(operation.SingleData); + relationship.SetValue(primaryResource, secondaryResource); + } + } + else if (relationship is HasManyAttribute) + { + if (operation.ManyData == null) + { + throw new JsonApiSerializationException( + "Expected data[] element for to-many relationship.", + $"Expected data[] element for '{relationship.PublicName}' relationship.", + atomicOperationIndex: AtomicOperationIndex); + } + + var secondaryResources = new List(); + + foreach (var resourceObject in operation.ManyData) + { + ValidateSingleDataForRelationship(resourceObject, secondaryResourceContext, "data[]"); + + var secondaryResource = ParseResourceObject(resourceObject); + secondaryResources.Add(secondaryResource); + } + + var rightResources = + TypeHelper.CopyToTypedCollection(secondaryResources, relationship.Property.PropertyType); + relationship.SetValue(primaryResource, rightResources); + } + } + + private void ValidateSingleDataForRelationship(ResourceObject dataResourceObject, + ResourceContext resourceContext, string elementPath) + { + AssertElementHasType(dataResourceObject, elementPath); + AssertElementHasIdOrLid(dataResourceObject, elementPath, true); + + var resourceContextInData = GetExistingResourceContext(dataResourceObject.Type); + + AssertCompatibleType(resourceContextInData, resourceContext, elementPath); + AssertCompatibleId(dataResourceObject, resourceContextInData.IdentityType); + } + + private void AssertCompatibleType(ResourceContext resourceContextInData, ResourceContext resourceContextInRef, + string elementPath) + { + if (!resourceContextInData.ResourceType.IsAssignableFrom(resourceContextInRef.ResourceType)) + { + throw new JsonApiSerializationException( + $"Resource type mismatch between 'ref.relationship' and '{elementPath}.type' element.", + $"Expected resource of type '{resourceContextInRef.PublicName}' in '{elementPath}.type', instead of '{resourceContextInData.PublicName}'.", + atomicOperationIndex: AtomicOperationIndex); + } + } + + private void AssertResourceIdIsNotTargeted(ITargetedFields targetedFields) + { + if (!_request.IsReadOnly && + targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) + { + throw new JsonApiSerializationException("Resource ID is read-only.", null, + atomicOperationIndex: AtomicOperationIndex); } } @@ -64,30 +497,52 @@ private void AssertResourceIdIsNotTargeted() /// The resource that was constructed from the document's body. /// The metadata for the exposed field. /// Relationship data for . Is null when is not a . - protected override void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipEntry data = null) + protected override void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, + RelationshipEntry data = null) { + bool isCreatingResource = IsCreatingResource(); + bool isUpdatingResource = IsUpdatingResource(); + if (field is AttrAttribute attr) { - if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Post.Method && - !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) + if (isCreatingResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) { throw new JsonApiSerializationException( "Setting the initial value of the requested attribute is not allowed.", - $"Setting the initial value of '{attr.PublicName}' is not allowed."); + $"Setting the initial value of '{attr.PublicName}' is not allowed.", + atomicOperationIndex: AtomicOperationIndex); } - if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Patch.Method && - !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) + if (isUpdatingResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) { throw new JsonApiSerializationException( "Changing the value of the requested attribute is not allowed.", - $"Changing the value of '{attr.PublicName}' is not allowed."); + $"Changing the value of '{attr.PublicName}' is not allowed.", + atomicOperationIndex: AtomicOperationIndex); } _targetedFields.Attributes.Add(attr); } else if (field is RelationshipAttribute relationship) + { _targetedFields.Relationships.Add(relationship); + } + } + + private bool IsCreatingResource() + { + return _request.Kind == EndpointKind.AtomicOperations + ? _request.OperationKind == OperationKind.CreateResource + : _request.Kind == EndpointKind.Primary && + _httpContextAccessor.HttpContext.Request.Method == HttpMethod.Post.Method; + } + + private bool IsUpdatingResource() + { + return _request.Kind == EndpointKind.AtomicOperations + ? _request.OperationKind == OperationKind.UpdateResource + : _request.Kind == EndpointKind.Primary && + _httpContextAccessor.HttpContext.Request.Method == HttpMethod.Patch.Method; } } } diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index 2b30768320..cef0f45efe 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -12,7 +12,7 @@ namespace JsonApiDotNetCore.Serialization { /// - /// Server serializer implementation of + /// Server serializer implementation of for resources of a specific type. /// /// /// Because in JsonApiDotNetCore every JSON:API request is associated with exactly one @@ -32,6 +32,9 @@ public class ResponseSerializer : BaseSerializer, IJsonApiSerializer private readonly ILinkBuilder _linkBuilder; private readonly IIncludedResourceObjectBuilder _includedBuilder; + /// + public string ContentType { get; } = HeaderConstants.MediaType; + public ResponseSerializer(IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, IIncludedResourceObjectBuilder includedBuilder, diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs index 6f995d061d..386fd2bf8b 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs @@ -25,6 +25,11 @@ public ResponseSerializerFactory(IJsonApiRequest request, IRequestScopedServiceP /// public IJsonApiSerializer GetSerializer() { + if (_request.Kind == EndpointKind.AtomicOperations) + { + return (IJsonApiSerializer)_provider.GetRequiredService(typeof(AtomicOperationsResponseSerializer)); + } + var targetType = GetDocumentType(); var serializerType = typeof(ResponseSerializer<>).MakeGenericType(targetType); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs new file mode 100644 index 0000000000..90884f55a3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs @@ -0,0 +1,209 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Controllers +{ + public sealed class AtomicConstrainedOperationsControllerTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicConstrainedOperationsControllerTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_create_resources_for_matching_resource_type() + { + // Arrange + var newTitle1 = _fakers.MusicTrack.Generate().Title; + var newTitle2 = _fakers.MusicTrack.Generate().Title; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle1 + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle2 + } + } + } + } + }; + + var route = "/operations/musicTracks/create"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + } + + [Fact] + public async Task Cannot_create_resource_for_mismatching_resource_type() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + } + } + } + } + }; + + var route = "/operations/musicTracks/create"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); + responseDocument.Errors[0].Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resources_for_matching_resource_type() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + } + } + } + } + }; + + var route = "/operations/musicTracks/create"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); + responseDocument.Errors[0].Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_for_matching_resource_type() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + var existingPerformer = _fakers.Performer.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingTrack, existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = existingPerformer.StringId + } + } + } + } + }; + + var route = "/operations/musicTracks/create"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); + responseDocument.Errors[0].Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs new file mode 100644 index 0000000000..1a3db3a952 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Controllers +{ + [DisableRoutingConvention, Route("/operations/musicTracks/create")] + public sealed class CreateMusicTrackOperationsController : JsonApiOperationsController + { + public CreateMusicTrackOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, loggerFactory, processor, request, targetedFields) + { + } + + public override async Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) + { + AssertOnlyCreatingMusicTracks(operations); + + return await base.PostOperationsAsync(operations, cancellationToken); + } + + private static void AssertOnlyCreatingMusicTracks(IEnumerable operations) + { + int index = 0; + foreach (var operation in operations) + { + if (operation.Kind != OperationKind.CreateResource || operation.Resource.GetType() != typeof(MusicTrack)) + { + throw new JsonApiException(new Error(HttpStatusCode.UnprocessableEntity) + { + Title = "Unsupported combination of operation code and resource type at this endpoint.", + Detail = "This endpoint can only be used to create resources of type 'musicTracks'.", + Source = + { + Pointer = $"/atomic:operations[{index}]" + } + }); + } + + index++; + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs new file mode 100644 index 0000000000..c073964d81 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -0,0 +1,785 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating +{ + public sealed class AtomicCreateResourceTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicCreateResourceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var newArtistName = _fakers.Performer.Generate().ArtistName; + var newBornAt = _fakers.Performer.Generate().BornAt; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + artistName = newArtistName, + bornAt = newBornAt + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("performers"); + responseDocument.Results[0].SingleData.Attributes["artistName"].Should().Be(newArtistName); + responseDocument.Results[0].SingleData.Attributes["bornAt"].Should().BeCloseTo(newBornAt); + responseDocument.Results[0].SingleData.Relationships.Should().BeNull(); + + var newPerformerId = int.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var performerInDatabase = await dbContext.Performers + .FirstAsync(performer => performer.Id == newPerformerId); + + performerInDatabase.ArtistName.Should().Be(newArtistName); + performerInDatabase.BornAt.Should().BeCloseTo(newBornAt); + }); + } + + [Fact] + public async Task Can_create_resources() + { + // Arrange + const int elementCount = 5; + + var newTracks = _fakers.MusicTrack.Generate(elementCount); + + var operationElements = new List(elementCount); + for (int index = 0; index < elementCount; index++) + { + operationElements.Add(new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTracks[index].Title, + lengthInSeconds = newTracks[index].LengthInSeconds, + genre = newTracks[index].Genre, + releasedAt = newTracks[index].ReleasedAt + } + } + }); + } + + var requestBody = new + { + atomic__operations = operationElements + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(elementCount); + + for (int index = 0; index < elementCount; index++) + { + responseDocument.Results[index].SingleData.Should().NotBeNull(); + responseDocument.Results[index].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[index].SingleData.Attributes["title"].Should().Be(newTracks[index].Title); + responseDocument.Results[index].SingleData.Attributes["lengthInSeconds"].As().Should().BeApproximately(newTracks[index].LengthInSeconds, 0.00000000001M); + responseDocument.Results[index].SingleData.Attributes["genre"].Should().Be(newTracks[index].Genre); + responseDocument.Results[index].SingleData.Attributes["releasedAt"].Should().BeCloseTo(newTracks[index].ReleasedAt); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + } + + var newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.SingleData.Id)); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var tracksInDatabase = await dbContext.MusicTracks + .Where(musicTrack => newTrackIds.Contains(musicTrack.Id)) + .ToListAsync(); + + tracksInDatabase.Should().HaveCount(elementCount); + + for (int index = 0; index < elementCount; index++) + { + var trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == Guid.Parse(responseDocument.Results[index].SingleData.Id)); + + trackInDatabase.Title.Should().Be(newTracks[index].Title); + trackInDatabase.LengthInSeconds.Should().BeApproximately(newTracks[index].LengthInSeconds, 0.00000000001M); + trackInDatabase.Genre.Should().Be(newTracks[index].Genre); + trackInDatabase.ReleasedAt.Should().BeCloseTo(newTracks[index].ReleasedAt); + } + }); + } + + [Fact] + public async Task Can_create_resource_without_attributes_or_relationships() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + }, + relationship = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("performers"); + responseDocument.Results[0].SingleData.Attributes["artistName"].Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["bornAt"].Should().BeCloseTo(default(DateTimeOffset)); + responseDocument.Results[0].SingleData.Relationships.Should().BeNull(); + + var newPerformerId = int.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var performerInDatabase = await dbContext.Performers + .FirstAsync(performer => performer.Id == newPerformerId); + + performerInDatabase.ArtistName.Should().BeNull(); + performerInDatabase.BornAt.Should().Be(default); + }); + } + + [Fact] + public async Task Can_create_resource_with_unknown_attribute() + { + // Arrange + var newName = _fakers.Playlist.Generate().Name; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + doesNotExist = "ignored", + name = newName + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); + responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newName); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + var newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var performerInDatabase = await dbContext.Playlists + .FirstAsync(playlist => playlist.Id == newPlaylistId); + + performerInDatabase.Name.Should().Be(newName); + }); + } + + [Fact] + public async Task Can_create_resource_with_unknown_relationship() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "lyrics", + relationships = new + { + doesNotExist = new + { + data = new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("lyrics"); + responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + var newLyricId = long.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var lyricInDatabase = await dbContext.Lyrics + .FirstAsync(lyric => lyric.Id == newLyricId); + + lyricInDatabase.Should().NotBeNull(); + }); + } + + [Fact] + public async Task Cannot_create_resource_with_client_generated_ID() + { + // Arrange + var newTitle = _fakers.MusicTrack.Generate().Title; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + attributes = new + { + title = newTitle + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Specifying the resource ID in operations that create a resource is not allowed."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + } + + [Fact] + public async Task Cannot_create_resource_for_href_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + href = "/api/v1/musicTracks" + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_resource_for_ref_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.relationship' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add" + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_type() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_resource_for_unknown_type() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "doesNotExist" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_resource_for_array() + { + // Arrange + var newArtistName = _fakers.Performer.Generate().ArtistName; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new[] + { + new + { + type = "performers", + attributes = new + { + artistName = newArtistName + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected single data element for create/update resource operation."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_resource_attribute_with_blocked_capability() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "lyrics", + attributes = new + { + createdAt = 12.July(1980) + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Setting the initial value of the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Setting the initial value of 'createdAt' is not allowed."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_resource_with_readonly_attribute() + { + // Arrange + var newPlaylistName = _fakers.Playlist.Generate().Name; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = newPlaylistName, + isArchived = true + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + responseDocument.Errors[0].Detail.Should().Be("Attribute 'isArchived' is read-only."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_resource_with_incompatible_attribute_value() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + bornAt = "not-a-valid-time" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'DateTimeOffset'. - Request body:"); + responseDocument.Errors[0].Source.Pointer.Should().BeNull(); + } + + [Fact] + public async Task Can_create_resource_with_attributes_and_multiple_relationship_types() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + var existingCompany = _fakers.RecordCompany.Generate(); + var existingPerformer = _fakers.Performer.Generate(); + + var newTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingLyric, existingCompany, existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle + }, + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + }, + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + }, + performers = new + { + data = new[] + { + new + { + type = "performers", + id = existingPerformer.StringId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTitle); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .Include(musicTrack => musicTrack.OwnedBy) + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Title.Should().Be(newTitle); + + trackInDatabase.Lyric.Should().NotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs new file mode 100644 index 0000000000..4befbd34db --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -0,0 +1,258 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating +{ + public sealed class AtomicCreateResourceWithClientGeneratedIdTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicCreateResourceWithClientGeneratedIdTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects() + { + // Arrange + var newLanguage = _fakers.TextLanguage.Generate(); + newLanguage.Id = Guid.NewGuid(); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "textLanguages", + id = newLanguage.StringId, + attributes = new + { + isoCode = newLanguage.IsoCode + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("textLanguages"); + responseDocument.Results[0].SingleData.Attributes["isoCode"].Should().Be(newLanguage.IsoCode); + responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("concurrencyToken"); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var languageInDatabase = await dbContext.TextLanguages + .FirstAsync(language => language.Id == newLanguage.Id); + + languageInDatabase.IsoCode.Should().Be(newLanguage.IsoCode); + }); + } + + [Fact] + public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() + { + // Arrange + var newTrack = _fakers.MusicTrack.Generate(); + newTrack.Id = Guid.NewGuid(); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + id = newTrack.StringId, + attributes = new + { + title = newTrack.Title, + lengthInSeconds = newTrack.LengthInSeconds + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .FirstAsync(musicTrack => musicTrack.Id == newTrack.Id); + + trackInDatabase.Title.Should().Be(newTrack.Title); + trackInDatabase.LengthInSeconds.Should().BeApproximately(newTrack.LengthInSeconds, 0.00000000001M); + }); + } + + [Fact] + public async Task Cannot_create_resource_for_existing_client_generated_ID() + { + // Arrange + var existingLanguage = _fakers.TextLanguage.Generate(); + existingLanguage.Id = Guid.NewGuid(); + + var languageToCreate = _fakers.TextLanguage.Generate(); + languageToCreate.Id = existingLanguage.Id; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextLanguages.Add(languageToCreate); + + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "textLanguages", + id = languageToCreate.StringId, + attributes = new + { + isoCode = languageToCreate.IsoCode + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Another resource with the specified ID already exists."); + responseDocument.Errors[0].Detail.Should().Be($"Another resource of type 'textLanguages' with ID '{languageToCreate.StringId}' already exists."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_resource_for_incompatible_ID() + { + // Arrange + var guid = Guid.NewGuid().ToString(); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "performers", + id = guid, + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_resource_for_ID_and_local_ID() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "lyrics", + id = 12345678, + lid = "local-1" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs new file mode 100644 index 0000000000..4c87dfa89f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -0,0 +1,606 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating +{ + public sealed class AtomicCreateResourceWithToManyRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicCreateResourceWithToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_create_HasMany_relationship() + { + // Arrange + var existingPerformers = _fakers.Performer.Generate(2); + var newTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.AddRange(existingPerformers); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle + }, + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers", + id = existingPerformers[0].StringId + }, + new + { + type = "performers", + id = existingPerformers[1].StringId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Performers.Should().HaveCount(2); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); + }); + } + + [Fact] + public async Task Can_create_HasManyThrough_relationship() + { + // Arrange + var existingTracks = _fakers.MusicTrack.Generate(3); + var newName = _fakers.Playlist.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = newName + }, + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + id = existingTracks[0].StringId + }, + new + { + type = "musicTracks", + id = existingTracks[1].StringId + }, + new + { + type = "musicTracks", + id = existingTracks[2].StringId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); + responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + var newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == newPlaylistId); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(3); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[2].Id); + }); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_type() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + performers = new + { + data = new[] + { + new + { + id = 12345678 + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected 'type' element in 'performers' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_unknown_relationship_type() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_ID() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers" + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected 'id' or 'lid' element in 'performers' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_unknown_relationship_IDs() + { + // Arrange + var newTitle = _fakers.MusicTrack.Generate().Title; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle + }, + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers", + id = 12345678 + }, + new + { + type = "performers", + id = 87654321 + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'performers' with ID '12345678' in relationship 'performers' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Related resource of type 'performers' with ID '87654321' in relationship 'performers' does not exist."); + responseDocument.Errors[1].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_on_relationship_type_mismatch() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "playlists", + id = 12345678 + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'performers' contains incompatible resource type 'playlists'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Can_create_with_duplicates() + { + // Arrange + var existingPerformer = _fakers.Performer.Generate(); + var newTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle + }, + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers", + id = existingPerformer.StringId + }, + new + { + type = "performers", + id = existingPerformer.StringId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); + }); + } + + [Fact] + public async Task Cannot_create_with_null_data_in_HasMany_relationship() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + performers = new + { + data = (object) null + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().Be("Expected data[] element for 'performers' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_with_null_data_in_HasManyThrough_relationship() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "playlists", + relationships = new + { + tracks = new + { + data = (object) null + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().Be("Expected data[] element for 'tracks' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs new file mode 100644 index 0000000000..d5603ec35a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -0,0 +1,604 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating +{ + public sealed class AtomicCreateResourceWithToOneRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicCreateResourceWithToOneRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "lyrics", + relationships = new + { + track = new + { + data = new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("lyrics"); + responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + var newLyricId = long.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var lyricInDatabase = await dbContext.Lyrics + .Include(lyric => lyric.Track) + .FirstAsync(lyric => lyric.Id == newLyricId); + + lyricInDatabase.Track.Should().NotBeNull(); + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Lyric.Should().NotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + }); + } + + [Fact] + public async Task Can_create_resources_with_ToOne_relationship() + { + // Arrange + const int elementCount = 5; + + var existingCompany = _fakers.RecordCompany.Generate(); + var newTrackTitles = _fakers.MusicTrack.Generate(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var operationElements = new List(elementCount); + for (int index = 0; index < elementCount; index++) + { + operationElements.Add(new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTrackTitles[index] + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + } + }); + } + + var requestBody = new + { + atomic__operations = operationElements + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(elementCount); + + for (int index = 0; index < elementCount; index++) + { + responseDocument.Results[index].SingleData.Should().NotBeNull(); + responseDocument.Results[index].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[index].SingleData.Attributes["title"].Should().Be(newTrackTitles[index]); + } + + var newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.SingleData.Id)); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var tracksInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .Where(musicTrack => newTrackIds.Contains(musicTrack.Id)) + .ToListAsync(); + + tracksInDatabase.Should().HaveCount(elementCount); + + for (int index = 0; index < elementCount; index++) + { + var trackInDatabase = tracksInDatabase.Single(musicTrack => + musicTrack.Id == Guid.Parse(responseDocument.Results[index].SingleData.Id)); + + trackInDatabase.Title.Should().Be(newTrackTitles[index]); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + } + }); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_type() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + lyric = new + { + data = new + { + id = 12345678 + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected 'type' element in 'lyric' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_unknown_relationship_type() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + lyric = new + { + data = new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_ID() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics" + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected 'id' or 'lid' element in 'lyric' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_with_unknown_relationship_ID() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics", + id = 12345678 + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'lyrics' with ID '12345678' in relationship 'lyric' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_on_relationship_type_mismatch() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + lyric = new + { + data = new + { + type = "playlists", + id = 12345678 + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'lyric' contains incompatible resource type 'playlists'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Can_create_resource_with_duplicate_relationship() + { + // Arrange + var existingCompany = _fakers.RecordCompany.Generate(); + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + }, + ownedBy_duplicate = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + } + } + } + }; + + var requestBodyText = JsonConvert.SerializeObject(requestBody).Replace("ownedBy_duplicate", "ownedBy"); + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBodyText); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + }); + } + + [Fact] + public async Task Cannot_create_with_data_array_in_relationship() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + lyric = new + { + data = new[] + { + new + { + type = "lyrics", + id = 12345678 + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected single data element for to-one relationship."); + responseDocument.Errors[0].Detail.Should().Be("Expected single data element for 'lyric' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs new file mode 100644 index 0000000000..67716414a5 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -0,0 +1,615 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Deleting +{ + public sealed class AtomicDeleteResourceTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicDeleteResourceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_delete_existing_resource() + { + // Arrange + var existingPerformer = _fakers.Performer.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "performers", + id = existingPerformer.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var performerInDatabase = await dbContext.Performers + .FirstOrDefaultAsync(performer => performer.Id == existingPerformer.Id); + + performerInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_delete_existing_resources() + { + // Arrange + const int elementCount = 5; + + var existingTracks = _fakers.MusicTrack.Generate(elementCount); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); + + var operationElements = new List(elementCount); + for (int index = 0; index < elementCount; index++) + { + operationElements.Add(new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTracks[index].StringId + } + }); + } + + var requestBody = new + { + atomic__operations = operationElements + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var tracksInDatabase = await dbContext.MusicTracks + .ToListAsync(); + + tracksInDatabase.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_delete_resource_with_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = existingLyric.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var lyricsInDatabase = await dbContext.Lyrics + .FirstOrDefaultAsync(lyric => lyric.Id == existingLyric.Id); + + lyricsInDatabase.Should().BeNull(); + + var trackInDatabase = await dbContext.MusicTracks + .FirstAsync(musicTrack => musicTrack.Id == existingLyric.Track.Id); + + trackInDatabase.Lyric.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var tracksInDatabase = await dbContext.MusicTracks + .FirstOrDefaultAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + tracksInDatabase.Should().BeNull(); + + var lyricInDatabase = await dbContext.Lyrics + .FirstAsync(lyric => lyric.Id == existingTrack.Lyric.Id); + + lyricInDatabase.Track.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_delete_existing_resource_with_HasMany_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .FirstOrDefaultAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Should().BeNull(); + + var performersInDatabase = await dbContext.Performers.ToListAsync(); + + performersInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingTrack.Performers.ElementAt(0).Id); + performersInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingTrack.Performers.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_delete_existing_resource_with_HasManyThrough_relationship() + { + // Arrange + var existingPlaylistMusicTrack = new PlaylistMusicTrack + { + Playlist = _fakers.Playlist.Generate(), + MusicTrack = _fakers.MusicTrack.Generate() + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PlaylistMusicTracks.Add(existingPlaylistMusicTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = existingPlaylistMusicTrack.Playlist.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .FirstOrDefaultAsync(playlist => playlist.Id == existingPlaylistMusicTrack.Playlist.Id); + + playlistInDatabase.Should().BeNull(); + + var playlistTracksInDatabase = await dbContext.PlaylistMusicTracks + .FirstOrDefaultAsync(playlistMusicTrack => playlistMusicTrack.Playlist.Id == existingPlaylistMusicTrack.Playlist.Id); + + playlistTracksInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_resource_for_href_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + href = "/api/v1/musicTracks/1" + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_delete_resource_for_missing_ref_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove" + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_delete_resource_for_missing_type() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + id = 99999999 + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_delete_resource_for_unknown_type() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "doesNotExist", + id = 99999999 + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_delete_resource_for_missing_ID() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_delete_resource_for_unknown_ID() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "performers", + id = 99999999 + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'performers' with ID '99999999' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_delete_resource_for_incompatible_ID() + { + // Arrange + var guid = Guid.NewGuid().ToString(); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = guid + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int64'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_delete_resource_for_ID_and_local_ID() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + lid = "local-1" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs new file mode 100644 index 0000000000..21f91eca53 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -0,0 +1,102 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Links +{ + public sealed class AtomicAbsoluteLinksTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicAbsoluteLinksTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddControllersFromExampleProject(); + + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + } + + [Fact] + public async Task Update_resource_with_side_effects_returns_absolute_links() + { + // Arrange + var existingLanguage = _fakers.TextLanguage.Generate(); + var existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingLanguage, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "update", + data = new + { + type = "textLanguages", + id = existingLanguage.StringId, + attributes = new + { + } + } + }, + new + { + op = "update", + data = new + { + type = "recordCompanies", + id = existingCompany.StringId, + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Links.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Links.Self.Should().Be("http://localhost/textLanguages/" + existingLanguage.StringId); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.Results[0].SingleData.Relationships["lyrics"].Links.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Relationships["lyrics"].Links.Self.Should().Be($"http://localhost/textLanguages/{existingLanguage.StringId}/relationships/lyrics"); + responseDocument.Results[0].SingleData.Relationships["lyrics"].Links.Related.Should().Be($"http://localhost/textLanguages/{existingLanguage.StringId}/lyrics"); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Links.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Links.Self.Should().Be("http://localhost/recordCompanies/" + existingCompany.StringId); + responseDocument.Results[1].SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.Results[1].SingleData.Relationships["tracks"].Links.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Relationships["tracks"].Links.Self.Should().Be($"http://localhost/recordCompanies/{existingCompany.StringId}/relationships/tracks"); + responseDocument.Results[1].SingleData.Relationships["tracks"].Links.Related.Should().Be($"http://localhost/recordCompanies/{existingCompany.StringId}/tracks"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs new file mode 100644 index 0000000000..cb3585d95f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Links +{ + public sealed class AtomicRelativeLinksWithNamespaceTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + + public AtomicRelativeLinksWithNamespaceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddControllersFromExampleProject(); + + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + } + + [Fact] + public async Task Create_resource_with_side_effects_returns_relative_links() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "textLanguages", + attributes = new + { + } + } + }, + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + } + } + } + } + }; + + var route = "/api/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + + var newLanguageId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + + responseDocument.Results[0].SingleData.Links.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Links.Self.Should().Be("/api/textLanguages/" + newLanguageId); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.Results[0].SingleData.Relationships["lyrics"].Links.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Relationships["lyrics"].Links.Self.Should().Be($"/api/textLanguages/{newLanguageId}/relationships/lyrics"); + responseDocument.Results[0].SingleData.Relationships["lyrics"].Links.Related.Should().Be($"/api/textLanguages/{newLanguageId}/lyrics"); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + + var newCompanyId = short.Parse(responseDocument.Results[1].SingleData.Id); + + responseDocument.Results[1].SingleData.Links.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Links.Self.Should().Be("/api/recordCompanies/" + newCompanyId); + responseDocument.Results[1].SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.Results[1].SingleData.Relationships["tracks"].Links.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Relationships["tracks"].Links.Self.Should().Be($"/api/recordCompanies/{newCompanyId}/relationships/tracks"); + responseDocument.Results[1].SingleData.Relationships["tracks"].Links.Related.Should().Be($"/api/recordCompanies/{newCompanyId}/tracks"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs new file mode 100644 index 0000000000..63cc817103 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -0,0 +1,2449 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.LocalIds +{ + public sealed class AtomicLocalIdTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicLocalIdTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_create_resource_with_ToOne_relationship_using_local_ID() + { + // Arrange + var newCompany = _fakers.RecordCompany.Generate(); + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + + const string companyLocalId = "company-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "recordCompanies", + lid = companyLocalId, + attributes = new + { + name = newCompany.Name, + countryOfResidence = newCompany.CountryOfResidence + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = companyLocalId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("recordCompanies"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newCompany.Name); + responseDocument.Results[0].SingleData.Attributes["countryOfResidence"].Should().Be(newCompany.CountryOfResidence); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + var newCompanyId = short.Parse(responseDocument.Results[0].SingleData.Id); + var newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Title.Should().Be(newTrackTitle); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); + trackInDatabase.OwnedBy.Name.Should().Be(newCompany.Name); + trackInDatabase.OwnedBy.CountryOfResidence.Should().Be(newCompany.CountryOfResidence); + }); + } + + [Fact] + public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID() + { + // Arrange + var newPerformer = _fakers.Performer.Generate(); + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + + const string performerLocalId = "performer-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "performers", + lid = performerLocalId, + attributes = new + { + artistName = newPerformer.ArtistName, + bornAt = newPerformer.BornAt + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers", + lid = performerLocalId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("performers"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["artistName"].Should().Be(newPerformer.ArtistName); + responseDocument.Results[0].SingleData.Attributes["bornAt"].Should().BeCloseTo(newPerformer.BornAt); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + var newPerformerId = int.Parse(responseDocument.Results[0].SingleData.Id); + var newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Title.Should().Be(newTrackTitle); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); + trackInDatabase.Performers[0].ArtistName.Should().Be(newPerformer.ArtistName); + trackInDatabase.Performers[0].BornAt.Should().BeCloseTo(newPerformer.BornAt); + }); + } + + [Fact] + public async Task Can_create_resource_with_ManyToMany_relationship_using_local_ID() + { + // Arrange + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + var newPlaylistName = _fakers.Playlist.Generate().Name; + + const string trackLocalId = "track-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = newPlaylistName + }, + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + lid = trackLocalId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("playlists"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(newPlaylistName); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + var newPlaylistId = long.Parse(responseDocument.Results[1].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == newPlaylistId); + + playlistInDatabase.Name.Should().Be(newPlaylistName); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(newTrackId); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Title.Should().Be(newTrackTitle); + }); + } + + [Fact] + public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() + { + // Arrange + const string companyLocalId = "company-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + data = new + { + type = "recordCompanies", + lid = companyLocalId, + relationships = new + { + parent = new + { + data = new + { + type = "recordCompanies", + lid = companyLocalId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Local ID cannot be both defined and used within the same operation."); + responseDocument.Errors[0].Detail.Should().Be("Local ID 'company-1' cannot be both defined and used within the same operation."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); + } + + [Fact] + public async Task Cannot_reassign_local_ID() + { + // Arrange + var newPlaylistName = _fakers.Playlist.Generate().Name; + const string playlistLocalId = "playlist-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + lid = playlistLocalId, + attributes = new + { + name = newPlaylistName + } + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + lid = playlistLocalId, + attributes = new + { + name = newPlaylistName + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Another local ID with the same name is already defined at this point."); + responseDocument.Errors[0].Detail.Should().Be("Another local ID with name 'playlist-1' is already defined at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); + } + + [Fact] + public async Task Can_update_resource_using_local_ID() + { + // Arrange + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + var newTrackGenre = _fakers.MusicTrack.Generate().Genre; + + const string trackLocalId = "track-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "update", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + genre = newTrackGenre + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].SingleData.Attributes["genre"].Should().BeNull(); + + responseDocument.Results[1].Data.Should().BeNull(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Title.Should().Be(newTrackTitle); + trackInDatabase.Genre.Should().Be(newTrackGenre); + }); + } + + [Fact] + public async Task Can_update_resource_with_relationships_using_local_ID() + { + // Arrange + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + var newArtistName = _fakers.Performer.Generate().ArtistName; + var newCompanyName = _fakers.RecordCompany.Generate().Name; + + const string trackLocalId = "track-1"; + const string performerLocalId = "performer-1"; + const string companyLocalId = "company-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "add", + data = new + { + type = "performers", + lid = performerLocalId, + attributes = new + { + artistName = newArtistName + } + } + }, + new + { + op = "add", + data = new + { + type = "recordCompanies", + lid = companyLocalId, + attributes = new + { + name = newCompanyName + } + } + }, + new + { + op = "update", + data = new + { + type = "musicTracks", + lid = trackLocalId, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = companyLocalId + } + }, + performers = new + { + data = new[] + { + new + { + type = "performers", + lid = performerLocalId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(4); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("performers"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName); + + responseDocument.Results[2].SingleData.Should().NotBeNull(); + responseDocument.Results[2].SingleData.Type.Should().Be("recordCompanies"); + responseDocument.Results[2].SingleData.Lid.Should().BeNull(); + responseDocument.Results[2].SingleData.Attributes["name"].Should().Be(newCompanyName); + + responseDocument.Results[3].Data.Should().BeNull(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + var newPerformerId = int.Parse(responseDocument.Results[1].SingleData.Id); + var newCompanyId = short.Parse(responseDocument.Results[2].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Title.Should().Be(newTrackTitle); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); + trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); + }); + } + + [Fact] + public async Task Can_create_ToOne_relationship_using_local_ID() + { + // Arrange + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + var newCompanyName = _fakers.RecordCompany.Generate().Name; + + const string trackLocalId = "track-1"; + const string companyLocalId = "company-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "add", + data = new + { + type = "recordCompanies", + lid = companyLocalId, + attributes = new + { + name = newCompanyName + } + } + }, + new + { + op = "update", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + lid = companyLocalId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(3); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("recordCompanies"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(newCompanyName); + + responseDocument.Results[2].Data.Should().BeNull(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + var newCompanyId = short.Parse(responseDocument.Results[1].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Title.Should().Be(newTrackTitle); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); + trackInDatabase.OwnedBy.Name.Should().Be(newCompanyName); + }); + } + + [Fact] + public async Task Can_create_OneToMany_relationship_using_local_ID() + { + // Arrange + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + var newArtistName = _fakers.Performer.Generate().ArtistName; + + const string trackLocalId = "track-1"; + const string performerLocalId = "performer-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "add", + data = new + { + type = "performers", + lid = performerLocalId, + attributes = new + { + artistName = newArtistName + } + } + }, + new + { + op = "update", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + lid = performerLocalId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(3); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("performers"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName); + + responseDocument.Results[2].Data.Should().BeNull(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + var newPerformerId = int.Parse(responseDocument.Results[1].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Title.Should().Be(newTrackTitle); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); + trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); + }); + } + + [Fact] + public async Task Can_create_ManyToMany_relationship_using_local_ID() + { + // Arrange + var newPlaylistName = _fakers.Playlist.Generate().Name; + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + + const string playlistLocalId = "playlist-1"; + const string trackLocalId = "track-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "playlists", + lid = playlistLocalId, + attributes = new + { + name = newPlaylistName + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "update", + @ref = new + { + type = "playlists", + lid = playlistLocalId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + lid = trackLocalId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(3); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newPlaylistName); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[2].Data.Should().BeNull(); + + var newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); + var newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == newPlaylistId); + + playlistInDatabase.Name.Should().Be(newPlaylistName); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(newTrackId); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Title.Should().Be(newTrackTitle); + }); + } + + [Fact] + public async Task Can_replace_OneToMany_relationship_using_local_ID() + { + // Arrange + var existingPerformer = _fakers.Performer.Generate(); + + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + var newArtistName = _fakers.Performer.Generate().ArtistName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + const string trackLocalId = "track-1"; + const string performerLocalId = "performer-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + }, + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers", + id = existingPerformer.StringId + } + } + } + } + } + }, + new + { + op = "add", + data = new + { + type = "performers", + lid = performerLocalId, + attributes = new + { + artistName = newArtistName + } + } + }, + new + { + op = "update", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + lid = performerLocalId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(3); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("performers"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName); + + responseDocument.Results[2].Data.Should().BeNull(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + var newPerformerId = int.Parse(responseDocument.Results[1].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Title.Should().Be(newTrackTitle); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); + trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); + }); + } + + [Fact] + public async Task Can_replace_ManyToMany_relationship_using_local_ID() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + var newPlaylistName = _fakers.Playlist.Generate().Name; + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + const string playlistLocalId = "playlist-1"; + const string trackLocalId = "track-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "playlists", + lid = playlistLocalId, + attributes = new + { + name = newPlaylistName + }, + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "update", + @ref = new + { + type = "playlists", + lid = playlistLocalId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + lid = trackLocalId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(3); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newPlaylistName); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[2].Data.Should().BeNull(); + + var newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); + var newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == newPlaylistId); + + playlistInDatabase.Name.Should().Be(newPlaylistName); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(newTrackId); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Title.Should().Be(newTrackTitle); + }); + } + + [Fact] + public async Task Can_add_to_OneToMany_relationship_using_local_ID() + { + // Arrange + var existingPerformer = _fakers.Performer.Generate(); + + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + var newArtistName = _fakers.Performer.Generate().ArtistName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + const string trackLocalId = "track-1"; + const string performerLocalId = "performer-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + }, + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers", + id = existingPerformer.StringId + } + } + } + } + } + }, + new + { + op = "add", + data = new + { + type = "performers", + lid = performerLocalId, + attributes = new + { + artistName = newArtistName + } + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + lid = performerLocalId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(3); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("performers"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName); + + responseDocument.Results[2].Data.Should().BeNull(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + var newPerformerId = int.Parse(responseDocument.Results[1].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Title.Should().Be(newTrackTitle); + + trackInDatabase.Performers.Should().HaveCount(2); + + trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); + trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); + + trackInDatabase.Performers[1].Id.Should().Be(newPerformerId); + trackInDatabase.Performers[1].ArtistName.Should().Be(newArtistName); + }); + } + + [Fact] + public async Task Can_add_to_ManyToMany_relationship_using_local_ID() + { + // Arrange + var existingTracks = _fakers.MusicTrack.Generate(2); + + var newPlaylistName = _fakers.Playlist.Generate().Name; + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + + const string playlistLocalId = "playlist-1"; + const string trackLocalId = "track-1"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "playlists", + lid = playlistLocalId, + attributes = new + { + name = newPlaylistName + }, + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + id = existingTracks[0].StringId + } + } + } + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "add", + @ref = new + { + type = "playlists", + lid = playlistLocalId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + lid = trackLocalId + } + } + }, + new + { + op = "add", + @ref = new + { + type = "playlists", + lid = playlistLocalId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingTracks[1].StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(4); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newPlaylistName); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[2].Data.Should().BeNull(); + + responseDocument.Results[3].Data.Should().BeNull(); + + var newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); + var newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == newPlaylistId); + + playlistInDatabase.Name.Should().Be(newPlaylistName); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(3); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == newTrackId); + }); + } + + [Fact] + public async Task Can_remove_from_OneToMany_relationship_using_local_ID() + { + // Arrange + var existingPerformer = _fakers.Performer.Generate(); + + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + var newArtistName1 = _fakers.Performer.Generate().ArtistName; + var newArtistName2 = _fakers.Performer.Generate().ArtistName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + const string trackLocalId = "track-1"; + const string performerLocalId1 = "performer-1"; + const string performerLocalId2 = "performer-2"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "performers", + lid = performerLocalId1, + attributes = new + { + artistName = newArtistName1 + } + } + }, + new + { + op = "add", + data = new + { + type = "performers", + lid = performerLocalId2, + attributes = new + { + artistName = newArtistName2 + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + }, + relationships = new + { + performers = new + { + data = new object[] + { + new + { + type = "performers", + id = existingPerformer.StringId + }, + new + { + type = "performers", + lid = performerLocalId1 + }, + new + { + type = "performers", + lid = performerLocalId2 + } + } + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + lid = performerLocalId1 + }, + new + { + type = "performers", + lid = performerLocalId2 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(4); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("performers"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["artistName"].Should().Be(newArtistName1); + + responseDocument.Results[1].SingleData.Should().NotBeNull(); + responseDocument.Results[1].SingleData.Type.Should().Be("performers"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName2); + + responseDocument.Results[2].SingleData.Should().NotBeNull(); + responseDocument.Results[2].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[2].SingleData.Lid.Should().BeNull(); + responseDocument.Results[2].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[3].Data.Should().BeNull(); + + var newTrackId = Guid.Parse(responseDocument.Results[2].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Title.Should().Be(newTrackTitle); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); + trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); + }); + } + + [Fact] + public async Task Can_remove_from_ManyToMany_relationship_using_local_ID() + { + // Arrange + var existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.PlaylistMusicTracks = new[] + { + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + }, + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + } + }; + + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + + const string trackLocalId = "track-1"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "add", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + lid = trackLocalId + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingPlaylist.PlaylistMusicTracks[1].MusicTrack.StringId + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + lid = trackLocalId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(4); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[1].Data.Should().BeNull(); + + responseDocument.Results[2].Data.Should().BeNull(); + + responseDocument.Results[3].Data.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingPlaylist.PlaylistMusicTracks[0].MusicTrack.Id); + }); + } + + [Fact] + public async Task Can_delete_resource_using_local_ID() + { + // Arrange + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + + const string trackLocalId = "track-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + lid = trackLocalId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + + responseDocument.Results[1].Data.Should().BeNull(); + + var newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .FirstOrDefaultAsync(musicTrack => musicTrack.Id == newTrackId); + + trackInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + lid = "doesNotExist" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Server-generated value for local ID is not available at this point."); + responseDocument.Errors[0].Detail.Should().Be("Server-generated value for local ID 'doesNotExist' is not available at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); + } + + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_data_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "update", + data = new + { + type = "musicTracks", + lid = "doesNotExist", + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Server-generated value for local ID is not available at this point."); + responseDocument.Errors[0].Detail.Should().Be("Server-generated value for local ID 'doesNotExist' is not available at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); + } + + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_data_array() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + lid = "doesNotExist" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Server-generated value for local ID is not available at this point."); + responseDocument.Errors[0].Detail.Should().Be("Server-generated value for local ID 'doesNotExist' is not available at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); + } + + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = "doesNotExist" + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Server-generated value for local ID is not available at this point."); + responseDocument.Errors[0].Detail.Should().Be("Server-generated value for local ID 'doesNotExist' is not available at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); + } + + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + lid = "doesNotExist" + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Server-generated value for local ID is not available at this point."); + responseDocument.Errors[0].Detail.Should().Be("Server-generated value for local ID 'doesNotExist' is not available at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); + } + + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() + { + // Arrange + const string trackLocalId = "track-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = trackLocalId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Type mismatch in local ID usage."); + responseDocument.Errors[0].Detail.Should().Be("Local ID 'track-1' belongs to resource type 'musicTracks' instead of 'recordCompanies'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); + } + + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_ref() + { + // Arrange + const string companyLocalId = "company-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + data = new + { + type = "recordCompanies", + lid = companyLocalId + } + }, + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + lid = companyLocalId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Type mismatch in local ID usage."); + responseDocument.Errors[0].Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'musicTracks'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); + } + + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_data_element() + { + // Arrange + const string performerLocalId = "performer-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + data = new + { + type = "performers", + lid = performerLocalId + } + }, + new + { + op = "update", + data = new + { + type = "playlists", + lid = performerLocalId, + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Type mismatch in local ID usage."); + responseDocument.Errors[0].Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'playlists'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); + } + + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_data_array() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + const string companyLocalId = "company-1"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + data = new + { + type = "recordCompanies", + lid = companyLocalId + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + lid = companyLocalId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Type mismatch in local ID usage."); + responseDocument.Errors[0].Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'performers'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); + } + + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_element() + { + // Arrange + var newPlaylistName = _fakers.Playlist.Generate().Name; + + const string playlistLocalId = "playlist-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + lid = playlistLocalId, + attributes = new + { + name = newPlaylistName + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = playlistLocalId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Type mismatch in local ID usage."); + responseDocument.Errors[0].Detail.Should().Be("Local ID 'playlist-1' belongs to resource type 'playlists' instead of 'recordCompanies'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); + } + + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_array() + { + // Arrange + const string performerLocalId = "performer-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, + new + { + op = "add", + data = new + { + type = "performers", + lid = performerLocalId + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + lid = performerLocalId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Type mismatch in local ID usage."); + responseDocument.Errors[0].Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'musicTracks'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs new file mode 100644 index 0000000000..a02949e2e9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs @@ -0,0 +1,24 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class Lyric : Identifiable + { + [Attr] + public string Format { get; set; } + + [Attr] + public string Text { get; set; } + + [HasOne] + public TextLanguage Language { get; set; } + + [Attr(Capabilities = AttrCapabilities.None)] + public DateTimeOffset CreatedAt { get; set; } + + [HasOne] + public MusicTrack Track { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs new file mode 100644 index 0000000000..b366560d13 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta +{ + public sealed class AtomicResourceMetaTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicResourceMetaTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddControllersFromExampleProject(); + + services.AddScoped, MusicTrackMetaDefinition>(); + services.AddScoped, TextLanguageMetaDefinition>(); + }); + } + + [Fact] + public async Task Returns_resource_meta_in_create_resource_with_side_effects() + { + // Arrange + var newTitle1 = _fakers.MusicTrack.Generate().Title; + var newTitle2 = _fakers.MusicTrack.Generate().Title; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle1, + releasedAt = 1.January(2018) + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle2, + releasedAt = 23.August(1994) + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Meta.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Meta["Copyright"].Should().Be("(C) 2018. All rights reserved."); + + responseDocument.Results[1].SingleData.Meta.Should().HaveCount(1); + responseDocument.Results[1].SingleData.Meta["Copyright"].Should().Be("(C) 1994. All rights reserved."); + } + + [Fact] + public async Task Returns_top_level_meta_in_update_resource_with_side_effects() + { + // Arrange + var existingLanguage = _fakers.TextLanguage.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextLanguages.Add(existingLanguage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "textLanguages", + id = existingLanguage.StringId, + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Meta.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Meta["Notice"].Should().Be("See https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes for ISO 639-1 language codes."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs new file mode 100644 index 0000000000..4fa740e292 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs @@ -0,0 +1,148 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta +{ + public sealed class AtomicResponseMetaTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicResponseMetaTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddControllersFromExampleProject(); + + services.AddSingleton(); + }); + } + + [Fact] + public async Task Returns_top_level_meta_in_create_resource_with_side_effects() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Meta.Should().HaveCount(3); + responseDocument.Meta["license"].Should().Be("MIT"); + responseDocument.Meta["projectUrl"].Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + + var versionArray = ((IEnumerable) responseDocument.Meta["versions"]).Select(token => token.ToString()).ToArray(); + + versionArray.Should().HaveCount(4); + versionArray.Should().Contain("v4.0.0"); + versionArray.Should().Contain("v3.1.0"); + versionArray.Should().Contain("v2.5.2"); + versionArray.Should().Contain("v1.3.1"); + } + + [Fact] + public async Task Returns_top_level_meta_in_update_resource_with_side_effects() + { + // Arrange + var existingLanguage = _fakers.TextLanguage.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextLanguages.Add(existingLanguage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "textLanguages", + id = existingLanguage.StringId, + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Meta.Should().HaveCount(3); + responseDocument.Meta["license"].Should().Be("MIT"); + responseDocument.Meta["projectUrl"].Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + + var versionArray = ((IEnumerable) responseDocument.Meta["versions"]).Select(token => token.ToString()).ToArray(); + + versionArray.Should().HaveCount(4); + versionArray.Should().Contain("v4.0.0"); + versionArray.Should().Contain("v3.1.0"); + versionArray.Should().Contain("v2.5.2"); + versionArray.Should().Contain("v1.3.1"); + } + } + + public sealed class AtomicResponseMeta : IResponseMeta + { + public IReadOnlyDictionary GetMeta() + { + return new Dictionary + { + ["license"] = "MIT", + ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", + ["versions"] = new[] + { + "v4.0.0", + "v3.1.0", + "v2.5.2", + "v1.3.1" + } + }; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs new file mode 100644 index 0000000000..2ff54786a0 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta +{ + public sealed class MusicTrackMetaDefinition : JsonApiResourceDefinition + { + public MusicTrackMetaDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + { + } + + public override IDictionary GetMeta(MusicTrack resource) + { + return new Dictionary + { + ["Copyright"] = $"(C) {resource.ReleasedAt.Year}. All rights reserved." + }; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs new file mode 100644 index 0000000000..e0fd3f9f78 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta +{ + public sealed class TextLanguageMetaDefinition : JsonApiResourceDefinition + { + public TextLanguageMetaDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + { + } + + public override IDictionary GetMeta(TextLanguage resource) + { + return new Dictionary + { + ["Notice"] = "See https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes for ISO 639-1 language codes." + }; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs new file mode 100644 index 0000000000..05d4196a47 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs @@ -0,0 +1,154 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed +{ + public sealed class AtomicRequestBodyTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + + public AtomicRequestBodyTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Cannot_process_for_missing_request_body() + { + // Arrange + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, null); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().BeEmpty(); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Cannot_process_for_broken_JSON_request_body() + { + // Arrange + var requestBody = "{\"atomic__operations\":[{\"op\":"; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Unexpected end of content while loading JObject."); + responseDocument.Errors[0].Source.Pointer.Should().BeNull(); + } + + [Fact] + public async Task Cannot_process_empty_operations_array() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[0] + { + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: No operations found."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().BeEmpty(); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Cannot_process_for_unknown_operation_code() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "merge", + data = new + { + type = "performers", + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Error converting value \"merge\" to type"); + responseDocument.Errors[0].Source.Pointer.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().BeEmpty(); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().BeEmpty(); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs new file mode 100644 index 0000000000..931af9e668 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed +{ + public sealed class MaximumOperationsPerRequestTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + + public MaximumOperationsPerRequestTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Cannot_process_more_operations_than_maximum() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.MaximumOperationsPerRequest = 2; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + } + }, + new + { + op = "remove", + data = new + { + } + }, + new + { + op = "update", + data = new + { + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request exceeds the maximum number of operations."); + responseDocument.Errors[0].Detail.Should().Be("The number of operations in this request (3) is higher than 2."); + responseDocument.Errors[0].Source.Pointer.Should().BeNull(); + } + + [Fact] + public async Task Can_process_operations_same_as_maximum() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.MaximumOperationsPerRequest = 2; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + } + } + }, + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_process_high_number_of_operations_when_unconstrained() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.MaximumOperationsPerRequest = null; + + const int elementCount = 100; + + var operationElements = new List(elementCount); + for (int index = 0; index < elementCount; index++) + { + operationElements.Add(new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + } + } + }); + } + + var requestBody = new + { + atomic__operations = operationElements + }; + + var route = "/operations"; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs new file mode 100644 index 0000000000..ef7c2d5a14 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -0,0 +1,489 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ModelStateValidation +{ + public sealed class AtomicModelStateValidationTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicModelStateValidationTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Cannot_create_resource_with_multiple_violations() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + lengthInSeconds = -1 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The Title field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[1].Title.Should().Be("Input validation failed."); + responseDocument.Errors[1].Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + responseDocument.Errors[1].Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); + } + + [Fact] + public async Task Can_create_resource_with_annotated_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + var newPlaylistName = _fakers.Playlist.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = newPlaylistName + }, + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + + var newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == newPlaylistId); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); + }); + } + + [Fact] + public async Task Cannot_update_resource_with_multiple_violations() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + title = (string) null, + lengthInSeconds = -1 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The Title field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[1].Title.Should().Be("Input validation failed."); + responseDocument.Errors[1].Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + responseDocument.Errors[1].Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); + } + + [Fact] + public async Task Can_update_resource_with_omitted_required_attribute() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + var newTrackGenre = _fakers.MusicTrack.Generate().Genre; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + genre = newTrackGenre + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Title.Should().Be(existingTrack.Title); + trackInDatabase.Genre.Should().Be(newTrackGenre); + }); + } + + [Fact] + public async Task Can_update_resource_with_annotated_relationship() + { + // Arrange + var existingPlaylist = _fakers.Playlist.Generate(); + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingPlaylist, existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); + }); + } + + [Fact] + public async Task Can_update_ToOne_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + var existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + }); + } + + [Fact] + public async Task Can_update_ToMany_relationship() + { + // Arrange + var existingPlaylist = _fakers.Playlist.Generate(); + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingPlaylist, existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); + }); + } + + [Fact] + public async Task Validates_all_operations_before_execution_starts() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "update", + data = new + { + type = "playlists", + id = 99999999, + attributes = new + { + name = (string) null + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + lengthInSeconds = -1 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(3); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The Name field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[1].Title.Should().Be("Input validation failed."); + responseDocument.Errors[1].Detail.Should().Be("The Title field is required."); + responseDocument.Errors[1].Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/title"); + + responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[2].Title.Should().Be("Input validation failed."); + responseDocument.Errors[2].Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + responseDocument.Errors[2].Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs new file mode 100644 index 0000000000..8639702c78 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class MusicTrack : Identifiable + { + [RegularExpression(@"(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$")] + public override Guid Id { get; set; } + + [Attr] + [Required] + public string Title { get; set; } + + [Attr] + [Range(1, 24 * 60)] + public decimal? LengthInSeconds { get; set; } + + [Attr] + public string Genre { get; set; } + + [Attr] + public DateTimeOffset ReleasedAt { get; set; } + + [HasOne] + public Lyric Lyric { get; set;} + + [HasOne] + public RecordCompany OwnedBy { get; set; } + + [HasMany] + public IList Performers { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTracksController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTracksController.cs new file mode 100644 index 0000000000..9cdbe029ac --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTracksController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class MusicTracksController : JsonApiController + { + public MusicTracksController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs new file mode 100644 index 0000000000..0e8ab53cdf --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class OperationsDbContext : DbContext + { + public DbSet Playlists { get; set; } + public DbSet MusicTracks { get; set; } + public DbSet PlaylistMusicTracks { get; set; } + public DbSet Lyrics { get; set; } + public DbSet TextLanguages { get; set; } + public DbSet Performers { get; set; } + public DbSet RecordCompanies { get; set; } + + public OperationsDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasKey(playlistMusicTrack => new {playlistMusicTrack.PlaylistId, playlistMusicTrack.MusicTrackId}); + + builder.Entity() + .HasOne(musicTrack => musicTrack.Lyric) + .WithOne(lyric => lyric.Track) + .HasForeignKey(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs new file mode 100644 index 0000000000..63734c5523 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Bogus; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + internal sealed class OperationsFakers : FakerContainer + { + private static readonly Lazy> _lazyLanguageIsoCodes = + new Lazy>(() => CultureInfo + .GetCultures(CultureTypes.NeutralCultures) + .Where(culture => !string.IsNullOrEmpty(culture.Name)) + .Select(culture => culture.Name) + .ToArray()); + + private readonly Lazy> _lazyPlaylistFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(playlist => playlist.Name, f => f.Lorem.Sentence())); + + private readonly Lazy> _lazyMusicTrackFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(musicTrack => musicTrack.Title, f => f.Lorem.Word()) + .RuleFor(musicTrack => musicTrack.LengthInSeconds, f => f.Random.Decimal(3 * 60, 5 * 60)) + .RuleFor(musicTrack => musicTrack.Genre, f => f.Lorem.Word()) + .RuleFor(musicTrack => musicTrack.ReleasedAt, f => f.Date.PastOffset())); + + private readonly Lazy> _lazyLyricFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(lyric => lyric.Text, f => f.Lorem.Text()) + .RuleFor(lyric => lyric.Format, "LRC")); + + private readonly Lazy> _lazyTextLanguageFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(textLanguage => textLanguage.IsoCode, f => f.PickRandom(_lazyLanguageIsoCodes.Value))); + + private readonly Lazy> _lazyPerformerFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(performer => performer.ArtistName, f => f.Name.FullName()) + .RuleFor(performer => performer.BornAt, f => f.Date.PastOffset())); + + private readonly Lazy> _lazyRecordCompanyFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(recordCompany => recordCompany.Name, f => f.Company.CompanyName()) + .RuleFor(recordCompany => recordCompany.CountryOfResidence, f => f.Address.Country())); + + public Faker Playlist => _lazyPlaylistFaker.Value; + public Faker MusicTrack => _lazyMusicTrackFaker.Value; + public Faker Lyric => _lazyLyricFaker.Value; + public Faker TextLanguage => _lazyTextLanguageFaker.Value; + public Faker Performer => _lazyPerformerFaker.Value; + public Faker RecordCompany => _lazyRecordCompanyFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Performer.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Performer.cs new file mode 100644 index 0000000000..9b6d85c610 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Performer.cs @@ -0,0 +1,15 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class Performer : Identifiable + { + [Attr] + public string ArtistName { get; set; } + + [Attr] + public DateTimeOffset BornAt { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs new file mode 100644 index 0000000000..11fd778cc2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class Playlist : Identifiable + { + [Attr] + [Required] + public string Name { get; set; } + + [NotMapped] + [Attr] + public bool IsArchived => false; + + [NotMapped] + [HasManyThrough(nameof(PlaylistMusicTracks))] + public IList Tracks { get; set; } + + public IList PlaylistMusicTracks { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs new file mode 100644 index 0000000000..9c5389867a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs @@ -0,0 +1,13 @@ +using System; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class PlaylistMusicTrack + { + public long PlaylistId { get; set; } + public Playlist Playlist { get; set; } + + public Guid MusicTrackId { get; set; } + public MusicTrack MusicTrack { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs new file mode 100644 index 0000000000..f20ec15e37 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -0,0 +1,419 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.QueryStrings +{ + public sealed class AtomicQueryStringTests + : IClassFixture, OperationsDbContext>> + { + private static readonly DateTime _frozenTime = 30.July(2018).At(13, 46, 12); + + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicQueryStringTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddControllersFromExampleProject(); + + services.AddSingleton(new FrozenSystemClock {UtcNow = _frozenTime}); + services.AddScoped, MusicTrackReleaseDefinition>(); + }); + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowQueryStringOverrideForSerializerDefaultValueHandling = true; + options.AllowQueryStringOverrideForSerializerNullValueHandling = true; + } + + [Fact] + public async Task Cannot_include_on_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + } + } + } + } + }; + + var route = "/operations?include=recordCompanies"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + responseDocument.Errors[0].Detail.Should().Be("The parameter 'include' cannot be used at this endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("include"); + } + + [Fact] + public async Task Cannot_filter_on_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + } + } + } + } + }; + + var route = "/operations?filter=equals(id,'1')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + responseDocument.Errors[0].Detail.Should().Be("The parameter 'filter' cannot be used at this endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_sort_on_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + } + } + } + } + }; + + var route = "/operations?sort=-id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + responseDocument.Errors[0].Detail.Should().Be("The parameter 'sort' cannot be used at this endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("sort"); + } + + [Fact] + public async Task Cannot_use_pagination_number_on_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + } + } + } + } + }; + + var route = "/operations?page[number]=1"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + responseDocument.Errors[0].Detail.Should().Be("The parameter 'page[number]' cannot be used at this endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[number]"); + } + + [Fact] + public async Task Cannot_use_pagination_size_on_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + } + } + } + } + }; + + var route = "/operations?page[size]=1"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + responseDocument.Errors[0].Detail.Should().Be("The parameter 'page[size]' cannot be used at this endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[size]"); + } + + [Fact] + public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + } + } + } + } + }; + + var route = "/operations?fields[recordCompanies]=id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + responseDocument.Errors[0].Detail.Should().Be("The parameter 'fields[recordCompanies]' cannot be used at this endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("fields[recordCompanies]"); + } + + [Fact] + public async Task Can_use_Queryable_handler_on_resource_endpoint() + { + // Arrange + var musicTracks = _fakers.MusicTrack.Generate(3); + musicTracks[0].ReleasedAt = _frozenTime.AddMonths(5); + musicTracks[1].ReleasedAt = _frozenTime.AddMonths(-5); + musicTracks[2].ReleasedAt = _frozenTime.AddMonths(-1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.AddRange(musicTracks); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/musicTracks?isRecentlyReleased=true"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(musicTracks[2].StringId); + } + + [Fact] + public async Task Cannot_use_Queryable_handler_on_operations_endpoint() + { + // Arrange + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTrackTitle + } + } + } + } + }; + + var route = "/operations?isRecentlyReleased=true"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Unknown query string parameter."); + responseDocument.Errors[0].Detail.Should().Be("Query string parameter 'isRecentlyReleased' is unknown. Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); + responseDocument.Errors[0].Source.Parameter.Should().Be("isRecentlyReleased"); + } + + [Fact] + public async Task Can_use_defaults_on_operations_endpoint() + { + // Arrange + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + var newTrackLength = _fakers.MusicTrack.Generate().LengthInSeconds; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTrackTitle, + lengthInSeconds = newTrackLength + } + } + } + } + }; + + var route = "/operations?defaults=false"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Attributes.Should().HaveCount(2); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].SingleData.Attributes["lengthInSeconds"].As().Should().BeApproximately(newTrackLength, 0.00000000001M); + } + + [Fact] + public async Task Can_use_nulls_on_operations_endpoint() + { + // Arrange + var newTrackTitle = _fakers.MusicTrack.Generate().Title; + var newTrackLength = _fakers.MusicTrack.Generate().LengthInSeconds; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTrackTitle, + lengthInSeconds = newTrackLength + } + } + } + } + }; + + var route = "/operations?nulls=false"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[0].SingleData.Attributes.Should().HaveCount(2); + responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].SingleData.Attributes["lengthInSeconds"].As().Should().BeApproximately(newTrackLength, 0.00000000001M); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs new file mode 100644 index 0000000000..439cd821b5 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.QueryStrings +{ + public sealed class MusicTrackReleaseDefinition : JsonApiResourceDefinition + { + private readonly ISystemClock _systemClock; + + public MusicTrackReleaseDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + : base(resourceGraph) + { + _systemClock = systemClock ?? throw new ArgumentNullException(nameof(systemClock)); + } + + public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + { + return new QueryStringParameterHandlers + { + ["isRecentlyReleased"] = FilterOnRecentlyReleased + }; + } + + private IQueryable FilterOnRecentlyReleased(IQueryable source, StringValues parameterValue) + { + if (bool.Parse(parameterValue)) + { + source = source.Where(musicTrack => + musicTrack.ReleasedAt < _systemClock.UtcNow && + musicTrack.ReleasedAt > _systemClock.UtcNow.AddMonths(-3)); + } + + return source; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs new file mode 100644 index 0000000000..5dc89a7a87 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class RecordCompany : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public string CountryOfResidence { get; set; } + + [HasMany] + public IList Tracks { get; set; } + + [HasOne] + public RecordCompany Parent { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs new file mode 100644 index 0000000000..653ff88a31 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -0,0 +1,161 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions +{ + public sealed class AtomicSparseFieldSetResourceDefinitionTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicSparseFieldSetResourceDefinitionTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddControllersFromExampleProject(); + + services.AddSingleton(); + services.AddScoped, LyricTextDefinition>(); + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + } + + [Fact] + public async Task Hides_text_in_create_resource_with_side_effects() + { + // Arrange + var provider = _testContext.Factory.Services.GetRequiredService(); + provider.CanViewText = false; + provider.HitCount = 0; + + var newLyrics = _fakers.Lyric.Generate(2); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "lyrics", + attributes = new + { + format = newLyrics[0].Format, + text = newLyrics[0].Text + } + } + }, + new + { + op = "add", + data = new + { + type = "lyrics", + attributes = new + { + format = newLyrics[1].Format, + text = newLyrics[1].Text + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Attributes["format"].Should().Be(newLyrics[0].Format); + responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("text"); + + responseDocument.Results[1].SingleData.Attributes["format"].Should().Be(newLyrics[1].Format); + responseDocument.Results[1].SingleData.Attributes.Should().NotContainKey("text"); + + provider.HitCount.Should().Be(4); + } + + [Fact] + public async Task Hides_text_in_update_resource_with_side_effects() + { + // Arrange + var provider = _testContext.Factory.Services.GetRequiredService(); + provider.CanViewText = false; + provider.HitCount = 0; + + var existingLyrics = _fakers.Lyric.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.AddRange(existingLyrics); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "lyrics", + id = existingLyrics[0].StringId, + attributes = new + { + } + } + }, + new + { + op = "update", + data = new + { + type = "lyrics", + id = existingLyrics[1].StringId, + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Attributes["format"].Should().Be(existingLyrics[0].Format); + responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("text"); + + responseDocument.Results[1].SingleData.Attributes["format"].Should().Be(existingLyrics[1].Format); + responseDocument.Results[1].SingleData.Attributes.Should().NotContainKey("text"); + + provider.HitCount.Should().Be(4); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricPermissionProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricPermissionProvider.cs new file mode 100644 index 0000000000..501c820391 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricPermissionProvider.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions +{ + public sealed class LyricPermissionProvider + { + public bool CanViewText { get; set; } + public int HitCount { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricTextDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricTextDefinition.cs new file mode 100644 index 0000000000..be315c3f0a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricTextDefinition.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions +{ + public sealed class LyricTextDefinition : JsonApiResourceDefinition + { + private readonly LyricPermissionProvider _lyricPermissionProvider; + + public LyricTextDefinition(IResourceGraph resourceGraph, LyricPermissionProvider lyricPermissionProvider) + : base(resourceGraph) + { + _lyricPermissionProvider = lyricPermissionProvider; + } + + public override SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + { + _lyricPermissionProvider.HitCount++; + + return _lyricPermissionProvider.CanViewText + ? base.OnApplySparseFieldSet(existingSparseFieldSet) + : existingSparseFieldSet.Excluding(lyric => lyric.Text, ResourceGraph); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs new file mode 100644 index 0000000000..57452eb2d2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class TextLanguage : Identifiable + { + [Attr] + public string IsoCode { get; set; } + + [NotMapped] + [Attr(Capabilities = AttrCapabilities.None)] + public Guid ConcurrencyToken + { + get => Guid.NewGuid(); + set { } + } + + [HasMany] + public ICollection Lyrics { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs new file mode 100644 index 0000000000..60f6818305 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs @@ -0,0 +1,108 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions +{ + public sealed class AtomicRollbackTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicRollbackTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_rollback_on_error() + { + // Arrange + var newArtistName = _fakers.Performer.Generate().ArtistName; + var newBornAt = _fakers.Performer.Generate().BornAt; + var newTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTablesAsync(); + }); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + artistName = newArtistName, + bornAt = newBornAt + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle + }, + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers", + id = 99999999 + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'performers' with ID '99999999' in relationship 'performers' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().BeEmpty(); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().BeEmpty(); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs new file mode 100644 index 0000000000..6097ca1ccd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions +{ + public sealed class AtomicTransactionConsistencyTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + + public AtomicTransactionConsistencyTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddControllersFromExampleProject(); + + services.AddResourceRepository(); + services.AddResourceRepository(); + services.AddResourceRepository(); + + string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; + string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; + + services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); + }); + } + + [Fact] + public async Task Cannot_use_non_transactional_repository() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Unsupported resource type in atomic:operations request."); + responseDocument.Errors[0].Detail.Should().Be("Operations on resources of type 'performers' cannot be used because transaction support is unavailable."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_use_transactional_repository_without_active_transaction() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); + responseDocument.Errors[0].Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_use_distributed_transaction() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "lyrics", + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); + responseDocument.Errors[0].Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs new file mode 100644 index 0000000000..f9776989a0 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions +{ + public sealed class ExtraDbContext : DbContext + { + public ExtraDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs new file mode 100644 index 0000000000..581ff9f64a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions +{ + public sealed class LyricRepository : EntityFrameworkCoreRepository + { + private readonly ExtraDbContext _extraDbContext; + + public override Guid? TransactionId => _extraDbContext.Database.CurrentTransaction.TransactionId; + + public LyricRepository(ExtraDbContext extraDbContext, ITargetedFields targetedFields, + IDbContextResolver contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) + { + _extraDbContext = extraDbContext; + + extraDbContext.Database.EnsureCreated(); + extraDbContext.Database.BeginTransaction(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs new file mode 100644 index 0000000000..740195172a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions +{ + public sealed class MusicTrackRepository : EntityFrameworkCoreRepository + { + public override Guid? TransactionId => null; + + public MusicTrackRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs new file mode 100644 index 0000000000..354e4a01a1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions +{ + public sealed class PerformerRepository : IResourceRepository + { + public Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetForCreateAsync(int id, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CreateAsync(Performer resourceFromRequest, Performer resourceForDatabase, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task UpdateAsync(Performer resourceFromRequest, Performer resourceFromDatabase, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task DeleteAsync(int id, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task SetRelationshipAsync(Performer primaryResource, object secondaryResourceIds, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task AddToToManyRelationshipAsync(int primaryId, ISet secondaryResourceIds, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task RemoveFromToManyRelationshipAsync(Performer primaryResource, ISet secondaryResourceIds, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs new file mode 100644 index 0000000000..303632937e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -0,0 +1,932 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships +{ + public sealed class AtomicAddToToManyRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicAddToToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Cannot_add_to_HasOne_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + var existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted in 'add' operations."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'ownedBy' must be a to-many relationship."); + } + + [Fact] + public async Task Can_add_to_HasMany_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); + + var existingPerformers = _fakers.Performer.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + dbContext.Performers.AddRange(existingPerformers); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = existingPerformers[0].StringId + } + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = existingPerformers[1].StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Performers.Should().HaveCount(3); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingTrack.Performers[0].Id); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); + }); + } + + [Fact] + public async Task Can_add_to_HasManyThrough_relationship() + { + // Arrange + var existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.PlaylistMusicTracks = new List + { + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + } + }; + + var existingTracks = _fakers.MusicTrack.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Playlists.Add(existingPlaylist); + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingTracks[0].StringId + } + } + }, + new + { + op = "add", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingTracks[1].StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(3); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingPlaylist.PlaylistMusicTracks[0].MusicTrack.Id); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); + }); + } + + [Fact] + public async Task Cannot_add_for_href_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + href = "/api/v1/musicTracks/1/relationships/performers" + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_missing_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + id = 99999999, + relationship = "tracks" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_unknown_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "doesNotExist", + id = 99999999, + relationship = "tracks" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_missing_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + relationship = "performers" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_unknown_ID_in_ref() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "recordCompanies", + id = 9999, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'recordCompanies' with ID '9999' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_ID_and_local_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + lid = "local-1", + relationship = "performers" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_missing_relationship_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + id = 99999999, + type = "musicTracks" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.relationship' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_unknown_relationship_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "performers", + id = 99999999, + relationship = "doesNotExist" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'performers' does not contain a relationship named 'doesNotExist'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_null_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = (object) null + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().Be("Expected data[] element for 'performers' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_missing_type_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "playlists", + id = 99999999, + relationship = "tracks" + }, + data = new[] + { + new + { + id = Guid.NewGuid().ToString() + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data[].type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_unknown_type_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "performers" + }, + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_missing_ID_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_ID_and_local_ID_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = 99999999, + lid = "local-1" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_unknown_IDs_in_data() + { + // Arrange + var existingCompany = _fakers.RecordCompany.Generate(); + var trackIds = new[] {Guid.NewGuid(), Guid.NewGuid()}; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "recordCompanies", + id = existingCompany.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = trackIds[0].ToString() + }, + new + { + type = "musicTracks", + id = trackIds[1].ToString() + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + responseDocument.Errors[1].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_for_relationship_mismatch_between_ref_and_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "playlists", + id = 88888888 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data[].type' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource of type 'performers' in 'data[].type', instead of 'playlists'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Can_add_with_empty_data_array() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new object[0] + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs new file mode 100644 index 0000000000..c7e77fa4e3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -0,0 +1,904 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships +{ + public sealed class AtomicRemoveFromToManyRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicRemoveFromToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Cannot_remove_from_HasOne_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingTrack.OwnedBy.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted in 'remove' operations."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'ownedBy' must be a to-many relationship."); + } + + [Fact] + public async Task Can_remove_from_HasMany_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(3); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = existingTrack.Performers[0].StringId + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = existingTrack.Performers[2].StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[1].Id); + + var performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Can_remove_from_HasManyThrough_relationship() + { + // Arrange + var existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.PlaylistMusicTracks = new List + { + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + }, + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + }, + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingPlaylist.PlaylistMusicTracks[0].MusicTrack.StringId + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingPlaylist.PlaylistMusicTracks[2].MusicTrack.StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingPlaylist.PlaylistMusicTracks[1].MusicTrack.Id); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Cannot_remove_for_href_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + href = "/api/v1/musicTracks/1/relationships/performers" + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_missing_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + id = 99999999, + relationship = "tracks" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_unknown_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "doesNotExist", + id = 99999999, + relationship = "tracks" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_missing_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + relationship = "performers" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_unknown_ID_in_ref() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "recordCompanies", + id = 9999, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'recordCompanies' with ID '9999' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_ID_and_local_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + lid = "local-1", + relationship = "performers" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_unknown_relationship_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "performers", + id = 99999999, + relationship = "doesNotExist" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'performers' does not contain a relationship named 'doesNotExist'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_null_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = (object) null + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().Be("Expected data[] element for 'performers' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_missing_type_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = 99999999, + relationship = "tracks" + }, + data = new[] + { + new + { + id = Guid.NewGuid().ToString() + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data[].type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_unknown_type_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "performers" + }, + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_missing_ID_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_ID_and_local_ID_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = 99999999, + lid = "local-1" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_unknown_IDs_in_data() + { + // Arrange + var existingCompany = _fakers.RecordCompany.Generate(); + var trackIds = new[] {Guid.NewGuid(), Guid.NewGuid()}; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "recordCompanies", + id = existingCompany.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = trackIds[0].ToString() + }, + new + { + type = "musicTracks", + id = trackIds[1].ToString() + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + responseDocument.Errors[1].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_remove_for_relationship_mismatch_between_ref_and_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "playlists", + id = 88888888 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data[].type' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource of type 'performers' in 'data[].type', instead of 'playlists'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Can_remove_with_empty_data_array() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new object[0] + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..bb87d19f2c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -0,0 +1,1003 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships +{ + public sealed class AtomicReplaceToManyRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicReplaceToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_clear_HasMany_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new object[0] + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Performers.Should().BeEmpty(); + + var performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_clear_HasManyThrough_relationship() + { + // Arrange + var existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.PlaylistMusicTracks = new List + { + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + }, + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new object[0] + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); + + playlistInDatabase.PlaylistMusicTracks.Should().BeEmpty(); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_replace_HasMany_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); + + var existingPerformers = _fakers.Performer.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + dbContext.Performers.AddRange(existingPerformers); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = existingPerformers[0].StringId + }, + new + { + type = "performers", + id = existingPerformers[1].StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Performers.Should().HaveCount(2); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); + + var performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Can_replace_HasManyThrough_relationship() + { + // Arrange + var existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.PlaylistMusicTracks = new List + { + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + } + }; + + var existingTracks = _fakers.MusicTrack.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Playlists.Add(existingPlaylist); + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingTracks[0].StringId + }, + new + { + type = "musicTracks", + id = existingTracks[1].StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(2); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Cannot_replace_for_href_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + href = "/api/v1/musicTracks/1/relationships/performers" + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_missing_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + id = 99999999, + relationship = "tracks" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "doesNotExist", + id = 99999999, + relationship = "tracks" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_missing_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + relationship = "performers" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_ID_in_ref() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "recordCompanies", + id = 9999, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'recordCompanies' with ID '9999' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_incompatible_ID_in_ref() + { + // Arrange + var guid = Guid.NewGuid().ToString(); + + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "recordCompanies", + id = guid, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int16'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_ID_and_local_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + lid = "local-1", + relationship = "performers" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_relationship_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers", + id = 99999999, + relationship = "doesNotExist" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'performers' does not contain a relationship named 'doesNotExist'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_null_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = (object) null + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().Be("Expected data[] element for 'performers' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_missing_type_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "playlists", + id = 99999999, + relationship = "tracks" + }, + data = new[] + { + new + { + id = Guid.NewGuid().ToString() + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data[].type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_type_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "performers" + }, + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_missing_ID_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_ID_and_local_ID_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = 99999999, + lid = "local-1" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_IDs_in_data() + { + // Arrange + var existingCompany = _fakers.RecordCompany.Generate(); + var trackIds = new[] {Guid.NewGuid(), Guid.NewGuid()}; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "recordCompanies", + id = existingCompany.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = trackIds[0].ToString() + }, + new + { + type = "musicTracks", + id = trackIds[1].ToString() + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + responseDocument.Errors[1].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_incompatible_ID_in_data() + { + // Arrange + var existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "recordCompanies", + id = existingCompany.StringId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = "invalid-guid" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_relationship_mismatch_between_ref_and_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "playlists", + id = 88888888 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data[].type' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource of type 'performers' in 'data[].type', instead of 'playlists'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..2cae0afafa --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -0,0 +1,1211 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships +{ + public sealed class AtomicUpdateToOneRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicUpdateToOneRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" + }, + data = (object) null + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var lyricInDatabase = await dbContext.Lyrics + .Include(lyric => lyric.Track) + .FirstAsync(lyric => lyric.Id == existingLyric.Id); + + lyricInDatabase.Track.Should().BeNull(); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(1); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = (object) null + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Lyric.Should().BeNull(); + + var lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); + lyricsInDatabase.Should().HaveCount(1); + }); + } + + [Fact] + public async Task Can_clear_ManyToOne_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = (object) null + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.OwnedBy.Should().BeNull(); + + var companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.Should().HaveCount(1); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingLyric, existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" + }, + data = new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var lyricInDatabase = await dbContext.Lyrics + .Include(lyric => lyric.Track) + .FirstAsync(lyric => lyric.Id == existingLyric.Id); + + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + var existingLyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingTrack, existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + }); + } + + [Fact] + public async Task Can_create_ManyToOne_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + var existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); + + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingLyric, existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" + }, + data = new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var lyricInDatabase = await dbContext.Lyrics + .Include(lyric => lyric.Track) + .FirstAsync(lyric => lyric.Id == existingLyric.Id); + + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); + + var existingLyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingTrack, existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + + var lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); + lyricsInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + + var existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + + var companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Cannot_create_for_href_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + href = "/api/v1/musicTracks/1/relationships/ownedBy" + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_missing_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + id = 99999999, + relationship = "track" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_unknown_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "doesNotExist", + id = 99999999, + relationship = "ownedBy" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_missing_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + relationship = "ownedBy" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_unknown_ID_in_ref() + { + // Arrange + string missingTrackId = Guid.NewGuid().ToString(); + + var existingLyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = missingTrackId, + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'musicTracks' with ID '{missingTrackId}' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_incompatible_ID_in_ref() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = "invalid-guid", + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_ID_and_local_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + lid = "local-1", + relationship = "ownedBy" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_unknown_relationship_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers", + id = 99999999, + relationship = "doesNotExist" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'performers' does not contain a relationship named 'doesNotExist'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_array_in_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = new[] + { + new + { + type = "lyrics", + id = 99999999 + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected single data element for to-one relationship."); + responseDocument.Errors[0].Detail.Should().Be("Expected single data element for 'lyric' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_missing_type_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "lyrics", + id = 99999999, + relationship = "track" + }, + data = new + { + id = Guid.NewGuid().ToString() + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_unknown_type_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "lyric" + }, + data = new + { + type = "doesNotExist", + id = 99999999 + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_missing_ID_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "lyric" + }, + data = new + { + type = "lyrics" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_ID_and_local_ID_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = 99999999, + lid = "local-1" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_unknown_ID_in_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = 99999999 + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'lyrics' with ID '99999999' in relationship 'lyric' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_incompatible_ID_in_data() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" + }, + data = new + { + type = "musicTracks", + id = "invalid-guid" + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_relationship_mismatch_between_ref_and_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = new + { + type = "playlists", + id = 99999999 + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data.type' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource of type 'lyrics' in 'data.type', instead of 'playlists'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..e8bdb8db3a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -0,0 +1,690 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources +{ + public sealed class AtomicReplaceToManyRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicReplaceToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_clear_HasMany_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + performers = new + { + data = new object[0] + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Performers.Should().BeEmpty(); + + var performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_clear_HasManyThrough_relationship() + { + // Arrange + var existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.PlaylistMusicTracks = new List + { + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + }, + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationships = new + { + tracks = new + { + data = new object[0] + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); + + playlistInDatabase.PlaylistMusicTracks.Should().BeEmpty(); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_replace_HasMany_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); + + var existingPerformers = _fakers.Performer.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + dbContext.Performers.AddRange(existingPerformers); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers", + id = existingPerformers[0].StringId + }, + new + { + type = "performers", + id = existingPerformers[1].StringId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Performers.Should().HaveCount(2); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); + + var performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Can_replace_HasManyThrough_relationship() + { + // Arrange + var existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.PlaylistMusicTracks = new List + { + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + } + }; + + var existingTracks = _fakers.MusicTrack.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Playlists.Add(existingPlaylist); + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + id = existingTracks[0].StringId + }, + new + { + type = "musicTracks", + id = existingTracks[1].StringId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var playlistInDatabase = await dbContext.Playlists + .Include(playlist => playlist.PlaylistMusicTracks) + .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) + .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); + + playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(2); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Cannot_replace_for_null_relationship_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + performers = new + { + data = (object) null + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().Be("Expected data[] element for 'performers' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_missing_type_in_relationship_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "playlists", + id = 99999999, + relationships = new + { + tracks = new + { + data = new[] + { + new + { + id = Guid.NewGuid().ToString() + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected 'type' element in 'tracks' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_type_in_relationship_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_missing_ID_in_relationship_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers" + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected 'id' or 'lid' element in 'performers' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_ID_and_local_ID_relationship_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "performers", + id = 99999999, + lid = "local-1" + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected 'id' or 'lid' element in 'performers' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_IDs_in_relationship_data() + { + // Arrange + var existingCompany = _fakers.RecordCompany.Generate(); + var trackIds = new[] {Guid.NewGuid(), Guid.NewGuid()}; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "recordCompanies", + id = existingCompany.StringId, + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + id = trackIds[0].ToString() + }, + new + { + type = "musicTracks", + id = trackIds[1].ToString() + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + responseDocument.Errors[1].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_relationship_mismatch() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + performers = new + { + data = new[] + { + new + { + type = "playlists", + id = 88888888 + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'performers' contains incompatible resource type 'playlists'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs new file mode 100644 index 0000000000..3529dd3e66 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -0,0 +1,1561 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources +{ + public sealed class AtomicUpdateResourceTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicUpdateResourceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_update_resources() + { + // Arrange + const int elementCount = 5; + + var existingTracks = _fakers.MusicTrack.Generate(elementCount); + var newTrackTitles = _fakers.MusicTrack.Generate(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); + + var operationElements = new List(elementCount); + for (int index = 0; index < elementCount; index++) + { + operationElements.Add(new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTracks[index].StringId, + attributes = new + { + title = newTrackTitles[index] + } + } + }); + } + + var requestBody = new + { + atomic__operations = operationElements + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var tracksInDatabase = await dbContext.MusicTracks + .ToListAsync(); + + tracksInDatabase.Should().HaveCount(elementCount); + + for (int index = 0; index < elementCount; index++) + { + var trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == existingTracks[index].Id); + + trackInDatabase.Title.Should().Be(newTrackTitles[index]); + trackInDatabase.Genre.Should().Be(existingTracks[index].Genre); + } + }); + } + + [Fact] + public async Task Can_update_resource_without_attributes_or_relationships() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Title.Should().Be(existingTrack.Title); + trackInDatabase.Genre.Should().Be(existingTrack.Genre); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + }); + } + + [Fact] + public async Task Can_update_resource_with_unknown_attribute() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + var newTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + title = newTitle, + doesNotExist = "Ignored" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Title.Should().Be(newTitle); + }); + } + + [Fact] + public async Task Can_update_resource_with_unknown_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + doesNotExist = new + { + data = new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Can_partially_update_resource_without_side_effects() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + + var newGenre = _fakers.MusicTrack.Generate().Genre; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + genre = newGenre + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Title.Should().Be(existingTrack.Title); + trackInDatabase.LengthInSeconds.Should().Be(existingTrack.LengthInSeconds); + trackInDatabase.Genre.Should().Be(newGenre); + trackInDatabase.ReleasedAt.Should().BeCloseTo(existingTrack.ReleasedAt); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + }); + } + + [Fact] + public async Task Can_completely_update_resource_without_side_effects() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + + var newTitle = _fakers.MusicTrack.Generate().Title; + var newLengthInSeconds = _fakers.MusicTrack.Generate().LengthInSeconds; + var newGenre = _fakers.MusicTrack.Generate().Genre; + var newReleasedAt = _fakers.MusicTrack.Generate().ReleasedAt; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + title = newTitle, + lengthInSeconds = newLengthInSeconds, + genre = newGenre, + releasedAt = newReleasedAt + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Title.Should().Be(newTitle); + trackInDatabase.LengthInSeconds.Should().Be(newLengthInSeconds); + trackInDatabase.Genre.Should().Be(newGenre); + trackInDatabase.ReleasedAt.Should().BeCloseTo(newReleasedAt); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + }); + } + + [Fact] + public async Task Can_update_resource_with_side_effects() + { + // Arrange + var existingLanguage = _fakers.TextLanguage.Generate(); + var newIsoCode = _fakers.TextLanguage.Generate().IsoCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextLanguages.Add(existingLanguage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "textLanguages", + id = existingLanguage.StringId, + attributes = new + { + isoCode = newIsoCode + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Type.Should().Be("textLanguages"); + responseDocument.Results[0].SingleData.Attributes["isoCode"].Should().Be(newIsoCode); + responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("concurrencyToken"); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var languageInDatabase = await dbContext.TextLanguages + .FirstAsync(language => language.Id == existingLanguage.Id); + + languageInDatabase.IsoCode.Should().Be(newIsoCode); + }); + } + + [Fact] + public async Task Update_resource_with_side_effects_hides_relationship_data_in_response() + { + // Arrange + var existingLanguage = _fakers.TextLanguage.Generate(); + existingLanguage.Lyrics = _fakers.Lyric.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextLanguages.Add(existingLanguage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "textLanguages", + id = existingLanguage.StringId + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().NotBeNull(); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.Results[0].SingleData.Relationships.Values.Should().OnlyContain(relationshipEntry => relationshipEntry.Data == null); + } + + [Fact] + public async Task Cannot_update_resource_for_href_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + href = "/api/v1/musicTracks/1" + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Can_update_resource_for_ref_element() + { + // Arrange + var existingPerformer = _fakers.Performer.Generate(); + var newArtistName = _fakers.Performer.Generate().ArtistName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers", + id = existingPerformer.StringId + }, + data = new + { + type = "performers", + id = existingPerformer.StringId, + attributes = new + { + artistName = newArtistName + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var performerInDatabase = await dbContext.Performers + .FirstAsync(performer => performer.Id == existingPerformer.Id); + + performerInDatabase.ArtistName.Should().Be(newArtistName); + performerInDatabase.BornAt.Should().BeCloseTo(existingPerformer.BornAt); + }); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + id = 12345678 + }, + data = new + { + type = "performers", + id = 12345678, + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers" + }, + data = new + { + type = "performers", + id = 12345678, + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_ID_and_local_ID_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers", + id = 12345678, + lid = "local-1" + }, + data = new + { + type = "performers", + id = 12345678, + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update" + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_type_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + id = 12345678, + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_ID_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "performers", + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "performers", + id = 12345678, + lid = "local-1", + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_array_in_data() + { + // Arrange + var existingPerformer = _fakers.Performer.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new[] + { + new + { + type = "performers", + id = existingPerformer.StringId, + attributes = new + { + artistName = existingPerformer.ArtistName + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected single data element for create/update resource operation."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers", + id = 12345678 + }, + data = new + { + type = "playlists", + id = 12345678, + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.type' and 'data.type' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource of type 'performers' in 'data.type', instead of 'playlists'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_on_resource_ID_mismatch_between_ref_and_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers", + id = 12345678 + }, + data = new + { + type = "performers", + id = 87654321, + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource ID mismatch between 'ref.id' and 'data.id' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource with ID '12345678' in 'data.id', instead of '87654321'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_on_resource_local_ID_mismatch_between_ref_and_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers", + lid = "local-1" + }, + data = new + { + type = "performers", + lid = "local-2", + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource local ID mismatch between 'ref.lid' and 'data.lid' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource with local ID 'local-1' in 'data.lid', instead of 'local-2'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_on_mixture_of_ID_and_local_ID_between_ref_and_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers", + id = "12345678" + }, + data = new + { + type = "performers", + lid = "local-1", + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource identity mismatch between 'ref.id' and 'data.lid' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource with ID '12345678' in 'data.id', instead of 'local-1' in 'data.lid'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_on_mixture_of_local_ID_and_ID_between_ref_and_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers", + lid = "local-1" + }, + data = new + { + type = "performers", + id = "12345678", + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource identity mismatch between 'ref.lid' and 'data.id' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource with local ID 'local-1' in 'data.lid', instead of '12345678' in 'data.id'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_unknown_type() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "doesNotExist", + id = 12345678, + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_unknown_ID() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "performers", + id = 99999999, + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'performers' with ID '99999999' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_incompatible_ID() + { + // Arrange + var guid = Guid.NewGuid().ToString(); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "performers", + id = guid + }, + data = new + { + type = "performers", + id = guid, + attributes = new + { + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_attribute_with_blocked_capability() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "lyrics", + id = existingLyric.StringId, + attributes = new + { + createdAt = 12.July(1980) + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Changing the value of 'createdAt' is not allowed."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_with_readonly_attribute() + { + // Arrange + var existingPlaylist = _fakers.Playlist.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "playlists", + id = existingPlaylist.StringId, + attributes = new + { + isArchived = true + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + responseDocument.Errors[0].Detail.Should().Be("Attribute 'isArchived' is read-only."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_change_ID_of_existing_resource() + { + // Arrange + var existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "recordCompanies", + id = existingCompany.StringId, + attributes = new + { + id = (existingCompany.Id + 1).ToString() + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource ID is read-only."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_with_incompatible_attribute_value() + { + // Arrange + var existingPerformer = _fakers.Performer.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "performers", + id = existingPerformer.StringId, + attributes = new + { + bornAt = "not-a-valid-time" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'DateTimeOffset'. - Request body:"); + responseDocument.Errors[0].Source.Pointer.Should().BeNull(); + } + + [Fact] + public async Task Can_update_resource_with_attributes_and_multiple_relationship_types() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); + + var newGenre = _fakers.MusicTrack.Generate().Genre; + + var existingLyric = _fakers.Lyric.Generate(); + var existingCompany = _fakers.RecordCompany.Generate(); + var existingPerformer = _fakers.Performer.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingTrack, existingLyric, existingCompany, existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + genre = newGenre + }, + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + }, + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + }, + performers = new + { + data = new[] + { + new + { + type = "performers", + id = existingPerformer.StringId + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .Include(musicTrack => musicTrack.OwnedBy) + .Include(musicTrack => musicTrack.Performers) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Title.Should().Be(existingTrack.Title); + trackInDatabase.Genre.Should().Be(newGenre); + + trackInDatabase.Lyric.Should().NotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + + trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..708ad40fbf --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -0,0 +1,932 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources +{ + public sealed class AtomicUpdateToOneRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicUpdateToOneRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "lyrics", + id = existingLyric.StringId, + relationships = new + { + track = new + { + data = (object) null + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var lyricInDatabase = await dbContext.Lyrics + .Include(lyric => lyric.Track) + .FirstAsync(lyric => lyric.Id == existingLyric.Id); + + lyricInDatabase.Track.Should().BeNull(); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(1); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + lyric = new + { + data = (object) null + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Lyric.Should().BeNull(); + + var lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); + lyricsInDatabase.Should().HaveCount(1); + }); + } + + [Fact] + public async Task Can_clear_ManyToOne_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + ownedBy = new + { + data = (object) null + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.OwnedBy.Should().BeNull(); + + var companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.Should().HaveCount(1); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingLyric, existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "lyrics", + id = existingLyric.StringId, + relationships = new + { + track = new + { + data = new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var lyricInDatabase = await dbContext.Lyrics + .Include(lyric => lyric.Track) + .FirstAsync(lyric => lyric.Id == existingLyric.Id); + + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + var existingLyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingTrack, existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + }); + } + + [Fact] + public async Task Can_create_ManyToOne_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + var existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); + + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingLyric, existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "lyrics", + id = existingLyric.StringId, + relationships = new + { + track = new + { + data = new + { + type = "musicTracks", + id = existingTrack.StringId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var lyricInDatabase = await dbContext.Lyrics + .Include(lyric => lyric.Track) + .FirstAsync(lyric => lyric.Id == existingLyric.Id); + + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); + + var existingLyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingTrack, existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + + var lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); + lyricsInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + + var existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + + var companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Cannot_create_for_array_in_relationship_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + lyric = new + { + data = new[] + { + new + { + type = "lyrics", + id = 99999999 + } + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected single data element for to-one relationship."); + responseDocument.Errors[0].Detail.Should().Be("Expected single data element for 'lyric' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_missing_type_in_relationship_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "lyrics", + id = 99999999, + relationships = new + { + track = new + { + data = new + { + id = Guid.NewGuid().ToString() + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected 'type' element in 'track' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_unknown_type_in_relationship_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationships = new + { + lyric = new + { + data = new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_missing_ID_in_relationship_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics" + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected 'id' or 'lid' element in 'lyric' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_ID_and_local_ID_in_relationship_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = Guid.NewGuid().ToString(), + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics", + id = 99999999, + lid = "local-1" + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); + responseDocument.Errors[0].Detail.Should().Be("Expected 'id' or 'lid' element in 'lyric' relationship."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_unknown_ID_in_relationship_data() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics", + id = 99999999 + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'lyrics' with ID '99999999' in relationship 'lyric' does not exist."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_create_for_relationship_mismatch() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + lyric = new + { + data = new + { + type = "playlists", + id = 99999999 + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'lyric' contains incompatible resource type 'playlists'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index ad647062c3..ac382e46ae 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -505,7 +505,7 @@ public async Task Cannot_remove_from_ManyToOne_relationship_for_unknown_relation // Arrange var existingDealership = new Dealership { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands", + Address = "Dam 1, 1012JS Amsterdam, the Netherlands" }; await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 4eafc2e40a..f1f9cbbb89 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -18,6 +18,8 @@ public sealed class AcceptHeaderTests public AcceptHeaderTests(ExampleIntegrationTestContext, PolicyDbContext> testContext) { _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] @@ -35,6 +37,41 @@ public async Task Permits_no_Accept_headers() httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); } + [Fact] + public async Task Permits_no_Accept_headers_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + var route = "/operations"; + var contentType = HeaderConstants.AtomicOperationsMediaType; + + var acceptHeaders = new MediaTypeWithQualityHeaderValue[0]; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, acceptHeaders); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + [Fact] public async Task Permits_global_wildcard_in_Accept_headers() { @@ -95,6 +132,48 @@ public async Task Permits_JsonApi_without_parameters_in_Accept_headers() httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); } + [Fact] + public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + var route = "/operations"; + var contentType = HeaderConstants.AtomicOperationsMediaType; + + var acceptHeaders = new[] + { + MediaTypeWithQualityHeaderValue.Parse("text/html"), + MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; profile=some"), + MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType), + MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected"), + MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType+";ext=\"https://jsonapi.org/ext/atomic\"; q=0.2") + }; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, acceptHeaders); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + [Fact] public async Task Denies_JsonApi_with_parameters_in_Accept_headers() { @@ -106,7 +185,8 @@ public async Task Denies_JsonApi_with_parameters_in_Accept_headers() MediaTypeWithQualityHeaderValue.Parse("text/html"), MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; profile=some"), MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; ext=other"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected") + MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected"), + MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType) }; // Act @@ -120,5 +200,48 @@ public async Task Denies_JsonApi_with_parameters_in_Accept_headers() responseDocument.Errors[0].Title.Should().Be("The specified Accept header value does not contain any supported media types."); responseDocument.Errors[0].Detail.Should().Be("Please include 'application/vnd.api+json' in the Accept header values."); } + + [Fact] + public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + var route = "/operations"; + var contentType = HeaderConstants.AtomicOperationsMediaType; + + var acceptHeaders = new[] + { + MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType) + }; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType, acceptHeaders); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotAcceptable); + responseDocument.Errors[0].Title.Should().Be("The specified Accept header value does not contain any supported media types."); + responseDocument.Errors[0].Detail.Should().Be("Please include 'application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"' in the Accept header values."); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 790370f12e..593f832cff 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -17,6 +17,8 @@ public sealed class ContentTypeHeaderTests public ContentTypeHeaderTests(ExampleIntegrationTestContext, PolicyDbContext> testContext) { _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] @@ -33,6 +35,39 @@ public async Task Returns_JsonApi_ContentType_header() httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); } + [Fact] + public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_extension() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); + } + [Fact] public async Task Denies_unknown_ContentType_header() { @@ -90,6 +125,39 @@ public async Task Permits_JsonApi_ContentType_header() httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); } + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_AtomicOperations_extension_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + var route = "/operations"; + var contentType = HeaderConstants.AtomicOperationsMediaType; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + [Fact] public async Task Denies_JsonApi_ContentType_header_with_profile() { @@ -152,6 +220,37 @@ public async Task Denies_JsonApi_ContentType_header_with_extension() responseDocument.Errors[0].Detail.Should().Be("Please specify 'application/vnd.api+json' instead of 'application/vnd.api+json; ext=something' for the Content-Type header value."); } + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_at_resource_endpoint() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + var route = "/policies"; + var contentType = HeaderConstants.AtomicOperationsMediaType; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + responseDocument.Errors[0].Title.Should().Be("The specified Content-Type header value is not supported."); + responseDocument.Errors[0].Detail.Should().Be("Please specify 'application/vnd.api+json' instead of 'application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"' for the Content-Type header value."); + } + [Fact] public async Task Denies_JsonApi_ContentType_header_with_CharSet() { @@ -213,5 +312,43 @@ public async Task Denies_JsonApi_ContentType_header_with_unknown_parameter() responseDocument.Errors[0].Title.Should().Be("The specified Content-Type header value is not supported."); responseDocument.Errors[0].Detail.Should().Be("Please specify 'application/vnd.api+json' instead of 'application/vnd.api+json; unknown=unexpected' for the Content-Type header value."); } + + [Fact] + public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + var route = "/operations"; + var contentType = HeaderConstants.MediaType; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + responseDocument.Errors[0].Title.Should().Be("The specified Content-Type header value is not supported."); + responseDocument.Errors[0].Detail.Should().Be("Please specify 'application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"' instead of 'application/vnd.api+json' for the Content-Type header value."); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs index 652ce88661..2d9cb8f5fa 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -486,11 +486,11 @@ public async Task Cannot_create_for_unknown_relationship_IDs() responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); - responseDocument.Errors[0].Detail.Should().StartWith("Related resource of type 'workItems' with ID '12345678' in relationship 'assignedItems' does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'workItems' with ID '12345678' in relationship 'assignedItems' does not exist."); responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); - responseDocument.Errors[1].Detail.Should().StartWith("Related resource of type 'workItems' with ID '87654321' in relationship 'assignedItems' does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Related resource of type 'workItems' with ID '87654321' in relationship 'assignedItems' does not exist."); } [Fact] @@ -666,5 +666,48 @@ public async Task Cannot_create_with_null_data_in_HasManyThrough_relationship() responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); } + + [Fact] + public async Task Cannot_create_resource_with_local_ID() + { + // Arrange + const string workItemLocalId = "wo-1"; + + var requestBody = new + { + data = new + { + type = "workItems", + lid = workItemLocalId, + relationships = new + { + children = new + { + data = new[] + { + new + { + type = "workItems", + lid = workItemLocalId + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Local IDs cannot be used at this endpoint."); + responseDocument.Errors[0].Detail.Should().StartWith("Local IDs cannot be used at this endpoint. - Request body: <<"); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs index e9a00616fc..888cea9571 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -429,7 +429,7 @@ public async Task Cannot_create_with_unknown_relationship_ID() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); - responseDocument.Errors[0].Detail.Should().StartWith("Related resource of type 'userAccounts' with ID '12345678' in relationship 'assignee' does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'userAccounts' with ID '12345678' in relationship 'assignee' does not exist."); } [Fact] @@ -589,5 +589,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected single data element for to-one relationship."); responseDocument.Errors[0].Detail.Should().StartWith("Expected single data element for 'assignee' relationship. - Request body: <<"); } + + [Fact] + public async Task Cannot_create_resource_with_local_ID() + { + // Arrange + const string workItemLocalId = "wo-1"; + + var requestBody = new + { + data = new + { + type = "workItems", + lid = workItemLocalId, + relationships = new + { + parent = new + { + data = new + { + type = "workItems", + lid = workItemLocalId + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Local IDs cannot be used at this endpoint."); + responseDocument.Errors[0].Detail.Should().StartWith("Local IDs cannot be used at this endpoint. - Request body: <<"); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index f4017c2492..f35fc9477d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -60,6 +60,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var userAccountInDatabase = await dbContext.UserAccounts + .FirstAsync(userAccount => userAccount.Id == existingUserAccount.Id); + + userAccountInDatabase.FirstName.Should().Be(existingUserAccount.FirstName); + userAccountInDatabase.LastName.Should().Be(existingUserAccount.LastName); + }); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs index 323576c49d..873add19b1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs @@ -14,7 +14,6 @@ public sealed class DisableQueryStringTests : IClassFixture, RestrictionDbContext>> { private readonly ExampleIntegrationTestContext, RestrictionDbContext> _testContext; - private readonly RestrictionFakers _fakers = new RestrictionFakers(); public DisableQueryStringTests(ExampleIntegrationTestContext, RestrictionDbContext> testContext) { diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index 12329bd1c8..8992bc6997 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -29,6 +29,15 @@ public abstract class IntegrationTest acceptHeaders); } + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecutePostAtomicAsync(string requestUrl, object requestBody, + string contentType = HeaderConstants.AtomicOperationsMediaType, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, + acceptHeaders); + } + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, object requestBody, string contentType = HeaderConstants.MediaType, @@ -56,6 +65,7 @@ public abstract class IntegrationTest if (!string.IsNullOrEmpty(requestText)) { + requestText = requestText.Replace("atomic__", "atomic:"); request.Content = new StringContent(requestText); if (contentType != null) diff --git a/test/UnitTests/Internal/TypeHelper_Tests.cs b/test/UnitTests/Internal/TypeHelper_Tests.cs index 55a539ea44..89ec11da09 100644 --- a/test/UnitTests/Internal/TypeHelper_Tests.cs +++ b/test/UnitTests/Internal/TypeHelper_Tests.cs @@ -191,6 +191,7 @@ private interface IType private sealed class Model : IIdentifiable { public string StringId { get; set; } + public string LocalId { get; set; } } } } diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index 7ec46bd674..0e8077ca79 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -30,11 +30,13 @@ public ResourceConstructionTests() public void When_resource_has_default_constructor_it_must_succeed() { // Arrange - var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) + var options = new JsonApiOptions(); + + var graph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) .Add() .Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object, options); var body = new { @@ -59,11 +61,13 @@ public void When_resource_has_default_constructor_it_must_succeed() public void When_resource_has_default_constructor_that_throws_it_must_fail() { // Arrange - var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) + var options = new JsonApiOptions(); + + var graph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) .Add() .Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object, options); var body = new { @@ -90,11 +94,13 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() public void When_resource_has_constructor_with_string_parameter_it_must_fail() { // Arrange - var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) + var options = new JsonApiOptions(); + + var graph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) .Add() .Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object, options); var body = new { diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index 7f628073c6..da492a2938 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.ComponentModel.Design; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -18,7 +19,7 @@ public sealed class RequestDeserializerTests : DeserializerTestsSetup private readonly Mock _requestMock = new Mock(); public RequestDeserializerTests() { - _deserializer = new RequestDeserializer(_resourceGraph, new ResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object, _mockHttpContextAccessor.Object, _requestMock.Object); + _deserializer = new RequestDeserializer(_resourceGraph, new ResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object, _mockHttpContextAccessor.Object, _requestMock.Object, new JsonApiOptions()); } [Fact]